diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d3e2d1587..9e0444fa3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,6 @@ image: debian:testing test: script: - apt-get update - - apt-get install -y python3-cryptography python3-dateutil python3-idna python3-numpy python3-pil.imagetk python3-requests python3-socks python3-setuptools python3-pyqrcode + - apt-get install -y python3-cryptography python3-dateutil python3-idna python3-numpy python3-pil.imagetk python3-requests python3-socks python3-setuptools python3-pyqrcode imagemagick gnupg - python3 epicyon.py --tests - python3 epicyon.py --testsnetwork diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 181e06f4e..25ea8abd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ By submitting code, documentation or artwork you agree that it will be licensed ## Security Vulnerability Disclosure -Create an issue on https://gitlab.com/bashrc2/epicyon/issues. If the vulnerability is especially sensitive then send an XMPP message to **bob@freedombone.net** or a Matrix message to **@bob:matrix.freedombone.net**. +Create an issue on https://gitlab.com/bashrc2/epicyon/issues. If the vulnerability is especially sensitive then send an XMPP message to **bob@libreserver.org** or a Matrix message to **@bob:matrix.libreserver.org**. ## Code of Conduct @@ -14,7 +14,7 @@ The code of conduct can be found [here](code-of-conduct.md). Submit to https://gitlab.com/bashrc2/epicyon/issues -You can also post patches in the old-fashioned style via email to **bob@freedombone.net**. Include **[Epicyon]** in the subject line, otherwise it may be ignored. +You can also post patches in the old-fashioned style via email to **bob@libreserver.org**. Include **[Epicyon]** in the subject line, otherwise it may be ignored. ## Development Style diff --git a/README.md b/README.md index db68f1513..5434eae41 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,6 @@ server { location / { proxy_http_version 1.1; client_max_body_size 31M; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forward-For $proxy_add_x_forwarded_for; @@ -185,10 +183,6 @@ server { proxy_redirect off; proxy_request_buffering off; proxy_buffering off; - location ~ ^/accounts/(avatars|headers)/(.*).(png|jpg|gif|webp|svg) { - expires 1d; - proxy_pass http://localhost:7156; - } proxy_pass http://localhost:7156; } } diff --git a/acceptreject.py b/acceptreject.py index d4d0e22ec..bd54600c3 100644 --- a/acceptreject.py +++ b/acceptreject.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" diff --git a/announce.py b/announce.py index 8fc49bf8d..603fd8af1 100644 --- a/announce.py +++ b/announce.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" @@ -23,6 +23,7 @@ from utils import saveJson from utils import undoAnnounceCollectionEntry from utils import updateAnnounceCollection from utils import localActorUrl +from utils import replaceUsersWithAt from posts import sendSignedJson from posts import getPersonBox from session import postJson @@ -121,7 +122,8 @@ def createAnnounce(session, baseDir: str, federationList: [], clientToServer: bool, sendThreads: [], postLog: [], personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates an announce message Typically toUrl will be https://www.w3.org/ns/activitystreams#Public and ccUrl might be a specific person favorited or repeated and the @@ -178,7 +180,8 @@ def createAnnounce(session, baseDir: str, federationList: [], announceNickname, announceDomain, announcePort, None, httpPrefix, True, clientToServer, federationList, sendThreads, postLog, cachedWebfingers, personCache, - debug, projectVersion, None, groupAccount) + debug, projectVersion, None, groupAccount, + signingPrivateKeyPem, 639633) return newAnnounce @@ -188,7 +191,8 @@ def announcePublic(session, baseDir: str, federationList: [], objectUrl: str, clientToServer: bool, sendThreads: [], postLog: [], personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Makes a public announcement """ fromDomain = getFullDomain(domain, port) @@ -201,7 +205,8 @@ def announcePublic(session, baseDir: str, federationList: [], objectUrl, True, clientToServer, sendThreads, postLog, personCache, cachedWebfingers, - debug, projectVersion) + debug, projectVersion, + signingPrivateKeyPem) def sendAnnounceViaServer(baseDir: str, session, @@ -209,7 +214,8 @@ def sendAnnounceViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, repeatObjectUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates an announce message via c2s """ if not session: @@ -241,7 +247,8 @@ def sendAnnounceViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: announce webfinger failed for ' + handle) @@ -254,13 +261,16 @@ def sendAnnounceViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle + originDomain = fromDomain (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - fromNickname, fromDomain, - postToBox, 73528) + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + fromNickname, fromDomain, + postToBox, 73528) if not inboxUrl: if debug: @@ -297,7 +307,8 @@ def sendUndoAnnounceViaServer(baseDir: str, session, domain: str, port: int, httpPrefix: str, repeatObjectUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Undo an announce message via c2s """ if not session: @@ -307,7 +318,7 @@ def sendUndoAnnounceViaServer(baseDir: str, session, domainFull = getFullDomain(domain, port) actor = localActorUrl(httpPrefix, nickname, domainFull) - handle = actor.replace('/users/', '/@') + handle = replaceUsersWithAt(actor) statusNumber, published = getStatusNumber() unAnnounceJson = { @@ -321,7 +332,8 @@ def sendUndoAnnounceViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: undo announce webfinger failed for ' + handle) @@ -334,13 +346,16 @@ def sendUndoAnnounceViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle + originDomain = domain (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, - postToBox, 73528) + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, + postToBox, 73528) if not inboxUrl: if debug: diff --git a/architecture/epicyon_groups_ActivityPub.png b/architecture/epicyon_groups_ActivityPub.png index 48615e578..fb0e822e7 100644 Binary files a/architecture/epicyon_groups_ActivityPub.png and b/architecture/epicyon_groups_ActivityPub.png differ diff --git a/architecture/epicyon_groups_ActivityPub_Core.png b/architecture/epicyon_groups_ActivityPub_Core.png index e64e8938c..41efb024a 100644 Binary files a/architecture/epicyon_groups_ActivityPub_Core.png and b/architecture/epicyon_groups_ActivityPub_Core.png differ diff --git a/architecture/epicyon_groups_ActivityPub_Security.png b/architecture/epicyon_groups_ActivityPub_Security.png index 0e16a9626..965224e6d 100644 Binary files a/architecture/epicyon_groups_ActivityPub_Security.png and b/architecture/epicyon_groups_ActivityPub_Security.png differ diff --git a/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png b/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png index 9a60266f4..b1551db4c 100644 Binary files a/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png and b/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png differ diff --git a/architecture/epicyon_groups_Commandline-Interface_Core.png b/architecture/epicyon_groups_Commandline-Interface_Core.png index 99a1d22e1..3e200a7a1 100644 Binary files a/architecture/epicyon_groups_Commandline-Interface_Core.png and b/architecture/epicyon_groups_Commandline-Interface_Core.png differ diff --git a/architecture/epicyon_groups_Core.png b/architecture/epicyon_groups_Core.png index 5ea67e7aa..ff28efe66 100644 Binary files a/architecture/epicyon_groups_Core.png and b/architecture/epicyon_groups_Core.png differ diff --git a/architecture/epicyon_groups_Core_Accessibility.png b/architecture/epicyon_groups_Core_Accessibility.png index f8a2eba5e..63fd67379 100644 Binary files a/architecture/epicyon_groups_Core_Accessibility.png and b/architecture/epicyon_groups_Core_Accessibility.png differ diff --git a/architecture/epicyon_groups_Core_Security.png b/architecture/epicyon_groups_Core_Security.png index 70196e4b3..2b53d14b7 100644 Binary files a/architecture/epicyon_groups_Core_Security.png and b/architecture/epicyon_groups_Core_Security.png differ diff --git a/architecture/epicyon_groups_Timeline_Core.png b/architecture/epicyon_groups_Timeline_Core.png index 434e99afd..4e23a02bb 100644 Binary files a/architecture/epicyon_groups_Timeline_Core.png and b/architecture/epicyon_groups_Timeline_Core.png differ diff --git a/architecture/epicyon_groups_Timeline_Security.png b/architecture/epicyon_groups_Timeline_Security.png index a7d60b4b1..2c1193750 100644 Binary files a/architecture/epicyon_groups_Timeline_Security.png and b/architecture/epicyon_groups_Timeline_Security.png differ diff --git a/architecture/epicyon_groups_Web-Interface-Columns_Core.png b/architecture/epicyon_groups_Web-Interface-Columns_Core.png index 4c0b9628c..5678cf445 100644 Binary files a/architecture/epicyon_groups_Web-Interface-Columns_Core.png and b/architecture/epicyon_groups_Web-Interface-Columns_Core.png differ diff --git a/architecture/epicyon_groups_Web-Interface_Accessibility.png b/architecture/epicyon_groups_Web-Interface_Accessibility.png index 69fdfe77f..738fd1f73 100644 Binary files a/architecture/epicyon_groups_Web-Interface_Accessibility.png and b/architecture/epicyon_groups_Web-Interface_Accessibility.png differ diff --git a/architecture/epicyon_groups_Web-Interface_Core.png b/architecture/epicyon_groups_Web-Interface_Core.png index 281401663..9943358a9 100644 Binary files a/architecture/epicyon_groups_Web-Interface_Core.png and b/architecture/epicyon_groups_Web-Interface_Core.png differ diff --git a/auth.py b/auth.py index de23e6130..0305dca68 100644 --- a/auth.py +++ b/auth.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Security" diff --git a/availability.py b/availability.py index 35ba9164e..ee6c2d5d6 100644 --- a/availability.py +++ b/availability.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" @@ -82,7 +82,8 @@ def sendAvailabilityViaServer(baseDir: str, session, httpPrefix: str, status: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Sets the availability for a person via c2s """ if not session: @@ -107,7 +108,8 @@ def sendAvailabilityViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: availability webfinger failed for ' + handle) @@ -120,12 +122,14 @@ def sendAvailabilityViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, nickname, - domain, postToBox, 57262) + originDomain = domain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, nickname, + domain, postToBox, 57262) if not inboxUrl: if debug: diff --git a/blocking.py b/blocking.py index 406b79717..ddfb688c6 100644 --- a/blocking.py +++ b/blocking.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" @@ -461,52 +461,66 @@ def mutePost(baseDir: str, nickname: str, domain: str, port: int, debug: bool) -> None: """ Mutes the given post """ + print('mutePost: postId ' + postId) postFilename = locatePost(baseDir, nickname, domain, postId) if not postFilename: + print('mutePost: file not found ' + postId) return postJsonObject = loadJson(postFilename) if not postJsonObject: + print('mutePost: object not loaded ' + postId) return + print('mutePost: ' + str(postJsonObject)) + postJsonObj = postJsonObject + alsoUpdatePostId = None if hasObjectDict(postJsonObject): - domainFull = getFullDomain(domain, port) - actor = localActorUrl(httpPrefix, nickname, domainFull) + postJsonObj = postJsonObject['object'] + else: + if postJsonObject.get('object'): + if isinstance(postJsonObject['object'], str): + alsoUpdatePostId = removeIdEnding(postJsonObject['object']) - if postJsonObject['object'].get('conversation'): - muteConversation(baseDir, nickname, domain, - postJsonObject['object']['conversation']) + domainFull = getFullDomain(domain, port) + actor = localActorUrl(httpPrefix, nickname, domainFull) - # does this post have ignores on it from differenent actors? - if not postJsonObject['object'].get('ignores'): - if debug: - print('DEBUG: Adding initial mute to ' + postId) - ignoresJson = { - "@context": "https://www.w3.org/ns/activitystreams", - 'id': postId, - 'type': 'Collection', - "totalItems": 1, - 'items': [{ - 'type': 'Ignore', - 'actor': actor - }] - } - postJsonObject['object']['ignores'] = ignoresJson - else: - if not postJsonObject['object']['ignores'].get('items'): - postJsonObject['object']['ignores']['items'] = [] - itemsList = postJsonObject['object']['ignores']['items'] - for ignoresItem in itemsList: - if ignoresItem.get('actor'): - if ignoresItem['actor'] == actor: - return - newIgnore = { + if postJsonObj.get('conversation'): + muteConversation(baseDir, nickname, domain, + postJsonObj['conversation']) + + # does this post have ignores on it from differenent actors? + if not postJsonObj.get('ignores'): + if debug: + print('DEBUG: Adding initial mute to ' + postId) + ignoresJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': postId, + 'type': 'Collection', + "totalItems": 1, + 'items': [{ 'type': 'Ignore', 'actor': actor - } - igIt = len(itemsList) - itemsList.append(newIgnore) - postJsonObject['object']['ignores']['totalItems'] = igIt - saveJson(postJsonObject, postFilename) + }] + } + postJsonObj['ignores'] = ignoresJson + else: + if not postJsonObj['ignores'].get('items'): + postJsonObj['ignores']['items'] = [] + itemsList = postJsonObj['ignores']['items'] + for ignoresItem in itemsList: + if ignoresItem.get('actor'): + if ignoresItem['actor'] == actor: + return + newIgnore = { + 'type': 'Ignore', + 'actor': actor + } + igIt = len(itemsList) + itemsList.append(newIgnore) + postJsonObj['ignores']['totalItems'] = igIt + postJsonObj['muted'] = True + if saveJson(postJsonObject, postFilename): + print('mutePost: saved ' + postFilename) # remove cached post so that the muted version gets recreated # without its content text and/or image @@ -514,7 +528,13 @@ def mutePost(baseDir: str, nickname: str, domain: str, port: int, getCachedPostFilename(baseDir, nickname, domain, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + print('MUTE: cached post removed ' + cachedPostFilename) + except BaseException: + pass + else: + print('MUTE: cached post not found ' + cachedPostFilename) with open(postFilename + '.muted', 'w+') as muteFile: muteFile.write('\n') @@ -526,14 +546,39 @@ def mutePost(baseDir: str, nickname: str, domain: str, port: int, removeIdEnding(postJsonObject['id']).replace('/', '#') if postId in recentPostsCache['index']: print('MUTE: ' + postId + ' is in recent posts cache') - if recentPostsCache['json'].get(postId): - postJsonObject['muted'] = True - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] - print('MUTE: ' + postId + - ' marked as muted in recent posts memory cache') + if recentPostsCache.get('json'): + recentPostsCache['json'][postId] = json.dumps(postJsonObject) + print('MUTE: ' + postId + + ' marked as muted in recent posts memory cache') + if recentPostsCache.get('html'): + if recentPostsCache['html'].get(postId): + del recentPostsCache['html'][postId] + print('MUTE: ' + postId + ' removed cached html') + + if alsoUpdatePostId: + postFilename = locatePost(baseDir, nickname, domain, alsoUpdatePostId) + if os.path.isfile(postFilename): + postJsonObj = loadJson(postFilename) + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, domain, + postJsonObj) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + try: + os.remove(cachedPostFilename) + print('MUTE: cached referenced post removed ' + + cachedPostFilename) + except BaseException: + pass + + if recentPostsCache.get('json'): + if recentPostsCache['json'].get(alsoUpdatePostId): + del recentPostsCache['json'][alsoUpdatePostId] + print('MUTE: ' + alsoUpdatePostId + ' removed referenced json') + if recentPostsCache.get('html'): + if recentPostsCache['html'].get(alsoUpdatePostId): + del recentPostsCache['html'][alsoUpdatePostId] + print('MUTE: ' + alsoUpdatePostId + ' removed referenced html') def unmutePost(baseDir: str, nickname: str, domain: str, port: int, @@ -550,37 +595,48 @@ def unmutePost(baseDir: str, nickname: str, domain: str, port: int, muteFilename = postFilename + '.muted' if os.path.isfile(muteFilename): - os.remove(muteFilename) + try: + os.remove(muteFilename) + except BaseException: + pass print('UNMUTE: ' + muteFilename + ' file removed') + postJsonObj = postJsonObject + alsoUpdatePostId = None if hasObjectDict(postJsonObject): - if postJsonObject['object'].get('conversation'): - unmuteConversation(baseDir, nickname, domain, - postJsonObject['object']['conversation']) + postJsonObj = postJsonObject['object'] + else: + if postJsonObject.get('object'): + if isinstance(postJsonObject['object'], str): + alsoUpdatePostId = removeIdEnding(postJsonObject['object']) - if postJsonObject['object'].get('ignores'): - domainFull = getFullDomain(domain, port) - actor = localActorUrl(httpPrefix, nickname, domainFull) - totalItems = 0 - if postJsonObject['object']['ignores'].get('totalItems'): - totalItems = \ - postJsonObject['object']['ignores']['totalItems'] - itemsList = postJsonObject['object']['ignores']['items'] - for ignoresItem in itemsList: - if ignoresItem.get('actor'): - if ignoresItem['actor'] == actor: - if debug: - print('DEBUG: mute was removed for ' + actor) - itemsList.remove(ignoresItem) - break - if totalItems == 1: - if debug: - print('DEBUG: mute was removed from post') - del postJsonObject['object']['ignores'] - else: - igItLen = len(postJsonObject['object']['ignores']['items']) - postJsonObject['object']['ignores']['totalItems'] = igItLen - saveJson(postJsonObject, postFilename) + if postJsonObj.get('conversation'): + unmuteConversation(baseDir, nickname, domain, + postJsonObj['conversation']) + + if postJsonObj.get('ignores'): + domainFull = getFullDomain(domain, port) + actor = localActorUrl(httpPrefix, nickname, domainFull) + totalItems = 0 + if postJsonObj['ignores'].get('totalItems'): + totalItems = postJsonObj['ignores']['totalItems'] + itemsList = postJsonObj['ignores']['items'] + for ignoresItem in itemsList: + if ignoresItem.get('actor'): + if ignoresItem['actor'] == actor: + if debug: + print('DEBUG: mute was removed for ' + actor) + itemsList.remove(ignoresItem) + break + if totalItems == 1: + if debug: + print('DEBUG: mute was removed from post') + del postJsonObj['ignores'] + else: + igItLen = len(postJsonObj['ignores']['items']) + postJsonObj['ignores']['totalItems'] = igItLen + postJsonObj['muted'] = False + saveJson(postJsonObject, postFilename) # remove cached post so that the muted version gets recreated # with its content text and/or image @@ -588,7 +644,10 @@ def unmutePost(baseDir: str, nickname: str, domain: str, port: int, getCachedPostFilename(baseDir, nickname, domain, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass # if the post is in the recent posts cache then mark it as unmuted if recentPostsCache.get('index'): @@ -596,14 +655,40 @@ def unmutePost(baseDir: str, nickname: str, domain: str, port: int, removeIdEnding(postJsonObject['id']).replace('/', '#') if postId in recentPostsCache['index']: print('UNMUTE: ' + postId + ' is in recent posts cache') - if recentPostsCache['json'].get(postId): - postJsonObject['muted'] = False - recentPostsCache['json'][postId] = json.dumps(postJsonObject) - if recentPostsCache.get('html'): - if recentPostsCache['html'].get(postId): - del recentPostsCache['html'][postId] - print('UNMUTE: ' + postId + - ' marked as unmuted in recent posts cache') + if recentPostsCache.get('json'): + recentPostsCache['json'][postId] = json.dumps(postJsonObject) + print('UNMUTE: ' + postId + + ' marked as unmuted in recent posts cache') + if recentPostsCache.get('html'): + if recentPostsCache['html'].get(postId): + del recentPostsCache['html'][postId] + print('UNMUTE: ' + postId + ' removed cached html') + if alsoUpdatePostId: + postFilename = locatePost(baseDir, nickname, domain, alsoUpdatePostId) + if os.path.isfile(postFilename): + postJsonObj = loadJson(postFilename) + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, domain, + postJsonObj) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + try: + os.remove(cachedPostFilename) + print('MUTE: cached referenced post removed ' + + cachedPostFilename) + except BaseException: + pass + + if recentPostsCache.get('json'): + if recentPostsCache['json'].get(alsoUpdatePostId): + del recentPostsCache['json'][alsoUpdatePostId] + print('UNMUTE: ' + + alsoUpdatePostId + ' removed referenced json') + if recentPostsCache.get('html'): + if recentPostsCache['html'].get(alsoUpdatePostId): + del recentPostsCache['html'][alsoUpdatePostId] + print('UNMUTE: ' + + alsoUpdatePostId + ' removed referenced html') def outboxMute(baseDir: str, httpPrefix: str, @@ -740,7 +825,10 @@ def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None: if not enabled: # remove instance allow list if os.path.isfile(allowFilename): - os.remove(allowFilename) + try: + os.remove(allowFilename) + except BaseException: + pass print('Broch mode turned off') else: if os.path.isfile(allowFilename): @@ -799,11 +887,14 @@ def brochModeLapses(baseDir: str, lapseDays: int = 7) -> bool: currTime = datetime.datetime.utcnow() daysSinceBroch = (currTime - modifiedDate).days if daysSinceBroch >= lapseDays: + removed = False try: os.remove(allowFilename) + removed = True + except BaseException: + pass + if removed: setConfigParam(baseDir, "brochMode", False) print('Broch mode has elapsed') return True - except BaseException: - pass return False diff --git a/blog.py b/blog.py index 58ac947ca..fbdcadad0 100644 --- a/blog.py +++ b/blog.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" diff --git a/bookmarks.py b/bookmarks.py index 921c057e4..fb0c3f769 100644 --- a/bookmarks.py +++ b/bookmarks.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -47,7 +47,10 @@ def undoBookmarksCollectionEntry(recentPostsCache: {}, domain, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass removePostFromCache(postJsonObject, recentPostsCache) # remove from the index @@ -152,7 +155,10 @@ def updateBookmarksCollection(recentPostsCache: {}, domain, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass removePostFromCache(postJsonObject, recentPostsCache) if not postJsonObject.get('object'): @@ -348,7 +354,8 @@ def sendBookmarkViaServer(baseDir: str, session, domain: str, fromPort: int, httpPrefix: str, bookmarkUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a bookmark via c2s """ if not session: @@ -377,7 +384,8 @@ def sendBookmarkViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: bookmark webfinger failed for ' + handle) @@ -390,12 +398,15 @@ def sendBookmarkViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, - postToBox, 52594) + originDomain = domain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, + postToBox, 58391) if not inboxUrl: if debug: @@ -433,7 +444,8 @@ def sendUndoBookmarkViaServer(baseDir: str, session, domain: str, fromPort: int, httpPrefix: str, bookmarkUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Removes a bookmark via c2s """ if not session: @@ -462,7 +474,8 @@ def sendUndoBookmarkViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: unbookmark webfinger failed for ' + handle) @@ -475,12 +488,15 @@ def sendUndoBookmarkViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, - postToBox, 52594) + originDomain = domain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, + postToBox, 52594) if not inboxUrl: if debug: diff --git a/briar.py b/briar.py index 6e3f1e1d0..76369208b 100644 --- a/briar.py +++ b/briar.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/cache.py b/cache.py index 9ba0111fb..7b4654944 100644 --- a/cache.py +++ b/cache.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" @@ -139,7 +139,8 @@ def getWebfingerFromCache(handle: str, cachedWebfingers: {}) -> {}: def getPersonPubKey(baseDir: str, session, personUrl: str, personCache: {}, debug: bool, projectVersion: str, httpPrefix: str, - domain: str, onionDomain: str) -> str: + domain: str, onionDomain: str, + signingPrivateKeyPem: str) -> str: if not personUrl: return None personUrl = personUrl.replace('#main-key', '') @@ -165,7 +166,8 @@ def getPersonPubKey(baseDir: str, session, personUrl: str, 'Accept': 'application/activity+json; profile="' + profileStr + '"' } personJson = \ - getJson(session, personUrl, asHeader, None, debug, + getJson(signingPrivateKeyPem, + session, personUrl, asHeader, None, debug, projectVersion, httpPrefix, personDomain) if not personJson: return None diff --git a/caddy.example.conf b/caddy.example.conf index 9029bf000..615501443 100644 --- a/caddy.example.conf +++ b/caddy.example.conf @@ -11,7 +11,7 @@ example.com { header / X-Download-Options "noopen" header / X-Frame-Options "DENY" header / X-Permitted-Cross-Domain-Policies "none" - header / X-Robots-Tag "noindex,nofollow,nosnippet,noarchive" + header / X-Robots-Tag "noindex" header / X-XSS-Protection "1; mode=block" proxy / http://localhost:7156 { diff --git a/categories.py b/categories.py index f60520b7e..f2834e9c2 100644 --- a/categories.py +++ b/categories.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "RSS Feeds" @@ -93,7 +93,10 @@ def updateHashtagCategories(baseDir: str) -> None: hashtagCategories = getHashtagCategories(baseDir) if not hashtagCategories: if os.path.isfile(categoryListFilename): - os.remove(categoryListFilename) + try: + os.remove(categoryListFilename) + except BaseException: + pass return categoryList = [] diff --git a/city.py b/city.py index a780996f6..b486c2de0 100644 --- a/city.py +++ b/city.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Metadata" diff --git a/code-of-conduct.md b/code-of-conduct.md index 73e4b2a1e..83ed3d4d1 100644 --- a/code-of-conduct.md +++ b/code-of-conduct.md @@ -52,7 +52,7 @@ If you're raising concerns about something or someone, there must be demonstrabl This is not a big project and so there is no division of labor or special enforcement committee or bureaucratic process. -Complaints should be either reported in the Matrix chat room **#epicyon:matrix.freedombone.net** or sent to bob@freedombone.net, preferably via XMPP/Conversations with OMEMO enabled but you can also use the same address for email correspondence. +Complaints should be either reported in the Matrix chat room **#epicyon:matrix.libreserver.org** or sent to bob@libreserver.org, preferably via XMPP/Conversations with OMEMO enabled but you can also use the same address for email correspondence. ## In case of violations @@ -60,6 +60,5 @@ Violators of this code of conduct will: * Be removed from any associated Matrix and/or XMPP chat rooms * Will not have pending or future patches or pull requests merged - * If they have a user account on *code.freedombone.net* it will be removed This applies regardless of past levels of commitment or technical abilities. diff --git a/content.py b/content.py index 0c13f0e23..fd3fb7626 100644 --- a/content.py +++ b/content.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" @@ -11,6 +11,7 @@ import os import email.parser import urllib.parse from shutil import copyfile +from utils import dangerousSVG from utils import removeDomainPort from utils import isValidLanguage from utils import getImageExtensions @@ -938,9 +939,15 @@ def saveMediaInFormPOST(mediaBytes, debug: bool, for ex in extensionTypes: possibleOtherFormat = filenameBase + '.' + ex if os.path.isfile(possibleOtherFormat): - os.remove(possibleOtherFormat) + try: + os.remove(possibleOtherFormat) + except BaseException: + pass if os.path.isfile(filenameBase): - os.remove(filenameBase) + try: + os.remove(filenameBase) + except BaseException: + pass if debug: print('DEBUG: No media found within POST') @@ -1006,7 +1013,17 @@ def saveMediaInFormPOST(mediaBytes, debug: bool, detectedExtension, '.' + ex) if os.path.isfile(possibleOtherFormat): - os.remove(possibleOtherFormat) + try: + os.remove(possibleOtherFormat) + except BaseException: + pass + + # don't allow scripts within svg files + if detectedExtension == 'svg': + svgStr = mediaBytes[startPos:] + svgStr = svgStr.decode() + if dangerousSVG(svgStr, False): + return None, None with open(filename, 'wb') as fp: fp.write(mediaBytes[startPos:]) diff --git a/context.py b/context.py index 20efb02d9..c7e51d6da 100644 --- a/context.py +++ b/context.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Security" diff --git a/conversation.py b/conversation.py index 32ecd0e08..f43b23419 100644 --- a/conversation.py +++ b/conversation.py @@ -3,13 +3,14 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" import os from utils import hasObjectDict from utils import acctDir +from utils import removeIdEnding def updateConversation(baseDir: str, nickname: str, domain: str, @@ -27,7 +28,7 @@ def updateConversation(baseDir: str, nickname: str, domain: str, os.mkdir(conversationDir) conversationId = postJsonObject['object']['conversation'] conversationId = conversationId.replace('/', '#') - postId = postJsonObject['object']['id'] + postId = removeIdEnding(postJsonObject['object']['id']) conversationFilename = conversationDir + '/' + conversationId if not os.path.isfile(conversationFilename): try: diff --git a/cwtch.py b/cwtch.py index 9619067f1..0d145d402 100644 --- a/cwtch.py +++ b/cwtch.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/daemon.py b/daemon.py index 3cd3036d3..ab64fabf9 100644 --- a/daemon.py +++ b/daemon.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" @@ -20,6 +20,7 @@ import pyqrcode # for saving images from hashlib import sha256 from hashlib import md5 +from shutil import copyfile from session import createSession from webfinger import webfingerMeta from webfinger import webfingerNodeInfo @@ -54,6 +55,7 @@ from donate import getWebsite from donate import setWebsite from person import setPersonNotes from person import getDefaultPersonContext +from person import getActorUpdateJson from person import savePersonQrcode from person import randomizeActorImages from person import personUpgradeActor @@ -70,6 +72,8 @@ from person import removeAccount from person import canRemovePost from person import personSnooze from person import personUnsnooze +from posts import savePostToBox +from posts import getInstanceActorKey from posts import removePostInteractions from posts import outboxMessageCreateWrap from posts import getPinnedPostAsJson @@ -94,6 +98,7 @@ from inbox import runInboxQueue from inbox import runInboxQueueWatchdog from inbox import savePostToInboxQueue from inbox import populateReplies +from follow import followerApprovalActive from follow import isFollowingActor from follow import getFollowingFeed from follow import sendFollowRequest @@ -115,6 +120,7 @@ from threads import removeDormantThreads from media import processMetaData from media import convertImageToLowBandwidth from media import replaceYouTube +from media import replaceTwitter from media import attachMedia from media import pathIsVideo from media import pathIsAudio @@ -184,6 +190,7 @@ from webapp_confirm import htmlConfirmFollow from webapp_confirm import htmlConfirmUnfollow from webapp_post import htmlPostReplies from webapp_post import htmlIndividualPost +from webapp_post import individualPostAsHtml from webapp_profile import htmlEditProfile from webapp_profile import htmlProfileAfterSearch from webapp_profile import htmlProfile @@ -226,6 +233,11 @@ from categories import setHashtagCategory from categories import updateHashtagCategories from languages import getActorLanguages from languages import setActorLanguages +from like import updateLikesCollection +from utils import setReplyIntervalHours +from utils import canReplyTo +from utils import isDM +from utils import replaceUsersWithAt from utils import localActorUrl from utils import isfloat from utils import validPassword @@ -262,7 +274,6 @@ from utils import isSystemAccount from utils import setConfigParam from utils import getConfigParam from utils import removeIdEnding -from utils import updateLikesCollection from utils import undoLikesCollectionEntry from utils import deletePost from utils import isBlogPost @@ -295,6 +306,7 @@ from cache import storePersonInCache from cache import getPersonFromCache from cache import getPersonPubKey from httpsig import verifyPostHeaders +from theme import scanThemesForScripts from theme import importTheme from theme import exportTheme from theme import isNewsThemeName @@ -454,7 +466,8 @@ class PubServer(BaseHTTPRequestHandler): if messageJson: # name field contains the answer messageJson['object']['name'] = answer - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, nickname): postFilename = \ locatePost(self.server.baseDir, nickname, self.server.domain, messageId) @@ -480,7 +493,10 @@ class PubServer(BaseHTTPRequestHandler): postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass # remove from memory cache removePostFromCache(postJsonObject, self.server.recentPostsCache) @@ -489,13 +505,12 @@ class PubServer(BaseHTTPRequestHandler): else: print('ERROR: unable to create vote') - def _blockedUserAgent(self, callingDomain: str) -> bool: + def _blockedUserAgent(self, callingDomain: str, agentStr: str) -> bool: """Should a GET or POST be blocked based upon its user agent? """ agentDomain = None - agentStr = None - if self.headers.get('User-Agent'): - agentStr = self.headers['User-Agent'] + + if agentStr: # is this a web crawler? If so the block it agentStrLower = agentStr.lower() if 'bot/' in agentStrLower or 'bot-' in agentStrLower: @@ -578,20 +593,28 @@ class PubServer(BaseHTTPRequestHandler): return False return True - def _fetchAuthenticated(self) -> bool: + def _secureMode(self) -> bool: """http authentication of GET requests for json """ - if not self.server.authenticatedFetch: + if not self.server.secureMode: return True + + signature = None + if self.headers.get('signature'): + signature = self.headers['signature'] + elif self.headers.get('Signature'): + signature = self.headers['Signature'] + # check that the headers are signed - if not self.headers.get('signature'): + if not signature: if self.server.debug: - print('WARN: authenticated fetch, ' + + print('AUTH: secure mode, ' + 'GET has no signature in headers') return False - # get the keyId + + # get the keyId, which is typically the instance actor keyId = None - signatureParams = self.headers['signature'].split(',') + signatureParams = signature.split(',') for signatureItem in signatureParams: if signatureItem.startswith('keyId='): if '"' in signatureItem: @@ -599,46 +622,49 @@ class PubServer(BaseHTTPRequestHandler): break if not keyId: if self.server.debug: - print('WARN: authenticated fetch, ' + + print('AUTH: secure mode, ' + 'failed to obtain keyId from signature') return False + + # remove #main-key + if '#' in keyId: + keyId = keyId.split('#')[0] + # is the keyId (actor) valid? if not urlPermitted(keyId, self.server.federationList): if self.server.debug: - print('Authorized fetch failed: ' + keyId + - ' is not permitted') + print('AUTH: Secure mode GET request not permitted: ' + keyId) return False + # make sure we have a session if not self.server.session: - print('DEBUG: creating new session during authenticated fetch') + print('DEBUG: creating new session during authorized fetch') self.server.session = createSession(self.server.proxyType) if not self.server.session: print('ERROR: GET failed to create session during ' + - 'authenticated fetch') + 'secure mode') return False + # obtain the public key pubKey = \ getPersonPubKey(self.server.baseDir, self.server.session, keyId, self.server.personCache, self.server.debug, - __version__, self.server.httpPrefix, - self.server.domain, self.server.onionDomain) + self.server.projectVersion, self.server.httpPrefix, + self.server.domain, self.server.onionDomain, + self.server.signingPrivateKeyPem) if not pubKey: if self.server.debug: - print('DEBUG: Authenticated fetch failed to ' + + print('AUTH: secure mode failed to ' + 'obtain public key for ' + keyId) return False - # it is assumed that there will be no message body on - # authenticated fetches and also consequently no digest - GETrequestBody = '' - GETrequestDigest = None + # verify the GET request without any digest - if verifyPostHeaders(self.server.httpPrefix, - pubKey, self.headers, - self.path, True, - GETrequestDigest, - GETrequestBody, - self.server.debug): + if verifyPostHeaders(self.server.httpPrefix, pubKey, self.headers, + self.path, True, None, '', self.server.debug): return True + + if self.server.debug: + print('AUTH: secure mode authorization failed for ' + keyId) return False def _login_headers(self, fileFormat: str, length: int, @@ -682,7 +708,7 @@ class PubServer(BaseHTTPRequestHandler): self.send_header('Set-Cookie', 'epicyon=; SameSite=Strict') self.send_header('Location', self._quoted_redirect(redirect)) self.send_header('Host', callingDomain) - self.send_header('InstanceID', self.server.instanceId) + self.send_header('X-AP-Instance-ID', self.server.instanceId) self.send_header('Content-Length', '0') self.end_headers() @@ -690,11 +716,33 @@ class PubServer(BaseHTTPRequestHandler): callingDomain: str, permissive: bool) -> None: self.send_response(200) self.send_header('Content-type', fileFormat) + if 'image/' in fileFormat or \ + 'audio/' in fileFormat or \ + 'video/' in fileFormat: + cache_control = \ + 'public, max-age=84600, must-revalidate, ' + \ + 'stale-while-revalidate=3600' + self.send_header('Cache-Control', cache_control) + else: + self.send_header('Cache-Control', 'public') + self.send_header('Origin', self.server.domainFull) + self.send_header('X-AP-Instance-ID', self.server.instanceId) + self.send_header('X-Clacks-Overhead', 'GNU Natalie Nguyen') if length > -1: self.send_header('Content-Length', str(length)) - self.send_header('Host', callingDomain) + if callingDomain: + self.send_header('Host', callingDomain) if permissive: self.send_header('Access-Control-Allow-Origin', '*') + if 'image/' in fileFormat or \ + 'audio/' in fileFormat or \ + 'video/' in fileFormat: + acStr = \ + 'Server, x-goog-meta-frames, Content-Length, ' + \ + 'Content-Type, Range, X-Requested-With, ' + \ + 'If-Modified-Since, If-None-Match' + self.send_header('Access-Control-Allow-Headers', acStr) + self.send_header('Access-Control-Expose-Headers', acStr) return if cookie: cookieStr = cookie @@ -703,11 +751,6 @@ class PubServer(BaseHTTPRequestHandler): cookieStr += '; Secure' cookieStr += '; HttpOnly; SameSite=Strict' self.send_header('Cookie', cookieStr) - self.send_header('Origin', self.server.domainFull) - self.send_header('InstanceID', self.server.instanceId) - self.send_header('X-Clacks-Overhead', 'GNU Natalie Nguyen') - self.send_header('Cache-Control', 'max-age=0') - self.send_header('Cache-Control', 'public') def _set_headers(self, fileFormat: str, length: int, cookie: str, callingDomain: str, permissive: bool) -> None: @@ -729,7 +772,6 @@ class PubServer(BaseHTTPRequestHandler): datalen = len(data) self._set_headers_base(fileFormat, datalen, cookie, callingDomain, permissive) - # self.send_header('Cache-Control', 'public, max-age=86400') etag = None if os.path.isfile(mediaFilename + '.etag'): try: @@ -744,8 +786,8 @@ class PubServer(BaseHTTPRequestHandler): etagFile.write(etag) except BaseException: pass - if etag: - self.send_header('ETag', '"' + etag + '"') + # if etag: + # self.send_header('ETag', '"' + etag + '"') if lastModified: self.send_header('last-modified', lastModified) self.end_headers() @@ -794,7 +836,7 @@ class PubServer(BaseHTTPRequestHandler): self.send_header('Set-Cookie', cookieStr) self.send_header('Location', self._quoted_redirect(redirect)) self.send_header('Host', callingDomain) - self.send_header('InstanceID', self.server.instanceId) + self.send_header('X-AP-Instance-ID', self.server.instanceId) self.send_header('Content-Length', '0') self.end_headers() @@ -830,6 +872,14 @@ class PubServer(BaseHTTPRequestHandler): 'This is nothing less ' + 'than an utter triumph') + def _403(self) -> None: + if self.server.translate: + self._httpReturnCode(403, self.server.translate['Forbidden'], + self.server.translate["You're not allowed"]) + else: + self._httpReturnCode(403, 'Forbidden', + "You're not allowed") + def _404(self) -> None: if self.server.translate: self._httpReturnCode(404, self.server.translate['Not Found'], @@ -904,6 +954,11 @@ class PubServer(BaseHTTPRequestHandler): def _hasAccept(self, callingDomain: str) -> bool: """Do the http headers have an Accept field? """ + if not self.headers.get('Accept'): + if self.headers.get('accept'): + print('Upper case Accept') + self.headers['Accept'] = self.headers['accept'] + if self.headers.get('Accept') or callingDomain.endswith('.b32.i2p'): if not self.headers.get('Accept'): self.headers['Accept'] = \ @@ -1129,7 +1184,7 @@ class PubServer(BaseHTTPRequestHandler): return True def _postToOutbox(self, messageJson: {}, version: str, - postToNickname: str = None) -> bool: + postToNickname: str) -> bool: """post is received by the outbox Client to server message post https://www.w3.org/TR/activitypub/#client-to-server-outbox-delivery @@ -1164,12 +1219,18 @@ class PubServer(BaseHTTPRequestHandler): self.server.proxyType, version, self.server.debug, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.allowLocalNetworkAccess, city, self.server.systemLanguage, self.server.sharedItemsFederatedDomains, self.server.sharedItemFederationTokens, - self.server.lowBandwidth) + self.server.lowBandwidth, + self.server.signingPrivateKeyPem, + self.server.peertubeInstances, + self.server.themeName, + self.server.maxLikeCount, + self.server.maxRecentPosts) def _postToOutboxThread(self, messageJson: {}) -> bool: """Creates a thread to send a post @@ -1191,7 +1252,8 @@ class PubServer(BaseHTTPRequestHandler): print('Creating outbox thread') self.server.outboxThread[accountOutboxThreadName] = \ threadWithTrace(target=self._postToOutbox, - args=(messageJson.copy(), __version__), + args=(messageJson.copy(), + self.server.projectVersion, None), daemon=True) print('Starting outbox thread') self.server.outboxThread[accountOutboxThreadName].start() @@ -1775,7 +1837,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.port, searchHandle, self.server.debug, - self.server.systemLanguage) + self.server.systemLanguage, + self.server.signingPrivateKeyPem) else: msg = \ htmlModerationInfo(self.server.cssCache, @@ -2219,7 +2282,10 @@ class PubServer(BaseHTTPRequestHandler): newswireBlockedFilename = accountDir + '/.nonewswire' if postsToNews == 'on': if os.path.isfile(newswireBlockedFilename): - os.remove(newswireBlockedFilename) + try: + os.remove(newswireBlockedFilename) + except BaseException: + pass refreshNewswire(self.server.baseDir) else: if os.path.isdir(accountDir): @@ -2254,7 +2320,10 @@ class PubServer(BaseHTTPRequestHandler): featuresBlockedFilename = accountDir + '/.nofeatures' if postsToFeatures == 'on': if os.path.isfile(featuresBlockedFilename): - os.remove(featuresBlockedFilename) + try: + os.remove(featuresBlockedFilename) + except BaseException: + pass refreshNewswire(self.server.baseDir) else: if os.path.isdir(accountDir): @@ -2289,7 +2358,10 @@ class PubServer(BaseHTTPRequestHandler): newswireModFilename = accountDir + '/.newswiremoderated' if modPostsToNews != 'on': if os.path.isfile(newswireModFilename): - os.remove(newswireModFilename) + try: + os.remove(newswireModFilename) + except BaseException: + pass else: if os.path.isdir(accountDir): nwFilename = newswireModFilename @@ -2336,8 +2408,9 @@ class PubServer(BaseHTTPRequestHandler): return # person options screen, follow button - # See htmlPersonOptions - if '&submitFollow=' in optionsConfirmParams: + # See htmlPersonOptions followStr + if '&submitFollow=' in optionsConfirmParams or \ + '&submitJoin=' in optionsConfirmParams: if debug: print('Following ' + optionsActor) msg = \ @@ -2355,8 +2428,9 @@ class PubServer(BaseHTTPRequestHandler): return # person options screen, unfollow button - # See htmlPersonOptions - if '&submitUnfollow=' in optionsConfirmParams: + # See htmlPersonOptions followStr + if '&submitUnfollow=' in optionsConfirmParams or \ + '&submitLeave=' in optionsConfirmParams: print('Unfollowing ' + optionsActor) msg = \ htmlConfirmUnfollow(self.server.cssCache, @@ -2395,7 +2469,7 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, reportPath, None, [optionsActor], None, None, - pageNumber, + pageNumber, '', chooserNickname, domain, domainFull, @@ -2418,6 +2492,7 @@ class PubServer(BaseHTTPRequestHandler): if isModerator(self.server.baseDir, chooserNickname): if debug: print('Showing info for ' + optionsActor) + signingPrivateKeyPem = self.server.signingPrivateKeyPem msg = \ htmlAccountInfo(self.server.cssCache, self.server.translate, @@ -2428,7 +2503,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.port, optionsActor, self.server.debug, - self.server.systemLanguage).encode('utf-8') + self.server.systemLanguage, + signingPrivateKeyPem).encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, cookie, callingDomain, False) @@ -2508,7 +2584,7 @@ class PubServer(BaseHTTPRequestHandler): baseDir, httpPrefix, reportPath, None, [], - None, postUrl, pageNumber, + None, postUrl, pageNumber, '', chooserNickname, domain, domainFull, @@ -2687,6 +2763,8 @@ class PubServer(BaseHTTPRequestHandler): else: print('Sending follow request from ' + followerNickname + ' to ' + followingActor) + if not self.server.signingPrivateKeyPem: + print('Sending follow request with no signing key') sendFollowRequest(self.server.session, baseDir, followerNickname, domain, port, @@ -2699,9 +2777,9 @@ class PubServer(BaseHTTPRequestHandler): self.server.sendThreads, self.server.postLog, self.server.cachedWebfingers, - self.server.personCache, - debug, - self.server.projectVersion) + self.server.personCache, debug, + self.server.projectVersion, + self.server.signingPrivateKeyPem) if callingDomain.endswith('.onion') and onionDomain: originPathStr = 'http://' + onionDomain + usersPath elif (callingDomain.endswith('.i2p') and i2pDomain): @@ -2955,12 +3033,14 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, self.server.projectVersion, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) if hashtagStr: msg = hashtagStr.encode('utf-8') msglen = len(msg) @@ -3011,12 +3091,14 @@ class PubServer(BaseHTTPRequestHandler): self.server.personCache, port, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, 'outbox', self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) if historyStr: msg = historyStr.encode('utf-8') msglen = len(msg) @@ -3047,12 +3129,14 @@ class PubServer(BaseHTTPRequestHandler): self.server.personCache, port, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, 'bookmarks', self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) if bookmarksStr: msg = bookmarksStr.encode('utf-8') msglen = len(msg) @@ -3104,7 +3188,8 @@ class PubServer(BaseHTTPRequestHandler): baseDir, httpPrefix, actor, self.server.personCache, - None, True) + None, True, + self.server.signingPrivateKeyPem) profilePathStr += \ '?options=' + actor + ';1;' + avatarUrl @@ -3124,6 +3209,10 @@ class PubServer(BaseHTTPRequestHandler): if self.server.keyShortcuts.get(nickname): accessKeys = self.server.keyShortcuts[nickname] + signingPrivateKeyPem = \ + self.server.signingPrivateKeyPem + twitterReplacementDomain = \ + self.server.twitterReplacementDomain profileStr = \ htmlProfileAfterSearch(self.server.cssCache, self.server.recentPostsCache, @@ -3142,6 +3231,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.debug, self.server.projectVersion, self.server.YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, self.server.defaultTimeline, self.server.peertubeInstances, @@ -3149,7 +3239,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.themeName, accessKeys, self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + signingPrivateKeyPem) if profileStr: msg = profileStr.encode('utf-8') msglen = len(msg) @@ -3692,7 +3783,10 @@ class PubServer(BaseHTTPRequestHandler): linksFile.write(linksStr) else: if os.path.isfile(linksFilename): - os.remove(linksFilename) + try: + os.remove(linksFilename) + except BaseException: + pass adminNickname = \ getConfigParam(baseDir, 'admin') @@ -3705,7 +3799,10 @@ class PubServer(BaseHTTPRequestHandler): aboutFile.write(aboutStr) else: if os.path.isfile(aboutFilename): - os.remove(aboutFilename) + try: + os.remove(aboutFilename) + except BaseException: + pass if fields.get('editedTOS'): TOSStr = fields['editedTOS'] @@ -3715,7 +3812,10 @@ class PubServer(BaseHTTPRequestHandler): TOSFile.write(TOSStr) else: if os.path.isfile(TOSFilename): - os.remove(TOSFilename) + try: + os.remove(TOSFilename) + except BaseException: + pass # redirect back to the default timeline self._redirect_headers(actorStr + '/' + defaultTimeline, @@ -3813,7 +3913,10 @@ class PubServer(BaseHTTPRequestHandler): else: categoryFilename = baseDir + '/tags/' + hashtag + '.category' if os.path.isfile(categoryFilename): - os.remove(categoryFilename) + try: + os.remove(categoryFilename) + except BaseException: + pass # redirect back to the default timeline self._redirect_headers(tagScreenStr, @@ -3891,7 +3994,10 @@ class PubServer(BaseHTTPRequestHandler): newswireFile.write(newswireStr) else: if os.path.isfile(newswireFilename): - os.remove(newswireFilename) + try: + os.remove(newswireFilename) + except BaseException: + pass # save filtered words list for the newswire filterNewswireFilename = \ @@ -3902,7 +4008,10 @@ class PubServer(BaseHTTPRequestHandler): filterfile.write(fields['filteredWordsNewswire']) else: if os.path.isfile(filterNewswireFilename): - os.remove(filterNewswireFilename) + try: + os.remove(filterNewswireFilename) + except BaseException: + pass # save news tagging rules hashtagRulesFilename = \ @@ -3912,7 +4021,10 @@ class PubServer(BaseHTTPRequestHandler): rulesfile.write(fields['hashtagRulesList']) else: if os.path.isfile(hashtagRulesFilename): - os.remove(hashtagRulesFilename) + try: + os.remove(hashtagRulesFilename) + except BaseException: + pass newswireTrustedFilename = baseDir + '/accounts/newswiretrusted.txt' if fields.get('trustedNewswire'): @@ -3923,7 +4035,10 @@ class PubServer(BaseHTTPRequestHandler): trustFile.write(newswireTrusted) else: if os.path.isfile(newswireTrustedFilename): - os.remove(newswireTrustedFilename) + try: + os.remove(newswireTrustedFilename) + except BaseException: + pass # redirect back to the default timeline self._redirect_headers(actorStr + '/' + defaultTimeline, @@ -3948,7 +4063,10 @@ class PubServer(BaseHTTPRequestHandler): acctDir(baseDir, nickname, domain) + '/.citations.txt' # remove any existing citations file if os.path.isfile(citationsFilename): - os.remove(citationsFilename) + try: + os.remove(citationsFilename) + except BaseException: + pass if newswire and \ ' boundary=' in self.headers['Content-type']: @@ -4130,8 +4248,8 @@ class PubServer(BaseHTTPRequestHandler): print('ERROR: saving newswire state, ' + str(e)) # remove any previous cached news posts - newsId = \ - postJsonObject['object']['id'].replace('/', '#') + newsId = removeIdEnding(postJsonObject['object']['id']) + newsId = newsId.replace('/', '#') clearFromPostCaches(baseDir, self.server.recentPostsCache, newsId) @@ -4250,7 +4368,10 @@ class PubServer(BaseHTTPRequestHandler): filenameBase = \ baseDir + '/imports/newtheme.zip' if os.path.isfile(filenameBase): - os.remove(filenameBase) + try: + os.remove(filenameBase) + except BaseException: + pass else: filenameBase = \ acctDir(baseDir, nickname, domain) + \ @@ -4442,6 +4563,12 @@ class PubServer(BaseHTTPRequestHandler): storeBasicCredentials(baseDir, nickname, fields['password']) + # reply interval in hours + if fields.get('replyhours'): + if fields['replyhours'].isdigit(): + setReplyIntervalHours(baseDir, nickname, domain, + fields['replyhours']) + # change city if fields.get('cityDropdown'): cityFilename = \ @@ -4611,6 +4738,29 @@ class PubServer(BaseHTTPRequestHandler): 'youtubedomain', '') self.server.YTReplacementDomain = None + # change twitter alternate domain + if fields.get('twitterdomain'): + currTwitterDomain = \ + self.server.twitterReplacementDomain + if fields['twitterdomain'] != currTwitterDomain: + newTwitterDomain = fields['twitterdomain'] + if '://' in newTwitterDomain: + newTwitterDomain = \ + newTwitterDomain.split('://')[1] + if '/' in newTwitterDomain: + newTwitterDomain = \ + newTwitterDomain.split('/')[0] + if '.' in newTwitterDomain: + setConfigParam(baseDir, + 'twitterdomain', + newTwitterDomain) + self.server.twitterReplacementDomain = \ + newTwitterDomain + else: + setConfigParam(baseDir, + 'twitterdomain', '') + self.server.twitterReplacementDomain = None + # change custom post submit button text currCustomSubmitText = \ getConfigParam(baseDir, 'customSubmitText') @@ -5287,14 +5437,20 @@ class PubServer(BaseHTTPRequestHandler): for ext in fontExt: if os.path.isfile(baseDir + '/fonts/custom.' + ext): - os.remove(baseDir + - '/fonts/custom.' + ext) + try: + os.remove(baseDir + + '/fonts/custom.' + ext) + except BaseException: + pass if os.path.isfile(baseDir + '/fonts/custom.' + ext + '.etag'): - os.remove(baseDir + - '/fonts/custom.' + ext + - '.etag') + try: + os.remove(baseDir + + '/fonts/custom.' + ext + + '.etag') + except BaseException: + pass currTheme = getTheme(baseDir) if currTheme: self.server.themeName = currTheme @@ -5342,7 +5498,10 @@ class PubServer(BaseHTTPRequestHandler): fFile.write('\n') if not followDMsActive: if os.path.isfile(followDMsFilename): - os.remove(followDMsFilename) + try: + os.remove(followDMsFilename) + except BaseException: + pass # remove Twitter retweets removeTwitterFilename = \ @@ -5357,7 +5516,10 @@ class PubServer(BaseHTTPRequestHandler): rFile.write('\n') if not removeTwitterActive: if os.path.isfile(removeTwitterFilename): - os.remove(removeTwitterFilename) + try: + os.remove(removeTwitterFilename) + except BaseException: + pass # hide Like button hideLikeButtonFile = \ @@ -5374,10 +5536,16 @@ class PubServer(BaseHTTPRequestHandler): rFile.write('\n') # remove notify likes selection if os.path.isfile(notifyLikesFilename): - os.remove(notifyLikesFilename) + try: + os.remove(notifyLikesFilename) + except BaseException: + pass if not hideLikeButtonActive: if os.path.isfile(hideLikeButtonFile): - os.remove(hideLikeButtonFile) + try: + os.remove(hideLikeButtonFile) + except BaseException: + pass # notify about new Likes if onFinalWelcomeScreen: @@ -5395,7 +5563,10 @@ class PubServer(BaseHTTPRequestHandler): rFile.write('\n') if not notifyLikesActive: if os.path.isfile(notifyLikesFilename): - os.remove(notifyLikesFilename) + try: + os.remove(notifyLikesFilename) + except BaseException: + pass # this account is a bot if fields.get('isBot'): @@ -5454,7 +5625,10 @@ class PubServer(BaseHTTPRequestHandler): filterfile.write(fields['filteredWords']) else: if os.path.isfile(filterFilename): - os.remove(filterFilename) + try: + os.remove(filterFilename) + except BaseException: + pass # word replacements switchFilename = \ @@ -5465,7 +5639,10 @@ class PubServer(BaseHTTPRequestHandler): switchfile.write(fields['switchWords']) else: if os.path.isfile(switchFilename): - os.remove(switchFilename) + try: + os.remove(switchFilename) + except BaseException: + pass # autogenerated tags autoTagsFilename = \ @@ -5476,7 +5653,10 @@ class PubServer(BaseHTTPRequestHandler): autoTagsFile.write(fields['autoTags']) else: if os.path.isfile(autoTagsFilename): - os.remove(autoTagsFilename) + try: + os.remove(autoTagsFilename) + except BaseException: + pass # autogenerated content warnings autoCWFilename = \ @@ -5487,7 +5667,10 @@ class PubServer(BaseHTTPRequestHandler): autoCWFile.write(fields['autoCW']) else: if os.path.isfile(autoCWFilename): - os.remove(autoCWFilename) + try: + os.remove(autoCWFilename) + except BaseException: + pass # save blocked accounts list blockedFilename = \ @@ -5498,7 +5681,10 @@ class PubServer(BaseHTTPRequestHandler): blockedfile.write(fields['blocked']) else: if os.path.isfile(blockedFilename): - os.remove(blockedFilename) + try: + os.remove(blockedFilename) + except BaseException: + pass # Save DM allowed instances list. # The allow list for incoming DMs, @@ -5511,7 +5697,10 @@ class PubServer(BaseHTTPRequestHandler): aFile.write(fields['dmAllowedInstances']) else: if os.path.isfile(dmAllowedInstancesFilename): - os.remove(dmAllowedInstancesFilename) + try: + os.remove(dmAllowedInstancesFilename) + except BaseException: + pass # save allowed instances list # This is the account level allow list @@ -5523,7 +5712,10 @@ class PubServer(BaseHTTPRequestHandler): aFile.write(fields['allowedInstances']) else: if os.path.isfile(allowedInstancesFilename): - os.remove(allowedInstancesFilename) + try: + os.remove(allowedInstancesFilename) + except BaseException: + pass # save blocked user agents # This is admin lebel and global to the instance @@ -5568,7 +5760,10 @@ class PubServer(BaseHTTPRequestHandler): self.server.peertubeInstances.append(url) else: if os.path.isfile(peertubeInstancesFile): - os.remove(peertubeInstancesFile) + try: + os.remove(peertubeInstancesFile) + except BaseException: + pass self.server.peertubeInstances.clear() # save git project names list @@ -5580,7 +5775,10 @@ class PubServer(BaseHTTPRequestHandler): aFile.write(fields['gitProjects'].lower()) else: if os.path.isfile(gitProjectsFilename): - os.remove(gitProjectsFilename) + try: + os.remove(gitProjectsFilename) + except BaseException: + pass # save actor json file within accounts if actorChanged: @@ -5619,25 +5817,12 @@ class PubServer(BaseHTTPRequestHandler): actorJson['id'].replace('/', '#') + '.json' saveJson(actorJson, actorCacheFilename) # send profile update to followers - pubStr = 'https://www.w3.org/ns/' + \ - 'activitystreams#Public' pubNumber, pubDate = getStatusNumber() - pubContext = actorJson['@context'].copy() - # remove the context from the actor json and put it - # at the start of the Upgrade activity - del actorJson['@context'] - updateActorJson = { - '@context': pubContext, - 'id': actorJson['id'] + '#updates/' + pubNumber, - 'type': 'Update', - 'actor': actorJson['id'], - 'to': [pubStr], - 'cc': [actorJson['id'] + '/followers'], - 'object': actorJson - } + updateActorJson = getActorUpdateJson(actorJson) print('Sending actor update: ' + str(updateActorJson)) self._postToOutbox(updateActorJson, - __version__, nickname) + self.server.projectVersion, + nickname) # deactivate the account if fields.get('deactivateThisAccount'): @@ -6184,6 +6369,7 @@ class PubServer(BaseHTTPRequestHandler): optionsLink = None if len(optionsList) > 3: optionsLink = optionsList[3] + isGroup = False donateUrl = None websiteUrl = None PGPpubKey = None @@ -6207,6 +6393,10 @@ class PubServer(BaseHTTPRequestHandler): if actorJson: if actorJson.get('movedTo'): movedTo = actorJson['movedTo'] + if '"' in movedTo: + movedTo = movedTo.split('"')[1] + if actorJson['type'] == 'Group': + isGroup = True lockedAccount = getLockedAccount(actorJson) donateUrl = getDonationUrl(actorJson) websiteUrl = getWebsite(actorJson, self.server.translate) @@ -6262,7 +6452,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.textModeBanner, self.server.newsInstance, authorized, - accessKeys).encode('utf-8') + accessKeys, isGroup).encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, cookie, callingDomain, False) @@ -6316,7 +6506,7 @@ class PubServer(BaseHTTPRequestHandler): mediaBinary = avFile.read() self._set_headers_etag(mediaFilename, mediaFileType, mediaBinary, None, - callingDomain, True, + None, True, lastModifiedTimeStr) self._write(mediaBinary) self._benchmarkGETtimings(GETstartTime, GETtimings, @@ -6325,6 +6515,51 @@ class PubServer(BaseHTTPRequestHandler): return self._404() + def _getOntology(self, callingDomain: str, + path: str, baseDir: str, + GETstartTime, GETtimings: {}) -> None: + """Returns an ontology file + """ + if '.owl' in path or '.rdf' in path or '.json' in path: + if '/ontologies/' in path: + ontologyStr = path.split('/ontologies/')[1].replace('#', '') + else: + ontologyStr = path.split('/data/')[1].replace('#', '') + ontologyFilename = None + ontologyFileType = 'application/rdf+xml' + if ontologyStr.startswith('DFC_'): + ontologyFilename = baseDir + '/ontology/DFC/' + ontologyStr + else: + ontologyStr = ontologyStr.replace('/data/', '') + ontologyFilename = baseDir + '/ontology/' + ontologyStr + if ontologyStr.endswith('.json'): + ontologyFileType = 'application/ld+json' + if os.path.isfile(ontologyFilename): + ontologyFile = None + with open(ontologyFilename, 'r') as fp: + ontologyFile = fp.read() + if ontologyFile: + ontologyFile = \ + ontologyFile.replace('static.datafoodconsortium.org', + callingDomain) + if not callingDomain.endswith('.i2p') and \ + not callingDomain.endswith('.onion'): + ontologyFile = \ + ontologyFile.replace('http://' + + callingDomain, + 'https://' + + callingDomain) + msg = ontologyFile.encode('utf-8') + msglen = len(msg) + self._set_headers(ontologyFileType, msglen, + None, callingDomain, False) + self._write(msg) + self._benchmarkGETtimings(GETstartTime, GETtimings, + 'show emoji done', + 'get onotology') + return + self._404() + def _showEmoji(self, callingDomain: str, path: str, baseDir: str, GETstartTime, GETtimings: {}) -> None: @@ -6447,7 +6682,7 @@ class PubServer(BaseHTTPRequestHandler): return self._404() - def _showCachedAvatar(self, callingDomain: str, path: str, + def _showCachedAvatar(self, refererDomain: str, path: str, baseDir: str, GETstartTime, GETtimings: {}) -> None: """Shows an avatar image obtained from the cache @@ -6464,7 +6699,7 @@ class PubServer(BaseHTTPRequestHandler): self._set_headers_etag(mediaFilename, mimeType, mediaBinary, None, - self.server.domainFull, + refererDomain, False, None) self._write(mediaBinary) self._benchmarkGETtimings(GETstartTime, GETtimings, @@ -6521,12 +6756,14 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, self.server.projectVersion, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) if hashtagStr: msg = hashtagStr.encode('utf-8') msglen = len(msg) @@ -6582,6 +6819,7 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, self.server.projectVersion, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.systemLanguage) if hashtagStr: msg = hashtagStr.encode('utf-8') @@ -6681,12 +6919,68 @@ class PubServer(BaseHTTPRequestHandler): self.server.personCache, self.server.cachedWebfingers, debug, - self.server.projectVersion) + self.server.projectVersion, + self.server.signingPrivateKeyPem) + announceFilename = None if announceJson: + # save the announce straight to the outbox + # This is because the subsequent send is within a separate thread + # but the html still needs to be generated before this call ends + announceId = removeIdEnding(announceJson['id']) + announceFilename = \ + savePostToBox(baseDir, httpPrefix, announceId, + self.postToNickname, domainFull, + announceJson, 'outbox') + # clear the icon from the cache so that it gets updated if self.server.iconsCache.get('repeat.png'): del self.server.iconsCache['repeat.png'] + + # send out the announce within a separate thread self._postToOutboxThread(announceJson) + + # generate the html for the announce + if announceJson and announceFilename: + print('Generating html post for announce') + cachedPostFilename = \ + getCachedPostFilename(baseDir, self.postToNickname, + domain, announceJson) + print('Announced post json: ' + str(announceJson)) + print('Announced post nickname: ' + + self.postToNickname + ' ' + domain) + print('Announced post cache: ' + str(cachedPostFilename)) + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + self.postToNickname, domain) + showRepeats = not isDM(announceJson) + individualPostAsHtml(self.server.signingPrivateKeyPem, False, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, domain, + self.server.port, announceJson, + None, True, + self.server.allowDeletion, + httpPrefix, self.server.projectVersion, + timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) + self.server.GETbusy = False actorAbsolute = self._getInstalceUrl(callingDomain) + actor actorPathStr = \ @@ -6842,7 +7136,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers, self.server.personCache, debug, - self.server.projectVersion) + self.server.projectVersion, + self.server.signingPrivateKeyPem) originPathStrAbsolute = \ httpPrefix + '://' + domainFull + originPathStr if callingDomain.endswith('.onion') and onionDomain: @@ -6999,7 +7294,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers, self.server.personCache, debug, - self.server.projectVersion) + self.server.projectVersion, + self.server.signingPrivateKeyPem) originPathStrAbsolute = \ httpPrefix + '://' + domainFull + originPathStr if callingDomain.endswith('.onion') and onionDomain: @@ -7080,28 +7376,75 @@ class PubServer(BaseHTTPRequestHandler): 'to': [actorLiked], 'object': likeUrl } + + # send out the like to followers + self._postToOutbox(likeJson, self.server.projectVersion, None) + + print('Locating liked post ' + likeUrl) # directly like the post file likedPostFilename = locatePost(baseDir, self.postToNickname, domain, likeUrl) if likedPostFilename: - if debug: - print('Updating likes for ' + likedPostFilename) + # if debug: + print('Updating likes for ' + likedPostFilename) updateLikesCollection(self.server.recentPostsCache, - baseDir, - likedPostFilename, likeUrl, - likeActor, - self.postToNickname, domain, + baseDir, likedPostFilename, likeUrl, + likeActor, self.postToNickname, domain, debug) + # if debug: + print('Regenerating html post for changed likes collection') + likedPostJson = loadJson(likedPostFilename, 0, 1) + if likedPostJson: + cachedPostFilename = \ + getCachedPostFilename(baseDir, self.postToNickname, + domain, likedPostJson) + print('Liked post json: ' + str(likedPostJson)) + print('Liked post nickname: ' + + self.postToNickname + ' ' + domain) + print('Liked post cache: ' + str(cachedPostFilename)) + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + self.postToNickname, domain) + showRepeats = not isDM(likedPostJson) + individualPostAsHtml(self.server.signingPrivateKeyPem, False, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, domain, + self.server.port, likedPostJson, + None, True, + self.server.allowDeletion, + httpPrefix, + self.server.projectVersion, + timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) + else: + print('WARN: Liked post not found: ' + likedPostFilename) # clear the icon from the cache so that it gets updated if self.server.iconsCache.get('like.png'): del self.server.iconsCache['like.png'] else: print('WARN: unable to locate file for liked post ' + likeUrl) - # send out the like to followers - self._postToOutbox(likeJson, self.server.projectVersion) + self.server.GETbusy = False actorAbsolute = self._getInstalceUrl(callingDomain) + actor actorPathStr = \ @@ -7183,6 +7526,10 @@ class PubServer(BaseHTTPRequestHandler): 'object': likeUrl } } + + # send out the undo like to followers + self._postToOutbox(undoLikeJson, self.server.projectVersion, None) + # directly undo the like within the post file likedPostFilename = locatePost(baseDir, self.postToNickname, @@ -7197,8 +7544,44 @@ class PubServer(BaseHTTPRequestHandler): # clear the icon from the cache so that it gets updated if self.server.iconsCache.get('like_inactive.png'): del self.server.iconsCache['like_inactive.png'] - # send out the undo like to followers - self._postToOutbox(undoLikeJson, self.server.projectVersion) + if debug: + print('Regenerating html post for changed likes collection') + likedPostJson = loadJson(likedPostFilename, 0, 1) + if likedPostJson: + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + self.postToNickname, domain) + showRepeats = not isDM(likedPostJson) + individualPostAsHtml(self.server.signingPrivateKeyPem, False, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, domain, + self.server.port, likedPostJson, + None, True, + self.server.allowDeletion, + httpPrefix, + self.server.projectVersion, timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) + else: + print('WARN: Unliked post not found: ' + likedPostFilename) + self.server.GETbusy = False actorAbsolute = self._getInstalceUrl(callingDomain) + actor actorPathStr = \ @@ -7285,7 +7668,53 @@ class PubServer(BaseHTTPRequestHandler): # clear the icon from the cache so that it gets updated if self.server.iconsCache.get('bookmark.png'): del self.server.iconsCache['bookmark.png'] - # self._postToOutbox(bookmarkJson, self.server.projectVersion) + bookmarkFilename = \ + locatePost(baseDir, self.postToNickname, domain, bookmarkUrl) + if bookmarkFilename: + print('Regenerating html post for changed bookmark') + bookmarkPostJson = loadJson(bookmarkFilename, 0, 1) + if bookmarkPostJson: + cachedPostFilename = \ + getCachedPostFilename(baseDir, self.postToNickname, + domain, bookmarkPostJson) + print('Bookmarked post json: ' + str(bookmarkPostJson)) + print('Bookmarked post nickname: ' + + self.postToNickname + ' ' + domain) + print('Bookmarked post cache: ' + str(cachedPostFilename)) + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + self.postToNickname, domain) + showRepeats = not isDM(bookmarkPostJson) + individualPostAsHtml(self.server.signingPrivateKeyPem, False, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, domain, + self.server.port, bookmarkPostJson, + None, True, + self.server.allowDeletion, + httpPrefix, self.server.projectVersion, + timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) + else: + print('WARN: Bookmarked post not found: ' + bookmarkFilename) + # self._postToOutbox(bookmarkJson, self.server.projectVersion, None) self.server.GETbusy = False actorAbsolute = self._getInstalceUrl(callingDomain) + actor actorPathStr = \ @@ -7371,7 +7800,54 @@ class PubServer(BaseHTTPRequestHandler): # clear the icon from the cache so that it gets updated if self.server.iconsCache.get('bookmark_inactive.png'): del self.server.iconsCache['bookmark_inactive.png'] - # self._postToOutbox(undoBookmarkJson, self.server.projectVersion) + # self._postToOutbox(undoBookmarkJson, + # self.server.projectVersion, None) + bookmarkFilename = \ + locatePost(baseDir, self.postToNickname, domain, bookmarkUrl) + if bookmarkFilename: + print('Regenerating html post for changed unbookmark') + bookmarkPostJson = loadJson(bookmarkFilename, 0, 1) + if bookmarkPostJson: + cachedPostFilename = \ + getCachedPostFilename(baseDir, self.postToNickname, + domain, bookmarkPostJson) + print('Unbookmarked post json: ' + str(bookmarkPostJson)) + print('Unbookmarked post nickname: ' + + self.postToNickname + ' ' + domain) + print('Unbookmarked post cache: ' + str(cachedPostFilename)) + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + self.postToNickname, domain) + showRepeats = not isDM(bookmarkPostJson) + individualPostAsHtml(self.server.signingPrivateKeyPem, False, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + self.postToNickname, domain, + self.server.port, bookmarkPostJson, + None, True, + self.server.allowDeletion, + httpPrefix, + self.server.projectVersion, timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) + else: + print('WARN: Unbookmarked post not found: ' + bookmarkFilename) self.server.GETbusy = False actorAbsolute = self._getInstalceUrl(callingDomain) + actor actorPathStr = \ @@ -7460,15 +7936,18 @@ class PubServer(BaseHTTPRequestHandler): self.server.translate, pageNumber, self.server.session, baseDir, deleteUrl, httpPrefix, - __version__, self.server.cachedWebfingers, + self.server.projectVersion, + self.server.cachedWebfingers, self.server.personCache, callingDomain, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) if deleteStr: deleteStrLen = len(deleteStr) self._set_headers('text/html', deleteStrLen, @@ -7510,12 +7989,76 @@ class PubServer(BaseHTTPRequestHandler): timelineStr = path.split('?tl=')[1] if '?' in timelineStr: timelineStr = timelineStr.split('?')[0] + pageNumber = 1 + if '?page=' in path: + pageNumberStr = path.split('?page=')[1] + if '?' in pageNumberStr: + pageNumberStr = pageNumberStr.split('?')[0] + if '#' in pageNumberStr: + pageNumberStr = pageNumberStr.split('#')[0] + if pageNumberStr.isdigit(): + pageNumber = int(pageNumberStr) actor = \ httpPrefix + '://' + domainFull + path.split('?mute=')[0] nickname = getNicknameFromActor(actor) mutePost(baseDir, nickname, domain, port, httpPrefix, muteUrl, self.server.recentPostsCache, debug) + muteFilename = \ + locatePost(baseDir, nickname, domain, muteUrl) + if muteFilename: + print('mutePost: Regenerating html post for changed mute status') + mutePostJson = loadJson(muteFilename, 0, 1) + if mutePostJson: + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, + domain, mutePostJson) + print('mutePost: Muted post json: ' + str(mutePostJson)) + print('mutePost: Muted post nickname: ' + + nickname + ' ' + domain) + print('mutePost: Muted post cache: ' + str(cachedPostFilename)) + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, + nickname, domain) + showRepeats = not isDM(mutePostJson) + showPublicOnly = False + storeToCache = True + useCacheOnly = False + allowDownloads = False + showAvatarOptions = True + avatarUrl = None + individualPostAsHtml(self.server.signingPrivateKeyPem, + allowDownloads, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + nickname, domain, + self.server.port, mutePostJson, + avatarUrl, showAvatarOptions, + self.server.allowDeletion, + httpPrefix, + self.server.projectVersion, timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + showPublicOnly, storeToCache, + useCacheOnly) + else: + print('WARN: Muted post not found: ' + muteFilename) + self.server.GETbusy = False if callingDomain.endswith('.onion') and onionDomain: actor = \ @@ -7555,12 +8098,76 @@ class PubServer(BaseHTTPRequestHandler): timelineStr = path.split('?tl=')[1] if '?' in timelineStr: timelineStr = timelineStr.split('?')[0] + pageNumber = 1 + if '?page=' in path: + pageNumberStr = path.split('?page=')[1] + if '?' in pageNumberStr: + pageNumberStr = pageNumberStr.split('?')[0] + if '#' in pageNumberStr: + pageNumberStr = pageNumberStr.split('#')[0] + if pageNumberStr.isdigit(): + pageNumber = int(pageNumberStr) actor = \ httpPrefix + '://' + domainFull + path.split('?unmute=')[0] nickname = getNicknameFromActor(actor) unmutePost(baseDir, nickname, domain, port, httpPrefix, muteUrl, self.server.recentPostsCache, debug) + muteFilename = \ + locatePost(baseDir, nickname, domain, muteUrl) + if muteFilename: + print('unmutePost: ' + + 'Regenerating html post for changed unmute status') + mutePostJson = loadJson(muteFilename, 0, 1) + if mutePostJson: + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, + domain, mutePostJson) + print('unmutePost: Unmuted post json: ' + str(mutePostJson)) + print('unmutePost: Unmuted post nickname: ' + + nickname + ' ' + domain) + print('unmutePost: Unmuted post cache: ' + + str(cachedPostFilename)) + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, nickname, domain) + showRepeats = not isDM(mutePostJson) + showPublicOnly = False + storeToCache = True + useCacheOnly = False + allowDownloads = False + showAvatarOptions = True + avatarUrl = None + individualPostAsHtml(self.server.signingPrivateKeyPem, + allowDownloads, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, baseDir, + self.server.session, + self.server.cachedWebfingers, + self.server.personCache, + nickname, domain, + self.server.port, mutePostJson, + avatarUrl, showAvatarOptions, + self.server.allowDeletion, + httpPrefix, + self.server.projectVersion, timelineStr, + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, + self.server.showPublishedDateOnly, + self.server.peertubeInstances, + self.server.allowLocalNetworkAccess, + self.server.themeName, + self.server.systemLanguage, + self.server.maxLikeCount, + showRepeats, + showIndividualPostIcons, + manuallyApproveFollowers, + showPublicOnly, storeToCache, + useCacheOnly) + else: + print('WARN: Unmuted post not found: ' + muteFilename) self.server.GETbusy = False if callingDomain.endswith('.onion') and onionDomain: actor = \ @@ -7656,6 +8263,8 @@ class PubServer(BaseHTTPRequestHandler): personCache = self.server.personCache projectVersion = self.server.projectVersion ytDomain = self.server.YTReplacementDomain + twitterReplacementDomain = \ + self.server.twitterReplacementDomain peertubeInstances = self.server.peertubeInstances msg = \ htmlPostReplies(self.server.cssCache, @@ -7673,19 +8282,21 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, projectVersion, ytDomain, + twitterReplacementDomain, self.server.showPublishedDateOnly, peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, cookie, callingDomain, False) self._write(msg) else: - if self._fetchAuthenticated(): + if self._secureMode(): msg = json.dumps(repliesJson, ensure_ascii=False) msg = msg.encode('utf-8') protocolStr = 'application/json' @@ -7745,6 +8356,8 @@ class PubServer(BaseHTTPRequestHandler): personCache = self.server.personCache projectVersion = self.server.projectVersion ytDomain = self.server.YTReplacementDomain + twitterReplacementDomain = \ + self.server.twitterReplacementDomain peertubeInstances = self.server.peertubeInstances msg = \ htmlPostReplies(self.server.cssCache, @@ -7762,12 +8375,14 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, projectVersion, ytDomain, + twitterReplacementDomain, self.server.showPublishedDateOnly, peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -7778,7 +8393,7 @@ class PubServer(BaseHTTPRequestHandler): 'individual post done', 'post replies done') else: - if self._fetchAuthenticated(): + if self._secureMode(): msg = json.dumps(repliesJson, ensure_ascii=False) msg = msg.encode('utf-8') @@ -7831,6 +8446,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers YTReplacementDomain = \ self.server.YTReplacementDomain + twitterReplacementDomain = \ + self.server.twitterReplacementDomain iconsAsButtons = \ self.server.iconsAsButtons @@ -7843,7 +8460,8 @@ class PubServer(BaseHTTPRequestHandler): getSpoofedCity(self.server.city, baseDir, nickname, domain) msg = \ - htmlProfile(self.server.rssIconAtTop, + htmlProfile(self.server.signingPrivateKeyPem, + self.server.rssIconAtTop, self.server.cssCache, iconsAsButtons, defaultTimeline, @@ -7857,6 +8475,7 @@ class PubServer(BaseHTTPRequestHandler): cachedWebfingers, self.server.personCache, YTReplacementDomain, + twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.themeName, @@ -7880,7 +8499,7 @@ class PubServer(BaseHTTPRequestHandler): 'post replies done', 'show roles') else: - if self._fetchAuthenticated(): + if self._secureMode(): rolesList = getActorRolesList(actorJson) msg = json.dumps(rolesList, ensure_ascii=False) @@ -7928,6 +8547,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers YTReplacementDomain = \ self.server.YTReplacementDomain + twitterReplacementDomain = \ + self.server.twitterReplacementDomain showPublishedDateOnly = \ self.server.showPublishedDateOnly iconsAsButtons = \ @@ -7946,8 +8567,11 @@ class PubServer(BaseHTTPRequestHandler): nickname, domain) sharedItemsFederatedDomains = \ self.server.sharedItemsFederatedDomains + signingPrivateKeyPem = \ + self.server.signingPrivateKeyPem msg = \ - htmlProfile(self.server.rssIconAtTop, + htmlProfile(signingPrivateKeyPem, + self.server.rssIconAtTop, self.server.cssCache, iconsAsButtons, defaultTimeline, @@ -7961,6 +8585,7 @@ class PubServer(BaseHTTPRequestHandler): cachedWebfingers, self.server.personCache, YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, self.server.newswire, self.server.themeName, @@ -7985,7 +8610,7 @@ class PubServer(BaseHTTPRequestHandler): 'post roles done', 'show skills') else: - if self._fetchAuthenticated(): + if self._secureMode(): actorSkillsList = \ getOccupationSkills(actorJson) skills = getSkillsFromList(actorSkillsList) @@ -8103,12 +8728,14 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, likedBy, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.peertubeInstances, self.server.allowLocalNetworkAccess, self.server.themeName, self.server.systemLanguage, - self.server.maxLikeCount) + self.server.maxLikeCount, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -8120,7 +8747,7 @@ class PubServer(BaseHTTPRequestHandler): 'done', 'show status') else: - if self._fetchAuthenticated(): + if self._secureMode(): msg = json.dumps(postJsonObject, ensure_ascii=False) msg = msg.encode('utf-8') @@ -8222,7 +8849,8 @@ class PubServer(BaseHTTPRequestHandler): personCache: {}, allowDeletion: bool, projectVersion: str, - YTReplacementDomain: str) -> bool: + YTReplacementDomain: str, + twitterReplacementDomain: str) -> bool: """Shows the inbox timeline """ if '/users/' in path: @@ -8306,6 +8934,7 @@ class PubServer(BaseHTTPRequestHandler): projectVersion, minimalNick, YTReplacementDomain, + twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -8322,7 +8951,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) if GETstartTime: self._benchmarkGETtimings(GETstartTime, GETtimings, 'show status done', @@ -8340,7 +8970,7 @@ class PubServer(BaseHTTPRequestHandler): 'show status done', 'show inbox') else: - # don't need authenticated fetch here because + # don't need authorized fetch here because # there is already the authorization check msg = json.dumps(inboxFeed, ensure_ascii=False) msg = msg.encode('utf-8') @@ -8448,6 +9078,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -8463,7 +9094,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -8473,7 +9105,7 @@ class PubServer(BaseHTTPRequestHandler): 'show inbox done', 'show dms') else: - # don't need authenticated fetch here because + # don't need authorized fetch here because # there is already the authorization check msg = json.dumps(inboxDMFeed, ensure_ascii=False) msg = msg.encode('utf-8') @@ -8582,6 +9214,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -8597,7 +9230,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -8607,7 +9241,7 @@ class PubServer(BaseHTTPRequestHandler): 'show dms done', 'show replies 2') else: - # don't need authenticated fetch here because there is + # don't need authorized fetch here because there is # already the authorization check msg = json.dumps(inboxRepliesFeed, ensure_ascii=False) @@ -8714,6 +9348,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -8730,7 +9365,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - self.server.sharedItemsFederatedDomains) + self.server.sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -8740,7 +9376,7 @@ class PubServer(BaseHTTPRequestHandler): 'show replies 2 done', 'show media 2') else: - # don't need authenticated fetch here because there is + # don't need authorized fetch here because there is # already the authorization check msg = json.dumps(inboxMediaFeed, ensure_ascii=False) @@ -8847,6 +9483,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -8863,7 +9500,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - self.server.sharedItemsFederatedDomains) + self.server.sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -8873,7 +9511,7 @@ class PubServer(BaseHTTPRequestHandler): 'show media 2 done', 'show blogs 2') else: - # don't need authenticated fetch here because there is + # don't need authorized fetch here because there is # already the authorization check msg = json.dumps(inboxBlogsFeed, ensure_ascii=False) @@ -8988,6 +9626,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, moderator, editor, @@ -9005,7 +9644,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - self.server.sharedItemsFederatedDomains) + self.server.sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -9015,7 +9655,7 @@ class PubServer(BaseHTTPRequestHandler): 'show blogs 2 done', 'show news 2') else: - # don't need authenticated fetch here because there is + # don't need authorized fetch here because there is # already the authorization check msg = json.dumps(inboxNewsFeed, ensure_ascii=False) @@ -9129,6 +9769,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -9145,7 +9786,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -9155,7 +9797,7 @@ class PubServer(BaseHTTPRequestHandler): 'show blogs 2 done', 'show news 2') else: - # don't need authenticated fetch here because there is + # don't need authorized fetch here because there is # already the authorization check msg = json.dumps(inboxFeaturesFeed, ensure_ascii=False) @@ -9229,6 +9871,7 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, self.server.projectVersion, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -9244,7 +9887,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - self.server.sharedItemsFederatedDomains) + self.server.sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -9310,6 +9954,7 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, self.server.projectVersion, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -9325,7 +9970,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - self.server.sharedItemsFederatedDomains) + self.server.sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -9427,6 +10073,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -9443,7 +10090,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -9453,7 +10101,7 @@ class PubServer(BaseHTTPRequestHandler): 'show shares 2 done', 'show bookmarks 2') else: - # don't need authenticated fetch here because + # don't need authorized fetch here because # there is already the authorization check msg = json.dumps(bookmarksFeed, ensure_ascii=False) @@ -9557,6 +10205,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.projectVersion, minimalNick, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -9573,7 +10222,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - self.server.sharedItemsFederatedDomains) + self.server.sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -9583,7 +10233,7 @@ class PubServer(BaseHTTPRequestHandler): 'show events done', 'show outbox') else: - if self._fetchAuthenticated(): + if self._secureMode(): msg = json.dumps(outboxFeed, ensure_ascii=False) msg = msg.encode('utf-8') @@ -9658,6 +10308,8 @@ class PubServer(BaseHTTPRequestHandler): sharedItemsFederatedDomains = \ self.server.sharedItemsFederatedDomains + twitterReplacementDomain = \ + self.server.twitterReplacementDomain msg = \ htmlModeration(self.server.cssCache, self.server.defaultTimeline, @@ -9677,6 +10329,7 @@ class PubServer(BaseHTTPRequestHandler): httpPrefix, self.server.projectVersion, self.server.YTReplacementDomain, + twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.positiveVoting, @@ -9693,7 +10346,8 @@ class PubServer(BaseHTTPRequestHandler): accessKeys, self.server.systemLanguage, self.server.maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._set_headers('text/html', msglen, @@ -9703,7 +10357,7 @@ class PubServer(BaseHTTPRequestHandler): 'show outbox done', 'show moderation') else: - # don't need authenticated fetch here because + # don't need authorized fetch here because # there is already the authorization check msg = json.dumps(moderationFeed, ensure_ascii=False) @@ -9785,7 +10439,8 @@ class PubServer(BaseHTTPRequestHandler): city = getSpoofedCity(self.server.city, baseDir, nickname, domain) msg = \ - htmlProfile(self.server.rssIconAtTop, + htmlProfile(self.server.signingPrivateKeyPem, + self.server.rssIconAtTop, self.server.cssCache, self.server.iconsAsButtons, self.server.defaultTimeline, @@ -9800,6 +10455,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers, self.server.personCache, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.themeName, @@ -9825,7 +10481,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.GETbusy = False return True else: - if self._fetchAuthenticated(): + if self._secureMode(): msg = json.dumps(shares, ensure_ascii=False) msg = msg.encode('utf-8') @@ -9901,7 +10557,8 @@ class PubServer(BaseHTTPRequestHandler): city = getSpoofedCity(self.server.city, baseDir, nickname, domain) msg = \ - htmlProfile(self.server.rssIconAtTop, + htmlProfile(self.server.signingPrivateKeyPem, + self.server.rssIconAtTop, self.server.cssCache, self.server.iconsAsButtons, self.server.defaultTimeline, @@ -9916,6 +10573,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers, self.server.personCache, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.themeName, @@ -9941,7 +10599,7 @@ class PubServer(BaseHTTPRequestHandler): 'show profile 3') return True else: - if self._fetchAuthenticated(): + if self._secureMode(): msg = json.dumps(following, ensure_ascii=False).encode('utf-8') msglen = len(msg) @@ -10016,7 +10674,8 @@ class PubServer(BaseHTTPRequestHandler): city = getSpoofedCity(self.server.city, baseDir, nickname, domain) msg = \ - htmlProfile(self.server.rssIconAtTop, + htmlProfile(self.server.signingPrivateKeyPem, + self.server.rssIconAtTop, self.server.cssCache, self.server.iconsAsButtons, self.server.defaultTimeline, @@ -10032,6 +10691,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers, self.server.personCache, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.themeName, @@ -10057,7 +10717,7 @@ class PubServer(BaseHTTPRequestHandler): 'show profile 4') return True else: - if self._fetchAuthenticated(): + if self._secureMode(): msg = json.dumps(followers, ensure_ascii=False).encode('utf-8') msglen = len(msg) @@ -10155,7 +10815,8 @@ class PubServer(BaseHTTPRequestHandler): city = getSpoofedCity(self.server.city, baseDir, nickname, domain) msg = \ - htmlProfile(self.server.rssIconAtTop, + htmlProfile(self.server.signingPrivateKeyPem, + self.server.rssIconAtTop, self.server.cssCache, self.server.iconsAsButtons, self.server.defaultTimeline, @@ -10171,6 +10832,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.cachedWebfingers, self.server.personCache, self.server.YTReplacementDomain, + self.server.twitterReplacementDomain, self.server.showPublishedDateOnly, self.server.newswire, self.server.themeName, @@ -10192,7 +10854,7 @@ class PubServer(BaseHTTPRequestHandler): 'show profile 4 done', 'show profile posts') else: - if self._fetchAuthenticated(): + if self._secureMode(): acceptStr = self.headers['Accept'] msgStr = json.dumps(actorJson, ensure_ascii=False) msg = msgStr.encode('utf-8') @@ -10212,6 +10874,78 @@ class PubServer(BaseHTTPRequestHandler): self.server.GETbusy = False return True + def _showInstanceActor(self, callingDomain: str, path: str, + baseDir: str, httpPrefix: str, + domain: str, domainFull: str, port: int, + onionDomain: str, i2pDomain: str, + GETstartTime, GETtimings: {}, + proxyType: str, cookie: str, + debug: str, + enableSharedInbox: bool) -> bool: + """Shows the instance actor + """ + if debug: + print('Instance actor requested by ' + callingDomain) + if self._requestHTTP(): + self._404() + return False + actorJson = personLookup(domain, path, baseDir) + if not actorJson: + print('ERROR: no instance actor found') + self._404() + return False + acceptStr = self.headers['Accept'] + if onionDomain and callingDomain.endswith('.onion'): + actorDomainUrl = 'http://' + onionDomain + elif i2pDomain and callingDomain.endswith('.i2p'): + actorDomainUrl = 'http://' + i2pDomain + else: + actorDomainUrl = httpPrefix + '://' + domainFull + actorUrl = actorDomainUrl + '/users/Actor' + removeFields = ('icon', 'image', 'tts', 'shares', + 'alsoKnownAs', 'hasOccupation', 'featured', + 'featuredTags', 'discoverable', 'published', + 'devices') + for r in removeFields: + if r in actorJson: + del actorJson[r] + actorJson['endpoints'] = {} + if enableSharedInbox: + actorJson['endpoints'] = { + 'sharedInbox': actorDomainUrl + '/inbox' + } + actorJson['name'] = 'ACTOR' + actorJson['preferredUsername'] = domainFull + actorJson['id'] = actorDomainUrl + '/actor' + actorJson['type'] = 'Application' + actorJson['summary'] = 'Instance Actor' + actorJson['publicKey']['id'] = actorDomainUrl + '/actor#main-key' + actorJson['publicKey']['owner'] = actorDomainUrl + '/actor' + actorJson['url'] = actorDomainUrl + '/actor' + actorJson['inbox'] = actorUrl + '/inbox' + actorJson['followers'] = actorUrl + '/followers' + actorJson['following'] = actorUrl + '/following' + msgStr = json.dumps(actorJson, ensure_ascii=False) + if onionDomain and callingDomain.endswith('.onion'): + msgStr = msgStr.replace(httpPrefix + '://' + domainFull, + 'http://' + onionDomain) + elif i2pDomain and callingDomain.endswith('.i2p'): + msgStr = msgStr.replace(httpPrefix + '://' + domainFull, + 'http://' + i2pDomain) + msg = msgStr.encode('utf-8') + msglen = len(msg) + if 'application/ld+json' in acceptStr: + self._set_headers('application/ld+json', msglen, + cookie, callingDomain, False) + elif 'application/jrd+json' in acceptStr: + self._set_headers('application/jrd+json', msglen, + cookie, callingDomain, False) + else: + self._set_headers('application/activity+json', msglen, + cookie, callingDomain, False) + self._write(msg) + return True + def _showBlogPage(self, authorized: bool, callingDomain: str, path: str, baseDir: str, httpPrefix: str, @@ -10280,6 +11014,8 @@ class PubServer(BaseHTTPRequestHandler): """ divertToLoginScreen = False if '/media/' not in path and \ + '/ontologies/' not in path and \ + '/data/' not in path and \ '/sharefiles/' not in path and \ '/statuses/' not in path and \ '/emoji/' not in path and \ @@ -10411,6 +11147,10 @@ class PubServer(BaseHTTPRequestHandler): nickname = getNicknameFromActor(path) bannerFilename = \ acctDir(baseDir, nickname, domain) + '/search_banner.png' + if not os.path.isfile(bannerFilename): + if os.path.isfile(baseDir + '/theme/default/search_banner.png'): + copyfile(baseDir + '/theme/default/search_banner.png', + bannerFilename) if os.path.isfile(bannerFilename): if self._etag_exists(bannerFilename): # The file has not changed @@ -10534,6 +11274,54 @@ class PubServer(BaseHTTPRequestHandler): self._404() return True + def _showDefaultProfileBackground(self, callingDomain: str, path: str, + baseDir: str, themeName: str, + GETstartTime, GETtimings: {}) -> bool: + """If a background image is missing after searching for a handle + then substitute this image + """ + imageExtensions = getImageExtensions() + for ext in imageExtensions: + bgFilename = \ + baseDir + '/theme/' + themeName + '/image.' + ext + if os.path.isfile(bgFilename): + if self._etag_exists(bgFilename): + # The file has not changed + self._304() + return True + + tries = 0 + bgBinary = None + while tries < 5: + try: + with open(bgFilename, 'rb') as avFile: + bgBinary = avFile.read() + break + except Exception as e: + print('ERROR: _showDefaultProfileBackground ' + + str(tries) + ' ' + str(e)) + time.sleep(1) + tries += 1 + if bgBinary: + if ext == 'jpg': + ext = 'jpeg' + self._set_headers_etag(bgFilename, + 'image/' + ext, + bgBinary, None, + self.server.domainFull, + False, None) + self._write(bgBinary) + self._benchmarkGETtimings(GETstartTime, + GETtimings, + 'search screen ' + + 'banner done', + 'background shown') + return True + break + + self._404() + return True + def _showShareImage(self, callingDomain: str, path: str, baseDir: str, GETstartTime, GETtimings: {}) -> bool: @@ -10568,7 +11356,7 @@ class PubServer(BaseHTTPRequestHandler): 'share files shown') return True - def _showAvatarOrBanner(self, callingDomain: str, path: str, + def _showAvatarOrBanner(self, refererDomain: str, path: str, baseDir: str, domain: str, GETstartTime, GETtimings: {}) -> bool: """Shows an avatar or banner or profile background image @@ -10622,7 +11410,7 @@ class PubServer(BaseHTTPRequestHandler): mediaBinary = avFile.read() self._set_headers_etag(avatarFilename, mediaImageType, mediaBinary, None, - callingDomain, True, + refererDomain, True, lastModifiedTimeStr) self._write(mediaBinary) self._benchmarkGETtimings(GETstartTime, GETtimings, @@ -10693,6 +11481,7 @@ class PubServer(BaseHTTPRequestHandler): baseDir: str, httpPrefix: str, inReplyToUrl: str, replyToList: [], shareDescription: str, replyPageNumber: int, + replyCategory: str, domain: str, domainFull: str, GETstartTime, GETtimings: {}, cookie, noDropDown: bool, conversationId: str) -> bool: @@ -10712,6 +11501,19 @@ class PubServer(BaseHTTPRequestHandler): if isNewPostEndpoint: nickname = getNicknameFromActor(path) + if inReplyToUrl: + replyIntervalHours = self.server.defaultReplyIntervalHours + if not canReplyTo(baseDir, nickname, domain, + inReplyToUrl, replyIntervalHours): + print('Reply outside of time window ' + inReplyToUrl + + str(replyIntervalHours) + ' hours') + self._403() + self.server.GETbusy = False + return True + elif self.server.debug: + print('Reply is within time interval: ' + + str(replyIntervalHours) + ' hours') + accessKeys = self.server.accessKeys if self.server.keyShortcuts.get(nickname): accessKeys = self.server.keyShortcuts[nickname] @@ -10727,6 +11529,7 @@ class PubServer(BaseHTTPRequestHandler): replyToList, shareDescription, None, replyPageNumber, + replyCategory, nickname, domain, domainFull, self.server.defaultTimeline, @@ -10771,6 +11574,7 @@ class PubServer(BaseHTTPRequestHandler): if self.server.keyShortcuts.get(nickname): accessKeys = self.server.keyShortcuts[nickname] + defaultReplyIntervalHours = self.server.defaultReplyIntervalHours msg = htmlEditProfile(self.server.cssCache, translate, baseDir, @@ -10783,7 +11587,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.textModeBanner, city, self.server.userAgentsBlocked, - accessKeys).encode('utf-8') + accessKeys, + defaultReplyIntervalHours).encode('utf-8') if msg: msglen = len(msg) self._set_headers('text/html', msglen, @@ -10946,11 +11751,62 @@ class PubServer(BaseHTTPRequestHandler): 'to': [toUrl], 'cc': [ccUrl] } - self._postToOutbox(blockJson, self.server.projectVersion) + self._postToOutbox(blockJson, self.server.projectVersion, + blockerNickname) return True + def _getRefererDomain(self, uaStr: str) -> str: + """Returns the referer domain + Which domain is the GET request coming from? + """ + refererDomain = None + if self.headers.get('referer'): + refererDomain, refererPort = \ + getDomainFromActor(self.headers['referer']) + refererDomain = getFullDomain(refererDomain, refererPort) + elif self.headers.get('Referer'): + refererDomain, refererPort = \ + getDomainFromActor(self.headers['Referer']) + refererDomain = getFullDomain(refererDomain, refererPort) + elif self.headers.get('Signature'): + if 'keyId="' in self.headers['Signature']: + refererDomain = self.headers['Signature'].split('keyId="')[1] + if '/' in refererDomain: + refererDomain = refererDomain.split('/')[0] + elif '#' in refererDomain: + refererDomain = refererDomain.split('#')[0] + elif '"' in refererDomain: + refererDomain = refererDomain.split('"')[0] + elif uaStr: + if '+https://' in uaStr: + refererDomain = uaStr.split('+https://')[1] + if '/' in refererDomain: + refererDomain = refererDomain.split('/')[0] + elif ')' in refererDomain: + refererDomain = refererDomain.split(')')[0] + elif '+http://' in uaStr: + refererDomain = uaStr.split('+http://')[1] + if '/' in refererDomain: + refererDomain = refererDomain.split('/')[0] + elif ')' in refererDomain: + refererDomain = refererDomain.split(')')[0] + return refererDomain + + def _getUserAgent(self) -> str: + """Returns the user agent string from the headers + """ + uaStr = None + if self.headers.get('User-Agent'): + uaStr = self.headers['User-Agent'] + elif self.headers.get('user-agent'): + uaStr = self.headers['user-agent'] + elif self.headers.get('User-agent'): + uaStr = self.headers['User-agent'] + return uaStr + def do_GET(self): callingDomain = self.server.domainFull + if self.headers.get('Host'): callingDomain = decodedHost(self.headers['Host']) if self.server.onionDomain: @@ -10960,6 +11816,13 @@ class PubServer(BaseHTTPRequestHandler): print('GET domain blocked: ' + callingDomain) self._400() return + elif self.server.i2pDomain: + if callingDomain != self.server.domain and \ + callingDomain != self.server.domainFull and \ + callingDomain != self.server.i2pDomain: + print('GET domain blocked: ' + callingDomain) + self._400() + return else: if callingDomain != self.server.domain and \ callingDomain != self.server.domainFull: @@ -10967,10 +11830,14 @@ class PubServer(BaseHTTPRequestHandler): self._400() return - if self._blockedUserAgent(callingDomain): + uaStr = self._getUserAgent() + + if self._blockedUserAgent(callingDomain, uaStr): self._400() return + refererDomain = self._getRefererDomain(uaStr) + GETstartTime = time.time() GETtimings = {} @@ -11040,6 +11907,29 @@ class PubServer(BaseHTTPRequestHandler): self.path.replace('/users/' + nickname + '/', '/users/' + nickname + '/statuses/') + # instance actor + if self.path == '/actor' or \ + self.path == '/users/actor' or \ + self.path == '/Actor' or \ + self.path == '/users/Actor': + self.path = '/users/inbox' + if self._showInstanceActor(callingDomain, self.path, + self.server.baseDir, + self.server.httpPrefix, + self.server.domain, + self.server.domainFull, + self.server.port, + self.server.onionDomain, + self.server.i2pDomain, + GETstartTime, GETtimings, + self.server.proxyType, + None, self.server.debug, + self.server.enableSharedInbox): + return + else: + self._404() + return + # turn off dropdowns on new post screen noDropDown = False if self.path.endswith('?nodropdown'): @@ -11367,7 +12257,6 @@ class PubServer(BaseHTTPRequestHandler): self._benchmarkGETtimings(GETstartTime, GETtimings, 'hasAccept', 'fonts') - # treat shared inbox paths consistently if self.path == '/sharedInbox' or \ self.path == '/users/inbox' or \ self.path == '/actor/inbox' or \ @@ -11542,7 +12431,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.systemLanguage) messageJson = {} if pinnedPostJson: - postId = pinnedPostJson['id'] + postId = removeIdEnding(pinnedPostJson['id']) messageJson = \ outboxMessageCreateWrap(self.server.httpPrefix, nickname, @@ -11551,7 +12440,7 @@ class PubServer(BaseHTTPRequestHandler): pinnedPostJson) messageJson['id'] = postId + '/activity' messageJson['object']['id'] = postId - messageJson['object']['url'] = postId.replace('/users/', '/@') + messageJson['object']['url'] = replaceUsersWithAt(postId) messageJson['object']['atomUri'] = postId msg = json.dumps(messageJson, ensure_ascii=False).encode('utf-8') @@ -11732,6 +12621,13 @@ class PubServer(BaseHTTPRequestHandler): # after selecting a shared item from the left column then show it if htmlGET and '?showshare=' in self.path and '/users/' in self.path: itemID = self.path.split('?showshare=')[1] + if '?' in itemID: + itemID = itemID.split('?')[0] + category = '' + if '?category=' in self.path: + category = self.path.split('?category=')[1] + if '?' in category: + category = category.split('?')[0] usersPath = self.path.split('?showshare=')[0] nickname = usersPath.replace('/users/', '') itemID = urllib.parse.unquote_plus(itemID.strip()) @@ -11742,7 +12638,7 @@ class PubServer(BaseHTTPRequestHandler): itemID, self.server.translate, self.server.sharedItemsFederatedDomains, self.server.defaultTimeline, - self.server.themeName, 'shares') + self.server.themeName, 'shares', category) if not msg: if callingDomain.endswith('.onion') and \ self.server.onionDomain: @@ -11766,6 +12662,11 @@ class PubServer(BaseHTTPRequestHandler): # after selecting a wanted item from the left column then show it if htmlGET and '?showwanted=' in self.path and '/users/' in self.path: itemID = self.path.split('?showwanted=')[1] + if ';' in itemID: + itemID = itemID.split(';')[0] + category = self.path.split('?category=')[1] + if ';' in category: + category = category.split(';')[0] usersPath = self.path.split('?showwanted=')[0] nickname = usersPath.replace('/users/', '') itemID = urllib.parse.unquote_plus(itemID.strip()) @@ -11776,7 +12677,7 @@ class PubServer(BaseHTTPRequestHandler): itemID, self.server.translate, self.server.sharedItemsFederatedDomains, self.server.defaultTimeline, - self.server.themeName, 'wanted') + self.server.themeName, 'wanted', category) if not msg: if callingDomain.endswith('.onion') and \ self.server.onionDomain: @@ -12271,6 +13172,13 @@ class PubServer(BaseHTTPRequestHandler): 'account qrcode done', 'search screen banner done') + if self.path.startswith('/defaultprofilebackground'): + self._showDefaultProfileBackground(callingDomain, self.path, + self.server.baseDir, + self.server.themeName, + GETstartTime, GETtimings) + return + if '-background.' in self.path: if self._showBackgroundImage(callingDomain, self.path, self.server.baseDir, @@ -12300,6 +13208,14 @@ class PubServer(BaseHTTPRequestHandler): GETstartTime, GETtimings) return + if '/ontologies/' in self.path or \ + '/data/' in self.path: + if not hasUsersPath(self.path): + self._getOntology(callingDomain, + self.path, self.server.baseDir, + GETstartTime, GETtimings) + return + self._benchmarkGETtimings(GETstartTime, GETtimings, 'show emoji done', 'show media done') @@ -12339,7 +13255,7 @@ class PubServer(BaseHTTPRequestHandler): # cached avatar images # Note that this comes before the busy flag to avoid conflicts if self.path.startswith('/avatars/'): - self._showCachedAvatar(self.server.domainFull, self.path, + self._showCachedAvatar(refererDomain, self.path, self.server.baseDir, GETstartTime, GETtimings) return @@ -12350,7 +13266,7 @@ class PubServer(BaseHTTPRequestHandler): # show avatar or background image # Note that this comes before the busy flag to avoid conflicts - if self._showAvatarOrBanner(callingDomain, self.path, + if self._showAvatarOrBanner(refererDomain, self.path, self.server.baseDir, self.server.domain, GETstartTime, GETtimings): @@ -12408,7 +13324,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.baseDir, self.server.httpPrefix, self.server.domainFull, - self.server.systemLanguage).encode('utf-8') + self.server.systemLanguage, + True).encode('utf-8') msglen = len(msg) self._login_headers('text/html', msglen, callingDomain) self._write(msg) @@ -12983,6 +13900,7 @@ class PubServer(BaseHTTPRequestHandler): # replyWithDM = False replyToList = [] replyPageNumber = 1 + replyCategory = '' shareDescription = None conversationId = None # replytoActor = None @@ -13052,6 +13970,8 @@ class PubServer(BaseHTTPRequestHandler): replyPageStr = m.replace('page=', '') if replyPageStr.isdigit(): replyPageNumber = int(replyPageStr) + elif m.startswith('category='): + replyCategory = m.replace('category=', '') elif m.startswith('sharedesc:'): # get the title for the shared item shareDescription = \ @@ -13159,6 +14079,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.httpPrefix, inReplyToUrl, replyToList, shareDescription, replyPageNumber, + replyCategory, self.server.domain, self.server.domainFull, GETstartTime, GETtimings, @@ -13306,7 +14227,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.personCache, self.server.allowDeletion, self.server.projectVersion, - self.server.YTReplacementDomain): + self.server.YTReplacementDomain, + self.server.twitterReplacementDomain): return self._benchmarkGETtimings(GETstartTime, GETtimings, @@ -13495,7 +14417,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.port, searchHandle, self.server.debug, - self.server.systemLanguage) + self.server.systemLanguage, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._login_headers('text/html', @@ -13530,7 +14453,8 @@ class PubServer(BaseHTTPRequestHandler): self.server.port, searchHandle, self.server.debug, - self.server.systemLanguage) + self.server.systemLanguage, + self.server.signingPrivateKeyPem) msg = msg.encode('utf-8') msglen = len(msg) self._login_headers('text/html', @@ -13685,16 +14609,16 @@ class PubServer(BaseHTTPRequestHandler): self.server.GETbusy = False return - if not self._fetchAuthenticated(): + if not self._secureMode(): if self.server.debug: - print('WARN: Unauthenticated GET') + print('WARN: Unauthorized GET') self._404() self.server.GETbusy = False return self._benchmarkGETtimings(GETstartTime, GETtimings, 'show profile posts done', - 'authenticated fetch') + 'authorized fetch') # check that the file exists filename = self.server.baseDir + self.path @@ -13710,7 +14634,7 @@ class PubServer(BaseHTTPRequestHandler): None, callingDomain, False) self._write(msg) self._benchmarkGETtimings(GETstartTime, GETtimings, - 'authenticated fetch', + 'authorized fetch', 'arbitrary json') else: if self.server.debug: @@ -13990,7 +14914,9 @@ class PubServer(BaseHTTPRequestHandler): pinPost(self.server.baseDir, nickname, self.server.domain, contentStr) return 1 - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): populateReplies(self.server.baseDir, self.server.httpPrefix, self.server.domainFull, @@ -14063,7 +14989,9 @@ class PubServer(BaseHTTPRequestHandler): if messageJson: if fields['schedulePost']: return 1 - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): refreshNewswire(self.server.baseDir) populateReplies(self.server.baseDir, self.server.httpPrefix, @@ -14149,6 +15077,9 @@ class PubServer(BaseHTTPRequestHandler): replaceYouTube(postJsonObject, self.server.YTReplacementDomain, self.server.systemLanguage) + replaceTwitter(postJsonObject, + self.server.twitterReplacementDomain, + self.server.systemLanguage) saveJson(postJsonObject, postFilename) # also save to the news actor if nickname != 'news': @@ -14203,7 +15134,9 @@ class PubServer(BaseHTTPRequestHandler): if messageJson: if fields['schedulePost']: return 1 - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): populateReplies(self.server.baseDir, self.server.httpPrefix, self.server.domain, @@ -14252,7 +15185,9 @@ class PubServer(BaseHTTPRequestHandler): if messageJson: if fields['schedulePost']: return 1 - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): populateReplies(self.server.baseDir, self.server.httpPrefix, self.server.domain, @@ -14307,7 +15242,9 @@ class PubServer(BaseHTTPRequestHandler): return 1 print('Sending new DM to ' + str(messageJson['object']['to'])) - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): populateReplies(self.server.baseDir, self.server.httpPrefix, self.server.domain, @@ -14358,7 +15295,9 @@ class PubServer(BaseHTTPRequestHandler): return 1 print('DEBUG: new reminder to ' + str(messageJson['object']['to'])) - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): return 1 else: return -1 @@ -14388,7 +15327,9 @@ class PubServer(BaseHTTPRequestHandler): self.server.systemLanguage, self.server.lowBandwidth) if messageJson: - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): return 1 else: return -1 @@ -14429,7 +15370,9 @@ class PubServer(BaseHTTPRequestHandler): if messageJson: if self.server.debug: print('DEBUG: new Question') - if self._postToOutbox(messageJson, __version__, nickname): + if self._postToOutbox(messageJson, + self.server.projectVersion, + nickname): return 1 return -1 elif postType == 'newshare' or postType == 'newwanted': @@ -14498,7 +15441,10 @@ class PubServer(BaseHTTPRequestHandler): self.server.lowBandwidth) if filename: if os.path.isfile(filename): - os.remove(filename) + try: + os.remove(filename) + except BaseException: + pass self.postToNickname = nickname return 1 return -1 @@ -14799,6 +15745,13 @@ class PubServer(BaseHTTPRequestHandler): print('POST domain blocked: ' + callingDomain) self._400() return + elif self.server.i2pDomain: + if callingDomain != self.server.domain and \ + callingDomain != self.server.domainFull and \ + callingDomain != self.server.i2pDomain: + print('POST domain blocked: ' + callingDomain) + self._400() + return else: if callingDomain != self.server.domain and \ callingDomain != self.server.domainFull: @@ -14806,7 +15759,9 @@ class PubServer(BaseHTTPRequestHandler): self._400() return - if self._blockedUserAgent(callingDomain): + uaStr = self._getUserAgent() + + if self._blockedUserAgent(callingDomain, uaStr): self._400() return @@ -15429,7 +16384,8 @@ class PubServer(BaseHTTPRequestHandler): # https://www.w3.org/TR/activitypub/#object-without-create if self.outboxAuthenticated: - if self._postToOutbox(messageJson, __version__): + if self._postToOutbox(messageJson, + self.server.projectVersion, None): if messageJson.get('id'): locnStr = removeIdEnding(messageJson['id']) self.headers['Location'] = locnStr @@ -15625,7 +16581,8 @@ def loadTokens(baseDir: str, tokensDict: {}, tokensLookup: {}) -> None: break -def runDaemon(lowBandwidth: bool, +def runDaemon(defaultReplyIntervalHours: int, + lowBandwidth: bool, maxLikeCount: int, sharedItemsFederatedDomains: [], userAgentsBlocked: [], @@ -15664,11 +16621,12 @@ def runDaemon(lowBandwidth: bool, baseDir: str, domain: str, onionDomain: str, i2pDomain: str, YTReplacementDomain: str, + twitterReplacementDomain: str, port: int = 80, proxyPort: int = 80, httpPrefix: str = 'https', fedList: [] = [], maxMentions: int = 10, maxEmoji: int = 10, - authenticatedFetch: bool = False, + secureMode: bool = False, proxyType: str = None, maxReplies: int = 64, domainMaxPostsPerDay: int = 8640, accountMaxPostsPerDay: int = 864, @@ -15707,6 +16665,12 @@ def runDaemon(lowBandwidth: bool, print('serverAddress: ' + str(serverAddress)) return False + # scan the theme directory for any svg files containing scripts + assert not scanThemesForScripts(baseDir) + + # initialize authorized fetch key + httpd.signingPrivateKeyPem = None + httpd.showNodeInfoAccounts = showNodeInfoAccounts httpd.showNodeInfoVersion = showNodeInfoVersion @@ -15753,6 +16717,11 @@ def runDaemon(lowBandwidth: bool, 'Public': 'p', 'Reminder': 'r' } + + # how many hours after a post was publushed can a reply be made + defaultReplyIntervalHours = 9999999 + httpd.defaultReplyIntervalHours = defaultReplyIntervalHours + httpd.keyShortcuts = {} loadAccessKeysForAccounts(baseDir, httpd.keyShortcuts, httpd.accessKeys) @@ -15768,6 +16737,7 @@ def runDaemon(lowBandwidth: bool, # unit tests are run on the local network with LAN addresses httpd.allowLocalNetworkAccess = True httpd.YTReplacementDomain = YTReplacementDomain + httpd.twitterReplacementDomain = twitterReplacementDomain # newswire storing rss feeds httpd.newswire = {} @@ -15886,7 +16856,7 @@ def runDaemon(lowBandwidth: bool, httpd.outboxThread = {} httpd.newPostThread = {} httpd.projectVersion = projectVersion - httpd.authenticatedFetch = authenticatedFetch + httpd.secureMode = secureMode # max POST size of 30M httpd.maxPostLength = 1024 * 1024 * 30 httpd.maxMediaSize = httpd.maxPostLength @@ -16030,7 +17000,7 @@ def runDaemon(lowBandwidth: bool, print('Creating expire thread for shared items') httpd.thrSharesExpire = \ threadWithTrace(target=runSharesExpire, - args=(__version__, baseDir), daemon=True) + args=(projectVersion, baseDir), daemon=True) if not unitTest: httpd.thrSharesExpireWatchdog = \ threadWithTrace(target=runSharesExpireWatchdog, @@ -16073,6 +17043,7 @@ def runDaemon(lowBandwidth: bool, allowDeletion, debug, maxMentions, maxEmoji, httpd.translate, unitTest, httpd.YTReplacementDomain, + httpd.twitterReplacementDomain, httpd.showPublishedDateOnly, httpd.maxFollowers, httpd.allowLocalNetworkAccess, @@ -16080,7 +17051,9 @@ def runDaemon(lowBandwidth: bool, verifyAllSignatures, httpd.themeName, httpd.systemLanguage, - httpd.maxLikeCount), daemon=True) + httpd.maxLikeCount, + httpd.signingPrivateKeyPem, + httpd.defaultReplyIntervalHours), daemon=True) print('Creating scheduled post thread') httpd.thrPostSchedule = \ @@ -16111,6 +17084,10 @@ def runDaemon(lowBandwidth: bool, print('Adding hashtag categories for language ' + httpd.systemLanguage) loadHashtagCategories(baseDir, httpd.systemLanguage) + # signing key used for authorized fetch + # this is the instance actor private key + httpd.signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) + if not unitTest: print('Creating inbox queue watchdog') httpd.thrWatchdog = \ diff --git a/defaultcategories/en.xml b/defaultcategories/en.xml index 7dde26b01..69555358f 100644 --- a/defaultcategories/en.xml +++ b/defaultcategories/en.xml @@ -4,674 +4,710 @@ #categories retro - retrocomputer kommunalwahl 90sretro A500 CreativeCommons ecommerce atarist SistersWithTransistors vax retroarch commodore retroffiting teletext Retromeme matariki floppy recommendation 8bit cassette arcade atari communicators atari800 oldschool trs80 communication atari8bit floppydisk retrocomputing recommended C64 nostalgia bbs ansi communicationtheory plan9 80s TransCrowdFund microcomputing kommunikation vaxvms retroarcade zdfretro cassette_tapes bonhomme omm acorn retrogaming z80 8bitdo retro atari800xl retrocom telekommunikation VollaCommunityDays retropie commodore64 cassettetapes retrogame Trans amiga bbcmicro retrofriday microcomputer bbsing commercial + retrocomputer kommunalwahl 90sretro A500 CreativeCommons ecommerce atarist SistersWithTransistors vax retroarch commodore retroffiting teletext Retromeme matariki floppy recommendation 8bit cassette arcade atari communicators atari800 oldschool trs80 communication atari8bit floppydisk retrocomputing recommended C64 nostalgia bbs ansi communicationtheory plan9 80s TransCrowdFund microcomputing kommunikation vaxvms retroarcade zdfretro cassette_tapes bonhomme omm acorn retrogaming z80 8bitdo retro atari800xl retrocom telekommunikation VollaCommunityDays retropie commodore64 cassettetapes retrogame Trans amiga bbcmicro retrofriday microcomputer bbsing commercial ansible - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT economics - Europe workercoop InformationFriction cooperatives accounting bank cooplife bitcoin noplanetb theWorkshop feministeconomics WealthConcentration valueflows coops holochain valuesovereignty cooperativism greatplains platformcoop pico coopstack transcrowdfund usebitcoin shitcoin gigeconomy consommation workercoops economics cooperationjackson cooperation radical value business platformcooperatives exoplanets shopping displacement economic poplar shop companyculture plaintextaccounting MarketForLemons sovereignty crowdfund ethereum oops fairtrade RIPpla bankingCartel rope Datenbank Bitcoin startups radicalcooperation HenryGeorge scar plausible economíasolidaria disablitycrowdfund crowdfunding limitstogrowth ponzi companies theygrowupfast hermannplatz sharingiscaring techcoops plastikfrei woocommerce plantprotein meetcoop disability micropatronage boarsplaining merz lgbtcrowdfund mehrplatzfürsrad monetize sineadoconnor postscarcity cooperativas ua cryptocurrencies coopjobs degrowth a2pico smallbusiness deliveroo intellectualproperty pla kommerzialisierung GitPay Fedigrowth gdp coopsday deplatforming timebank coop cooperativismo smallbusinesses europeancentralbank banknotes whyBitcoin cryptocurrency infoshop sine grow telecoop growth DesignForDisassembly limits fuckfoodbanks btc values banks planetary plannedObsolence planet worldbank + Europe workercoop InformationFriction cooperatives accounting bank cooplife bitcoin noplanetb theWorkshop feministeconomics WealthConcentration valueflows coops holochain valuesovereignty cooperativism greatplains platformcoop pico coopstack transcrowdfund usebitcoin shitcoin gigeconomy consommation workercoops economics cooperationjackson cooperation radical value business platformcooperatives exoplanets shopping displacement economic poplar shop companyculture plaintextaccounting MarketForLemons sovereignty crowdfund ethereum oops fairtrade RIPpla bankingCartel rope Datenbank Bitcoin startups radicalcooperation HenryGeorge scar plausible economíasolidaria disablitycrowdfund crowdfunding limitstogrowth ponzi companies theygrowupfast hermannplatz sharingiscaring techcoops plastikfrei woocommerce plantprotein meetcoop disability micropatronage boarsplaining merz lgbtcrowdfund mehrplatzfürsrad monetize sineadoconnor postscarcity cooperativas ua cryptocurrencies coopjobs degrowth a2pico smallbusiness deliveroo intellectualproperty pla kommerzialisierung GitPay Fedigrowth gdp coopsday deplatforming timebank coop cooperativismo smallbusinesses europeancentralbank banknotes whyBitcoin cryptocurrency infoshop sine grow telecoop growth DesignForDisassembly limits fuckfoodbanks btc values banks planetary plannedObsolence planet worldbank circulareconomy scoop cooperativa AlternativeEconomics platformcooperative sharing banking openvalue radicals coopalooza - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT climate - YouStormOutlook metoffice heatwave energyconsumption consumption riscos energy energyuse SoilCarbon vampire renewables fuel clouds apollo racisme antira greenhousegas ClimateEmergency openscience overconsumption renewableenergy climatejustice ipcc ClimateMeme amp Nyurbinsky climateemergency climatechos gordoncampbell extremeweather ClimateAction climate climateracism renewable windenergy ClimateDenial ClimateProtection sciences ClimateStrike CycloneTauktae emissions coal climatecase climatestrike globalsouth ClimatePodcast weatherforecast kaspersky crisis foodcrisis vampiro voteclimate energyvisions klimaatcrisis environment skypack climatecrises sustainability risc ar6 fossilfuel history_of_science earthscience tramp globalwarming mitigation limitededition weather ragingqueerenergy fossilcriminals camps climatecamp ClimateRefigees Podcast windpower sealevelrise ClimateCase globally globalization climatechoas endfossilfuels emergency CarbonOffsets heatwaves basecamp exitpoll Tyskysour pollution global parisclimateagreement science fossil energyefficiency OABarcamp21 climatecatastrophe mitmachen fossilfuels Climate sky climatescience energytransition climateaction ClimateCrisis storms RacistClimate warm biofuel globalviews headlamp whisky climatemitigation environmentalism Ruttecrisis climatecrisis + YouStormOutlook metoffice heatwave energyconsumption consumption riscos energy energyuse SoilCarbon vampire renewables fuel clouds apollo racisme antira greenhousegas ClimateEmergency openscience overconsumption renewableenergy climatejustice ipcc ClimateMeme amp Nyurbinsky climateemergency climatechos extremeweather ClimateAction climate climateracism renewable windenergy ClimateDenial ClimateProtection sciences ClimateStrike CycloneTauktae emissions coal climatecase climatestrike globalsouth ClimatePodcast weatherforecast kaspersky crisis foodcrisis vampiro voteclimate energyvisions klimaatcrisis environment skypack climatecrises sustainability risc ar6 fossilfuel history_of_science earthscience tramp globalwarming mitigation limitededition weather ragingqueerenergy fossilcriminals camps climatecamp ClimateRefigees Podcast windpower sealevelrise ClimateCase globally globalization climatechoas endfossilfuels emergency CarbonOffsets heatwaves basecamp exitpoll Tyskysour pollution global parisclimateagreement science fossil energyefficiency OABarcamp21 climatecatastrophe mitmachen fossilfuels Climate sky climatescience energytransition climateaction ClimateCrisis storms RacistClimate warm biofuel globalviews headlamp whisky climatemitigation environmentalism Ruttecrisis climatecrisis environmentfriendlytech environmentfriendly greenenergy climatereport datascience climateactionnow crisiscamp Podcasts climechoas jetstream - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + people + gordoncampbell Melissa harold paul Zachary JusticiaParaVictoria danielle dylan scott Barbara Kenneth theresa Denise FrankLeech louisrossmann Jesse Adam justin JonathanCulbreath elinorostrom katherine judith Karen Patricia russell Metalang99 juan diane Rebecca donna LouisRossmann olivia peter troy William denise NathanDufour Betty evelyn Christina brittany Jennifer Gregory Wayne Andrychów ethan Ralph Peter ecc americalatina jacobites jean laura betty nathan brownmark margaret alexanderlukashenko Bryan Virginia Jose Rose eric james BomberBradbury david Joshua christine haaland Billy CapitolRiot ostrom natalie daniel Jonathan Michael susan George johnny bookmark MichaelWood Lauren christina Amy kevin Natalie kenneth noahkathryn mannaggia Lawrence aaron donaldtrump gregory LindaLindas Amber alexa Robert Edward Patrick Rachel Verwaltunsgericht willemalexander bruce Forms dennis LegalCannabis Kayla frank KarenArmstrong Diane AliceHasters Donna Jack Paul Janice Brenda alexis sylvanasimons timothy vincent Alice sarah amy Daniel RobertKMerton jeff charlotte carolyn Emma Kyle Sean emily linda Olivia Eugene johnpilger Donald janet ryan Bookmarker stdavids RichardDWolff bryan DonnaStrickland Hannah anna doctorow MalcolmJohnson gretathunberg Catherine Alexander Christopher bob doris Anthony singlemarket Jean diana Beverly frances Sarah margaretthatcher Jordan peterrdevries JensStuhldreier Anna Ethan hackchrist Amanda jeremy donald NatashaAKelly mark matthew julie ryanair BenSchrader DrJessicaHutchings stephanie Jerry SEKFrankfurt Diana David Linda adam richard henry RoyalFamily Isabella elizabeth nachrichten steven jessica Walter dry jeffrey Kevin Justin mountanMaryland grace martinluther PeterGelderloos brandon mary anwarshaikh jamesbaldwin sharon nicholas Benjamin GeorgeFloyd amanda Emily Ruth heather stephenlawrence albert julianassange Julie marktwirtschaft nancy stephen Cannabis James CarlSpender Megan bettydog Raymond eugenetica michelle frankgehtran Nancy Fedimarket Frances Henry andrew kevinRuddCoup Jessica zurich IgorBancer julia marketing Dorothy BadVisualisation LoganGrendel Jason Charles JonathanMorris Danielle Brandon jose noamchomsky virginia beverly obituary ronald Bob BarbaraKay madison alberta ceph Helen MarkoBogoievski Jeff helen Sophia larry bookmarks dorothy Dennis JamesEPetts monbiot Nicholas Frank jack Stephen Janet ScottRosenberg georgemonbiot Alexis Pamela Jacqueline Dylan roy brenda jackal jesse Roger Jeffrey Brittany Shirley putkevinback Nathan christopher Carol Susan jason Philip Logan sandra jacob rose isabella Cynthia Joan jackieweaver aldoushuxley Maria martha Randy SarahEverard carl kyle karen raymond alice jerry carol RussellBrown Victoria Steven Douglas Lisa JonathanZittrain Julia joshua jacqueline Ashley assange eugene Bruce Albert Austin thomas Evelyn Gary Scott kimberly lawrence virgin jennifer Russell austin erdogan betterlatethannever ShhDontTellJack logan Laura Chris walters Teresa GeorgeGalloway Aaron Keith brian marktwain maryanning LamySafari maria Joseph Andrew Vincent Katherine Joyce NathanJRobinson lauren Ryan amber davidgraeber UrsulaFranklin alan ralph princephilip DennisTheMenace megan Kathleen sophia Cheryl abigail cynthia john richardstallman Alan AnnihilationOfCaste Debra GeorgeHoare arthurgloria mariadb LouisFurther Christine marilyn anthony chris Berichte Elizabeth sean Louis Larry AnnSophieBarwich christian deborah billy Abigail joesara AndreaBeste keith Jeremy CapitolRiots markkennedy zachary ruth Grace teresa Doris benjamin Willie george PeterHitchens methane barbara scottish Charlotte philip DaveCunliffe ethanzuckerman randy Margaret Heather Bradley Jacob shirley pamela Matthew Nicole joan judy Kelly savannah Brian melissa Sandra stallman markstone joseph oberverwaltungsgericht andrea shamelessselfplug Joe Sara robert alicevision aaronswartz better Bobby emma willie william angela rich SachaChua samuel Postmarketos tyler Thomas John kroger patricia ashley bobby roses kelly fuckamerica ThomasCahill hannah Carolyn Ann CrimsonRosella Jeangu gary wayne DavidRose Marilyn Deborah christenunie rms Sharon gare Mary frankfurt Samuel BreonnaTaylor Mark walter rebecca RaymondHill helendixon Madison Juan lisa cheryl janice ChristopherTrout jeffreyepstein Christian gerald Timothy roger edward bradley Gerald PiersMorgan patrickrachel framalang Kimberly steve Gabriel Marie EmmaFerris PeterHoffmann PaulBaran louis kathleen Arthur Gloria terry royals freejeremy bernardhickey Richard jonathan Harold shame Roy samantha DavidSeymour Carl chalice Eric AndreiKazimirov RebeccaHarvey relationships visuallyimpaired nicole Andrea Judith Terry Stephanie Johnny Angela Noah Kathryn RichardBoeth Ronald AskVanta Michelle Theresa gabrielmarie Samantha Judy michael charles GeorgeGerbner Tyler philipmorris amaryllis DouglasPFry kayla catherinealexander Martha debra JohnMichaelGreer stevewozniak joyce julialang JonathanDollimore ProfMichaelBaker ChristinePeterson bettytest JacobNielsen FrankfortSchool HannaArendt PaulWolfowitz JoschaBach LexFridman ShinzenYoung shameonyou bernard PeterRollins JeffMoyer saulwilliams taxtherich newmarket remarkable + + Fri, 01 Oct 2021 18:50:09 UT art - proudhon productivity cherrytree Fediverse oilpaint economiasolidaria arttips mastoartist paperart libreart cali TraditionalArt Linke subresourceintegrity glitchart Art ocart robincolors resource urban article penandink webcomics startpage CommissionsOpen glassart afrique martialarts watercolours artsurbains artalley artvsartist2020 circulareconomy abstract artreference commission horrorart Earthquakes poe nomadbsd proxmoxve MartyMacMarty tgif coloringpage dccomics colored inkscape blink modelrailway artificalintelligence draw circuitsculpture ttip watercolor proceduralart existentialcomics FediverseGuide resources poetesss memes pinksterlanddagen FediverseForum ghibligif speedpaint SankeyCharts bengarrison subpoena autisticartist barrigòtic art sona animalart krita foraBolsonaroGenocida insights FreeColouringPage anthroart urbanart sigh queerart deviantart communityresources desigualdad pastel fantasyart drawings 20thcenturyillustration grafana daria artdeco adultcolouring source J19forabolsonaro collective openstreeetmap cryptoart politicalprisoners fantasy collage jordanlynngribbleart theGalley ToryParty educpop TheArtsHole linksunten risograph pro links CodeZwart thinkabout dndmemes fanfic articles protein forabolsonaro PartyPooperPost harmreductionart adhdmeme MastoArtHeader openra demoscene witch FreeArtLicense wallpaper generative political streetart coverart streetcomplete fountainpen stickers partners watercolour economy combyne freeculture fiberart PalestinianPoliticalPrisoners jet labyrinth educators mermay dpa artsale edu MastoArt particl PrisonNews FediverseApp urbansketchers ParticlV3 creativetoots culture ganart evenepoel opencl fiberarts polArt ink painting Leitartikel marten opencoop digitalart comic flyingonthewater kenmurestreet libreculture sartre artwork mandala FediverseTown b3d politicalcartoon blackart artsderue makingcomics glitch politicalprisoner junkart wallpapers railway linker riso xkcd supportartists proctorio drawtober startinblox comics intelligence linkinbio conceptart mastoart urbanterror illustration artopencall Hinkley gnuimagemanipulationprogram os studioghibli 2MinuteSketch wireart cartoon artistontwittter oc csa AccidentalGraffiti eink OriginalCharacter educator farts hattip poezio webcomic fleischproduktion nekodoodle DigitalArt pinkwashing partnership potentieldememe oilpainting kickstarter furryart twinkle DisabledArtist unixstickers pink fursona afriquedusud comicsans openstreetmap inkjet generativeart VaccineApartheid sticker enbyart originalart arts heartmind artbreeder 17maart fart TsunderdogArt videoart ivalice adultcoloring djmartinpowers arttherapy Cartudy extreemrechts fractal enby TattoosOfTheFediverse doodle artikel WorldLocalizationDay colouringpage worldwaterday NFTart netart signalstickers memex artschool digitalpainting intel politicaltheatre artvsartist dorktower maart abstractart drawing sig circular adhd sculpture artist pcbart meme cultureshipnames concretepoetry artwithopensource pinkwug Streeck VTMtober commissions pronouns opencallforartists DesolateEarthForThePoor VizierOpLinks commissionsopen fanon KartaView alroeart article17 fountainpenink MartinVanBeynen peppertop speedpainting animalpainting visionaryart blackartist worldpay figureart zine artists heart quickdraw error supportthearts genart urbanfantasy stickerei CurzioMalaparte tree lineart smartcard pixelart alisajart openframeworks professor smolzine networknomicon openrailwaymap politicalpolicing Earthstar JuliaHartleyBrewer fan digitalArt artistsOfMastodon glitchsoc paintings railways mermay2021 + proudhon productivity cherrytree oilpaint economiasolidaria arttips mastoartist paperart libreart cali TraditionalArt Linke subresourceintegrity glitchart Art ocart robincolors resource urban article penandink webcomics startpage CommissionsOpen glassart afrique martialarts watercolours artsurbains artalley artvsartist2020 abstract artreference commission horrorart Earthquakes poe nomadbsd proxmoxve MartyMacMarty tgif coloringpage dccomics colored inkscape blink modelrailway artificalintelligence draw circuitsculpture ttip watercolor proceduralart existentialcomics FediverseGuide resources poetesss memes pinksterlanddagen FediverseForum ghibligif speedpaint SankeyCharts bengarrison subpoena autisticartist barrigòtic art sona animalart krita foraBolsonaroGenocida insights FreeColouringPage anthroart urbanart sigh queerart deviantart communityresources desigualdad pastel fantasyart drawings 20thcenturyillustration grafana daria artdeco adultcolouring source J19forabolsonaro collective openstreeetmap cryptoart politicalprisoners fantasy collage jordanlynngribbleart theGalley ToryParty educpop TheArtsHole linksunten risograph pro links CodeZwart thinkabout dndmemes fanfic articles protein forabolsonaro PartyPooperPost harmreductionart adhdmeme MastoArtHeader openra demoscene witch FreeArtLicense wallpaper generative political streetart coverart streetcomplete fountainpen stickers partners watercolour economy combyne freeculture fiberart PalestinianPoliticalPrisoners jet labyrinth educators mermay dpa artsale edu MastoArt particl PrisonNews FediverseApp urbansketchers ParticlV3 creativetoots culture ganart evenepoel opencl fiberarts polArt ink painting Leitartikel marten opencoop digitalart comic flyingonthewater kenmurestreet libreculture sartre artwork mandala FediverseTown b3d politicalcartoon blackart artsderue makingcomics glitch junkart wallpapers railway linker riso xkcd supportartists proctorio drawtober startinblox comics intelligence linkinbio conceptart mastoart urbanterror illustration artopencall Hinkley gnuimagemanipulationprogram os studioghibli 2MinuteSketch wireart cartoon artistontwittter oc csa AccidentalGraffiti eink OriginalCharacter educator farts poezio webcomic fleischproduktion nekodoodle DigitalArt pinkwashing partnership potentieldememe oilpainting kickstarter furryart twinkle DisabledArtist unixstickers pink fursona afriquedusud comicsans openstreetmap inkjet generativeart VaccineApartheid sticker enbyart originalart arts heartmind artbreeder 17maart fart TsunderdogArt videoart ivalice adultcoloring djmartinpowers arttherapy Cartudy extreemrechts fractal enby TattoosOfTheFediverse doodle artikel WorldLocalizationDay colouringpage worldwaterday NFTart netart signalstickers memex artschool digitalpainting politicaltheatre artvsartist dorktower maart abstractart drawing sig circular adhd sculpture artist pcbart meme cultureshipnames concretepoetry artwithopensource pinkwug Streeck VTMtober commissions pronouns opencallforartists DesolateEarthForThePoor VizierOpLinks commissionsopen fanon KartaView alroeart article17 fountainpenink MartinVanBeynen peppertop speedpainting animalpainting visionaryart blackartist worldpay figureart zine artists heart quickdraw error supportthearts genart urbanfantasy stickerei CurzioMalaparte tree lineart smartcard pixelart alisajart openframeworks smolzine networknomicon openrailwaymap politicalpolicing Earthstar JuliaHartleyBrewer fan digitalArt artistsOfMastodon glitchsoc paintings railways mermay2021 moa fractionalScaling FediverseOrigins colors glitchSoc ResourceDepletion ADayOffTwitch ArtistResidency artresource odoo fedimeme Mastocartolaio sipwitch walkableStreets Norwegianartist Dutchartist Twitch indianart nativeamericanart indigenousartist openstreetmaps theStudio 48WeeksOfColor - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + activitypub + Fediverse gotosocial followerpower FederatedSocialMedia mastodevs kazarma activitypub activertypub FediTips tootfic askthefedi fedivision pleroma losttoot Rss2Fedi PeerTube CreativeToots devices gofed getfedihired collaborate pixelfedlabs hometown homelab RedactionWeb fediblock fediverso lazyfedi happyfedi2u federation Invite2Fedi instances fedilab bandsofmastodon Wallabag blocks pixiv mastotips TheFediverseChallenge sammelabschiebung toot fedilabfeature mastodev fediversetv pixel Ktistec mastodontips catsofthefediverse misskeydev mastotip pixel3a wallaby MastoDev friendica mastodontip talesfromthefediverse mastofficina fleamarket ap_c2s hiveway bands mastodonart mast Moneylab Mosstodon Adblocker fedionly DeveloperExperience askthefediverse misskey collaboraoffice activitypub_conf plsboost BlackFedi joinmastodon AskPixelfed siskin socialhub followers fediart blocking fedifreebies Metatext FediBlock SocialMediaReimagined fediverse13 mondkapjesplicht Pixelfed contentwarnings pixelfed labournettv fediverseplaysjackbox mapeocolaborativo fedihive greeninstances fedidb block FediMemories mastectomy Feditip devs fediverseparty collabathon Dev Fediseminar onlyfedi admin socialcg teamtoot masterton fedbox FediMeta sponsorblock SocialNetworkingReimagined tusky retoot contentwarning peertubers imagedescription joinpeertube anastasia feditips tootcat dnsssecmastery2e fedizens alimonda Mastodon following epicyon afediversechat andstatus peertubeadmin leylableibt fediversefleamarket mastomagic YearOfTheFediverse dearMastomind thatsthetoot mastodob fediadmin pleaseboost mastodonhost mond pixeldev pixelfont timeline socialmedia tips wedistribute fedivisionCollab fosstodon instanceblock freetoot mastodonmonday fedihelp fediWhen fedicat asta collaborative isolategab greenmastodon FediverseFixesThis fedireads pixeldroid networkTimeline PeertubeMastodonHost boost AskFediverse Bookwyrm federated socialhome greenfediverse WriteFreely fédiverse microblocks collabora fedivers MastodonMondays fediverse imagedescriptions mastobikes gbadev lemmy Fedilab bunsenlabs mastoadmin smithereen hackerstown uadblock c2s FediverseFutures latenighttoots mastodon pcmasterrace developingcountries boostswelcome PixelfedDev fedi fediversefriday mondkapje fediplay activity widevine socialcoop peertube fieldlabs mastomind lab fediversepower BlackMastadon fedeproxy boosten tootorial boostwelcome lazyfediverse mastoaiuto mobilizon Fediverse13 lazy gemifedi activityPubRocks fediversehistory fedibird proxy activitystreams fediversetown pixelPrinciples zap spritely honk thefederation + + Fri, 01 Oct 2021 18:50:09 UT + + + politics + politicalprisoner hate conspiracytheory TakeOurPowerBack redessociais solidarität trump Anarchy association cia socialjustice neoliberalisme eee workerowned alwaysantifascist sabotage qtibpoc VivotecniaCrueldadAnimal solidarityeconomy pressfreedom community systemicracism wageslavery immigration antifascismo liberal telemetry dissent liberation unions endprisonslavery laws fascism farmersrprotest techtuesday warc skyofmywindow techthursday nooneisillegal capitale freedomofspeech anarchist prochoice freeexpression EthnicCleansing anticapitalist RacialHealing fascisme liberalisme humanrights Anarchisme crime leftists Socialism ukpol FreeKeithLamar Antifascisme copwatch capitalismkills fireworks homeless menschenrecht left petition BorisJohnson meteorología independant antifaschismus freedom EURvalues greens photomanipulation techtalk bikesforrefugees housingcrisis techdirt ontologicalanarchy labourabolition techsit union tories abolitionnow anarchism wegmetdemonarchie abuse DefundThePolice nazis earthship SocialCritique repression legaltech technews pelmets Jurastil meto devimage legal meeting polizeigewalt dannenröderwald venturecapital FediAntifa police nzpolitics multicast antifascists oilwars multiverse antropocene kommunismus censored postttruth technik rightorepair control nuclear bjp ThirdRunway conservatives multi seaslug UnitedInDiversity maidsafe testing nazisme hierarchy avatars chehalisrivermutualaidnetwork vat ImmigrationEnforcement election republicans opinie diversity solidarity chipstrike techwear communitycontrol metantispecismo hypocrits slavery sociaalDarwinisme metoo Avanti anticiv refugeeswelcome Coronariots seashepherd ecotech reform2 mybodymychoice generalstrike fuckBiden call2power DefendDemocracy personhood wildfire neoliberal antipolitics charity AntiLiberalisme abolition digitalfreedom transrightsarehumanrights ScottishElections2021 mayday unionyes again hatespeech fascists antropoceno policerepression LateStageOfCapitalism earth stopchasseacourre solawi ciencia smashturkishfascism afropessimism antivax cognition fedibikes Electricians apartheidisrael burntheprisons conservation seamonkey trumpism cyberlaw bossnapping peerproduction policiaasesina atlantik corporations iww pushbacksareillegal indianpirates DisabilityPolicy vice SomethingIsGoingWrongHere til labor intersectional commons choice depressionen feelthefreedom Riot corporatewatch postcapitalism intersectionalfeminism smalltechnology wageslave uspol frontex quarantine communism mutualaidpdx RemoveThePolice makecapitalismhistory deathvalley NewPoliticalMap chipocalypse criminalization abolishpolice nationalisme oist methaan anarchisten Immigration competition biometric brexitreality neoliberalism NeverTrustSimone socialecology wald whistleblower wroclawskierewolucjonistki icons MutualAid capitalism technology ACAB prisons unsolicitedadvice feministhackmeetings wealth supremecourt conspiracytheories corporatecrime DirectAction ChildLabour FossilFreeRevolution parliament communist daretocare KeirStarmer NoMoreEmptyPromises greenpeace digitalslavery bushfire censor decrecimiento helmet refugeesgr taoteching technopolice anarchismus policeviolence politiikka kapitalisme retrotechnology ZwartFront bipoc housing decriminalization decolonisation politics WarCommentary inclusivity parametric gravimetry bosch Megaprisons decreased publicknowledge antiracism government neocities greendatacenter SocialDarwinism repressions brightgreen poc privatisierung anarchisme wayfire feminist colonialism DominicCummings nzpol peoplepower homelessness Bookchin informationtechnology ClemencyNow Inauguration2021 arran Revolutionary techthoughts brexit anarchistaction antimonopoly privileged totalitarianism localelections raid privatisation stillwithher TyskySour Labour democraciasindical nonprofitindustrialcomplex death fires LabourLeaks riots freethemall bolsonarogenocida green SocialJustice neoliberaal corporateStateTotalitarianism labour BAME decolonizeyourmind alternative privilege antikapitalisme masssurveillance hamas legalcounsel AbolishPrisonsAbolishPolice despotism mntreform damangecontrol earthovershootday DecentraliseThePlanet anti surfaceworldblows ecofascism opentechnologyfund depression nuclearpower popularitycontest usestandardpowerjacksforfucksake pdxmutualaid PoliceTenSeven LhubTV SocietalChange facialrecognition ModiFailsIndia cotech antisemitism politicaeattualità corruption florespondece hypocrisy BernieSandersMeme staterepression anarchy fire colonization Feminism propaganda dcc greenit endsars celebratingfreedom Antillia corporateState SocialCentres decolonization digitalrights feminism freepress Lhub HightechProblems datacenter osstotd academictwitter farm problem hochschwarzwald collaboration pentesting polizei neo democracy anarchistki Govts antikapitalismus powerpolitics bikes 18Source hungerstrike censorshipBook radicaltech 56aInfoshop saytheirnames witchesagainstwhitesupremacy gulag digitalmarketsact yes socialist conspiracy anarchistbookclub redandanarchistskinheads peace housingproject hostileenvironment technically lawyer corporate radicaldemocracy endmodernslaveryatsea PritiPatel nationaalparkdebiesbosch stonewallwasariot oiseau surveillance latestagecapitalism bos racist economiafeminista cancelculture postcolonial Syndicalism callfortesting dec AmbLastillaAlCor Selfsuffciency nonazis MexicanRevolution elections ACABPoland greatgreenwall RussellMaroonShoatz LhubSocial OctoberRevolution bigproblems logitech methods Flatseal repressionen commonspub warcrimes sea policing white governance waldstattasphalt prisoners earthday2021 warrants policebrutality techshit earthday antirepression capitalismo borisjohnson wildfires fritolaystrike ACABSpring2021 technopopulism Anarchist deepspeech notacopshop body johnson rhetoric press routerfreedom Anarchism mutuality StillTwitterpated whitehouse metropolitanpolice espresso LabourParty haltandcatchfire freedomofexpression censorship deathbycapitalism communities CancelCulture decolonize deconstruct HanauWarKeinEinzelfall musictechnology EatTheRich druglawreform keinmenschistillegal immigrationraids emmet racism fascisten decenterwhiteness Biden kapitalismus FossilFreePolitics ChineseAppBan multiplesklerose todoist cooperative trespass modi NtechLab antifa alternativen law prison chip LabourMovement deathtoamerica manipulation ParticipatoryCultureFoundation firetotheprisons consumer solidaritaet PlanetarySocial britpol financial gravimetrie BiodiversityDay Capitalism surveillancecapitalism leftist greenland general Revolution ukpolitics greenparty mdcommunity glenngreenwald support JeremyCorbyn blacklivesmatter freedomofthepress academicfreedom wled HeinsbergStudie apartheid FreeAlabamaMovement Anarchismus bundespolizei strike mononeon rentstrike evergreen equality dsa informationstechnik piracy liberty lawandorder feminismus migration power IndividualSovereignty oiseaux techmess neoist edtech capitalismenumérique mutualaid capital waldspaziergang cymru multipleexposure socialsolidarityeconomy humanetechnology criminal AbolishPrison solidaritynotcharity anarchists fascist righttochoice InformationAsymmetry inequality vim apocalypseworld DefundSurveillanceCapitalism feministserver prisonersupport platformcapitalism decolonizeconservation anarchistprisoners whistleblowers polizeiproblem notallmen opensoundcontrol hf prisonabolition fightthepower UniversalBasicServices fuckcapitalism speech uselection IDPol Antifa deathtofascism mediafreedom lesanarchistes libertarianism Slavetrade PostTrade met democracia antitrespass drugtesting populism selfcensorship consumerism greenwashing ourstreets reform MeToo failedstatesaxony extremist bright freespeech comune anticonsumerism kapital refugee neorodiversiteit whitesupremacy SueveillanceCapitalism refugees BlackProtestLegalSupport riot BernieSanders texttospeech acab ecology yesminister realcompetition antifascist SurveillanceCapitalism vimeo antifascism GlobalCapitalism Politics homeoffice bodyshaming empowerment whitepaper pdx seascape freewestpapua eris AnarchistUnionofIranandAfghanistan hambacherwald dui nyt justice powstaniewgetciewarszawskim sunnytech FolksWhoFailAtCapitalism expression feudalism espressif violence legalmatters tech capitalismodisorveglianza disinformation deepfakes fediversity greenvps communityserver fucknazis okopress techtips Feminisme surveillanceware cybercrime notmetoo precrime humanetech greifswald MorteAlCapitalismo techjobs antibodies firstworldproblems powerpoint bonfire institutionalcorruption justiceforElijah monopoly deathsentence digitalfeudalism PiratesOfTheSingleMarket dueprocess apoc AutonomyOfInformation InformationAutonomy zombieapocalypse smallvictories predictivepolicing counterinsurgency warfare calmtech earthquake neonazis digitalcommons deepfake migrantifa artadvice levelling zapatista zapatistas sonar antifascisten defundthepolice care prisonerart chiapasmexico chiapas + + Fri, 01 Oct 2021 18:50:09 UT + + + other + hattip falling ageassurance pentester bullshit klimaatbeleid justasleepypanda extinctionrebellion fail masseffect lastpass yolo nothingnew Lastpass extinction weareclosed happy efail bripe MasseyUniversity PassSanitaire solution dansenmetjanssen messageToSelf TagGegenAntimuslimischenRassismus quecksilber itscomplicated Erzvorkommen test isntreal gentests rzeźwołyńska massextinction misc tw rants manutentore frantzfanon shots assaultcube shitpost denachtvanjanssen biomassacentrale mining rising devilsadvocate ACA pinside xp impfpass cda rant Terrassen righttodisassemble rassismus MassoudBarzani koerden CovPass nahrungskette SomeUsefulAndRelvantHashtag LanguageHelpForMigrants nsfw dungeonsAndDragons biomass rassismustötet oversleep ass id Chiacoin futtermittel CubanProtests geo oerde m assassinfly migrantstruggles sleep PointlessGriping close decluttering OCUPACAOCARLOSMARIGHELLA happyfriday uxfail WoonProtest LondonProtest EnquiringMinds sleepy nftcommunity badboy + + Fri, 01 Oct 2021 18:50:09 UT + + + hardware + intel fablab plugandplay bluetooth printnightmare singleboardcomputer purism dating schematics opennic tektronix zomertijd librehardware BoBurnham restauration rmw riscv solarpower carbonFootprintSham mietendeckel PersonalComputer cyberdeck PineCUBE firmware tex keyboards debuerreotype electron ChromebookDuet AbolishFrontex webcam bond hibernation PneumaticLoudspeakers schreibmaschine imac Nottingham schwarmwissen elitesoldat handheld screenless megapixels BibliothekDerFreien KeepTheDiskSpinning homebrewcomputing FarmersTractorRally pinebook farming modem lowtech biblatex allwinner daten home pimeroni 68 lebensmittelsicherheit industrial hambibleibt analogcomputing homer TrueDelta keyboard screenprinting robotics Pinecil mutantC raspberrypi3 pocketchip oshw misterfpga noisebridge disapora T440p ArmWorkstation datensicherheit latexrun hardwarehacking mer picodisplay laptops electronics scuttlebutt ham teamdatenschutz charm SectorDisk wolnabiblioteka preprint permacomputing uart panasonic pcb almere armbian performance kopimi printmaker deck making hambi powerpc solar ssd acoustics ibmcompatible webcams modular larp tweedekamer cybredeck latex 3dprinted MacBook emmc ipadproart computing laptop solarpunk isa recycling modularsynth apparmor repairability macbook theatrelighting pc lenovo updates fairelectronics industrialmusic librem carbonsequestration electronica sed TokyoCameraClub MacBookProService pocket box86 JingPad righttorepair mac trackball fuse date solarpunkactionweek ibm 3dprinting electro carbon MechcanicalKeyboards netbook hardware m68k pisa retrohardware pinetab sicherheit openhardware raspberrypi irobot datenautobahn webtoprint 3dprinter barcode lüneburg Quartz64 PlanetComputer jtag ebu merseyside itsicherheit CompressedAirAmplification pinetime screens pinebookpro lebensmittel 3d batteries PinebookPro 3dprint pim Handprint modemmanager securescuttlebutt keyboardio mechanicalkeyboard electronicmusic solarpunks carbondioxide robot arm lowerdecks sonic ipad FireAlarms PinePower paperComputer amd openpower poweredSpeaker devopa a64 eeepc bahn F9600 rpi4 thinkpad RaspberryPiPico iot dat BeagleV arm64 merveilles repairable sbc circuitbending raspberrypi4 print displayport akihabara analog electronic FrameworkLaptop wireless rockpro64 filmmaking playdate mechanicalkeyboards svendsolar solpunk xiaomi slowtech carboncapture raspberry restorativefarming robotapoc hdhomerun + + Fri, 01 Oct 2021 18:50:09 UT + + + academic + professor academic academia professorlife academiclife + + Fri, 01 Oct 2021 18:50:09 UT sport - billiard darts olympics2020 swim olympics motorsport snooker sports locksport swimming trailrunning marathon hockey aikido bouldering diving baseball Millwall mma mammal sailing athletics nook olympic dumpsterdiving sportsball bing skating skiing sport footballers climbing football combatsports golf + billiard darts olympics2020 swim olympics motorsport snooker sports locksport swimming trailrunning marathon hockey aikido bouldering diving baseball Millwall mma mammal sailing athletics nook olympic dumpsterdiving sportsball bing skating skiing sport footballers climbing football combatsports golf tuebingen unitübingen tübingen - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT games - miniature appdesign gameofshrooms minecraft soloRPG nbsdgames karma tetris99 gamestop libregaming ageofempires mondragon BiophilicDesign videogame ksp TerraNil productdesign dungeonmaster gogodotjam AudioGame runequest miniatures dragonfall boardgames computergames creature fucknintendo fudgedice angrydesigner gameassets gamestonk fossgaming videogames FediDesign gameboy puzzle indiegames gamedesign shadowrun spot godotengine adventuregames chess gamejam nintendoswitch mudrunner mud indiegame game 0ad dragon playlog gameart orca sdg lovewood designfail opengameart sign asset gilgamesh fudgerpg ttrpg fudge gamedev freegames guildwars2 creaturedesign bideogames adventuregame TetrisGore gaming gamemaker gameing nintendo roleplayinggames itch unvanquished gamesdonequick Gamesphere devilutionx rpg gamespot tetris dosgaming supertuxkart freegaming DnD socialdesign karmaisabitch cyber2077 godot gamestudies tarot cyberpunk2077 gamesforcats FreeNukum spelunkspoil boardgaming supermariomaker2 neopets minetest omake guildwars dice dnd games playing + miniature appdesign gameofshrooms minecraft soloRPG nbsdgames karma tetris99 gamestop libregaming ageofempires mondragon BiophilicDesign videogame ksp TerraNil productdesign dungeonmaster gogodotjam AudioGame runequest miniatures dragonfall boardgames computergames creature fucknintendo fudgedice angrydesigner gameassets gamestonk fossgaming videogames FediDesign gameboy puzzle indiegames gamedesign shadowrun spot godotengine adventuregames chess gamejam nintendoswitch mudrunner mud indiegame game 0ad dragon playlog gameart sdg lovewood designfail opengameart sign asset gilgamesh fudgerpg ttrpg fudge gamedev freegames guildwars2 creaturedesign bideogames adventuregame TetrisGore gaming gamemaker gameing nintendo roleplayinggames itch unvanquished gamesdonequick Gamesphere devilutionx rpg gamespot tetris dosgaming supertuxkart freegaming DnD socialdesign karmaisabitch cyber2077 godot gamestudies tarot cyberpunk2077 gamesforcats FreeNukum spelunkspoil boardgaming supermariomaker2 neopets minetest omake guildwars dice dnd games playing dungeonsanddragons subwaycreatures nintendo64 ageofempires2 indiegamedev hotspot pets - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + accessibility + orca you a11y accessibility captionyourimages hardofhearing + + Fri, 01 Oct 2021 18:50:09 UT bots posthumanism mrrobot human dehumanification Militanzverbot nobot botanists humanity militanzverbot Sabot44 humanrobotinteraction therobots humanetechnow verbote humankind - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT events - neverforget TuesdayVibe award daffodilday carbonemissions OONIbday waybackwednesday thursdayvibes fridayfilm todayilearned ShowYourStripesDay thursdaythought sun futuroparanissan IndigenousPeoplesDay5 notifications hissyfit ddosecrets solo throwbackthursday nissan valentinesday adventskalender live dos livehack Day deepthoughts solorpg thingaday idahobit screenshotsaturday warmingup thursdaythoughts fridays ipv hackathons thursdaymorning Gesundheitskrise throwback RomaDay assweek animalsweatersunday justwatched TooMuchScreenTime beethoven250thbirthday valentine humanrightsday time followfriday wednesdaythought afediversechristmas whydopeopledoshitlikethis birthdaypresent festivals wednesdaymotivation early MayDay2021 SwissOvershootDay IllustrationDay bigbrotherawards cccamp19 lovewhereyoulive screenshot thelibrary PostLikeYouThinkACrabWouldSunday showerthoughts BIJ1 worldpenguinday animal ScreenshotSaturday beethoven anarchymonday treibhausgasemissionen solokey tipoftheday Verkiezingsfestival future primeday IRL paperoftheday bundesnetzagentur thimblefulthursday FreeAssangeYesterday 100DaysToOffload iScreech hackathon ff kids holiday folklorethursday LURKbirthday tomorrowspaperstoday wenbiesday punday ipv4flagday ipv6 christmas livecoding verfassungsschutz weeknotes LINMOBlive week FlashFictionFriday mothersday gsd koningsdag scree concert folklore festival FridayFolklore pride poll screenshottuesday animals VerkiezingsfestivalBIJ1 motivation towertuesday doesliverpool fujifilmxt2 Docuthon Nakbaday kdenlive dontstarve onthisday GlobalMayDay2021 simplescreenrecorder insideoutsockday screenshots livestream blissos whiskerswednesday BowieDay morningcrew theskytoday InternationalAsexualityDay tzag TinyTuesday FridaysForFuture sunday notification Koning weekendvibes screenshotsunday worldenvironmentday2021 showerthought library koningshuis cree VerseThursday liverpool waitangiday esc2021 bigbrotheraward caturday adayinthelife goodmorning Caturday day InternationalCheetahDay flatfuckfriday songfestival ItchCreatorDay iss RabbitRoadTrip2021 interestingtimes sideprojectsunday birthday sixonsaturday supdate StPatricksDay2021 koningsdag2021 wordoftheday theweeknd christmaslights AfricaDay livefree CancelCanadaDay worldenvironmentday fridaysforfuture nationallibraryweek meetup FathersDay transpride sex kidsthesedays rechtsextreme + neverforget TuesdayVibe award daffodilday carbonemissions OONIbday waybackwednesday thursdayvibes fridayfilm todayilearned ShowYourStripesDay thursdaythought sun futuroparanissan IndigenousPeoplesDay5 notifications hissyfit ddosecrets solo throwbackthursday nissan valentinesday adventskalender live dos livehack Day deepthoughts solorpg thingaday idahobit screenshotsaturday warmingup thursdaythoughts fridays ipv hackathons thursdaymorning Gesundheitskrise throwback RomaDay assweek animalsweatersunday justwatched TooMuchScreenTime beethoven250thbirthday valentine humanrightsday time followfriday wednesdaythought afediversechristmas whydopeopledoshitlikethis birthdaypresent festivals wednesdaymotivation early MayDay2021 SwissOvershootDay IllustrationDay bigbrotherawards cccamp19 lovewhereyoulive screenshot thelibrary PostLikeYouThinkACrabWouldSunday showerthoughts BIJ1 worldpenguinday animal ScreenshotSaturday beethoven anarchymonday treibhausgasemissionen solokey tipoftheday Verkiezingsfestival future primeday IRL paperoftheday bundesnetzagentur thimblefulthursday FreeAssangeYesterday 100DaysToOffload iScreech hackathon ff kids holiday folklorethursday LURKbirthday tomorrowspaperstoday wenbiesday punday ipv4flagday ipv6 christmas livecoding verfassungsschutz weeknotes LINMOBlive week FlashFictionFriday mothersday gsd koningsdag scree concert folklore festival FridayFolklore pride poll screenshottuesday animals VerkiezingsfestivalBIJ1 motivation towertuesday doesliverpool fujifilmxt2 Docuthon Nakbaday kdenlive dontstarve onthisday GlobalMayDay2021 simplescreenrecorder insideoutsockday screenshots livestream blissos whiskerswednesday BowieDay morningcrew theskytoday InternationalAsexualityDay tzag TinyTuesday FridaysForFuture sunday notification Koning weekendvibes screenshotsunday worldenvironmentday2021 showerthought koningshuis cree VerseThursday liverpool waitangiday esc2021 bigbrotheraward caturday adayinthelife goodmorning Caturday day InternationalCheetahDay flatfuckfriday songfestival ItchCreatorDay iss RabbitRoadTrip2021 interestingtimes sideprojectsunday birthday sixonsaturday supdate StPatricksDay2021 koningsdag2021 wordoftheday theweeknd christmaslights AfricaDay livefree CancelCanadaDay worldenvironmentday fridaysforfuture nationallibraryweek meetup FathersDay transpride sex kidsthesedays rechtsextreme listeningtonow monday docshackathon ddos nowavailable agda hott simple bivisibilityday watched indigenouspeoplesday thedaily - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT - politics - hate biometrics conspiracytheory TakeOurPowerBack redessociais solidarität trump Anarchy association cia socialjustice neoliberalisme eee workerowned alwaysantifascist sabotage qtibpoc VivotecniaCrueldadAnimal solidarityeconomy pressfreedom community systemicracism wageslavery immigration antifascismo liberal telemetry dissent liberation unions endprisonslavery laws fascism farmersrprotest techtuesday warc skyofmywindow techthursday nooneisillegal capitale freedomofspeech anarchist prochoice freeexpression EthnicCleansing anticapitalist RacialHealing fascisme liberalisme humanrights Anarchisme crime leftists turkish Socialism ukpol FreeKeithLamar Antifascisme copwatch capitalismkills fireworks homeless menschenrecht left petition BorisJohnson meteorología independant antifaschismus freedom EURvalues greens photomanipulation techtalk bikesforrefugees housingcrisis techdirt ontologicalanarchy labourabolition techsit union tories abolitionnow anarchism wegmetdemonarchie abuse DefundThePolice nazis earthship SocialCritique repression legaltech technews pelmets Jurastil meto devimage legal meeting polizeigewalt dannenröderwald venturecapital FediAntifa police nzpolitics multicast antifascists oilwars multiverse antropocene kommunismus censored postttruth technik rightorepair control nuclear bjp ThirdRunway conservatives multi seaslug UnitedInDiversity maidsafe testing nazisme hierarchy avatars chehalisrivermutualaidnetwork vat ImmigrationEnforcement election republicans opinie diversity solidarity chipstrike techwear communitycontrol metantispecismo hypocrits slavery sociaalDarwinisme metoo Avanti anticiv refugeeswelcome Coronariots seashepherd ecotech reform2 mybodymychoice generalstrike fuckBiden call2power DefendDemocracy personhood wildfire neoliberal antipolitics charity AntiLiberalisme abolition digitalfreedom transrightsarehumanrights ScottishElections2021 mayday unionyes again hatespeech fascists antropoceno policerepression LateStageOfCapitalism earth stopchasseacourre solawi ciencia smashturkishfascism afropessimism antivax cognition fedibikes Electricians apartheidisrael burntheprisons conservation seamonkey qt trumpism cyberlaw bossnapping peerproduction policiaasesina atlantik corporations iww pushbacksareillegal indianpirates DisabilityPolicy vice SomethingIsGoingWrongHere til labor intersectional commons choice depressionen feelthefreedom Riot corporatewatch postcapitalism intersectionalfeminism smalltechnology wageslave uspol frontex quarantine communism mutualaidpdx RemoveThePolice makecapitalismhistory deathvalley NewPoliticalMap chipocalypse criminalization abolishpolice nationalisme oist methaan anarchisten Immigration competition biometric brexitreality neoliberalism NeverTrustSimone socialecology wald whistleblower wroclawskierewolucjonistki icons MutualAid capitalism technology ACAB prisons unsolicitedadvice feministhackmeetings wealth supremecourt conspiracytheories corporatecrime DirectAction ChildLabour FossilFreeRevolution parliament communist daretocare KeirStarmer NoMoreEmptyPromises greenpeace digitalslavery bushfire censor decrecimiento helmet refugeesgr taoteching technopolice anarchismus policeviolence politiikka kapitalisme retrotechnology ZwartFront bipoc housing decriminalization decolonisation politics WarCommentary inclusivity parametric gravimetry bosch Megaprisons decreased publicknowledge antiracism government neocities greendatacenter SocialDarwinism repressions brightgreen poc privatisierung anarchisme wayfire feminist colonialism DominicCummings nzpol peoplepower homelessness Bookchin informationtechnology ClemencyNow Inauguration2021 arran Revolutionary techthoughts brexit anarchistaction antimonopoly privileged totalitarianism localelections raid privatisation stillwithher TyskySour Labour democraciasindical nonprofitindustrialcomplex death fires LabourLeaks riots freethemall bolsonarogenocida green SocialJustice neoliberaal corporateStateTotalitarianism labour BAME decolonizeyourmind alternative privilege antikapitalisme masssurveillance hamas legalcounsel AbolishPrisonsAbolishPolice despotism mntreform damangecontrol earthovershootday palantir DecentraliseThePlanet anti surfaceworldblows ecofascism opentechnologyfund depression nuclearpower popularitycontest usestandardpowerjacksforfucksake pdxmutualaid PoliceTenSeven LhubTV SocietalChange facialrecognition ModiFailsIndia cotech antisemitism politicaeattualità corruption florespondece hypocrisy BernieSandersMeme staterepression anarchy fire colonization Feminism propaganda dcc greenit endsars celebratingfreedom Antillia corporateState SocialCentres decolonization digitalrights feminism freepress Lhub HightechProblems datacenter osstotd academictwitter farm problem hochschwarzwald collaboration pentesting polizei neo democracy anarchistki Govts antikapitalismus powerpolitics bikes 18Source hungerstrike censorshipBook radicaltech 56aInfoshop saytheirnames witchesagainstwhitesupremacy gulag digitalmarketsact yes socialist conspiracy anarchistbookclub redandanarchistskinheads peace housingproject hostileenvironment technically lawyer corporate osint radicaldemocracy endmodernslaveryatsea PritiPatel nationaalparkdebiesbosch stonewallwasariot oiseau surveillance latestagecapitalism bos racist economiafeminista cancelculture postcolonial Syndicalism callfortesting dec AmbLastillaAlCor Selfsuffciency nonazis MexicanRevolution elections ACABPoland greatgreenwall RussellMaroonShoatz LhubSocial OctoberRevolution bigproblems logitech methods Flatseal repressionen commonspub warcrimes sea policing white governance waldstattasphalt prisoners earthday2021 warrants policebrutality techshit earthday antirepression capitalismo borisjohnson wildfires fritolaystrike ACABSpring2021 technopopulism Anarchist deepspeech notacopshop body johnson rhetoric press routerfreedom Anarchism mutuality StillTwitterpated whitehouse metropolitanpolice espresso LabourParty haltandcatchfire freedomofexpression censorship deathbycapitalism communities CancelCulture decolonize deconstruct HanauWarKeinEinzelfall musictechnology EatTheRich druglawreform keinmenschistillegal immigrationraids emmet racism fascisten decenterwhiteness Biden kapitalismus FossilFreePolitics ChineseAppBan multiplesklerose todoist cooperative trespass modi NtechLab antifa alternativen law prison chip LabourMovement deathtoamerica manipulation ParticipatoryCultureFoundation firetotheprisons consumer solidaritaet PlanetarySocial britpol financial gravimetrie BiodiversityDay Capitalism surveillancecapitalism leftist greenland general Revolution ukpolitics greenparty mdcommunity glenngreenwald support JeremyCorbyn blacklivesmatter freedomofthepress academicfreedom wled HeinsbergStudie apartheid FreeAlabamaMovement Anarchismus bundespolizei strike mononeon rentstrike evergreen equality dsa informationstechnik piracy liberty lawandorder feminismus migration power IndividualSovereignty oiseaux techmess neoist edtech capitalismenumérique mutualaid capital waldspaziergang cymru multipleexposure socialsolidarityeconomy humanetechnology criminal AbolishPrison solidaritynotcharity anarchists fascist righttochoice InformationAsymmetry inequality vim apocalypseworld DefundSurveillanceCapitalism feministserver prisonersupport platformcapitalism decolonizeconservation anarchistprisoners whistleblowers polizeiproblem notallmen opensoundcontrol hf prisonabolition fightthepower UniversalBasicServices fuckcapitalism speech uselection IDPol Antifa deathtofascism mediafreedom lesanarchistes libertarianism Slavetrade PostTrade met democracia antitrespass drugtesting populism selfcensorship consumerism greenwashing ourstreets reform MeToo failedstatesaxony extremist bright freespeech comune anticonsumerism kapital refugee neorodiversiteit whitesupremacy SueveillanceCapitalism refugees BlackProtestLegalSupport riot BernieSanders texttospeech acab ecology yesminister realcompetition antifascist SurveillanceCapitalism vimeo antifascism GlobalCapitalism Politics homeoffice bodyshaming empowerment whitepaper pdx seascape freewestpapua eris AnarchistUnionofIranandAfghanistan hambacherwald dui nyt justice powstaniewgetciewarszawskim sunnytech FolksWhoFailAtCapitalism expression feudalism espressif violence legalmatters academic tech capitalismodisorveglianza + books + library readinggroup bookstore publicvoit bookbinding preview justhollythings secondhandbooks bookclub fake earthsea review ebooks docbook book notebook public amreading publishing republicday publichealth bookworm bookwyrm 5minsketch artbook republique bookreview reading sketching theLibrary audiobooks Gempub selfpublishing sketchbook wayfarers books peerreview bookreviews failbooks sketch ebook wikibooks booktodon epub cookbook bibliothèque AnarchoBookClub monthlyreview reviews desktoppublishing zlibrary fakefood - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT - places - communedeparis lapaz luanda klimakatastrophe asunción salisbury nouakchott conakry kyiv enviromentalism gadgetbridge moscow winchester cardiff saipan gibraltar dublin KlimaGerechtigkeit stuff catalunya dannibleibt avarua lilo wolverhampton hargeisa delhi niamey chișinău freestuff colombo dundee brasília StupidComparisons brushes phnompenh mbabane danni belgrade rotterdam stasaph belmopan detroit ghent pyongyang hannover strawinsky calls ulaanbaatar oranjestad kali Reykjavik Barliman gaborone seattle ndjamena lancaster chelmsford raw singapore classicalmusic tuberlin Lanarkshire feedbackwanted preston lincoln kingedwardpoint abidjan nuuk york asshole pretoria papeete DreamtimeVillage washingtonstate bradford malé rhetorical robberfly sunderland zagreb gitega abudhabi flyingfishcove castries revil georgetown suffolk wickr hagåtña podman lochlomond videoFreex oric ella lichtenberg videofeedback borikua basseterre hamburg southeastasia afrika kinshasa Schadensersatzforderung streetartparis suva klimaatverandering valparaíso athens roseau sheffield baku aberdeen charlotteamalie antananarivo domi pristina northumberland RadentscheidJena bordeaux diff MakoYass videocalls santiago fsb sukhumi berlin urk bristol uptronicsberlin funafuti libreville newry rush radentscheid puertorico ClimateChange hanoi philipsburg tehran banjul prague Stockente rawhide andorralavella daw yerevan portauprince videoprojects sensorica mewcastle dakar asu paramaribo tifariti durham CrisiClimatica capetown rigaer94 dma tirana klima ankara ipswich managua lisbon bishkek amsterdam climatchoas kent klimaat EastVirginia portonovo santodomingo wakefield bangkok texas coventry bucharest kathmandu aden buchtipp madrid cleanarchitecture paris14 sanjuan vienna kingston stuttgart Utrecht inverness kabul damascus stockholm douglas ClassOf2015 willemstad brighton klimaschutz klimaatnoodtoestand hibernoenglish thehague panamacity RassismusTötet beirut belfast amman newdelhi tórshavn nottingham nouméa oslo alofi gustavia paris fromembers cockburntown berlinale manchester dominio ottawa classical buch stepanakert portofspain klimakrise class fsberlin honiara berniememe asmara florida nicosia helsinki anywhere taipei salford tegucigalpa bridge tokyo tashkent larochelle vr gabocom MadeInEU sarajevo algiers southampton KlimaKrise nairobi muscat monaco riyadh flying lusaka perth wellington wick bissau juba mariehamn majuro parisagreement mumiaabujamal norwich buenosaires douglasrushkoff ngerulmud dhaka berlinhateigenbedarf guatemalacity washington bedarf vatican kuwaitcity martlesham Erdmannhausen Puntarella londonboaters SystemChangeNotClimateChange bern mexicocity amap bratislava myasstodontownhall bridgetown delhipolice stokeonTrent crowsnestpass leeds tunis manila warwickshire architecture rigaer94verteidigen arctic stanley matautu copenhagen hereford barcelona lomé videocall budapest ouagadougou mogadishu PrawnOS freetown victoria bangor lora brazzaville portmoresby ashgabat kampala Klimaatalarm gigabitvoucher kirigami webassembly yorkshire elaaiún kalimantan vilnius guineabissau ContourDrawing bloemfontein gnuassembly swansea classe sucre london passalong marseille berniesanders pagopago bradesestate oakland vaduz birmingham addis lisburn nürnberg naypyidaw derry CassetteNavigation khartoum baghdad bandar truro moroni cuirass rigaer lehavre klimaliste portvila kingstown armagh Klima ulm ChrisCrawford reykjavík lofi manama accra mewport windhoek fortworth nukualofa classic ciutatvella tbilisi canberra quito maputo cetinje adams putrajaya lichfield ramallah solimaske oslotown bogotá warming portsmouth dodoma berkeley harare stirling havana warsaw klimapolitik rigaer94bleibt münster valletta snes localberlin ljubljana bamako leicester kualalumpur peterborough podgorica rabat cotonou oranje plymouth seoul westminster neumünster Portland dushanbe bangui aotearoa theCellar canterbury westisland tskhinvali palikir caracas brussel jamestown rome gloucester munich cambridge ripon carlisle freestuffberlin wells chichester sãotomé jakarta floraspondence daressalaam sansalvador seo apia essex klimawandel yaren cairo jerusalem brussels kigali southtarawa beijing minsk montevideo vientiane philips maseru klimaatopwarming hamilton lorawan lurk doha klimaatwake worcester tripoli celtic portlouis stalbans lima adamstown deventer weimar abuja fuckalabamapower saw lilongwe nassau lobamba heathrow nyc oxford fly montreal klimaatzaakshell rawtherapee dili feedback thesprawl riga r94 assembly lesbos monrovia nursultan Neuzulassung caste gab sanjosé klimaatrechtvaardigheid marigot islamabad fb malabo tallinn sahara thimphu seattleprotestnetwork klimaatzaak exeter oranjeklanten klimanotstand chester brest yaoundé praia bujumbura strawberries washingtondc derby sofia skopje + surveillance + biometrics osint - Tue, 10 Aug 2021 08:34:29 UT - - - internet - homeserver datasette onlinesafetybill linkeddata markdown selfsufficiency webgl LoveWattsBLM decentralised immersiveweb pep decentraliseren i2p sceptic earlyinternet Clubhouse CooperativeClouds spam firefox redecentralize NYCMesh decentral socializing Burocratic toxicmasculinity staticsitegenerator wikipedia zeitschriften maps rtmp PlasticFreeJuly dataprotection NNCP decentralization inclusiónsocial decentralize IPFSing w3c OsmFinds datacollection files dotConism offlineimap DutchPolitics internetaccess agnostic gotosocial geminispace archivists gaza selfhosted piratenpartij mapuche videohosting DarkPatternsFTC metafilter maille meta wikibase CooperativeTechnology torrent mailab geocaching freenode MollyBrown mailfence bot adblocker tox k9mail nylasmail smalltech data socialism basemap webarchive sitejs meshroom protocol anticolonial VerkehrsswendeJetzt thecloud Jabbber worldbusterssocialclub publicserviceinternet networks criticism bioinformatics online openddata centralisation flameshot internetarchaeology WordPress darkages hiddenServices chainmail datarecovery self elinks saferinternetday selfhost text SeattleHellDay contentmoderation distributed OperationPanopticon mappe mydata webhosting decentralizedweb mailman SOASJusticeforCleaners natto p2pleft socialdistancing router sysadminday protection rne dataretention speedtest ublockorigin bigdata routeros internetofthings -} - -data greenhosting selfhosting forkawesome communityhosting TikTok tilde CriminalJusticeBill networking brave panopticon aldi icann selfsustaining hosting mailart DAOs discourse digitalcolonialism weblate kinosocial libera coopserver PeerToPeer wikis dns decentralizetheweb stripe service openstandards economíasocial responsiveness nojs ejabberd amusewiki freifunk oauth Anticon tic foxes hypercore CDNsAreEvil meshtastic piratebay protonmail TubEdu standards StuffCircuit yourdataisyourdata internetfreedom mirroring onlineWhiteboard gemini antarctic zeit webui InternetCrimeFamily wlan boilemMashEmStickEmInAStew internetBanking SmallWeb fedwiki snikket redessociales fleenode ircd coopcloud cw internetshutdown democratic criticalmass masculinity datadetox mailpile clearnet cdn cloudflared liberapay pinterest brahmaputra distributedcoop xmpp semanticweb identicurse socialnetwork Disarchive selfie anticolonialism website datasets SaferInternetDay content splinternet participation highavailability webstandards mapa groenlinks domains ntp centralized cloudfront socialnetworks metadata wikileaks disconnect Meme aioxmpp database socialanxiety proton disco web3 cloudfirewall TLSmastery descentralizare icmp organicMaps oop videocast governement jabber cleanuptheweb webbrowsers webhook communications decentralized userdata selflove wiki cloudron bsi browserextensions Fragattacks RedditDown ssb darknet cookies Qute MattHancock darkweb netcat webInstaller liberachat safety uberspace map Konfekoop Reddit archiv recaptcha server browser cloudy IPFS p2p social chainmaille antisocial tiddlywiki www missioncritical FreenodeTakeover ageverification corne fortinet Pluralistic databreach opendata ilovewikipedia web WebsiteStatus ownyourdata battiato netshutdowns alttext xep callforparticipation twitch im darkmode 9front bbb quadraticvoting GaiaX gavcloud decentralise att jabberspam theserverroom antarctica shutdowns Watomatic datafree greenhost domain mesh selfemployed hackint OpenStreetMap gnusocial darkambient RudolfBerner slixmpp geminiprotocol statistics BurnermailIO irc osm eveonline pirate plaintext Graphika datacracy filesharing sysadminlife ownlittlebitofinternet squatting sysadmin misinformation rss ipns mozilla twitchbannedrevision voicemail gazaunderattack mapbox Nyxt legacyInternet yacy webrtc databases symbiotic debloattheweb crosspost fastmail sysadmins jmap mail tinycircuits bureaucratic i2pd aesthetic ipfs internetradio bravenewworld routers practice browsers wikidata selfpub decentralizeit ballpointpen puredata netscape SSH mixcloud RSS DecolonizeTheInternet gmail openculture websites letthenetwork cyberspace SwitchToXmpp messaging selfies offthegrid enxeñeríasocial cloud ddg bopwiki blabber snailmail cleanup selfdefense internet moderation decentralisation justcloudflarethings webinar metaverse qutebrowser _w3c_ socialcooling intox scholarsocial Seattle fox umap centralization ssbroom pihole serverMeddling sealioning missingmaps qtox puremaps archiving bravesearch sneakernet NatureNeedsJustice Nextcloud internetarchive dataintegration mydataismydata dweb kmail js metatext adblock dark captcha socialNetworks BlackHatSEO beakerbrowser LiberaChat openweb soulseek NetShutdown enigmail libervia failwhale onlineharms webp gooddata mailinglist kernelupgrade dot wifi Internet descentralizarea thepiratebay internetshutdowns fixtheweb mapporn contentid lazyweb servers atom kernel socialweb colonial AtomPub firewall shutdown ambient socialists kernenergie ebay zeitschrift mozillahubs instantmessaging publicservice interoperabilitate SolidProject tiktok Justice4MohamudHassan cloudflare - - Tue, 10 Aug 2021 08:34:29 UT - - - employment - justworked futureofwork InterviewQuestions jechercheunjob mywork remote employees hiring TheNetwork workingfromhome ProgrammingJob reproductivework frame workinprogress bullshitjobs car workplace DigitalNetwork antiwork workshops kreaturworks workers worklog sexworkers remotejob mainframe remotework remotejobs migrantworkers job culturalworkers DjangoJob teamwork framework hire KDEGear careers hirefedi career SocialNotworks workshop bedfordshire illustratorforhire OpenHospitalityNetwork tidyworkshops carework AtlasNetwork nowhiring KDE remoteaccess rds KDEGear21 obs workersrights obsolescence records KDEFrameworks plannedobsolescence work hertfordshire flossjobs jobs workflow precariousworkers carddav sexworker theworkshop nerdsnipe employee overwork - - Tue, 10 Aug 2021 08:34:29 UT - - - gafam - zuckerberg caringissharing ads apple youtuberegrets antitrust SpringerEnteignen peoplefarming deletewhatsapp advertisingandmarketing chromevox GoogleDown aws AppleSearch Floc bankruptBezos googlesearch googleio mycologists bringBunysBack youtube Goggle twitterkurds banadvertising chromebook fuckfacebook headset arcgis ffs FacebookEvents AmazonMeansCops facebook wandering 100heads 20thcenturyadvertising amazon googlevoracle amazonprimeday dystopia microsoftgithub farcebook myco boycottinstagram FlocOff stopgafam genoegisgenoeg legislation amazonprime deletewhatsappday amazonring Gafam googleplus soldering GoogleForms weirdyoutuberecomendations HaringeyAnti delete FoodSharing lobbyregister degooglisation florespondance linkedin siri Apple Facebook LeiharbeitAbschaffen PoweringProgress advertising monopolies googleanalytics ausländerzentralregister adtech fuckgoogle storing plottertwitter failbook kadse microsoft deletechrome alanturing dtm poledance HeadscarfBan twitter skype azure chrome logistics googledoodles hildebrandt twitterblue corporateGiant Tracking uitkeringen FlocOffGoogle sidewalk plot zuck nogafam youtubedl degoogled Google youtubers google Microsoft stemverklaring gis walledgarden GAFCAM dt GooglevsOracle dotcoms deleteyoutube datafarms Instagram walledgardens agistri appleevent offseting Hypnagogist appleii facebookoversightboard fascistbook FuckGoogle degoogle boringdystopia fuschia ohneamazon appleiie deleteinstagram ungoogled ring stopgoogle affordances googledown decentring YouTube gafam inspiring oracle killedbygoogle fuckoffgoogle dance deletefacebook gradschool fakebook GoogleIsBad fuckoffgoogleandco office365 lordoftherings turingpi amazonas instagram TrackingFreeAds FlocBloc playstore synergistic bigtech boycottamazon amazonien whatsapp mytwitteranniversary deleteamazon bluesky Amazon - - Tue, 10 Aug 2021 08:34:29 UT - - - people - Melissa harold paul Zachary JusticiaParaVictoria danielle dylan scott Barbara Kenneth theresa Denise FrankLeech louisrossmann Jesse Adam justin JonathanCulbreath elinorostrom katherine judith Karen Patricia russell Metalang99 juan diane Rebecca donna LouisRossmann olivia peter troy William denise NathanDufour Betty evelyn Christina brittany Jennifer Gregory Wayne Andrychów ethan Ralph Peter ecc americalatina jacobites jean laura betty nathan brownmark margaret alexanderlukashenko Bryan Virginia Jose Rose eric james BomberBradbury david Joshua christine haaland Billy CapitolRiot ostrom natalie daniel Jonathan Michael susan George johnny bookmark MichaelWood Lauren christina Amy kevin Natalie kenneth noahkathryn mannaggia Lawrence aaron donaldtrump gregory LindaLindas Amber alexa Robert Edward Patrick Rachel Verwaltunsgericht willemalexander bruce Forms dennis LegalCannabis Kayla frank KarenArmstrong Diane AliceHasters Donna Jack Paul Janice Brenda alexis sylvanasimons timothy vincent Alice sarah amy Daniel RobertKMerton jeff charlotte carolyn Emma Kyle Sean emily linda Olivia Eugene johnpilger Donald janet ryan Bookmarker stdavids RichardDWolff bryan DonnaStrickland Hannah anna doctorow MalcolmJohnson gretathunberg Catherine Alexander Christopher bob doris Anthony singlemarket Jean diana Beverly frances Sarah margaretthatcher Jordan peterrdevries JensStuhldreier Anna Ethan hackchrist Amanda jeremy donald NatashaAKelly mark matthew julie ryanair BenSchrader DrJessicaHutchings stephanie Jerry SEKFrankfurt Diana David Linda adam richard henry RoyalFamily Isabella elizabeth nachrichten steven jessica Walter dry jeffrey Kevin Justin mountanMaryland grace martinluther PeterGelderloos brandon mary anwarshaikh jamesbaldwin sharon nicholas Benjamin GeorgeFloyd amanda Emily Ruth heather stephenlawrence albert julianassange Julie marktwirtschaft nancy stephen Cannabis James CarlSpender Megan bettydog Raymond eugenetica michelle frankgehtran Nancy Fedimarket Frances Henry andrew kevinRuddCoup Jessica zurich IgorBancer julia marketing Dorothy BadVisualisation LoganGrendel Jason Charles JonathanMorris Danielle Brandon jose noamchomsky virginia beverly obituary ronald Bob BarbaraKay madison alberta ceph Helen MarkoBogoievski Jeff helen Sophia larry bookmarks dorothy Dennis JamesEPetts monbiot Nicholas Frank jack Stephen Janet ScottRosenberg georgemonbiot Alexis Pamela Jacqueline Dylan roy brenda jackal jesse Roger Jeffrey Brittany Shirley putkevinback Nathan christopher Carol Susan jason Philip Logan sandra jacob rose isabella Cynthia Joan jackieweaver aldoushuxley Maria martha Randy SarahEverard carl kyle karen raymond alice jerry carol RussellBrown Victoria Steven Douglas Lisa JonathanZittrain Julia joshua jacqueline Ashley assange eugene Bruce Albert Austin thomas Evelyn Gary Scott kimberly lawrence virgin jennifer Russell austin erdogan betterlatethannever ShhDontTellJack logan Laura Chris walters Teresa GeorgeGalloway Aaron Keith brian marktwain maryanning LamySafari maria Joseph Andrew Vincent Katherine Joyce NathanJRobinson lauren Ryan amber davidgraeber UrsulaFranklin alan ralph princephilip DennisTheMenace megan Kathleen sophia Cheryl abigail cynthia john richardstallman Alan AnnihilationOfCaste Debra GeorgeHoare arthurgloria mariadb LouisFurther Christine marilyn anthony chris Berichte Elizabeth sean Louis Larry AnnSophieBarwich christian deborah billy Abigail joesara AndreaBeste keith Jeremy CapitolRiots markkennedy zachary ruth Grace teresa Doris benjamin Willie george PeterHitchens methane barbara scottish Charlotte philip DaveCunliffe ethanzuckerman randy Margaret Heather Bradley Jacob shirley pamela Matthew Nicole joan judy Kelly savannah Brian melissa Sandra stallman markstone joseph oberverwaltungsgericht andrea shamelessselfplug Joe Sara robert alicevision aaronswartz better Bobby emma willie william angela rich SachaChua samuel Postmarketos tyler Thomas John kroger patricia ashley bobby roses kelly fuckamerica ThomasCahill hannah Carolyn Ann CrimsonRosella Jeangu gary wayne DavidRose Marilyn Deborah christenunie rms Sharon gare Mary frankfurt Samuel BreonnaTaylor Mark walter rebecca RaymondHill helendixon Madison Juan lisa cheryl janice ChristopherTrout jeffreyepstein Christian gerald Timothy roger edward bradley Gerald PiersMorgan patrickrachel framalang Kimberly steve Gabriel Marie EmmaFerris PeterHoffmann PaulBaran louis kathleen Arthur Gloria terry royals freejeremy bernardhickey Richard jonathan Harold shame Roy samantha DavidSeymour Carl chalice Eric AndreiKazimirov RebeccaHarvey relationships visuallyimpaired nicole Andrea Judith Terry Stephanie Johnny Angela Noah Kathryn RichardBoeth Ronald AskVanta Michelle Theresa gabrielmarie Samantha Judy michael charles GeorgeGerbner Tyler philipmorris amaryllis DouglasPFry kayla catherinealexander Martha debra JohnMichaelGreer stevewozniak joyce - - Tue, 10 Aug 2021 08:34:29 UT - - - activitypub - followerpower FederatedSocialMedia mastodevs kazarma activitypub activertypub FediTips tootfic askthefedi fedivision pleroma losttoot Rss2Fedi PeerTube CreativeToots devices gofed getfedihired collaborate pixelfedlabs hometown homelab RedactionWeb fediblock fediverso lazyfedi happyfedi2u federation Invite2Fedi instances fedilab bandsofmastodon Wallabag blocks pixiv mastotips TheFediverseChallenge sammelabschiebung toot fedilabfeature mastodev fediversetv pixel Ktistec mastodontips catsofthefediverse misskeydev mastotip pixel3a wallaby MastoDev friendica mastodontip talesfromthefediverse mastofficina fleamarket ap_c2s hiveway bands mastodonart mast Moneylab Mosstodon Adblocker fedionly DeveloperExperience askthefediverse misskey collaboraoffice activitypub_conf plsboost BlackFedi joinmastodon AskPixelfed siskin socialhub followers fediart blocking fedifreebies Metatext FediBlock SocialMediaReimagined fediverse13 mondkapjesplicht Pixelfed contentwarnings pixelfed labournettv fediverseplaysjackbox mapeocolaborativo fedihive greeninstances fedidb block FediMemories mastectomy Feditip devs fablab fediverseparty collabathon Dev Fediseminar onlyfedi admin socialcg teamtoot masterton fedbox FediMeta sponsorblock SocialNetworkingReimagined tusky retoot contentwarning peertubers imagedescription joinpeertube anastasia feditips tootcat dnsssecmastery2e fedizens alimonda Mastodon following epicyon afediversechat andstatus peertubeadmin leylableibt fediversefleamarket mastomagic YearOfTheFediverse dearMastomind thatsthetoot mastodob fediadmin pleaseboost mastodonhost mond pixeldev pixelfont timeline socialmedia tips wedistribute fedivisionCollab fosstodon instanceblock softwaredevelopment freetoot mastodonmonday fedihelp fediWhen fedicat asta collaborative isolategab greenmastodon FediverseFixesThis fedireads pixeldroid networkTimeline PeertubeMastodonHost boost AskFediverse Bookwyrm federated socialhome greenfediverse WriteFreely fédiverse microblocks collabora fedivers MastodonMondays fediverse imagedescriptions mastobikes gbadev lemmy Fedilab bunsenlabs mastoadmin smithereen hackerstown uadblock c2s FediverseFutures latenighttoots mastodon pcmasterrace developingcountries boostswelcome PixelfedDev fedi fediversefriday mondkapje fediplay activity widevine socialcoop peertube fieldlabs mastomind lab fediversepower BlackMastadon fedeproxy boosten tootorial boostwelcome lazyfediverse mastoaiuto mobilizon Fediverse13 lazy gemifedi activityPubRocks - - Tue, 10 Aug 2021 08:34:29 UT - - - linux - pubnix fishshell linuxboot compiz osdev musescore commandline opensuse share linuxisnotanos elementaryos cli buster viernesdeescritorio voidlinux shell nu cliff olinuxino deb composite beschbleibt kde FragAttacks Debian11 reprobuilds pureos nospoilers kdepim thisweekinlinux slackware search bsd tap openwrt falling runbsd distros stapler viernes tmux nixos alpine nix DebianBullseye jobsearch rm xfce ubuntubuzz gnutools vaguejoke ack shareyourdesktop shellagm personal wireguard posix lightweight whonix hardenedbsd Guix linuxaudio mate haikuos usb initramfs nushell LinuxTablets nixpkgs wordsearch landback osi alpines computertruhe nonmateria torvalds gtk linuxmint DebianAcademy debian chroot trisquel studio gnome distrowatch oldcomputerchallenge linuxposting fedoraonpinephone trackers console showyourdesktop FuckDeMonarchie researchassistants anarchie windowmanager desktop GuixSystem arch chaoscomputerclub personalities platform ubuntu personalwiki jodee snowfall gnulinux patriarchat aur tuxjam justlinuxthings xubuntu thesuicidesquad kdeframeworks5 stackoverflow unix fedora openbsd centos nos fittrackee tuxedocomputers tracker openmandriva backwaren gentoo buildroot aurora researcher archive icarosdesktop BlackLives liveusb dee SearchFu personalarchive usergroup StockOS systemd linuxgaming Debian distro 1492LandBackLane Racklet theartofcomputerprogramming icecat tape puppylinux destinationlinux LinuxSpotted lovelinux thestudio suicide aros show Squarch monstrosities computer gtk3 blackout deepBlah escritoriognulinux acepride materials qubesos i3wm clipstudiopaint dadjokes kubuntu epr artixlinux JuiceFS reproducible kdecommunity haiku alpinelinux linuxisnotaplatform clip fall linux EMMS planetdebian minicomputer altap raspbian netbsd DanctNIX termux btrfs reproduciblebuilds showTheRainbow gravitationalwaves joke artix gtk4 esc linuxexpress archlinuxarm bash dd exposingtheinvisible archlinux hare ubuntucore linuxconfau newinbullseye researchers AuratAzadiMarch gnomebuilder void GNUlinux rhel debianinstaller debianindia linuxisajoke tux devuan debían suse zsh linuxconsole scoobySnacks bullseye - - Tue, 10 Aug 2021 08:34:29 UT - - - programming - Easer psychotherapie DigitalInfrastructure cpp digitalpreservation programming css maui rubyonrails objects Python system digitaldivide digitalisierung FrancisBacon2020 dracut gitea orgmode mixers webdev proofing developerexperience seguridaddigital gui digital release ada schutzstreifen pypi crust codeforge workaround proofofwork zorg node websocket proofofstake ecosystem rustlang systemwandel DigitalTech python2 ocaml NapierBarracks system76 program ngiforum21 DigitalSouveräneSchule request_reaction sqlite guile capitolhillautonomouszone transcript TransZorgNu nim warsawhackerspace uptronics algorithmicharm hypocritcal profiles digitalsketch DeutschlandDigitalSicherBSI typescript forums vscode aapihm gitsyncmurder musicforhackers publiccode ocr computerscience hackers guidelines vieprivée Digitalzwangmelder laravel vala adventofcode cgit solidarność DigitalPayments beginnersguide CommonJS webdev101 scripting coding warn mauikit digitalesouveränität DevelopmentBlog anime ohShitGit digitalzwang meld git org QR_code proof sourcehut ui nocode solid nodejs systemchange trevornoah zinccoop tailwindcss terminalporn Wassersouveränität guix libertàdigitali js_of_ocaml raku fedidev c script freenode-services sourcecode audiodescription publiekecode framaforms WendyLPatrick DigitalAutonomy grep django gmic zim sackthelot amada gitportal Acode gitlab crusty decoder bulldada readability parrot relevance_P1Y mnt digitalartwork Verkada react dogfooding webdevelopment kingparrot Leiharbeit programmer trunk java haskell OpenSourceHardware CodedBias codelyoko workstation guixhome Tarifvertrag capitolhill Auto desperatehousehackers esm penguin unicode development gittutors ursulakleguin programminglanguage gerrit db frgmntscnr Fagradalsfjall dev github freecodecamp openrc tuskydev threema recoverourdigitalspace html5 algorithms PythonJob lisp digitaldefenders codeberg souveränität forge ursulaleguin pleaseshare rustprogramming EspacioDigital HirsuteHippo resnetting frontenddevelopment animatedgif fourtwenty rails rakudev adaptation programme developers bug fortran libraries drivers animation printingsystems freecode forgefed javascript fragment cpm code elisp JardínOpenSource commands patterns eq ECMAScriptModules html codeofconduct vintagecomputers ConstructiveAmbiguity rakulang portal terminal c99 SemillasOpenSource rust programminghumor lowcode request AreWeTheBorg spiritbomb r FOSSlight bugbounty dramasystem go forges digitalaudioworkstation esbuild federadas commonlisp golang clojurescript nodemcu vintage ruby releaseday rustc contractpatch rubylang deceptionpatterns mugorg debugging makejavascriptoptional nodefwd obsolescence_programmée computers developer darkpatterns racket sourceforge forum ksh digitalprivacy minimumwage bugreport mercurial aapi adafruit openappecosystem python fontforge webdeveloper indiedev ocrodjvu sh digitalGardens api assembler kabelfernsehen OpenSource Scheibenwischer - - Tue, 10 Aug 2021 08:34:29 UT - - - legal - NoALaReformaTributaria justafewlines eek scanlines kurmancî rma informatik formatie2021 hfgkarlsruhe doj amro karlsruhe dmc remotelearning tamron formatie SpreekJeUitBekenKleur newnormal line OfflineNavigation disinformation kurmanji OnlineHarms GameSphere squeekboard mermaid stopline3 DNSmugOfTheWeek permagold OnlineHarmsBill laipower gdpr intros Anticritique energyflow peekier MovieGeek OnlineMeetings scan informationsfreiheit mojeek digitalservicesact line3 disinfo mainline freiheit darmanin airline OfflineHarms permafrost geekproblem dmca - - Tue, 10 Aug 2021 08:34:29 UT - - - nature - hiking camping RedNeckedWallaby reforestation hillwalking wat hambach nsu20 marsupial lightning StormBella zensurheberrecht insect morning lavawervelwind seashell delightful plankton otterbox trees sky_of_my_window lichen MicroOrganisms badger nsu2 ProForestation nonsupremacy light gecko birds nature embargo_watch volcano teamcapy butterflies Nature snowden actiblizzwalkout frogs rainforest snow sunrise fossils hambacherforest forestfinance lighthouse hitchhiking leopardgecko moutains coldwater rocks inaturalist revuestarlight clamfacts sunset naturereserve forest LandRestoration australianwildlife forests capybara rgblighting enlightened waterfall sundaymorning forestation enlightenment natur lightening finance walking watches deforestation desert lava natural WoodWideWeb birdsarentreal lichensubscribe morningwalk lighttheme nsu retraction_watch SpringRockShed insects wildlife GreatInsults snowdrift afforestation northernlights RainforestAlliance ProtégeonsLaNature amphibians Bear walk desertification otter - - Tue, 10 Aug 2021 08:34:29 UT - - - writing - blog framablog interactive amwriting authors writingprompt poem lime cutupmethod story pdf linkblog blogPages swap shortstory prompts magazine smallstories prompt blogging smallpoems sciencefiction responsetootherblogs writing proverbs quotes blogs teleprompters noblogo otf logo playwright hedgedoc FediWriters interactivestorytelling westernjournal AuthorsofSocialCoop Videopoetry quote olimex QuickSummary letterwriting icanhazpdf microblog bulletjournal storytelling goodread goodreads journalist creativewriting horror wordplay writers limerick journals artjournaling zineswap zines shortstories journalists journal writingcommunity poetry 20thcenturypoetry logos amwritingfiction - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT countries - roma thai romania korean burma lithuania solomon chile europeanparliament Instanz boycottisrael fiji tajikistan benin paraguay eeuu icelandtrip senegal ukraine italy brunei nicaragua guyana Pflanzenbestimmung grönland euphoria zambia PalestineStrike iceland europeancouncil morocco netherlands swaziland EuropeanUnion bosnian suriname welcome2america winningatlife elsalvador russia freeburma samoa StaatstrojanerParteiDeutschlands romanian asl european czech belarus hayabusa2 bw kyrgyzstan english uk translation sanmarino catalonia panama africa west indians unitedkingdom japan Netherlands buyused venezuela gambia freeNukem kuwait barbados papua greece switzerland brasilien uae mau england FuckIsrael nigeria usa angola honduras djibouti laos sierraleone nonprofit investigation artemis britain cambodia translators ych vietnam esperanto neofeud zealios seychelles marshall kazakhstan estonia investigate tonga stlucia burundi bangladesh egypt nachhaltigkeit japanese mali congo us IcelandicVerminControl jordan MusiciansForPalestine americangods digitaleurope speedrun grenada israel lowestoft psychic algeria ghana bosnia translations russian LateAmericanSentences eritrea bhutan armenian hama hungary Störungsverbot saudi slovenia tig czechosvlovakia bahamas america libadwaita australia kiribati togo DeathToAmerica koreanorth poland Überbevölkerung malawi AlwaysBetterTogether capeverde armenia american hautrauswasgeht bahrain mozambique WichtigerHinweis abcbelarus japaneseglare americanpinemarten beleuchtung southsudan adminlife citylife europehoax Martesamericana syria german micronesia maldives iran indigenous sweden bijîberxwedanarojava ethiopia sid cuba liberia canada burkina indian Südwestgrönland somalia Chile whatshappeningintigray scotland Enlgand russiaToday vaticancity easttimor austria EuropeanUnionNews turkey yemen Bolivia denmark USBased domesticabuse austrianworldsummit madagascar finland Wales Iran philippines ivorycoast haiti ecuador Portugal azerbaijan gasuk spain albania massachusetts afghanistan europe mauritania dominica ökonomisierung thailand belize westpapuauprising nerdlife macedonia montenegro BelarusProtests ChileDesperto thenetherlands qatar mongolia costarica boatingeurope birdsofkenya Australia boat latvia uzbekistan fatigue kabelaufklärung ireland iraq malaysia mexico investigations mauritius dezentralisierung oman chad nz de georgia zimbabwe france serbia lesotho romani halflife oddmuse tunisia argentina czechia cameroon namibia sudan indonesia lifeboat colombia worldwildlifeday kryptowährung tuvalu britainology merica beckychambers turkmenistan tanzania germany trojan neuhier norway comoros auteursrecht guatemala Thailand kosovo eastgermany andorra wales indiastrikes vanlife Palestine servus pakistan belgium china 3615malife antigua life europeanvalues koreasouth newzealand visiticeland einzelfall rwanda luxembourg libya indywales italyisntreal nauru moldova bad spanish eastindiacompany northernireland stigmergic palau taiwan kenya trinidad eu botswana Lebensmittelzusatzstoff CuriosidadesVariadas jamaica vanuatu cyprus aminus3 israele malta Icelandic psychedelia niger s3 westpapua busse unitedstates myanmar saintvincent guinea nepal peru uganda uruguay india pacificnorthwest lebanon neurodiversity southafrica writer arte croatia europeanunion writerslife bolivia chinese dominican europeancommission srilanka bulgaria etherpad slovakia speedrunning gabon psychedelicart ether palestine stkitts liechtenstein saveabkindonesia neofeudalism surinam brazil shutdowncanada + turkish roma thai romania korean burma lithuania solomon chile europeanparliament Instanz boycottisrael fiji tajikistan benin paraguay eeuu icelandtrip senegal ukraine italy brunei nicaragua guyana Pflanzenbestimmung grönland euphoria zambia PalestineStrike iceland europeancouncil morocco netherlands swaziland EuropeanUnion bosnian suriname welcome2america winningatlife elsalvador russia freeburma samoa StaatstrojanerParteiDeutschlands romanian asl european czech belarus hayabusa2 bw kyrgyzstan english uk translation sanmarino catalonia panama africa west indians unitedkingdom japan Netherlands buyused venezuela gambia freeNukem kuwait barbados papua greece switzerland brasilien uae mau england FuckIsrael nigeria usa angola honduras djibouti laos sierraleone nonprofit investigation artemis britain cambodia translators ych vietnam esperanto neofeud zealios seychelles marshall kazakhstan estonia investigate tonga stlucia burundi bangladesh egypt nachhaltigkeit japanese mali congo us IcelandicVerminControl jordan MusiciansForPalestine americangods digitaleurope speedrun grenada israel lowestoft psychic algeria ghana bosnia translations russian LateAmericanSentences eritrea bhutan armenian hama hungary Störungsverbot saudi slovenia tig czechosvlovakia bahamas america libadwaita australia kiribati togo DeathToAmerica koreanorth poland Überbevölkerung malawi AlwaysBetterTogether capeverde armenia american hautrauswasgeht bahrain mozambique WichtigerHinweis abcbelarus japaneseglare americanpinemarten beleuchtung southsudan adminlife citylife europehoax Martesamericana syria german micronesia maldives iran indigenous sweden bijîberxwedanarojava ethiopia sid cuba liberia canada burkina indian Südwestgrönland somalia Chile whatshappeningintigray scotland Enlgand russiaToday vaticancity easttimor austria EuropeanUnionNews turkey yemen Bolivia denmark USBased domesticabuse austrianworldsummit madagascar finland Wales Iran philippines ivorycoast haiti ecuador Portugal azerbaijan gasuk spain albania massachusetts afghanistan europe mauritania dominica ökonomisierung thailand belize westpapuauprising nerdlife macedonia montenegro BelarusProtests ChileDesperto thenetherlands qatar mongolia costarica boatingeurope birdsofkenya Australia boat latvia uzbekistan fatigue kabelaufklärung ireland iraq malaysia mexico investigations mauritius dezentralisierung oman chad nz de georgia zimbabwe france serbia lesotho romani halflife oddmuse tunisia argentina czechia cameroon namibia sudan indonesia lifeboat colombia worldwildlifeday kryptowährung tuvalu britainology merica beckychambers turkmenistan tanzania germany trojan neuhier norway comoros auteursrecht guatemala Thailand kosovo eastgermany andorra wales indiastrikes vanlife Palestine servus pakistan belgium china 3615malife antigua life europeanvalues koreasouth newzealand visiticeland einzelfall rwanda luxembourg libya indywales italyisntreal nauru moldova bad spanish eastindiacompany northernireland stigmergic palau taiwan kenya trinidad eu botswana Lebensmittelzusatzstoff CuriosidadesVariadas jamaica vanuatu cyprus aminus3 israele malta Icelandic psychedelia niger s3 westpapua busse unitedstates myanmar saintvincent guinea nepal peru uganda uruguay india pacificnorthwest lebanon neurodiversity southafrica writer arte croatia europeanunion writerslife bolivia chinese dominican europeancommission srilanka bulgaria etherpad slovakia speedrunning gabon psychedelicart ether palestine stkitts liechtenstein saveabkindonesia neofeudalism surinam brazil shutdowncanada southamerica studyingermany sliceoflife mychemicalromance study marshallmcluhan aether croatian deutschewelle boats unite company nomanssky echo haitians echolocation northamerica - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + programming + qt softwaredevelopment Easer psychotherapie DigitalInfrastructure cpp digitalpreservation programming css maui rubyonrails objects Python system digitaldivide digitalisierung FrancisBacon2020 dracut gitea orgmode mixers webdev proofing developerexperience seguridaddigital gui digital release ada schutzstreifen pypi crust codeforge workaround proofofwork zorg node websocket proofofstake ecosystem rustlang systemwandel DigitalTech python2 ocaml NapierBarracks system76 program ngiforum21 DigitalSouveräneSchule request_reaction sqlite guile capitolhillautonomouszone transcript TransZorgNu nim warsawhackerspace uptronics algorithmicharm hypocritcal profiles digitalsketch DeutschlandDigitalSicherBSI typescript forums vscode aapihm gitsyncmurder musicforhackers publiccode ocr computerscience hackers guidelines vieprivée Digitalzwangmelder laravel vala adventofcode cgit solidarność DigitalPayments beginnersguide CommonJS webdev101 scripting coding warn mauikit digitalesouveränität DevelopmentBlog anime ohShitGit digitalzwang meld git org QR_code proof sourcehut ui nocode solid nodejs systemchange trevornoah zinccoop tailwindcss terminalporn Wassersouveränität guix libertàdigitali js_of_ocaml raku fedidev c script freenode-services sourcecode audiodescription publiekecode framaforms WendyLPatrick DigitalAutonomy grep django gmic zim sackthelot amada gitportal Acode gitlab crusty decoder bulldada readability parrot relevance_P1Y mnt digitalartwork Verkada react dogfooding webdevelopment kingparrot Leiharbeit programmer trunk java haskell OpenSourceHardware CodedBias codelyoko workstation guixhome Tarifvertrag capitolhill Auto desperatehousehackers esm penguin unicode development gittutors ursulakleguin programminglanguage gerrit db frgmntscnr Fagradalsfjall dev github freecodecamp openrc tuskydev threema recoverourdigitalspace html5 algorithms PythonJob lisp digitaldefenders codeberg souveränität forge ursulaleguin pleaseshare rustprogramming EspacioDigital HirsuteHippo resnetting frontenddevelopment animatedgif fourtwenty rails rakudev adaptation programme developers bug fortran libraries drivers animation printingsystems freecode forgefed javascript fragment cpm code elisp JardínOpenSource commands patterns eq ECMAScriptModules html codeofconduct vintagecomputers ConstructiveAmbiguity rakulang portal terminal c99 SemillasOpenSource rust programminghumor lowcode request AreWeTheBorg spiritbomb r FOSSlight bugbounty dramasystem go forges digitalaudioworkstation esbuild federadas commonlisp golang clojurescript nodemcu vintage ruby releaseday rustc contractpatch rubylang deceptionpatterns mugorg debugging makejavascriptoptional nodefwd obsolescence_programmée computers developer darkpatterns racket sourceforge forum ksh digitalprivacy minimumwage bugreport mercurial aapi adafruit openappecosystem python fontforge webdeveloper indiedev ocrodjvu sh digitalGardens api assembler kabelfernsehen OpenSource Scheibenwischer mainline smalltalk digitalindia postgresql emscripten digitalsobriety featurerequest Payment iode Slight ThisIsWhatAutonomyLooksLike frontend rivers WebDivelopment asyncio riversedge graphql Blog DroneCI WoodpeckerCI continuousintegration Devs + + Fri, 01 Oct 2021 18:50:09 UT + + + corporations + palantir starbucks + + Fri, 01 Oct 2021 18:50:09 UT + + + places + communedeparis lapaz luanda klimakatastrophe asunción salisbury nouakchott conakry kyiv enviromentalism gadgetbridge moscow winchester cardiff saipan gibraltar dublin KlimaGerechtigkeit stuff catalunya dannibleibt avarua lilo wolverhampton hargeisa delhi niamey chișinău freestuff colombo dundee brasília StupidComparisons brushes phnompenh mbabane danni belgrade rotterdam stasaph belmopan detroit ghent pyongyang hannover strawinsky calls ulaanbaatar oranjestad kali Reykjavik Barliman gaborone seattle ndjamena lancaster chelmsford raw singapore classicalmusic tuberlin Lanarkshire feedbackwanted preston lincoln kingedwardpoint abidjan nuuk york asshole pretoria papeete DreamtimeVillage washingtonstate bradford malé rhetorical robberfly sunderland zagreb gitega abudhabi flyingfishcove castries revil georgetown suffolk wickr hagåtña podman lochlomond videoFreex oric ella lichtenberg videofeedback borikua basseterre hamburg southeastasia afrika kinshasa Schadensersatzforderung streetartparis suva klimaatverandering valparaíso athens roseau sheffield baku aberdeen charlotteamalie antananarivo domi pristina northumberland RadentscheidJena bordeaux diff MakoYass videocalls santiago fsb sukhumi berlin urk bristol uptronicsberlin funafuti libreville newry rush radentscheid puertorico ClimateChange hanoi philipsburg tehran banjul prague Stockente rawhide andorralavella daw yerevan portauprince videoprojects sensorica mewcastle dakar asu paramaribo tifariti durham CrisiClimatica capetown rigaer94 dma tirana klima ankara ipswich managua lisbon bishkek amsterdam climatchoas kent klimaat EastVirginia portonovo santodomingo wakefield bangkok texas coventry bucharest kathmandu aden buchtipp madrid cleanarchitecture paris14 sanjuan vienna kingston stuttgart Utrecht inverness kabul damascus stockholm douglas ClassOf2015 willemstad brighton klimaschutz klimaatnoodtoestand hibernoenglish thehague panamacity RassismusTötet beirut belfast amman newdelhi tórshavn nottingham nouméa oslo alofi gustavia paris fromembers cockburntown berlinale manchester dominio ottawa classical buch stepanakert portofspain klimakrise class fsberlin honiara berniememe asmara florida nicosia helsinki anywhere taipei salford tegucigalpa bridge tokyo tashkent larochelle vr gabocom MadeInEU sarajevo algiers southampton KlimaKrise nairobi muscat monaco riyadh flying lusaka perth wellington wick bissau juba mariehamn majuro parisagreement mumiaabujamal norwich buenosaires douglasrushkoff ngerulmud dhaka berlinhateigenbedarf guatemalacity washington bedarf vatican kuwaitcity martlesham Erdmannhausen Puntarella londonboaters SystemChangeNotClimateChange bern mexicocity amap bratislava myasstodontownhall bridgetown delhipolice stokeonTrent crowsnestpass leeds tunis manila warwickshire architecture rigaer94verteidigen arctic stanley matautu copenhagen hereford barcelona lomé videocall budapest ouagadougou mogadishu PrawnOS freetown victoria bangor lora brazzaville portmoresby ashgabat kampala Klimaatalarm gigabitvoucher kirigami webassembly yorkshire elaaiún kalimantan vilnius guineabissau ContourDrawing bloemfontein gnuassembly swansea classe sucre london passalong marseille berniesanders pagopago bradesestate oakland vaduz birmingham addis lisburn nürnberg naypyidaw derry CassetteNavigation khartoum baghdad bandar truro moroni cuirass rigaer lehavre klimaliste portvila kingstown armagh Klima ulm ChrisCrawford reykjavík lofi manama accra mewport windhoek fortworth nukualofa classic ciutatvella tbilisi canberra quito maputo cetinje adams putrajaya lichfield ramallah solimaske oslotown bogotá warming portsmouth dodoma berkeley harare stirling havana warsaw klimapolitik rigaer94bleibt münster valletta snes localberlin ljubljana bamako leicester kualalumpur peterborough podgorica rabat cotonou oranje plymouth seoul westminster neumünster Portland dushanbe bangui aotearoa theCellar canterbury westisland tskhinvali palikir caracas brussel jamestown rome gloucester munich cambridge ripon carlisle freestuffberlin wells chichester sãotomé jakarta floraspondence daressalaam sansalvador seo apia essex klimawandel yaren cairo jerusalem brussels kigali southtarawa beijing minsk montevideo vientiane philips maseru klimaatopwarming hamilton lorawan lurk doha klimaatwake worcester tripoli celtic portlouis stalbans lima adamstown deventer weimar abuja fuckalabamapower saw lilongwe nassau lobamba heathrow nyc oxford fly montreal klimaatzaakshell rawtherapee dili feedback thesprawl riga r94 assembly lesbos monrovia nursultan Neuzulassung caste gab sanjosé klimaatrechtvaardigheid marigot islamabad fb malabo tallinn sahara thimphu seattleprotestnetwork klimaatzaak exeter oranjeklanten klimanotstand chester brest yaoundé praia bujumbura strawberries washingtondc derby sofia skopje jest newyorkcity NewYorkflooding helm bushwick Glassholes anarchistbookfairinlondon Puntarella33 floral klimaflucht cambridgeshire felixstowe colchester derbyshire allefürsklima brush ScuolaPuntarella + + Fri, 01 Oct 2021 18:50:09 UT + + + internet + homeserver datasette onlinesafetybill linkeddata markdown selfsufficiency webgl LoveWattsBLM decentralised immersiveweb pep decentraliseren i2p sceptic earlyinternet Clubhouse CooperativeClouds spam firefox redecentralize NYCMesh decentral socializing Burocratic toxicmasculinity staticsitegenerator wikipedia zeitschriften maps rtmp PlasticFreeJuly dataprotection NNCP decentralization inclusiónsocial decentralize IPFSing w3c OsmFinds datacollection files dotConism offlineimap DutchPolitics internetaccess agnostic geminispace archivists gaza selfhosted piratenpartij mapuche videohosting DarkPatternsFTC metafilter maille meta wikibase CooperativeTechnology torrent mailab geocaching freenode MollyBrown mailfence bot adblocker tox k9mail nylasmail smalltech data socialism basemap webarchive sitejs meshroom protocol anticolonial VerkehrsswendeJetzt thecloud Jabbber worldbusterssocialclub publicserviceinternet networks criticism bioinformatics online openddata centralisation flameshot internetarchaeology WordPress darkages hiddenServices chainmail datarecovery self elinks saferinternetday selfhost text SeattleHellDay contentmoderation distributed OperationPanopticon mappe mydata webhosting decentralizedweb mailman SOASJusticeforCleaners natto p2pleft socialdistancing router sysadminday protection rne dataretention speedtest ublockorigin bigdata routeros internetofthings -} + +data greenhosting selfhosting forkawesome communityhosting TikTok tilde CriminalJusticeBill networking brave panopticon aldi icann selfsustaining hosting mailart DAOs discourse digitalcolonialism weblate kinosocial libera coopserver PeerToPeer wikis dns decentralizetheweb stripe service openstandards economíasocial responsiveness nojs ejabberd amusewiki freifunk oauth Anticon tic foxes hypercore CDNsAreEvil meshtastic piratebay protonmail TubEdu standards StuffCircuit yourdataisyourdata internetfreedom mirroring onlineWhiteboard gemini antarctic zeit webui InternetCrimeFamily wlan boilemMashEmStickEmInAStew internetBanking SmallWeb fedwiki snikket redessociales fleenode ircd coopcloud cw internetshutdown democratic criticalmass masculinity datadetox mailpile clearnet cdn cloudflared liberapay pinterest brahmaputra distributedcoop xmpp semanticweb identicurse socialnetwork Disarchive selfie anticolonialism website datasets SaferInternetDay content splinternet participation highavailability webstandards mapa groenlinks domains ntp centralized cloudfront socialnetworks metadata wikileaks disconnect Meme aioxmpp database socialanxiety proton disco web3 cloudfirewall TLSmastery descentralizare icmp organicMaps oop videocast governement jabber cleanuptheweb webbrowsers webhook communications decentralized userdata selflove wiki cloudron bsi browserextensions Fragattacks RedditDown ssb darknet cookies Qute MattHancock darkweb netcat webInstaller liberachat safety uberspace map Konfekoop Reddit archiv recaptcha server browser cloudy IPFS p2p social chainmaille antisocial tiddlywiki www missioncritical FreenodeTakeover ageverification corne fortinet Pluralistic databreach opendata ilovewikipedia web WebsiteStatus ownyourdata battiato netshutdowns alttext xep callforparticipation twitch im darkmode 9front bbb quadraticvoting GaiaX gavcloud decentralise att jabberspam theserverroom antarctica shutdowns Watomatic datafree greenhost domain mesh selfemployed hackint OpenStreetMap gnusocial darkambient RudolfBerner slixmpp geminiprotocol statistics BurnermailIO irc osm eveonline pirate plaintext Graphika datacracy filesharing sysadminlife ownlittlebitofinternet squatting sysadmin misinformation rss ipns mozilla twitchbannedrevision voicemail gazaunderattack mapbox Nyxt legacyInternet yacy webrtc databases symbiotic debloattheweb crosspost fastmail sysadmins jmap mail tinycircuits bureaucratic i2pd aesthetic ipfs internetradio bravenewworld routers practice browsers wikidata selfpub decentralizeit ballpointpen puredata netscape SSH mixcloud RSS DecolonizeTheInternet gmail openculture websites letthenetwork cyberspace SwitchToXmpp messaging selfies offthegrid enxeñeríasocial cloud ddg bopwiki blabber snailmail cleanup selfdefense internet moderation decentralisation justcloudflarethings webinar metaverse qutebrowser _w3c_ socialcooling intox scholarsocial Seattle fox umap centralization ssbroom pihole serverMeddling sealioning missingmaps qtox puremaps archiving bravesearch sneakernet NatureNeedsJustice Nextcloud internetarchive dataintegration mydataismydata dweb kmail js metatext adblock dark captcha socialNetworks BlackHatSEO beakerbrowser LiberaChat openweb soulseek NetShutdown enigmail libervia failwhale onlineharms webp gooddata mailinglist kernelupgrade dot wifi Internet descentralizarea thepiratebay internetshutdowns fixtheweb mapporn contentid lazyweb servers atom kernel socialweb colonial AtomPub firewall shutdown ambient socialists kernenergie ebay zeitschrift mozillahubs instantmessaging publicservice interoperabilitate SolidProject tiktok Justice4MohamudHassan cloudflare nextgenerationinternet webdesign taco dataareliability theinternetarchive undemocratic onlinesafety torbrowser Word oldmanyellingatcloud cisco TechWontSaveUs gajim groupcall xmppc static catalystcloud microservers LibreServer CloudIsland talklikeapirateday webbrowser ffdweb webarchives servicetoot serverscoop contextpatrol MikeSearches computerSaysNo pirateparty ZeroData searchengine DuckDuckGo + + Fri, 01 Oct 2021 18:50:09 UT + + + employment + justworked futureofwork InterviewQuestions jechercheunjob mywork remote employees hiring TheNetwork workingfromhome ProgrammingJob reproductivework frame workinprogress bullshitjobs car workplace DigitalNetwork antiwork workshops kreaturworks workers worklog sexworkers remotejob mainframe remotework remotejobs migrantworkers job culturalworkers DjangoJob teamwork framework hire KDEGear careers hirefedi career SocialNotworks workshop bedfordshire illustratorforhire tidyworkshops carework AtlasNetwork nowhiring KDE remoteaccess rds KDEGear21 obs workersrights obsolescence records KDEFrameworks plannedobsolescence work hertfordshire flossjobs jobs workflow precariousworkers carddav sexworker theworkshop nerdsnipe employee overwork vacation cheshire horizontalNetwork + + Fri, 01 Oct 2021 18:50:09 UT + + + hospitality + OpenHospitalityNetwork Couchers couchsurfing + + Fri, 01 Oct 2021 18:50:09 UT + + + gafam + zuckerberg caringissharing ads apple youtuberegrets antitrust SpringerEnteignen peoplefarming deletewhatsapp advertisingandmarketing chromevox GoogleDown aws AppleSearch Floc bankruptBezos googlesearch googleio mycologists bringBunysBack youtube Goggle twitterkurds banadvertising chromebook fuckfacebook headset arcgis ffs FacebookEvents AmazonMeansCops facebook wandering 100heads 20thcenturyadvertising amazon googlevoracle amazonprimeday dystopia microsoftgithub farcebook myco boycottinstagram FlocOff stopgafam genoegisgenoeg legislation amazonprime deletewhatsappday amazonring Gafam googleplus soldering GoogleForms weirdyoutuberecomendations HaringeyAnti delete FoodSharing lobbyregister degooglisation florespondance linkedin siri Apple Facebook LeiharbeitAbschaffen PoweringProgress advertising monopolies googleanalytics ausländerzentralregister adtech fuckgoogle storing plottertwitter failbook kadse microsoft deletechrome alanturing dtm poledance HeadscarfBan twitter skype azure chrome logistics googledoodles hildebrandt twitterblue corporateGiant Tracking uitkeringen FlocOffGoogle sidewalk plot zuck nogafam youtubedl degoogled Google youtubers google Microsoft stemverklaring gis walledgarden GAFCAM dt GooglevsOracle dotcoms deleteyoutube datafarms Instagram walledgardens agistri appleevent offseting Hypnagogist appleii facebookoversightboard fascistbook FuckGoogle degoogle boringdystopia fuschia ohneamazon appleiie deleteinstagram ungoogled ring stopgoogle affordances googledown decentring YouTube gafam inspiring oracle killedbygoogle fuckoffgoogle dance deletefacebook gradschool fakebook GoogleIsBad fuckoffgoogleandco office365 lordoftherings turingpi amazonas instagram TrackingFreeAds FlocBloc playstore synergistic bigtech boycottamazon amazonien whatsapp mytwitteranniversary deleteamazon bluesky Amazon weird boycott AppleToo aiweirdness medtwitter twitterstorians chromecast GoogleOP1 anniversary FuckYouGoogle BringSundiataHome SilosSuck deletegoogle BoycotGoogle boycottgoogle GoogleImage + + Fri, 01 Oct 2021 18:50:09 UT + + + linux + pubnix fishshell linuxboot compiz osdev musescore commandline opensuse share linuxisnotanos elementaryos cli buster viernesdeescritorio voidlinux shell nu cliff olinuxino deb composite beschbleibt kde FragAttacks Debian11 reprobuilds pureos nospoilers kdepim thisweekinlinux slackware search bsd tap openwrt runbsd distros stapler viernes tmux nixos alpine nix DebianBullseye jobsearch rm xfce ubuntubuzz gnutools vaguejoke ack shareyourdesktop shellagm personal wireguard posix lightweight whonix hardenedbsd Guix linuxaudio mate haikuos usb initramfs nushell LinuxTablets nixpkgs wordsearch landback osi alpines computertruhe nonmateria torvalds gtk linuxmint DebianAcademy debian chroot trisquel studio gnome distrowatch oldcomputerchallenge linuxposting fedoraonpinephone trackers console showyourdesktop FuckDeMonarchie researchassistants anarchie windowmanager desktop GuixSystem arch chaoscomputerclub personalities platform ubuntu personalwiki jodee snowfall gnulinux patriarchat aur tuxjam justlinuxthings xubuntu thesuicidesquad kdeframeworks5 stackoverflow unix fedora openbsd centos nos fittrackee tuxedocomputers tracker openmandriva backwaren gentoo buildroot aurora researcher archive icarosdesktop BlackLives liveusb dee SearchFu personalarchive usergroup StockOS systemd linuxgaming Debian distro 1492LandBackLane Racklet theartofcomputerprogramming icecat tape puppylinux destinationlinux LinuxSpotted lovelinux thestudio suicide aros show Squarch monstrosities computer gtk3 blackout deepBlah escritoriognulinux acepride materials qubesos i3wm clipstudiopaint dadjokes kubuntu epr artixlinux JuiceFS reproducible kdecommunity haiku alpinelinux linuxisnotaplatform clip fall linux EMMS planetdebian minicomputer altap raspbian netbsd DanctNIX termux btrfs reproduciblebuilds showTheRainbow gravitationalwaves joke artix gtk4 esc linuxexpress archlinuxarm bash dd exposingtheinvisible archlinux hare ubuntucore linuxconfau newinbullseye researchers AuratAzadiMarch gnomebuilder void GNUlinux rhel debianinstaller debianindia linuxisajoke tux devuan debían suse zsh linuxconsole scoobySnacks bullseye gnomemaps fedisearch SharedEarningsAgreement archives elementary MNTResearch thejoker debianedu gnomesettings tuxedo RasPup linuxdesktop KDEapps bunniestudios KDEAplications ubuntustudio frankendebian DistroHoppingFromAndToWindows Freeshell + + Fri, 01 Oct 2021 18:50:09 UT + + + legal + NoALaReformaTributaria justafewlines eek scanlines kurmancî rma informatik formatie2021 hfgkarlsruhe doj amro karlsruhe dmc remotelearning tamron formatie SpreekJeUitBekenKleur newnormal line OfflineNavigation kurmanji OnlineHarms GameSphere squeekboard mermaid stopline3 DNSmugOfTheWeek permagold OnlineHarmsBill laipower gdpr intros Anticritique energyflow peekier MovieGeek OnlineMeetings scan informationsfreiheit mojeek digitalservicesact line3 disinfo freiheit darmanin airline OfflineHarms permafrost dmca Tuscany formatiedebat religionsfreiheit scotus NeitherPipelinesNorPoliticians + + Fri, 01 Oct 2021 18:50:09 UT + + + indymedia + geekproblem encryptionist fpga hs2 dotcons visionontv geek tredtionalmedia indiemedia degeek globleIMC indymediaback pga mainstreaming indymedia closed stupid foo encryptionsist hs2IMC indymediaIMC network networkmonitoring Blackfoot roadsIMC stupidindivialisam roadstonowhere networkeffect lifecult closedweb avgeek monitor dotconsall omn tv roadstonowhereIMC kiss UKIMC fluffy 4opens openmedianetwork deathcult stupidiindividualism dorcons meshnetwork stupidindividualism geekproblm + + Fri, 01 Oct 2021 18:50:09 UT + + + nature + hiking camping RedNeckedWallaby reforestation hillwalking wat hambach nsu20 marsupial lightning StormBella zensurheberrecht insect morning lavawervelwind seashell delightful plankton otterbox trees sky_of_my_window lichen MicroOrganisms badger nsu2 ProForestation nonsupremacy light gecko birds nature embargo_watch volcano teamcapy butterflies Nature snowden actiblizzwalkout frogs rainforest snow sunrise fossils hambacherforest forestfinance lighthouse hitchhiking leopardgecko moutains coldwater rocks inaturalist revuestarlight clamfacts sunset naturereserve forest LandRestoration australianwildlife forests capybara rgblighting enlightened waterfall sundaymorning forestation enlightenment natur lightening finance walking watches deforestation desert lava natural WoodWideWeb birdsarentreal lichensubscribe morningwalk lighttheme nsu retraction_watch SpringRockShed insects wildlife GreatInsults snowdrift afforestation northernlights RainforestAlliance ProtégeonsLaNature amphibians Bear walk desertification otter toadstool EyesOnNature alligator woodpecker + + Fri, 01 Oct 2021 18:50:09 UT + + + writing + blog framablog interactive amwriting authors writingprompt poem lime cutupmethod story pdf linkblog blogPages swap shortstory prompts magazine smallstories prompt blogging smallpoems sciencefiction responsetootherblogs writing proverbs quotes blogs teleprompters noblogo otf logo playwright hedgedoc FediWriters interactivestorytelling westernjournal AuthorsofSocialCoop Videopoetry quote olimex QuickSummary letterwriting icanhazpdf microblog bulletjournal storytelling goodread goodreads journalist creativewriting horror wordplay writers limerick journals artjournaling zineswap zines shortstories journalists journal writingcommunity poetry 20thcenturypoetry logos amwritingfiction stories wether whether microblogging shorts Fotojournalist fantomesZine + + Fri, 01 Oct 2021 18:50:09 UT music - LibreMusicChallenge musicprodution KobiRock iea travessiapelavida LaurieAnderson ics punk punkname ourbeats gas vollgasindiekrise indieweb musician cypherpunk rutasenemigas synthesizer daftpunk bootstrappable kenloach indiemusic collapseos meatpunks LibreGraphicsMeetup cipherpunk 20thcenturyjazz acousticguitar synthpop psychedelicrock steamlinux playingnow streetpunk loader hydrapaper bikepunks bandcamp mymusic pop countryrock musicians jamendo ipod skinheadmusic jam rap shoegaze mp3 nettlepunk steam indie steganography PegasusSpyware steampunk ldjam48 indieauthor composing folkrock perlligraphy nazipunksfuckoff Music strap EnvoieStopHashtagAu81212 anarchopunk eurovisionsongcontest biography musicmaking psychedelic thecure posthardcore vaporwave IndustrialMusicForIndustrialPeople Mixtip dubstep synthwave bootstrap princeday oi graphisme rave freemusic nowplaying hiphop hardcore frappuccino Musicsoft experimentalmusic nazi folk cp TravesíaPorLaVida spotify fedimusic ml bootstrapping webscraping elisamusicplayer funkloch musicbrainz eurovision lasvegas catsWithMusicalTalent PegasusSnoopingScandal eos90D soundcloud psicodelia frankiegoestohollywood gastropod whenyoulistentocoildoyouthinkofmusic trial soundsynthesis PigTrap bassguitar collapse 20thcenturymusic powerpop vinyl rock ccmusic typographie dj newwave dorkwave producing experimental celticmetal prince musicproduction chiptune scraping loa Schleprock thrash bluestacks lastfm uploadfilters tekno ripprince Eurvision maunaloa technocracy asus 1 funkwhale 20thcenturyrock eos wp playlist retrosynth NowPlaying contest libremusicproduction psychrock MusicAdvent poppy coinkydink appropriatetechnology toledo samensterk indiepop rockalternativo MusicTouring indierock pmbootstrap midi arianagrande indiecember synth guitar blues musiciens listeningtonow abandonedplaces music folkpunk np bass techno gmtkjam musicmonday jazz production graphics dieanstalt perl darkwave mastomusic band TheGrunge metal chipmusic graphviz tigase polychromatic funk mindjammer popos magnatune fediversemusic pegasus PegasusProject grunge postpunk punkrock indieauth cyberpunkmusic raveculture cleantechnologies ldjam ftp BandcampFriday elisa mixtape garagerock MusicsoftDownloader camanachd + LibreMusicChallenge musicprodution KobiRock iea travessiapelavida LaurieAnderson ics punk punkname ourbeats gas vollgasindiekrise indieweb musician cypherpunk rutasenemigas synthesizer daftpunk bootstrappable kenloach indiemusic collapseos meatpunks LibreGraphicsMeetup cipherpunk 20thcenturyjazz acousticguitar synthpop psychedelicrock steamlinux playingnow streetpunk loader hydrapaper bikepunks bandcamp mymusic pop countryrock musicians jamendo ipod skinheadmusic jam rap shoegaze mp3 nettlepunk steam indie steganography PegasusSpyware steampunk ldjam48 indieauthor composing folkrock perlligraphy nazipunksfuckoff Music strap EnvoieStopHashtagAu81212 anarchopunk eurovisionsongcontest biography musicmaking psychedelic thecure posthardcore vaporwave IndustrialMusicForIndustrialPeople Mixtip dubstep synthwave bootstrap princeday oi graphisme rave freemusic nowplaying hiphop hardcore frappuccino Musicsoft experimentalmusic nazi folk cp TravesíaPorLaVida spotify fedimusic ml bootstrapping webscraping elisamusicplayer funkloch musicbrainz eurovision lasvegas catsWithMusicalTalent PegasusSnoopingScandal eos90D soundcloud psicodelia frankiegoestohollywood gastropod whenyoulistentocoildoyouthinkofmusic trial soundsynthesis PigTrap bassguitar collapse 20thcenturymusic powerpop vinyl rock ccmusic typographie dj newwave dorkwave producing experimental celticmetal prince musicproduction chiptune scraping loa Schleprock thrash bluestacks lastfm uploadfilters tekno ripprince Eurvision maunaloa technocracy asus 1 funkwhale 20thcenturyrock eos wp playlist retrosynth NowPlaying contest libremusicproduction psychrock MusicAdvent poppy coinkydink appropriatetechnology toledo samensterk indiepop rockalternativo MusicTouring indierock pmbootstrap midi arianagrande indiecember synth guitar blues musiciens abandonedplaces music folkpunk np bass techno gmtkjam musicmonday jazz production graphics dieanstalt perl darkwave mastomusic band TheGrunge metal chipmusic graphviz tigase polychromatic funk mindjammer popos magnatune fediversemusic pegasus PegasusProject grunge postpunk punkrock indieauth cyberpunkmusic raveculture cleantechnologies ldjam ftp BandcampFriday elisa mixtape garagerock MusicsoftDownloader camanachd bassed technologie rockbox anki hexd - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT gardening - seedstarting BlagueDeCodeur sporespondence blockade inde mastogarden kinder communitygardening som deno composting soil sehenswert cabbage bundeswehr opensourceseeds onions lettuce blossoms gardenersofmastodon datenschleuder florespondence garten rinder succulent mulberry weekendGardeningThoughts cherryblossoms garden thyme flower horticulture DailyFlowers Schlachthofblockade cherryblossom agriculture acu vegetable plant bricolage financialindependence plasticflowersneverdie kinderbijslag permaculture awesome teracube hens papuamerdeka Auflagen wildflowers lag independenceday CompanionPlanting vlag gardens independence flowers seed kale seedvault plants thegardenpath devilslettuce vegetables thegarden fahrräder gardenersworld recyclage golden beekeeping toeslagenaffaire seeds Opensourcegarden toeslagenschandaal vegetablegarden + seedstarting BlagueDeCodeur sporespondence blockade inde mastogarden kinder communitygardening som deno composting soil sehenswert cabbage bundeswehr opensourceseeds onions lettuce blossoms gardenersofmastodon datenschleuder florespondence garten rinder succulent mulberry weekendGardeningThoughts cherryblossoms garden thyme flower horticulture DailyFlowers Schlachthofblockade cherryblossom agriculture acu vegetable plant bricolage financialindependence plasticflowersneverdie kinderbijslag permaculture awesome teracube hens papuamerdeka Auflagen wildflowers lag independenceday CompanionPlanting vlag gardens independence flowers seed kale seedvault plants thegardenpath devilslettuce vegetables thegarden fahrräder gardenersworld recyclage golden beekeeping toeslagenaffaire seeds Opensourcegarden toeslagenschandaal vegetablegarden seedsaving kirchensteuer compost compostmag - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT privacy - privacyplease appleprivacyletter state whatip auditableprivacy appleprivacy PrivacyBook SearchHistory privacyaware dataprivacyday profiling what3words surveillancestate Privacy datenschutz privacypolicy WhatsApp privacyrights privacytoolsio privacyshield makeprivacystick privacyweek surveillancetech onlineprivacy developertools WhatMakesMeReallyAngry privacyredirect Liberanet LiberanetChat drugpolicy privacymatters policy privacyMatters whatsappprivacypolicy dataprivacy privacywashing fight4privacy privacy privacyinternational whowhatwere hat NoToWhatsApp DataPrivacyDay2020 investinprivacy PrivacyFlaw statePropaganda nl privacytools WhatsappPrivacy tool + privacyplease appleprivacyletter state whatip auditableprivacy appleprivacy PrivacyBook SearchHistory privacyaware dataprivacyday profiling what3words surveillancestate Privacy datenschutz privacypolicy WhatsApp privacyrights privacytoolsio privacyshield makeprivacystick privacyweek surveillancetech onlineprivacy developertools WhatMakesMeReallyAngry privacyredirect Liberanet LiberanetChat drugpolicy privacymatters policy privacyMatters whatsappprivacypolicy dataprivacy privacywashing fight4privacy privacy privacyinternational whowhatwere hat NoToWhatsApp DataPrivacyDay2020 investinprivacy PrivacyFlaw statePropaganda nl privacytools WhatsappPrivacy tool policymaking FutureOfPrivacy - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT media - tradicional InfiniTime livestreaming ip digitalmedia mustwatch sustainable videobearbeitung transparency polarbears mediathek mianstreaming stream videoconferencias trad AtlanticGulfstream maistreaming ime sustainabilty mixxx shortfilm selfsustainable amstrad kawaiipunkstreams mainstream films streaming weAreAllCrazy video streamdeck puns maiabeyrouti videoconference shortfilms mix MediaEU mixed diymedia Fairtrade drmfree film streams massmedia stummfilm submedia theatlantic traditionalmedia videos Internetradio mediawatch mainstreamining newsmedia audiovideo videosynthesis filmnoir wikimedia railroad mixedmedia railroads heat documentary streamers artstream vi nationalgeographic folktraditions gstreamer tootstream taina ai mediawiki slowtv bear realmedia media independentmedia SiberianTimes theintercept + tradicional InfiniTime livestreaming ip digitalmedia mustwatch sustainable videobearbeitung transparency polarbears mediathek mianstreaming stream videoconferencias trad AtlanticGulfstream maistreaming ime sustainabilty mixxx shortfilm selfsustainable amstrad kawaiipunkstreams mainstream films streaming weAreAllCrazy video streamdeck puns maiabeyrouti videoconference shortfilms mix MediaEU mixed diymedia Fairtrade drmfree film streams massmedia stummfilm submedia theatlantic traditionalmedia videos Internetradio mediawatch mainstreamining newsmedia audiovideo videosynthesis filmnoir wikimedia railroad mixedmedia railroads heat documentary streamers artstream vi nationalgeographic folktraditions gstreamer tootstream taina mediawiki slowtv bear realmedia media independentmedia SiberianTimes theintercept novaramedia filmpreservation disintermedia mediagoblin - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + ai + ai macos machinelearning openai EthicsInAI smartcity + + Fri, 01 Oct 2021 18:50:09 UT health - eventsourcing merchandise FreedomIsTheOnlyTreatment gnuhealth water 4 medical CoronaApp bundesregierung runningdownthewalls watersnood EfeLevent autism burnout Underunderstood cannabis hand event healthinsurance medicine anxiety freshwater mh inflammation run eternalpuberty NHSDataGrab treatment EmotionalFirstAid safeabortion4all maryjane organisierung autistic BlockBrunsbüttel running neurodivergent health motion crunchbang actuallyautistic meds PatientSafety marijuana suicideprevention mentalhealth postmortem H5N8 healthy DarrenChandler autismmeme einzelhandel drugs atm neurodiverse asperger cigarettes insurance hearingimpairment selfcare autismus + eventsourcing merchandise FreedomIsTheOnlyTreatment gnuhealth water 4 medical CoronaApp bundesregierung runningdownthewalls watersnood EfeLevent autism burnout Underunderstood cannabis hand event healthinsurance medicine anxiety freshwater mh inflammation run eternalpuberty NHSDataGrab treatment EmotionalFirstAid safeabortion4all maryjane organisierung autistic BlockBrunsbüttel running neurodivergent health motion crunchbang actuallyautistic meds PatientSafety marijuana suicideprevention mentalhealth postmortem H5N8 healthy DarrenChandler autismmeme einzelhandel drugs atm neurodiverse asperger cigarettes insurance hearingimpairment selfcare autismus medicineedison abortion libhandy medicalcannabis paramedic ambulance - Tue, 10 Aug 2021 08:34:29 UT - - - hardware - plugandplay bluetooth printnightmare singleboardcomputer purism dating schematics opennic tektronix zomertijd librehardware BoBurnham restauration rmw riscv solarpower carbonFootprintSham mietendeckel PersonalComputer cyberdeck PineCUBE firmware tex keyboards debuerreotype electron ChromebookDuet AbolishFrontex webcam bond hibernation PneumaticLoudspeakers schreibmaschine imac Nottingham schwarmwissen elitesoldat handheld screenless megapixels BibliothekDerFreien KeepTheDiskSpinning homebrewcomputing FarmersTractorRally pinebook farming modem lowtech biblatex allwinner daten home pimeroni 68 lebensmittelsicherheit industrial hambibleibt analogcomputing homer TrueDelta keyboard screenprinting robotics Pinecil mutantC raspberrypi3 pocketchip oshw misterfpga noisebridge disapora T440p ArmWorkstation datensicherheit latexrun hardwarehacking mer picodisplay laptops electronics scuttlebutt ham teamdatenschutz charm SectorDisk wolnabiblioteka preprint permacomputing uart panasonic pcb almere armbian performance kopimi printmaker deck making hambi powerpc solar ssd acoustics ibmcompatible webcams modular larp tweedekamer cybredeck latex 3dprinted MacBook emmc ipadproart computing laptop solarpunk isa recycling modularsynth apparmor repairability macbook theatrelighting pc lenovo updates fairelectronics industrialmusic librem carbonsequestration electronica sed TokyoCameraClub MacBookProService pocket box86 JingPad righttorepair mac trackball fuse date solarpunkactionweek ibm 3dprinting electro carbon MechcanicalKeyboards netbook hardware m68k pisa retrohardware pinetab sicherheit openhardware raspberrypi irobot datenautobahn webtoprint 3dprinter barcode lüneburg Quartz64 PlanetComputer jtag ebu merseyside itsicherheit CompressedAirAmplification pinetime screens pinebookpro lebensmittel 3d batteries PinebookPro 3dprint pim Handprint modemmanager securescuttlebutt keyboardio mechanicalkeyboard electronicmusic solarpunks carbondioxide robot arm lowerdecks sonic ipad FireAlarms PinePower paperComputer amd openpower poweredSpeaker devopa a64 eeepc bahn F9600 rpi4 thinkpad RaspberryPiPico iot dat BeagleV arm64 merveilles repairable sbc circuitbending raspberrypi4 print displayport akihabara analog electronic FrameworkLaptop - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT food - battery vitamind cake veganism teamviewer FoodHardship vanilla pankow margarine zwartepiet panthera dessert foils salsa caviar utopie brot theexpanse BellaSpielt cookery pietons Ôtepoti panther food cakecutting skillet teamgodzilla openfoodnetwork spiel Vegan liquor SoilSovereignty milk bolognese recipe foodporn yeast drinking VendrediPeanutsNouka plate waffle pansexual biscuit glaze omelette veganismo morel filet pastry wine woke Caribbeans hamburger juice unauthorizedbread Amazfish Avocados management sourdough gedankenspiel cagefree words MauriceSchuhmann nuts gras toast broth batter foodie breadposting spiele zerowaste haggis ketchup carrots go-nuts damnfinecoffee divoc seasoning mayo nowords MastoEats soup arpanet SteamDeck pan voc imateapot Anglefish mayoverse potatoes mayonnaise vegan dish avocado spice keto bakery butterfly cooking teamhuman SailfishOS Trypanophobia AgentProvocatuer yogurt rok thecandycrystalrainbowcodex crumble PropaneSalute cider caffeine Kinipan butter mastokitchen triceratops cook rain pottery kurdish creepypasta wastemanagement kitchencounter mastocook cobbler steak pizza vocaloid crystal soda fedikitchen coffeebreak aroma oil Miroil kochbrothers flour foodsovereignty cream nutella pie cut cuisine potse meatismurder freerange tartar kropotkin tea marinade cakes mushroom thekitchen govegan entree lfi dominospizza bread salad beans mush fresh syrup fermentation teamsky mushrooms cookie cookiebanners olivetti wordstoliveby curd soysauce lowcarb pudding plantbased tema beer organicfood peterkropotkin fish grasslands panoptykon spanisch honeypot foodnotbombs foodwaste organic wholeGrain wheat pot TeamFerment timewaster Wypierdalaj sauerkraut stew weltspiegel chocolate paste soynuevo wok rainbow recipes kitchengarden expanse olive burger mrpotatohead candy lifesnacks Steam kitchen coffee foodshortage bagel batterylife OpTinfoil teams taste SpieleWinter2020 meat johannisbeeren noodle raclette caramel rice eggs grill davewiner DavePollard poutine demoteam lard croissant pasta vegane strawberry toomuchcaffeine morelmushroom foods coffeeaddict WaterDrinkers cheese oregano drink muffin bikekitchen krop LowRefresh kyotocandy foie onepiece sauce foodanddrink soy foodpics growyourfood vore mushtodon wholewheat pandemie cocoa sandwich bigoil mousse waste chili redfish + battery vitamind cake veganism teamviewer FoodHardship vanilla pankow margarine zwartepiet panthera dessert foils salsa caviar utopie brot theexpanse BellaSpielt cookery pietons Ôtepoti panther food cakecutting skillet teamgodzilla openfoodnetwork spiel Vegan liquor SoilSovereignty milk bolognese recipe foodporn yeast drinking VendrediPeanutsNouka plate waffle pansexual biscuit glaze omelette veganismo morel filet pastry wine woke Caribbeans hamburger juice unauthorizedbread Amazfish Avocados management sourdough gedankenspiel cagefree words MauriceSchuhmann nuts gras toast broth batter foodie breadposting spiele zerowaste haggis ketchup carrots go-nuts damnfinecoffee divoc seasoning mayo nowords MastoEats soup arpanet SteamDeck pan voc imateapot Anglefish mayoverse potatoes mayonnaise vegan dish avocado spice keto bakery butterfly cooking teamhuman SailfishOS Trypanophobia AgentProvocatuer yogurt rok thecandycrystalrainbowcodex crumble PropaneSalute cider caffeine Kinipan butter mastokitchen triceratops cook rain pottery kurdish creepypasta wastemanagement kitchencounter mastocook cobbler steak pizza vocaloid crystal soda fedikitchen coffeebreak aroma oil Miroil kochbrothers flour foodsovereignty cream nutella pie cut cuisine potse meatismurder freerange tartar kropotkin tea marinade cakes mushroom thekitchen govegan entree lfi dominospizza bread salad beans mush fresh syrup fermentation teamsky mushrooms cookie cookiebanners olivetti wordstoliveby curd soysauce lowcarb pudding plantbased tema beer organicfood peterkropotkin fish grasslands panoptykon spanisch honeypot foodnotbombs foodwaste organic wholeGrain wheat pot TeamFerment timewaster Wypierdalaj sauerkraut stew weltspiegel chocolate paste soynuevo wok rainbow recipes kitchengarden expanse olive burger mrpotatohead candy lifesnacks Steam kitchen coffee foodshortage bagel batterylife OpTinfoil teams taste SpieleWinter2020 meat johannisbeeren noodle raclette caramel rice eggs grill davewiner DavePollard poutine demoteam lard croissant pasta vegane strawberry toomuchcaffeine morelmushroom foods coffeeaddict WaterDrinkers cheese oregano drink muffin bikekitchen krop LowRefresh kyotocandy foie onepiece sauce foodanddrink soy foodpics growyourfood vore mushtodon wholewheat pandemie cocoa sandwich bigoil mousse waste chili redfish warmcookiesoftherevolution winemaking ukrainian - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT security - zuluCrypt signalboost encrypt letsencrypt messengers autos BrowserHistory FlexibilizaciónResponsable puntarellaparty autoritäreretatismus BlacksInCyber omemo autotomy saveanonymity alg onionrouting Installationsanleitung dataleak messenger foodinsecurity password keepassxc partyline cryptography party cybersecuritynews pipewire Installation cryptolalaland solarwinds bitwarden communityalgorithmictrust infosec gchq GemeinsamGegenDieTierindustrie mitm wireless castor repairing IHaveSomethingToHide fotografie passwords gif IronySec cryptowars anonym encryptioncan supplychainattacks UseAMaskUseTor anonymous cyberattack editors security tor comb e2e supplychain bruceschneier gigafactory vpn BlacksInCybersecurity ransomware wireapp toreador itsec dnssecmastery2e openssh factorio Reactorweg openssl backdoored spyware dorfleaks torx encryptionsts e2ee sequoia backdoor cryptotokens NSAmeansNationalScammingAgency stork conscientiousobjectors ed25519 torproject cryptomeanscryptography encryption 0day informationsecurity ssh misshaialert cybersec restore FileSecurity FormFactors crypto theObservatory autokorrektur giftofencryption CyberSecurity foodsecurity kansascity auto signalapp firejail anonymity endtoendcrypto automattic fotografía onionshare onion encryptionist kontor autofahrer infosecbikini autocrypt malware switchtosignal 0days cloudsecurity corydoctorow RestoreOurEarth radiorepair algérie WebAuthn hexeditor nsogroup automotive distortions cryptographyisoverparty opsec InfoSec keepass encryptionists TastySecurity cryptobros securitybyobscurity torsocks toronto nsa autorenleben schneier protonvpn trustissues InsecurityByObscurity yubikey nitrokey encrypted 1password openpgp pgpainless tatort ghibli afraleaks castor9 deletesignal prismbreak gpgtools autodidactic gpg automation fotopiastory equatorial sequoiapgp cybersecurity Tor CryptoWars signal noscript redaktor vector trust backdoors Torge Torfverbrennung sasl emailsecurity cryptoparty pentest wire historia AllmendeKontor itsecurity websecurity foto pgp RobinHoodStore cryptomator signalmessenger openvpn CyberAttack datasecurity autorotate regulators anleitung leak drugstore encryptiost libresignal doctors securitynow storage tracking + zuluCrypt signalboost encrypt letsencrypt messengers autos BrowserHistory FlexibilizaciónResponsable puntarellaparty autoritäreretatismus BlacksInCyber omemo autotomy saveanonymity alg onionrouting Installationsanleitung dataleak messenger foodinsecurity password keepassxc partyline cryptography party cybersecuritynews pipewire Installation cryptolalaland solarwinds bitwarden communityalgorithmictrust infosec gchq GemeinsamGegenDieTierindustrie mitm castor repairing IHaveSomethingToHide fotografie passwords gif IronySec cryptowars anonym encryptioncan supplychainattacks UseAMaskUseTor anonymous cyberattack editors security tor comb e2e supplychain bruceschneier gigafactory vpn BlacksInCybersecurity ransomware wireapp toreador itsec dnssecmastery2e openssh factorio Reactorweg openssl backdoored spyware dorfleaks torx encryptionsts e2ee sequoia backdoor cryptotokens NSAmeansNationalScammingAgency stork conscientiousobjectors ed25519 torproject cryptomeanscryptography encryption 0day informationsecurity ssh misshaialert cybersec restore FileSecurity FormFactors crypto theObservatory autokorrektur giftofencryption CyberSecurity foodsecurity kansascity auto signalapp firejail anonymity endtoendcrypto automattic fotografía onionshare onion kontor autofahrer infosecbikini autocrypt malware switchtosignal 0days cloudsecurity corydoctorow RestoreOurEarth radiorepair algérie WebAuthn hexeditor nsogroup automotive distortions cryptographyisoverparty opsec InfoSec keepass encryptionists TastySecurity cryptobros securitybyobscurity torsocks toronto nsa autorenleben schneier protonvpn trustissues InsecurityByObscurity yubikey nitrokey encrypted 1password openpgp pgpainless tatort ghibli afraleaks castor9 deletesignal prismbreak gpgtools autodidactic gpg automation fotopiastory equatorial sequoiapgp cybersecurity Tor CryptoWars signal noscript redaktor vector trust backdoors Torge Torfverbrennung sasl emailsecurity cryptoparty pentest wire historia AllmendeKontor itsecurity websecurity foto pgp RobinHoodStore cryptomator signalmessenger openvpn CyberAttack datasecurity autorotate regulators anleitung leak drugstore encryptiost libresignal doctors securitynow storage tracking odin dnssec autonomy algorithm adtracking threatpost espionage openkeychain issue EncryptTheWeb OpSecReview - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT science - engineering math politicalgeography epidemiology stemfie OpenScienceUN TranslateScience electrochemistry ethnology womeninstem archeology botany STEM biodiversity ocean stemgeenFVD linguistic anthro supercollider nextgeneration zoology linguistics climatology oceans SolarSystems reasoning awk dna geography physics intergenerational archaeologist generalstreik geology ClinicalPsychology generationidentitaire economicanthropology Science SystemicRacism OpenScience corrosion research stemwijzer systemsmap bioengineering GotScience sistemainoperativo stemgeenPVV knowledge stemgeenVVD botanical dream dawkins ineigenersache psychogeography stemgeenVVS holo graphTheory deepdreamgenerator AnnualStatisticalReview trilateralresearch meterology botanicalart JA21 regenerative ScienceDenial biotech stemgeenJA21 regeneration psychology dreamtime pataphysics particles biology bughunting researching_research hunt pacificocean generation gene fossilhunting arthunt badscience mathematics chemistry muon processengineering paleontology oceanography stem anthropocene particlephysics nextgenerationinternet biomedical mechanicalengineering anthropology + engineering math politicalgeography epidemiology stemfie OpenScienceUN TranslateScience electrochemistry ethnology womeninstem archeology botany STEM biodiversity ocean stemgeenFVD linguistic anthro supercollider nextgeneration zoology linguistics climatology oceans SolarSystems reasoning awk dna geography physics intergenerational archaeologist generalstreik geology ClinicalPsychology generationidentitaire economicanthropology Science SystemicRacism OpenScience corrosion research stemwijzer systemsmap bioengineering GotScience sistemainoperativo stemgeenPVV knowledge stemgeenVVD botanical dream dawkins ineigenersache psychogeography stemgeenVVS holo graphTheory deepdreamgenerator AnnualStatisticalReview trilateralresearch meterology botanicalart JA21 regenerative ScienceDenial biotech stemgeenJA21 regeneration psychology dreamtime pataphysics particles biology bughunting researching_research hunt pacificocean generation gene fossilhunting arthunt badscience mathematics chemistry muon processengineering paleontology oceanography stem anthropocene particlephysics biomedical mechanicalengineering anthropology helloSystem WoozlesEpistemicParadox epistemicSystems nethunter scihub - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT photos - smartphonephotography nikon 90mm photography fujifilm rewild photogrammetry wildlifephotography wild affinityphoto photocló photo photographe photogrpahy photographer tokyocameraclub nikond90 photos macrophotography photoshop photographie photovoltaik seancephoto camera crops photomanager macropod uwphoto wildbiene macronie photographers cameras fossphotography phototherapie phonephotography myphoto rewilding naturephotography microplastics fediphoto picture wildfood macro intothewild streetphotography FujinonXF90mm wildcat photoreference crop phototherapy pictures + smartphonephotography nikon 90mm photography fujifilm rewild photogrammetry wildlifephotography wild affinityphoto photocló photo photographe photogrpahy photographer tokyocameraclub nikond90 photos macrophotography photoshop photographie photovoltaik seancephoto camera crops photomanager macropod uwphoto wildbiene macronie photographers cameras fossphotography phototherapie phonephotography myphoto rewilding naturephotography microplastics fediphoto picture wildfood macro intothewild streetphotography FujinonXF90mm wildcat photoreference crop phototherapy pictures libcamera - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT history musichistory heirloom monarchs holocaust history arthistory makeinghistory History anarchisthistory indigenoushistorymonth CarHistory gaminghistory womenshistorymonth NetworkingHistory blackhistory otd monarch computerhistory HistoryOfArt - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT software - beta borgbackup forms app FLOSS freeUP1 freedombox windows edit nginx transclusion krebsrisiken proprietarysoftware cooperativetechnology freepalestin calibre misophonia fosshost postscript nota AAPIHeritageMonth freenet freebsd kc font Framasoft tts E40 Flisol2021 invidious drm freedos softwarelibero alternativesto Raychat publicdomain ilovefreesoftware hydra readers StoryMapJS kubernetes openvms luca nodrm copyleft fossmendations happyauthor freedoom librespeed jami betatesting NottsTV libregraphics genossenschaft FuckOffZoom quicksy thunder whiteboard free docker softwarelibre opensourcehardware uxdesign interoperability impression3d freesoftware gimp krebs backups foss matrix fonts dinosaur mossad unfa weechat clapper designjustice thefreethoughtproject filesystems nextcloud translate wechat notmatrix gnupg lucaApp chats duplicati HappyLight opensourcesoftware permissionless compression ArchLinux openscad freeganizm uidesign softwaredeveloper neochat TabOrder searx ikiwiki prosody Linux FreeSoftware userresearch FlisolLibre2021 DisCOElements Audio rocketchat thanksfreesw libres webapps immers outreachy synapse API freelibre lyft freekirtaner nitter monitoring misogyny virtualbox ngi4eu discord reverseengineering whisperfish ee opensourcedesign vaporware opensource diaspora yunohost oss librelounge AudioCreation chickadee appstore dégooglisons littlebigdetails cabal conferencing cadmium libreboot blueridgeabc musiquelibre mycroft smokefree devops kdeapplications owncast lovewins phabricator emacs freiesoftware FLOSSvol moss fluffychat dinoim impress writefreely videoconferencing bigbluebutton tile_map_editor email moa ngi esri chatapps HappyNewYear Eiskappe fossilfriday umatrix floss plugins softwaresuite frecklesallovertheshow graphic libresoftware softwareengineering mosstodon expandocat deltachat application uifail FOSS peatfree lucaapp GNOMECircle rockpro64 bittorrent palestinewillbefree penpot vlc zoom southasia tiling session diaspora0800 FriendofGNOME Senfstoffknappheit usability winamp opendesign obnam snap appim ProprietarySoftwareProblems pandoc Happy4thJuly freemumia write artificialintelligence blackcappedchickadee cryptpad software libretranslate OwnStream upstream maplibre slack Hummingbard userfreedom hydrated emacslisp Element freeware DismantleFossilCompanies safenetwork asia jit SoftwareLibre zrythm gnu CTZN silicongraphics mumble strugglesessions grsync freecad telegram containers tails freeschool chatons blockchain windows11 irssi HabKeinWhatsapp information mcclim jitsimeet dedrm iso mutt librelingo freetibet WeAreAlmaLinux tilingwm sri design gameoftrees GnuLinuxAudio freegan freeriding freetool backup trueLinuxPhone ngio rotonde freetube jumpdrive GNU speechrecognition eurovison skydroid thunderbird it sound alternativeto screenreader parler bison apps chat licensing fossasia inclusivedesign ethicalsoftware defectivebydesign berne metager digitalsustainability screenreaders ZeroCool LINMOBapps freedombone uber obsproject arti librecast softwareheritage pittsburgh profanity delta Tankklappe doomemacs imageeditor ffmpeg fossandcrafts GNOME40 telesoftware proprietary love notabug reboot opensourcegardens musique switchingsoftware hydrangeas OSM freesw agpl distribute magnifyingglass GNOME freeganizmniewybacza drive botlove duolingo freesoftare AlmaLinux GreenandBlackCross strafmaatschappij freetillie distributedledger mattermost principiadiscordia blue LinuxPhones filesystem rocket ghostscript win10 Zoom tibet ComputerFolklore fossaudio elemental SocialCreditScores flisoltenerife libreops appsec element platforms inclusive uxn librelabucm engineer softwareNotAsAService ptp chatty Matrix lucafail fontawesome informationwantstobefree softwareGripe nativeApp MatrixEffect culturalibre jitsi taintedlove flisol engineers dinosaurier wordpress SwitchToJami mongodb ux rsync libreoffice chatbot crossstitch webdesign Encrochat dino RainbOSM plugin xwiki tecc openoffice container discordia softwaredesign redeslibres ledger sounddesign chatcontrol alternatives glimpse libregraphicsmeeting + beta borgbackup forms app FLOSS freeUP1 freedombox windows edit nginx transclusion krebsrisiken proprietarysoftware cooperativetechnology freepalestin calibre misophonia fosshost postscript nota AAPIHeritageMonth freenet freebsd kc font Framasoft tts E40 Flisol2021 invidious drm freedos softwarelibero alternativesto Raychat publicdomain ilovefreesoftware hydra readers StoryMapJS kubernetes openvms luca nodrm copyleft fossmendations happyauthor freedoom librespeed jami betatesting NottsTV libregraphics genossenschaft FuckOffZoom quicksy thunder whiteboard free docker softwarelibre opensourcehardware uxdesign interoperability impression3d freesoftware gimp krebs backups foss matrix fonts dinosaur mossad unfa weechat clapper designjustice thefreethoughtproject filesystems nextcloud translate wechat notmatrix gnupg lucaApp chats duplicati HappyLight opensourcesoftware permissionless compression ArchLinux openscad freeganizm uidesign softwaredeveloper neochat TabOrder searx ikiwiki prosody Linux FreeSoftware userresearch FlisolLibre2021 DisCOElements Audio rocketchat thanksfreesw libres webapps immers outreachy synapse API freelibre lyft freekirtaner nitter monitoring misogyny virtualbox ngi4eu discord reverseengineering whisperfish ee opensourcedesign vaporware opensource diaspora yunohost oss librelounge AudioCreation chickadee appstore dégooglisons littlebigdetails cabal conferencing cadmium libreboot blueridgeabc musiquelibre mycroft smokefree devops kdeapplications owncast lovewins phabricator emacs freiesoftware FLOSSvol moss fluffychat dinoim impress writefreely videoconferencing bigbluebutton tile_map_editor email ngi esri chatapps HappyNewYear Eiskappe fossilfriday umatrix floss plugins softwaresuite frecklesallovertheshow graphic libresoftware softwareengineering mosstodon expandocat deltachat application uifail FOSS peatfree lucaapp GNOMECircle bittorrent palestinewillbefree penpot vlc zoom southasia tiling session diaspora0800 FriendofGNOME Senfstoffknappheit usability winamp opendesign obnam snap appim ProprietarySoftwareProblems pandoc Happy4thJuly freemumia write artificialintelligence blackcappedchickadee cryptpad software libretranslate OwnStream upstream maplibre slack Hummingbard userfreedom hydrated emacslisp Element freeware DismantleFossilCompanies safenetwork asia jit SoftwareLibre zrythm gnu CTZN silicongraphics mumble strugglesessions grsync freecad telegram containers tails freeschool chatons blockchain windows11 irssi HabKeinWhatsapp information mcclim jitsimeet dedrm iso mutt librelingo freetibet WeAreAlmaLinux tilingwm sri design gameoftrees GnuLinuxAudio freegan freeriding freetool backup trueLinuxPhone ngio rotonde freetube jumpdrive GNU speechrecognition eurovison skydroid thunderbird it sound alternativeto screenreader parler bison apps chat licensing fossasia inclusivedesign ethicalsoftware defectivebydesign berne metager digitalsustainability screenreaders ZeroCool freedombone uber obsproject arti librecast softwareheritage pittsburgh profanity delta Tankklappe doomemacs imageeditor ffmpeg fossandcrafts GNOME40 telesoftware proprietary love notabug reboot opensourcegardens musique switchingsoftware hydrangeas OSM freesw agpl distribute magnifyingglass GNOME freeganizmniewybacza drive botlove duolingo freesoftare AlmaLinux GreenandBlackCross strafmaatschappij freetillie distributedledger mattermost principiadiscordia blue LinuxPhones filesystem rocket ghostscript win10 Zoom tibet ComputerFolklore fossaudio elemental SocialCreditScores flisoltenerife libreops appsec element platforms inclusive uxn librelabucm engineer softwareNotAsAService ptp chatty Matrix lucafail fontawesome informationwantstobefree softwareGripe nativeApp MatrixEffect culturalibre jitsi taintedlove flisol engineers dinosaurier wordpress SwitchToJami mongodb ux rsync libreoffice chatbot crossstitch Encrochat dino RainbOSM plugin xwiki tecc openoffice container discordia softwaredesign redeslibres ledger sounddesign chatcontrol alternatives glimpse libregraphicsmeeting libre ox mycroftai kiwi multics sustainablewebdesign spacemacs meditation composeui coreboot libreelec kiwix Buongiornissimo dmenu opendesigncourse fairemail doom TechnologiesOfHope rocketeer appimage uiuxdesign webapp computerchronicles GNOME41 GNUNameSystem posteo editor snapchat participatiewet mediterranean fossdev freedomboxdev ultrasound bikelove freebritney deltarune framasphere - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + phones + LINMOBapps mobileapp cellular fairphone3 téléphone nemomobile fairtec linuxfr conocimientolibre manjaro Jingos plasmaDev TourBrandenburg21 rand 5g mobian LinuxPhoneApps lg pine Brandkopf alarmphone androidemulator fdroid plasmamobile shotonpinephone fairuse android smartphonepic nophone ubportsqanda linuxmobile sailfish phones fennecfdroid Mobian osmf AlpineConf automobile smartphone plasma5 ios selinux mobileGNU PinePhoneOrderDay exxon sms4you mob bp microphone linuxconnexion smart smartphones iOS14 pinemarten linuxphones openmoko mobilecoin mobilelinux freeyourandroid fair QWERTYphones exxonmobil sailfishos siskinim epic monal android10 osmocom Smartphones WakeMobile androids lineageos molly angelfish androiddev Briar manjarolinux quasseldroid wirtschaft plasma mobilephones phosh BriarProject Fairphone librem5 ubportsinstaller linuxphone shotonlibrem5 pinephone Teracube PinePhone pinedio mobile pinephones manjaroarm sms pine64 automobiles fairphone ubuntutouch linphone Android osmirl ubports gnomeonmobile immobilienwirtschaft Bramble osmand vodafone gnomemobile linuxonmobile iphones postmarketos iOS microg brandenburg librecellular GetSession grapheneos sail recycletechjunkuselinux phone cm mobileKüfA lineage josm iphone linuxappsummit Xperia10mark2 newprofilepic Sipcraft tmobile headphones dumbphones telephones spyphone SipWitchQt LineageOS Librem5 Apps smartwatch smartcities evergrande OTA19 10YearPhone Fairphone4 + + Fri, 01 Oct 2021 18:50:09 UT conferences - FOSDEM2021 stackconf fossnorth debconf debconf21 FOSDEM talk fossdem FreedomBoxSummit apconf2020 schmoocon Aktionscamp realtalk persco penguicon2021 letstalkaboutyes summit confidenceTricks agm libreplanet SeaGL2021 confindustria confluence minidebconf edw2021 maintainerssummit rc3worldleaks rightscon StopStalkerAds SeaGL penguicon emacsconf MCH2021 conferencecalls flossconference LGM2021 conferences LibrePlanet defcon emfcamp flossevent askpinetalk bc conf talks defcon201 rC3 rC3World FOSDEM21 conference mozfest flossconf bootcamp apconf ccc persconferentie GeekBeaconFest rC3one GenCon smalltalk camp g7 C3 config penguicon2022 confy + FOSDEM2021 stackconf fossnorth debconf debconf21 FOSDEM talk fossdem FreedomBoxSummit apconf2020 schmoocon Aktionscamp realtalk persco penguicon2021 letstalkaboutyes summit confidenceTricks agm libreplanet SeaGL2021 confindustria confluence minidebconf edw2021 maintainerssummit rc3worldleaks rightscon StopStalkerAds SeaGL penguicon emacsconf MCH2021 conferencecalls flossconference LGM2021 conferences LibrePlanet defcon emfcamp flossevent askpinetalk bc conf talks defcon201 rC3 rC3World FOSDEM21 conference mozfest flossconf bootcamp apconf ccc persconferentie GeekBeaconFest rC3one GenCon camp g7 C3 config penguicon2022 confy debconf20 confession scam sfcamp ossummit - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT farming johndeere deer - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT facts - lifehacking funfact lifehack + lifehacking funfact lifehack lifehacks - Tue, 10 Aug 2021 08:34:29 UT - - - indymedia - fpga hs2 dotcons visionontv geek tredtionalmedia indiemedia degeek globleIMC indymediaback pga mainstreaming indymedia closed stupid foo encryptionsist hs2IMC indymediaIMC network networkmonitoring Blackfoot roadsIMC stupidindivialisam roadstonowhere networkeffect lifecult closedweb avgeek monitor dotconsall omn tv roadstonowhereIMC kiss UKIMC fluffy 4opens openmedianetwork - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT cycling bicycle bicyles cycle bic cycling bicycleday DataRecycling arabic bike motorbike reusereducerecycle bikeing cyclingtour thingsonbikes openbikesensor bikeways Snowbike cyclist - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT gender - black blackcompany transparantie transistors transparenz broketrans transition internationalwomensday2021 transwomen transformativejustice womenwhocode transfobie buildless WomenInHistory sf transmission transgender cashless RaquelvanHaver caféLatte transdayofresistance mens vieillesse womensart blacktranslivesmatter female nonbinary womensday vantascape van blacktransmagic less nb trans patriarchy nonbinarycommunity transpositivity LucyLawless transdayofvisibility lgbtqia transphobia transmitter women menschheit lgbt bodypositive nonbinarypeoplesday transzorg womenrock estradiol lgbtq transaid queerartist KCHomelessUnion transgenders girlboss pointlesslygendered queer transdayofvisbility nonbinaryday genderQuiz gender genderqueerpositivity NonBinaryPositivity dagvandearbeid woman transrights transdayofrevenge + black blackcompany transparantie transistors transparenz broketrans transition internationalwomensday2021 transwomen transformativejustice womenwhocode transfobie buildless WomenInHistory sf transmission transgender cashless RaquelvanHaver caféLatte transdayofresistance mens vieillesse womensart blacktranslivesmatter female nonbinary womensday vantascape van blacktransmagic less nb trans patriarchy nonbinarycommunity transpositivity LucyLawless transdayofvisibility lgbtqia transphobia transmitter women menschheit lgbt bodypositive nonbinarypeoplesday transzorg womenrock estradiol lgbtq transaid queerartist KCHomelessUnion transgenders girlboss pointlesslygendered queer transdayofvisbility nonbinaryday genderQuiz gender genderqueerpositivity NonBinaryPositivity dagvandearbeid woman transrights transdayofrevenge transmetropolitan blackface - Tue, 10 Aug 2021 08:34:29 UT - - - phones - mobileapp cellular fairphone3 téléphone libre nemomobile fairtec linuxfr conocimientolibre manjaro Jingos plasmaDev TourBrandenburg21 rand 5g mobian LinuxPhoneApps lg pine Brandkopf alarmphone androidemulator fdroid plasmamobile shotonpinephone fairuse android smartphonepic nophone ubportsqanda linuxmobile sailfish phones fennecfdroid Mobian osmf AlpineConf automobile smartphone plasma5 ios selinux mobileGNU PinePhoneOrderDay exxon sms4you mob bp microphone linuxconnexion smart smartphones iOS14 pinemarten linuxphones openmoko mobilecoin mobilelinux freeyourandroid fair QWERTYphones exxonmobil sailfishos siskinim epic monal android10 osmocom Smartphones WakeMobile androids lineageos molly angelfish androiddev Briar manjarolinux quasseldroid wirtschaft plasma mobilephones phosh BriarProject Fairphone librem5 ubportsinstaller linuxphone shotonlibrem5 pinephone Teracube PinePhone pinedio mobile pinephones manjaroarm sms pine64 automobiles fairphone ubuntutouch linphone Android osmirl ubports gnomeonmobile immobilienwirtschaft Bramble osmand vodafone gnomemobile linuxonmobile iphones postmarketos iOS microg brandenburg librecellular GetSession grapheneos sail recycletechjunkuselinux phone cm mobileKüfA lineage josm iphone linuxappsummit Xperia10mark2 newprofilepic - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT activism - UniteInResistance rightwing rights activism protestor dutysolicitor roots WeDemandTransparency CallToAction annonce rojava PrisonLivesMatter clearchannel nog20 Lobauautobahn farright eni tyrannyofconvenience grassroot nonviolentcommunication FreeLibreOpen protesters g20 ShaleMustFall JusticeForRapheal rig augustriseup bekannt farmersprotest animalrights protests resistance cyborgrights riseup resistg7 DontShootTheMessenger demo PrisonSolidarity linnemann sflc uprootthesystem DanniVive apt freeassange dangote reuse stopspyingonus keepiton Dannenroederforst FSFE20 ClimateJusticeMovement fsfe killthebill edri softwarefreedom indigenousrights activists unautremondeestpossible AntiCopyright Rojava ilovefs stopnacjonalizmowi ann activist wec HeroesResist edrigram xr SustainableUserFreedom bannerlord systemchangenotclimatechange undercurrents riseup4rojava righttoexist seachange directaction mannheim Doulingo politicalactivism diskriminierung wechange seattleprotests eff Gardening gamechanger root change openrightsgroup protest icantbreathe channelname JeffreySDukes planning FSF userrights LaptevSea actiondirecte kroymann climatechange protestsupport channel climatchange HS2 ngo MarcWittmann StandWithTillie Danni FrightfulFive fsf fsfi StopHS2 grassroots HS2Rebellion protestcamp resist openrights TalesFromTheExtinction FreeJournalistAssange announcements antireport ClimateJustice RodrigoNunes FreedomCamping BLM ExtinctionRebellion shellmustfall namechange changeisinyourhands wlroots weareallassange conservancy ngos UserFreedom sp bin JefferySaunders freepalestine CopsOffCampus GreatGreenWall LiliannePloumen freeassangenow savetheplanet freeradical directactiongetsthegoods hauptmann activismandlaw climatechangeadaptation Kolektiva Indigenousresistance BayouBridgePipeline XR freeolabini tellthetruth announcement isolateByoblu annieleonard + UniteInResistance rightwing rights activism protestor dutysolicitor roots WeDemandTransparency CallToAction annonce rojava PrisonLivesMatter clearchannel nog20 Lobauautobahn farright eni tyrannyofconvenience grassroot nonviolentcommunication FreeLibreOpen protesters g20 ShaleMustFall JusticeForRapheal rig augustriseup bekannt farmersprotest animalrights protests resistance cyborgrights riseup resistg7 DontShootTheMessenger demo PrisonSolidarity linnemann sflc uprootthesystem DanniVive apt freeassange dangote reuse stopspyingonus keepiton Dannenroederforst FSFE20 ClimateJusticeMovement fsfe killthebill edri softwarefreedom indigenousrights activists unautremondeestpossible AntiCopyright Rojava ilovefs stopnacjonalizmowi ann activist wec HeroesResist edrigram xr SustainableUserFreedom bannerlord systemchangenotclimatechange undercurrents riseup4rojava righttoexist seachange directaction mannheim Doulingo politicalactivism diskriminierung wechange seattleprotests eff Gardening gamechanger root change openrightsgroup protest icantbreathe channelname JeffreySDukes planning FSF userrights LaptevSea actiondirecte kroymann climatechange protestsupport channel climatchange HS2 ngo MarcWittmann StandWithTillie Danni FrightfulFive fsf fsfi StopHS2 grassroots HS2Rebellion protestcamp resist openrights TalesFromTheExtinction FreeJournalistAssange announcements antireport ClimateJustice RodrigoNunes FreedomCamping BLM ExtinctionRebellion shellmustfall namechange changeisinyourhands wlroots weareallassange conservancy ngos UserFreedom sp bin JefferySaunders freepalestine CopsOffCampus GreatGreenWall LiliannePloumen freeassangenow savetheplanet freeradical directactiongetsthegoods hauptmann activismandlaw climatechangeadaptation Kolektiva Indigenousresistance BayouBridgePipeline XR freeolabini tellthetruth announcement isolateByoblu annieleonard digitalhumanrights prototypedemos womensrights ThinkBeforeSharing ShutEmDown2021 localresilience softwarefreedomday trustroots rightsOfPassage freeallpoliticalprisoners disroot - Tue, 10 Aug 2021 08:34:29 UT - - - accessibility - you a11y accessibility captionyourimages hardofhearing - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT pandemic - covid19 coronaPolicies gevaccineerd corona getvaccinated CovidImpacts psmeandmywholefamilycaughtcovidfromwork Coronavirus CoronaWarnApp facemasks vaccines wijvaccineren culturalrevolution pandemics vaccine vaccinesupply JournalistsSpeakUpForAssange Covid vaccinated coranavirus NoCovidMonopolies pandemic sayhername internationalproletarianrevolution Zbalermorna internationalcatday covidville ZeroCovid vaccini pandemia coronapps volkstheater COVID19india contacttracing VaccinePatents coronavaccinatie SùghAnEòrna tier4 coronapandemie covid pand SarsCoV2 volla volodine COVID19NL covidmask Moderna coronavirus masks viruses Moderna2 COVIDrelief coronapas virus contacttracingapps moderna coronadebat vaccin COVIDー19 Lockdown rna unvaccinated codid19 CripCOVID19 LongCovid COVID19 vaccination YesWeWork ContactTracing vol coronaviruses CoronaCrisis COVID coronamaatregelen debat international internationalsolidarity coronabeleid + covid19 coronaPolicies gevaccineerd corona getvaccinated CovidImpacts psmeandmywholefamilycaughtcovidfromwork Coronavirus CoronaWarnApp facemasks vaccines wijvaccineren culturalrevolution pandemics vaccine vaccinesupply JournalistsSpeakUpForAssange Covid vaccinated coranavirus NoCovidMonopolies pandemic sayhername internationalproletarianrevolution Zbalermorna internationalcatday covidville ZeroCovid vaccini pandemia coronapps volkstheater COVID19india contacttracing VaccinePatents coronavaccinatie SùghAnEòrna tier4 coronapandemie covid pand SarsCoV2 volla volodine COVID19NL covidmask Moderna coronavirus masks viruses Moderna2 COVIDrelief coronapas virus contacttracingapps moderna coronadebat vaccin COVIDー19 Lockdown rna unvaccinated codid19 CripCOVID19 LongCovid COVID19 vaccination YesWeWork ContactTracing vol coronaviruses CoronaCrisis COVID coronamaatregelen debat international internationalsolidarity coronabeleid ZeroCOVID family evolution internationalcoffeeday - Tue, 10 Aug 2021 08:34:29 UT - - - books - readinggroup bookstore publicvoit bookbinding preview justhollythings secondhandbooks bookclub fake earthsea review ebooks docbook book notebook public amreading publishing republicday publichealth bookworm bookwyrm 5minsketch artbook republique bookreview reading sketching theLibrary audiobooks Gempub selfpublishing sketchbook wayfarers books peerreview bookreviews failbooks sketch ebook wikibooks booktodon epub cookbook bibliothèque AnarchoBookClub - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT crafts - repair topic_imadethis hackerexchange exchange quilts textile upholstery hackgregator gatos gato hackspacers nrw shack 3dmodeling dust3d hackerspaces hacklab tryhackme sanding solvespace theglassroom sundiy craft wirtschafthacken papercrafts maker knitting hack workspace craftsmanship wood hacked Sipcraft calligraphy biohacking wip spacecrafts hacktheplanet jewelry diy textiles projects hackerweekend handicrafts Handicraft lovecraftcountry upcycling Minecraft woodworking 3dcad glass origami hackerexchange + repair topic_imadethis hackerexchange exchange quilts textile upholstery hackgregator gatos gato hackspacers nrw shack 3dmodeling dust3d hackerspaces hacklab tryhackme sanding solvespace sundiy craft wirtschafthacken papercrafts maker knitting hack workspace craftsmanship wood hacked calligraphy biohacking wip spacecrafts hacktheplanet jewelry diy textiles projects hackerweekend handicrafts Handicraft lovecraftcountry upcycling Minecraft woodworking 3dcad glass origami hackerexchange -]] makers nrwe quilting crafting sparkwoodand21 hacker quilt crafts rwe weaving 3dmodel handtools tinkering project hacking woodwork ceramics handmade embroidery shacks teardown +]] makers nrwe quilting crafting sparkwoodand21 hacker quilt crafts rwe weaving 3dmodel handtools tinkering project hacking woodwork ceramics handmade embroidery shacks teardown lockdownrepair lockdown level4lockdown lockdownRepairs childrensartproject therepairshop projectwelove hackspace hacktoberfest theFurnace - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + exhibitions + theglassroom + + Fri, 01 Oct 2021 18:50:09 UT war - ru DonavynCoffey Myanmarmilitarycoup civilwar antiwar bomber coup weapon tank handforth landmine tankies military autonomousweapons army Etankstelle weaponsofmathdestruction conflict navy warplane fort guns Myanmarcoup weapons siege hbomberguy battle WMD wmd airforce forth + ru DonavynCoffey Myanmarmilitarycoup civilwar antiwar bomber coup weapon tank handforth landmine tankies military autonomousweapons army Etankstelle weaponsofmathdestruction conflict navy warplane fort guns Myanmarcoup weapons siege hbomberguy battle WMD wmd airforce forth Stankface - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT techbros - bubbles bubble color redbubble securedrop einfachredeneben redditodicittadinanza coloredpencil redhat redwood hackernews weareredhat redmi red pencil reddit redon redis infrared VendrediNouka redshift optreden sec + bubbles bubble color redbubble securedrop einfachredeneben redditodicittadinanza coloredpencil redhat redwood hackernews weareredhat redmi red pencil reddit redon redis infrared VendrediNouka redshift optreden sec colorado - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT astronomy telescope immersspace mercury guide pluto planets galaxy spaceport venus mars bloodmoon amateurastronomy uranus spacex nebula astronomy hubblespacetelescope neptune space jupiter rpc blackhole asteroid BackYardAstronomy moon thehitchhikersguidetothegalaxy observatory euspace asteroidos saturn milkyway telescopes spacelarpcafe - Tue, 10 Aug 2021 08:34:29 UT - - - other - ageassurance pentester bullshit klimaatbeleid justasleepypanda extinctionrebellion fail masseffect lastpass yolo nothingnew Lastpass extinction weareclosed happy efail bripe MasseyUniversity PassSanitaire solution dansenmetjanssen messageToSelf TagGegenAntimuslimischenRassismus quecksilber itscomplicated Erzvorkommen test isntreal gentests rzeźwołyńska massextinction misc tw rants manutentore frantzfanon shots assaultcube shitpost denachtvanjanssen biomassacentrale mining rising devilsadvocate ACA pinside xp impfpass cda rant Terrassen righttodisassemble rassismus MassoudBarzani koerden CovPass nahrungskette SomeUsefulAndRelvantHashtag LanguageHelpForMigrants nsfw dungeonsAndDragons biomass rassismustötet oversleep ass id Chiacoin futtermittel CubanProtests geo oerde m assassinfly migrantstruggles sleep PointlessGriping close decluttering OCUPACAOCARLOSMARIGHELLA - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT photography peppercarrotmini NoShothgunParsers pea CanonSL2 landscapephotography landscapeart XSystem darktable photograph peppercarrot speakers hippeastrum landscape blackandwhite hot twinpeaks - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT month - maythe4thbewithyou yt ots april juneteenth PrideMonth2021 bots 1may july VeganMay march pridemonth chapril marchofrobots2021 october november august june blackherstorymonth december september augustusinc may feburary jejune PrideMonth january marchofrobots eternalseptember blackhistorymonth march4justice month robots maythe4th blacktheirstorymonth + maythe4thbewithyou yt ots april juneteenth PrideMonth2021 bots 1may july VeganMay march pridemonth chapril marchofrobots2021 october november august june blackherstorymonth december september augustusinc may feburary jejune PrideMonth january marchofrobots eternalseptember blackhistorymonth march4justice month robots maythe4th blacktheirstorymonth september11 - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT news - basicincome report news 56kNews Newsfeed krautreporter flash basic Wikileaks newsletter aljazeera nothingnews newsflash contemporaneous_reports newsroom EUNews Worldnews rt bbc foxnews journalismisnotacrime News bbcbasic goodnews flashcrash doubledownnews bbcnews reuters newschool theguardian fieldreport badReporting newsboat journalism SkyNews crash lobsters + basicincome report news 56kNews Newsfeed krautreporter flash basic Wikileaks newsletter aljazeera nothingnews newsflash contemporaneous_reports newsroom EUNews Worldnews rt bbc foxnews journalismisnotacrime News bbcbasic goodnews flashcrash doubledownnews bbcnews reuters newschool theguardian fieldreport badReporting newsboat journalism SkyNews crash lobsters newsmax minorityreport flashmob - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT cats Cat dailycatpic dxp MastoCats DailyCatVid Cats katze kotorico kot ketikoti qualitätskatze CatsOfMastodon Catshuis Leopard SpaceCatsFightFascism CatBellies catbellies LapCats qualitätskatzen katzen - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT podcasts - beautiful podcasting IntergalacticWasabiHour JenaFahrradies podcast rad radiopodcast postmarketOSpodcast TraditionCruelle podcasting20 tilderadio tildes podcasts tildeverse radverkehr smallisbeautiful fertilizers PineTalk radweg tilvids fahrrad tildetown qtile trillbilliespodcast + beautiful podcasting IntergalacticWasabiHour JenaFahrradies podcast rad radiopodcast postmarketOSpodcast TraditionCruelle podcasting20 tilderadio tildes podcasts tildeverse radverkehr smallisbeautiful fertilizers PineTalk radweg tilvids fahrrad tildetown qtile trillbilliespodcast postmarketOS - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT radio cbradio worldradioday radiokookpunt hamr freieradios varia why radioamateur shoshanazuboff winlink tootlabradio pouetradio schenklradio dx macintosh radioactive ntsradio amateurradio radiohost radiokapital talkradio localization shortwave nwr vantaradio roadsafety ca radio healthcare listening hamradio FreeAllPoliticalPrisoners variabroadcasts card10 fastapi webradio freeradio radiobroadcasting radiosurvivor Poecileatricapillus apis radioshow local cellbroadcast radio3 noshame osh audycja hackerpublicradio kosher radioalhara Phosh audycjaradiowa california road nowlistening radiobroadcast radiostation mastoradio broadcasting radiodread amateurr radiolibre modelrailroad spazradio anonradio Capitaloceno kolektywneradio io - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT pets - buny spinning catpics shepherd leiningen uninstallman ExposureNotifications ats germanshepherd catofmastodon nin TheRabbitHole staatstrojaner deskcat verification eurocrats QuestioningTechnology toocute cataloging cathedrals petpeeve Stelleninserat acidification reEducationCamp mastodogs rats puppets catbehaviour digidog dogecoin Stallman Coolcats petrats governing dogsofmastodon gentrification evening broadcats gattini bunyPosting benjennings kitten fostercats gamification woningnet WegenErdogan jürgenconings cats uninStallman kittens Uninstallman pet dog scotties Pruning woningnood acat catontour catsofmastodon leninismo podcatcher meow cute mastocat lenin catstodon dogs reimagining catsofparkdale mastocats W3CSpecification mastodog notpixiethecat londoninnercitykitties cat blackcat furry petitie JuliaKitten dogsofmaston JurgenConings training scottie catcontent UserDomestication + buny spinning catpics shepherd leiningen uninstallman ExposureNotifications ats germanshepherd catofmastodon nin TheRabbitHole staatstrojaner deskcat verification eurocrats QuestioningTechnology toocute cataloging cathedrals petpeeve Stelleninserat acidification reEducationCamp mastodogs rats puppets catbehaviour digidog dogecoin Stallman Coolcats petrats governing dogsofmastodon gentrification evening broadcats gattini bunyPosting benjennings kitten fostercats gamification woningnet WegenErdogan jürgenconings cats uninStallman kittens Uninstallman pet dog scotties Pruning woningnood acat catontour catsofmastodon leninismo podcatcher meow cute mastocat lenin catstodon dogs reimagining catsofparkdale mastocats W3CSpecification mastodog notpixiethecat londoninnercitykitties cat blackcat furry petitie JuliaKitten dogsofmaston JurgenConings training scottie catcontent UserDomestication puppet login dogwalk - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT years - newyearsresolutions resolutions Year2020 year 1yrago newyear happynewyear ox 5yrsago yearoftheox newyearseve + newyearsresolutions resolutions Year2020 year 1yrago newyear happynewyear 5yrsago yearoftheox newyearseve resolution - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT philosophy - postmeritocracy post minimalism maximalist Allposts nationalpost maximalism digitalminimalism postprocess philosophy erp stoic spiderposting postfordismo postmodernism minimalist + postmeritocracy post minimalism maximalist Allposts nationalpost maximalism digitalminimalism postprocess philosophy erp stoic spiderposting postfordismo postmodernism minimalist minimal postfix - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT transport - deutschland luggage Gütertransporte publictransport busses activetransport transportation train transport trains deutsch deutscheumwelthilfe airway journey motorway aviation deutschebahn travel ev prorail airport rail + deutschland luggage Gütertransporte publictransport busses activetransport transportation train transport trains deutsch deutscheumwelthilfe airway journey motorway aviation deutschebahn travel ev prorail airport rail roadtrip a14 - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT ethics - licenses digitalethics ethicaltech ethics ethicallicense ethicswashing ethical ethicsintech + licenses digitalethics ethicaltech ethics ethicallicense ethicswashing ethical ethicsintech wash - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT commons - ed openformat mentalillness opennmsgroup OpenAccessButton niemandistvergessen distraction open linkedopenactors openaccess reopening openocd openengiadina opennms ess badges opensocial commonscloud activisim openlibrary characters opensourcing innovation openpublishing verge InstantMessenger LessIsMore openrefine openworlds extraction openwashing publicinterest besserorganisieren exittocommunity openinnovation opennmt openbadges act accessable openfest2021 ManufacturaIndependente openspades Accessibility keinvergessen openrepos2021 openftw Bessa + ed openformat mentalillness opennmsgroup OpenAccessButton niemandistvergessen distraction open linkedopenactors openaccess reopening openocd openengiadina opennms ess badges opensocial commonscloud activisim openlibrary characters opensourcing innovation openpublishing verge InstantMessenger LessIsMore openrefine openworlds extraction openwashing publicinterest besserorganisieren exittocommunity openinnovation opennmt openbadges act accessable openfest2021 ManufacturaIndependente openspades Accessibility keinvergessen openrepos2021 openftw Bessa openarena nova - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT seasons - mailspring officehours spring lupin thespinoff Dadvice autumn abolishice desummersummit licenziamenti namedropping office hooping sipping es fuckice winter EthicalLicenses ice luejenspringer hpintegrity pingpong santa summer iced LibreOffice summerschool onlyoffice pinball icedipping solstice unicef officework wintersolstice FederalOffice summerRolls pin mice + mailspring officehours spring lupin thespinoff Dadvice autumn abolishice desummersummit licenziamenti namedropping office hooping sipping es fuckice winter EthicalLicenses ice luejenspringer hpintegrity pingpong santa summer iced LibreOffice summerschool onlyoffice pinball icedipping solstice unicef officework wintersolstice FederalOffice summerRolls pin mice school - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT questions - checking kayaking askmastodon flockingbird biking questions king euskadi asking mask lockpicking Hacking GlobalFrackingBan factchecking askfedi basketball smoking WorldAgainstFracking askafriend flask GlobalBanOnFracking TraditionalWoodworking question ska askmasto breaking scrap_booking maskengate criticalthinking askfediverse fucking totallyaskingforafriend ask daretoask askfosstodon + checking kayaking askmastodon flockingbird biking questions king euskadi asking mask lockpicking Hacking GlobalFrackingBan factchecking askfedi basketball smoking WorldAgainstFracking askafriend flask GlobalBanOnFracking TraditionalWoodworking question ska askmasto breaking scrap_booking maskengate criticalthinking askfediverse fucking totallyaskingforafriend ask daretoask askfosstodon lockin fuckingblokes askingforafriend - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT fiction ABoringDystopia interactivefiction cyberpunk VersGNRWstoppen thehobbit fiction microfiction stopCGL nonfiction DystopianCyberpunkFuture stoptmx top flashfiction cyberpunk2020 genrefiction - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT audio feed audiophile liveaudio audioproduction feeds pulseaudio audi webaudio feedbackd audioprogramming mastoaudio audioengineering audience audiogames audiofeedback audio auditoriasocial - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT garbage Anonymous cumbria documentation no QAnonAnonymous docu cardano documents cum u ChanCulture - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT birds - RainbowBeeEater aves birb pigeon cawbird pigeonlover bird birdposting birdwatch birdsite birding birbposting birdwatching + RainbowBeeEater aves birb pigeon cawbird pigeonlover bird birdposting birdwatch birdsite birding birbposting birdwatching birdbutt - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT disability ableism disabled ableismus - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT travel tax travellers taxi airtravel - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT religion atheist buddhist ama neopagan pagan catholic paganism genesis jesuit secularism SiddarthaGautama oorlogspropaganda - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT culture etiquette - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT funding - donate disabilitycrowdfund disabledcrowdfund erschöpfung funding now oled LuisaToledo alledoerferbleiben LeylaKhaled ethicalfunding mastercard netzfundstück didyouknow fundraiser BreakWalledGardens ki membership fundamentals nzSuperFund ngizero fun oer zeroknowledge edge led zerohedge DefundLine3 vkickstarter fungiverse alledörferbleiben fungus fundingmatters SmallPiecesLooselyCoupled hedgedog fungi EntangledLife desperate opencollective patreon FundOSS + donate disabilitycrowdfund disabledcrowdfund erschöpfung funding now oled LuisaToledo alledoerferbleiben LeylaKhaled ethicalfunding mastercard netzfundstück didyouknow fundraiser BreakWalledGardens ki membership fundamentals nzSuperFund ngizero fun oer zeroknowledge edge led zerohedge DefundLine3 vkickstarter fungiverse alledörferbleiben fungus fundingmatters SmallPiecesLooselyCoupled hedgedog fungi EntangledLife desperate opencollective patreon FundOSS nlnet gnutaler - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT identity genx boomer genz zoomer - Tue, 10 Aug 2021 08:34:29 UT - - - ai - macos machinelearning openai EthicsInAI - - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT licenses commongoods creativecommonsrocks voice violation agplv3 tootle commoning commonvoice CommunitySource place copyright commonspoly creative netcommons common gpl plugplugplug copyrightlaw commonplacebook license EthicalSource questioncopyright tragedyofthecommons cc0 creativecommons commongood cc creativetoot - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT political - copservation housekeeping gan ram progress slaughterhouse rog cops houseless brogue progresso joerogan theteahouse bibliogram house hydrogen straming theGreenhouse spycops teahouse progressivehouse techhouse clubhouse yayagram PDXdefendthehouseless pdxhouseless EnergyFlowDiagrams pr progress_note deephouse roguelike linguisticProgramming gancio + copservation housekeeping gan ram progress slaughterhouse rog cops houseless brogue progresso joerogan theteahouse bibliogram house hydrogen straming theGreenhouse spycops teahouse progressivehouse techhouse clubhouse yayagram PDXdefendthehouseless pdxhouseless EnergyFlowDiagrams pr progress_note deephouse roguelike linguisticProgramming gancio tram roadhouse - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT organisations foundation scpfoundation scp - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT fashion brasil fashionistas fashionesta bras fashionista fashion punkwear earrings socks patches feditats zebras - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT welfare CreditReporting universalcredit welfare socialwelfare credit - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT scotland lan atlanta glasgow highlands edinburgh loch - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT antisocial - stalking cyberstalking + stalking cyberstalking antivaxx - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT comedy - laugh farce humour swisshumor satire irony standup funny humor punishment pun + laugh farce humour swisshumor satire irony standup funny humor punishment pun theonion swiss - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT obituaries ueberwachung siberia tripadvisor rip JavaScriptSucks ratgeber obit ecmascript keyenberg raspberripi döppersberg cybergrooming Gudensberg überblick obituaries ber civilliberties rubber cyber - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT introductions reintroductions newhere firsttoot recommends stt Introduction Introductions reintroduction introductons introduction intro introductions - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT geography - theCartographer graph + theCartographer graph cartography - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT education - SchoolForAfrica PhDstudent mitbewohnerin techlearningcollective oh languages student teaching tutorials education academics mit academia teach Lebensmittelfarbstoff elearning learning languagelearning tutorial mitkatzundkegel ec language deeplearning collect teacher cad mitteleuropa + SchoolForAfrica PhDstudent mitbewohnerin techlearningcollective oh languages student teaching tutorials education academics mit teach Lebensmittelfarbstoff elearning learning languagelearning tutorial mitkatzundkegel ec language deeplearning collect teacher cad mitteleuropa populareducation students - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT scifi startrekdiscovery startrek discover SoftwareJob LegDichNieMitSchwarzenKatzenAn starwars ds9 discovery trek SchwarzeFrauen babylon NGIForward war babylon5 - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT microcontroller e kontrollieren microcontroller trolls Chatkontrolle troll arduinoide arduino - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT design userfriendly friendly rf - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT help mastohelp MutualAidRequest helpwanted lpf helpful MutualAidReques hilfe helpMeOutHere help - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT automotive volkswagen - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT fantasy discworld godzilla - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT entertainment CircusInPlace legallyblonde watching theCinema Thundercat makingof entertainment me un nowwatching mandalorian themandalorian nt - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT election Rainbowvote voted vote - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT moderation fedblock - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT languages lojban gaelic - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT environment s crisisclimatica clim climatechaos climateadaptation - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT organization conceptmap mindmapping mapping mindmap notetoself pi - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT industrial powerplants - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT technology AvatarResearch tools LowtechSolutions literatools - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT microcontrollers esp32c3 microcontrollers esp8266 esp32 - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT agriculture farmers - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT organisation InstitutionalMemory - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT skills gardening baking - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT france Macronavirus - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT memes tired - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT sailing theBoatyard - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT parenting dadposting - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT jewelry bracelet - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT architecture concrete - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT licences TVRights - Tue, 10 Aug 2021 08:34:29 UT + Fri, 01 Oct 2021 18:50:09 UT + + + accommodation + househunting + + Fri, 01 Oct 2021 18:50:09 UT diff --git a/delete.py b/delete.py index ef042d8a3..df7ad694c 100644 --- a/delete.py +++ b/delete.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" @@ -30,7 +30,8 @@ def sendDeleteViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, deleteObjectUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a delete request message via c2s """ if not session: @@ -57,7 +58,8 @@ def sendDeleteViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: delete webfinger failed for ' + handle) @@ -70,11 +72,13 @@ def sendDeleteViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle + originDomain = fromDomain (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, personCache, - projectVersion, httpPrefix, fromNickname, - fromDomain, postToBox, 53036) + displayName, _) = getPersonBox(signingPrivateKeyPem, originDomain, + baseDir, session, wfRequest, personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 53036) if not inboxUrl: if debug: diff --git a/desktop_client.py b/desktop_client.py index 353a682c1..2fb9fc0be 100644 --- a/desktop_client.py +++ b/desktop_client.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Client" @@ -418,7 +418,8 @@ def _desktopReplyToPost(session, postId: str, debug: bool, subject: str, screenreader: str, systemLanguage: str, espeak, conversationId: str, - lowBandwidth: bool) -> None: + lowBandwidth: bool, + signingPrivateKeyPem: str) -> None: """Use the desktop client to send a reply to the most recent post """ if '://' not in postId: @@ -463,7 +464,7 @@ def _desktopReplyToPost(session, postId: str, city = 'London, England' sayStr = 'Sending reply' _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - if sendPostViaServer(__version__, + if sendPostViaServer(signingPrivateKeyPem, __version__, baseDir, session, nickname, password, domain, port, toNickname, toDomain, toPort, ccUrl, @@ -486,7 +487,8 @@ def _desktopNewPost(session, cachedWebfingers: {}, personCache: {}, debug: bool, screenreader: str, systemLanguage: str, - espeak, lowBandwidth: bool) -> None: + espeak, lowBandwidth: bool, + signingPrivateKeyPem: str) -> None: """Use the desktop client to create a new post """ conversationId = None @@ -527,7 +529,7 @@ def _desktopNewPost(session, subject = None sayStr = 'Sending' _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - if sendPostViaServer(__version__, + if sendPostViaServer(signingPrivateKeyPem, __version__, baseDir, session, nickname, password, domain, port, None, '#Public', port, ccUrl, @@ -661,7 +663,9 @@ def _readLocalBoxPost(session, nickname: str, domain: str, systemLanguage: str, screenreader: str, espeak, translate: {}, yourActor: str, - domainFull: str, personCache: {}) -> {}: + domainFull: str, personCache: {}, + signingPrivateKeyPem: str, + blockedCache: {}) -> {}: """Reads a post from the given timeline Returns the post json """ @@ -688,6 +692,7 @@ def _readLocalBoxPost(session, nickname: str, domain: str, recentPostsCache = {} allowLocalNetworkAccess = False YTReplacementDomain = None + twitterReplacementDomain = None postJsonObject2 = \ downloadAnnounce(session, baseDir, httpPrefix, @@ -695,10 +700,13 @@ def _readLocalBoxPost(session, nickname: str, domain: str, postJsonObject, __version__, translate, YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, recentPostsCache, False, systemLanguage, - domainFull, personCache) + domainFull, personCache, + signingPrivateKeyPem, + blockedCache) if postJsonObject2: if hasObjectDict(postJsonObject2): if postJsonObject2['object'].get('attributedTo') and \ @@ -742,7 +750,7 @@ def _readLocalBoxPost(session, nickname: str, domain: str, if isPGPEncrypted(content): sayStr = 'Encrypted message. Please enter your passphrase.' _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - content = pgpDecrypt(domain, content, actor) + content = pgpDecrypt(domain, content, actor, signingPrivateKeyPem) if isPGPEncrypted(content): sayStr = 'Message could not be decrypted' _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) @@ -823,7 +831,7 @@ def _desktopShowProfile(session, nickname: str, domain: str, systemLanguage: str, screenreader: str, espeak, translate: {}, yourActor: str, - postJsonObject: {}) -> {}: + postJsonObject: {}, signingPrivateKeyPem: str) -> {}: """Shows the profile of the actor for the given post Returns the actor json """ @@ -854,7 +862,8 @@ def _desktopShowProfile(session, nickname: str, domain: str, if 'http://' in actor: isHttp = True actorJson, asHeader = \ - getActorJson(domain, actor, isHttp, False, False, True) + getActorJson(domain, actor, isHttp, False, False, True, + signingPrivateKeyPem) _desktopShowActor(baseDir, actorJson, translate, systemLanguage, screenreader, espeak) @@ -868,12 +877,14 @@ def _desktopShowProfileFromHandle(session, nickname: str, domain: str, systemLanguage: str, screenreader: str, espeak, translate: {}, yourActor: str, - postJsonObject: {}) -> {}: + postJsonObject: {}, + signingPrivateKeyPem: str) -> {}: """Shows the profile for a handle Returns the actor json """ actorJson, asHeader = \ - getActorJson(domain, handle, False, False, False, True) + getActorJson(domain, handle, False, False, False, True, + signingPrivateKeyPem) _desktopShowActor(baseDir, actorJson, translate, systemLanguage, screenreader, espeak) @@ -1112,7 +1123,8 @@ def _desktopNewDM(session, toHandle: str, cachedWebfingers: {}, personCache: {}, debug: bool, screenreader: str, systemLanguage: str, - espeak, lowBandwidth: bool) -> None: + espeak, lowBandwidth: bool, + signingPrivateKeyPem: str) -> None: """Use the desktop client to create a new direct message which can include multiple destination handles """ @@ -1133,7 +1145,8 @@ def _desktopNewDM(session, toHandle: str, cachedWebfingers, personCache, debug, screenreader, systemLanguage, - espeak, lowBandwidth) + espeak, lowBandwidth, + signingPrivateKeyPem) def _desktopNewDMbase(session, toHandle: str, @@ -1142,7 +1155,8 @@ def _desktopNewDMbase(session, toHandle: str, cachedWebfingers: {}, personCache: {}, debug: bool, screenreader: str, systemLanguage: str, - espeak, lowBandwidth: bool) -> None: + espeak, lowBandwidth: bool, + signingPrivateKeyPem: str) -> None: """Use the desktop client to create a new direct message """ conversationId = None @@ -1201,7 +1215,8 @@ def _desktopNewDMbase(session, toHandle: str, for after in range(randint(1, 16)): paddedMessage += ' ' cipherText = \ - pgpEncryptToActor(domain, paddedMessage, toHandle) + pgpEncryptToActor(domain, paddedMessage, toHandle, + signingPrivateKeyPem) if not cipherText: sayStr = \ toHandle + ' has no PGP public key. ' + \ @@ -1222,7 +1237,7 @@ def _desktopNewDMbase(session, toHandle: str, sayStr = 'Sending' _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) - if sendPostViaServer(__version__, + if sendPostViaServer(signingPrivateKeyPem, __version__, baseDir, session, nickname, password, domain, port, toNickname, toDomain, toPort, ccUrl, @@ -1301,6 +1316,11 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, """Runs the desktop and screen reader client, which announces new inbox items """ + # TODO: this should probably be retrieved somehow from the server + signingPrivateKeyPem = None + + blockedCache = {} + indent = ' ' if showNewPosts: indent = '' @@ -1400,7 +1420,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, nickname, password, domain, port, httpPrefix, cachedWebfingers, personCache, - debug, False) + debug, False, + signingPrivateKeyPem) sayStr = indent + 'PGP public key uploaded' _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) @@ -1410,7 +1431,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, nickname, password, domain, port, httpPrefix, currTimeline, pageNumber, - debug) + debug, signingPrivateKeyPem) followRequestsJson = \ getFollowRequestsViaServer(baseDir, session, @@ -1418,14 +1439,16 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, 1, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) if not (currTimeline == 'inbox' and pageNumber == 1): # monitor the inbox to generate notifications inboxJson = c2sBoxJson(baseDir, session, nickname, password, domain, port, httpPrefix, - 'inbox', 1, debug) + 'inbox', 1, debug, + signingPrivateKeyPem) else: inboxJson = boxJson newDMsExist = False @@ -1502,7 +1525,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, nickname, password, domain, port, httpPrefix, currTimeline, pageNumber, - debug) + debug, signingPrivateKeyPem) if boxJson: _desktopShowBox(indent, followRequestsJson, yourActor, currTimeline, boxJson, @@ -1519,7 +1542,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, nickname, password, domain, port, httpPrefix, currTimeline, pageNumber, - debug) + debug, signingPrivateKeyPem) if boxJson: _desktopShowBox(indent, followRequestsJson, yourActor, currTimeline, boxJson, @@ -1537,7 +1560,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, nickname, password, domain, port, httpPrefix, currTimeline, pageNumber, - debug) + debug, signingPrivateKeyPem) if boxJson: _desktopShowBox(indent, followRequestsJson, yourActor, currTimeline, boxJson, @@ -1556,7 +1579,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, nickname, password, domain, port, httpPrefix, currTimeline, pageNumber, - debug) + debug, signingPrivateKeyPem) if boxJson: _desktopShowBox(indent, followRequestsJson, yourActor, currTimeline, boxJson, @@ -1583,7 +1606,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, nickname, password, domain, port, httpPrefix, currTimeline, pageNumber, - debug) + debug, signingPrivateKeyPem) if boxJson: _desktopShowBox(indent, followRequestsJson, yourActor, currTimeline, boxJson, @@ -1606,7 +1629,9 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, pageNumber, postIndex, boxJson, systemLanguage, screenreader, espeak, translate, yourActor, - domainFull, personCache) + domainFull, personCache, + signingPrivateKeyPem, + blockedCache) print('') sayStr = 'Press Enter to continue...' sayStr2 = _highlightText(sayStr) @@ -1628,7 +1653,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, boxJson, systemLanguage, screenreader, espeak, translate, yourActor, - postJsonObject) + postJsonObject, + signingPrivateKeyPem) else: postIndexStr = '1' else: @@ -1643,7 +1669,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, currTimeline, profileHandle, systemLanguage, screenreader, espeak, translate, yourActor, - None) + None, signingPrivateKeyPem) sayStr = 'Press Enter to continue...' sayStr2 = _highlightText(sayStr) _sayCommand(sayStr2, sayStr, @@ -1661,7 +1687,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, pageNumber, postIndex, boxJson, systemLanguage, screenreader, espeak, translate, yourActor, - None) + None, signingPrivateKeyPem) sayStr = 'Press Enter to continue...' sayStr2 = _highlightText(sayStr) _sayCommand(sayStr2, sayStr, @@ -1689,7 +1715,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, debug, subject, screenreader, systemLanguage, espeak, conversationId, - lowBandwidth) + lowBandwidth, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr == 'post' or commandStr == 'p' or @@ -1723,7 +1750,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, cachedWebfingers, personCache, debug, screenreader, systemLanguage, - espeak, lowBandwidth) + espeak, lowBandwidth, + signingPrivateKeyPem) refreshTimeline = True else: # public post @@ -1733,7 +1761,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, cachedWebfingers, personCache, debug, screenreader, systemLanguage, - espeak, lowBandwidth) + espeak, lowBandwidth, + signingPrivateKeyPem) refreshTimeline = True print('') elif commandStr == 'like' or commandStr.startswith('like '): @@ -1759,7 +1788,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, postJsonObject['id'], cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr == 'undo mute' or @@ -1797,7 +1827,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, postJsonObject['id'], cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr == 'mute' or @@ -1826,7 +1857,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, postJsonObject['id'], cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr == 'undo bookmark' or @@ -1867,7 +1899,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, postJsonObject['id'], cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr == 'bookmark' or @@ -1896,7 +1929,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, postJsonObject['id'], cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr.startswith('undo block ') or @@ -1931,7 +1965,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, blockActor, cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif commandStr.startswith('block '): @@ -1976,7 +2011,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, blockActor, cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif commandStr == 'unlike' or commandStr == 'undo like': @@ -2003,7 +2039,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, postJsonObject['id'], cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr.startswith('announce') or @@ -2033,7 +2070,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, postId, cachedWebfingers, personCache, - True, __version__) + True, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr.startswith('unannounce') or @@ -2067,7 +2105,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, httpPrefix, postId, cachedWebfingers, personCache, - True, __version__) + True, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') elif (commandStr == 'follow requests' or @@ -2083,7 +2122,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, currPage, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) if followRequestsJson: if isinstance(followRequestsJson, dict): _desktopShowFollowRequests(followRequestsJson, @@ -2102,7 +2142,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, currPage, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) if followingJson: if isinstance(followingJson, dict): _desktopShowFollowing(followingJson, translate, @@ -2122,7 +2163,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, domain, port, httpPrefix, currPage, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) if followersJson: if isinstance(followersJson, dict): _desktopShowFollowing(followersJson, translate, @@ -2161,7 +2203,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, httpPrefix, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) else: if followHandle: sayStr = followHandle + ' is not valid' @@ -2195,7 +2238,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, httpPrefix, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) else: sayStr = followHandle + ' is not valid' _sayCommand(sayStr, sayStr, @@ -2224,7 +2268,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, cachedWebfingers, personCache, debug, - __version__) + __version__, + signingPrivateKeyPem) else: if approveHandle: sayStr = approveHandle + ' is not valid' @@ -2256,7 +2301,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, cachedWebfingers, personCache, debug, - __version__) + __version__, + signingPrivateKeyPem) else: if denyHandle: sayStr = denyHandle + ' is not valid' @@ -2331,6 +2377,7 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, recentPostsCache = {} allowLocalNetworkAccess = False YTReplacementDomain = None + twitterReplacementDomain = None postJsonObject2 = \ downloadAnnounce(session, baseDir, httpPrefix, @@ -2338,10 +2385,13 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, postJsonObject, __version__, translate, YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, recentPostsCache, False, systemLanguage, - domainFull, personCache) + domainFull, personCache, + signingPrivateKeyPem, + blockedCache) if postJsonObject2: postJsonObject = postJsonObject2 if postJsonObject: @@ -2423,7 +2473,8 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, postJsonObject['id'], cachedWebfingers, personCache, - False, __version__) + False, __version__, + signingPrivateKeyPem) refreshTimeline = True print('') diff --git a/devices.py b/devices.py index 8b56e5d37..242a0a75a 100644 --- a/devices.py +++ b/devices.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Security" @@ -44,7 +44,10 @@ def E2EEremoveDevice(baseDir: str, nickname: str, domain: str, personDir = acctDir(baseDir, nickname, domain) deviceFilename = personDir + '/devices/' + deviceId + '.json' if os.path.isfile(deviceFilename): - os.remove(deviceFilename) + try: + os.remove(deviceFilename) + except BaseException: + pass return True return False diff --git a/donate.py b/donate.py index a8f4e26d3..f15d5cb80 100644 --- a/donate.py +++ b/donate.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/emoji/default_emoji.json b/emoji/default_emoji.json index ab8ac89f3..f43d04a1c 100644 --- a/emoji/default_emoji.json +++ b/emoji/default_emoji.json @@ -769,5 +769,6 @@ "void": "void", "openbsd": "openbsd", "freebsd": "freebsd", - "orgmode": "orgmode" + "orgmode": "orgmode", + "kde": "kde" } diff --git a/emoji/kde.png b/emoji/kde.png new file mode 100644 index 000000000..7dc2d0adf Binary files /dev/null and b/emoji/kde.png differ diff --git a/epicyon-profile.css b/epicyon-profile.css index 8a91c7ddf..192d648fd 100644 --- a/epicyon-profile.css +++ b/epicyon-profile.css @@ -201,6 +201,13 @@ figure { height: auto; } +mark { + background-color: var(--main-bg-color); + color: var(--main-fg-color); + font-size: 130%; + font-weight: bold; +} + .accesskeys { border: 0; width: 100%; @@ -1383,6 +1390,26 @@ div.container { margin-bottom: var(--button-bottom-margin); margin-left: var(--button-left-margin); } + .contactbutton { + border-radius: var(--button-corner-radius); + background-color: var(--button-background); + color: var(--button-text); + text-align: center; + font-size: var(--font-size-header); + font-family: var(--header-font); + padding: var(--button-height-padding); + width: 20%; + margin: var(--button-margin); + min-width: var(--button-width-chars); + transition: all 0.5s; + cursor: pointer; + border-top: var(--tab-border-width) solid var(--tab-border-color); + border-bottom: none; + border-left: var(--tab-border-width) solid var(--tab-border-color); + border-right: var(--tab-border-width) solid var(--tab-border-color); + margin-bottom: var(--button-bottom-margin); + margin-left: var(--button-left-margin); + } .buttonDesktop { border-radius: var(--button-corner-radius); background-color: var(--button-background); @@ -1667,6 +1694,8 @@ div.container { .columnIcons img { float: right; } + .pageslist { + } } @media screen and (min-width: 2200px) { @@ -1696,7 +1725,7 @@ div.container { color: var(--title-color); } blockquote { - font-size: var(--quote-font-size-mobile); + font-size: var(--quote-font-size-mobile); } .accountsTable { width: 100%; @@ -2017,6 +2046,25 @@ div.container { border-right: var(--tab-border-width) solid var(--tab-border-color); margin-bottom: var(--button-bottom-margin); } + .contactbutton { + border-radius: var(--button-corner-radius); + background-color: var(--button-background); + color: var(--button-text); + text-align: center; + font-size: var(--font-size-button-mobile); + font-family: var(--header-font); + padding: var(--button-height-padding-mobile); + width: 30%; + min-width: var(--button-width-chars); + transition: all 0.5s; + cursor: pointer; + margin: var(--button-margin); + border-top: var(--tab-border-width) solid var(--tab-border-color); + border-bottom: none; + border-left: var(--tab-border-width) solid var(--tab-border-color); + border-right: var(--tab-border-width) solid var(--tab-border-color); + margin-bottom: var(--button-bottom-margin); + } .frontPageMobileButtons{ display: block; border: var(--border-width-header) solid var(--border-color); @@ -2323,4 +2371,6 @@ div.container { float: right; margin-right: 1vw; } + .pageslist { + } } diff --git a/epicyon.py b/epicyon.py index 6748ee1b4..897ddafa3 100644 --- a/epicyon.py +++ b/epicyon.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Commandline Interface" @@ -25,6 +25,7 @@ from roles import setRole from webfinger import webfingerHandle from bookmarks import sendBookmarkViaServer from bookmarks import sendUndoBookmarkViaServer +from posts import getInstanceActorKey from posts import sendMuteViaServer from posts import sendUndoMuteViaServer from posts import c2sBoxJson @@ -169,6 +170,11 @@ parser.add_argument('--dormantMonths', default=3, help='How many months does a followed account need to ' + 'be unseen for before being considered dormant') +parser.add_argument('--defaultReplyIntervalHours', + dest='defaultReplyIntervalHours', type=int, + default=1000, + help='How many hours after publication of a post ' + + 'are replies to it permitted') parser.add_argument('--sendThreadsTimeoutMins', dest='sendThreadsTimeoutMins', type=int, default=30, @@ -217,6 +223,9 @@ parser.add_argument('--path', dest='baseDir', parser.add_argument('--ytdomain', dest='YTReplacementDomain', type=str, default=None, help='Domain used to replace youtube.com') +parser.add_argument('--twitterdomain', dest='twitterReplacementDomain', + type=str, default=None, + help='Domain used to replace twitter.com') parser.add_argument('--language', dest='language', type=str, default=None, help='Language code, eg. en/fr/de/es') @@ -406,10 +415,11 @@ parser.add_argument("--debug", type=str2bool, nargs='?', parser.add_argument("--notificationSounds", type=str2bool, nargs='?', const=True, default=True, help="Play notification sounds") -parser.add_argument("--authenticatedFetch", type=str2bool, nargs='?', +parser.add_argument("--secureMode", type=str2bool, nargs='?', const=True, default=False, - help="Enable authentication on GET requests" + - " for json (authenticated fetch)") + help="Requires all GET requests to be signed, " + + "so that the sender can be identifies and " + + "blocked if neccessary") parser.add_argument("--instanceOnlySkillsSearch", type=str2bool, nargs='?', const=True, default=False, help="Skills searches only return " + @@ -633,12 +643,13 @@ if args.tests: sys.exit() if args.testsnetwork: print('Network Tests') - testSharedItemsFederation() - testGroupFollow() - testPostMessageBetweenServers() - testFollowBetweenServers() - testClientToServer() - testUpdateActor() + baseDir = os.getcwd() + testSharedItemsFederation(baseDir) + testGroupFollow(baseDir) + testPostMessageBetweenServers(baseDir) + testFollowBetweenServers(baseDir) + testClientToServer(baseDir) + testUpdateActor(baseDir) print('All tests succeeded') sys.exit() @@ -662,6 +673,12 @@ if args.libretranslateApiKey: setConfigParam(baseDir, 'libretranslateApiKey', args.libretranslateApiKey) if args.posts: + if not args.domain: + originDomain = getConfigParam(baseDir, 'domain') + else: + originDomain = args.domain + if debug: + print('originDomain: ' + str(originDomain)) if '@' not in args.posts: if '/users/' in args.posts: postsNickname = getNicknameFromActor(args.posts) @@ -688,9 +705,11 @@ if args.posts: proxyType = 'gnunet' if not args.language: args.language = 'en' + signingPrivateKeyPem = getInstanceActorKey(baseDir, originDomain) getPublicPostsOfPerson(baseDir, nickname, domain, False, True, proxyType, args.port, httpPrefix, debug, - __version__, args.language) + __version__, args.language, + signingPrivateKeyPem, originDomain) sys.exit() if args.postDomains: @@ -722,13 +741,22 @@ if args.postDomains: domainList = [] if not args.language: args.language = 'en' + signingPrivateKeyPem = None + if not args.domain: + originDomain = getConfigParam(baseDir, 'domain') + else: + originDomain = args.domain + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, originDomain) domainList = getPublicPostDomains(None, baseDir, nickname, domain, + originDomain, proxyType, args.port, httpPrefix, debug, __version__, wordFrequency, domainList, - args.language) + args.language, + signingPrivateKeyPem) for postDomain in domainList: print(postDomain) sys.exit() @@ -765,13 +793,17 @@ if args.postDomainsBlocked: domainList = [] if not args.language: args.language = 'en' + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) domainList = getPublicPostDomainsBlocked(None, baseDir, nickname, domain, proxyType, args.port, httpPrefix, debug, __version__, wordFrequency, domainList, - args.language) + args.language, + signingPrivateKeyPem) for postDomain in domainList: print(postDomain) sys.exit() @@ -806,12 +838,16 @@ if args.checkDomains: maxBlockedDomains = 0 if not args.language: args.language = 'en' + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) checkDomains(None, baseDir, nickname, domain, proxyType, args.port, httpPrefix, debug, __version__, - maxBlockedDomains, False, args.language) + maxBlockedDomains, False, args.language, + signingPrivateKeyPem) sys.exit() if args.socnet: @@ -825,10 +861,19 @@ if args.socnet: proxyType = 'tor' if not args.language: args.language = 'en' + if not args.domain: + args.domain = getConfigParam(baseDir, 'domain') + domain = '' + if args.domain: + domain = args.domain + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) dotGraph = instancesGraph(baseDir, args.socnet, proxyType, args.port, httpPrefix, debug, - __version__, args.language) + __version__, args.language, + signingPrivateKeyPem) try: with open('socnet.dot', 'w+') as fp: fp.write(dotGraph) @@ -838,6 +883,12 @@ if args.socnet: sys.exit() if args.postsraw: + if not args.domain: + originDomain = getConfigParam(baseDir, 'domain') + else: + originDomain = args.domain + if debug: + print('originDomain: ' + str(originDomain)) if '@' not in args.postsraw: print('Syntax: --postsraw nickname@domain') sys.exit() @@ -854,9 +905,11 @@ if args.postsraw: proxyType = 'gnunet' if not args.language: args.language = 'en' + signingPrivateKeyPem = getInstanceActorKey(baseDir, originDomain) getPublicPostsOfPerson(baseDir, nickname, domain, False, False, proxyType, args.port, httpPrefix, debug, - __version__, args.language) + __version__, args.language, + signingPrivateKeyPem, originDomain) sys.exit() if args.json: @@ -865,8 +918,20 @@ if args.json: asHeader = { 'Accept': 'application/ld+json; profile="' + profileStr + '"' } - testJson = getJson(session, args.json, asHeader, None, - debug, __version__, httpPrefix, None) + if not args.domain: + args.domain = getConfigParam(baseDir, 'domain') + domain = '' + if args.domain: + domain = args.domain + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) + if debug: + print('baseDir: ' + str(baseDir)) + if signingPrivateKeyPem: + print('Obtained instance actor signing key') + else: + print('Did not obtain instance actor key for ' + domain) + testJson = getJson(signingPrivateKeyPem, session, args.json, asHeader, + None, debug, __version__, httpPrefix, domain) pprint(testJson) sys.exit() @@ -1075,6 +1140,11 @@ if args.approve: postLog = [] cachedWebfingers = {} personCache = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) manualApproveFollowRequest(session, baseDir, httpPrefix, args.nickname, domain, port, @@ -1082,7 +1152,8 @@ if args.approve: federationList, sendThreads, postLog, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) sys.exit() if args.deny: @@ -1097,6 +1168,11 @@ if args.deny: postLog = [] cachedWebfingers = {} personCache = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) manualDenyFollowRequest(session, baseDir, httpPrefix, args.nickname, domain, port, @@ -1104,7 +1180,8 @@ if args.deny: federationList, sendThreads, postLog, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) sys.exit() if args.followerspending: @@ -1184,9 +1261,14 @@ if args.message: replyTo = args.replyto followersOnly = False isArticle = False + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending post to ' + args.sendto) - sendPostViaServer(__version__, + sendPostViaServer(signingPrivateKeyPem, __version__, baseDir, session, args.nickname, args.password, domain, port, toNickname, toDomain, toPort, ccUrl, @@ -1216,13 +1298,18 @@ if args.announce: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending announce/repeat of ' + args.announce) sendAnnounceViaServer(baseDir, session, args.nickname, args.password, domain, port, httpPrefix, args.announce, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1255,13 +1342,18 @@ if args.box: args.port = 80 elif args.gnunet: proxyType = 'gnunet' + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) session = createSession(proxyType) boxJson = c2sBoxJson(baseDir, session, args.nickname, args.password, domain, port, httpPrefix, args.box, args.pageNumber, - args.debug) + args.debug, signingPrivateKeyPem) if boxJson: pprint(boxJson) else: @@ -1311,6 +1403,11 @@ if args.itemName: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending shared item: ' + args.itemName) sendShareViaServer(baseDir, session, @@ -1327,7 +1424,8 @@ if args.itemName: args.duration, cachedWebfingers, personCache, debug, __version__, - args.itemPrice, args.itemCurrency) + args.itemPrice, args.itemCurrency, + signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1348,6 +1446,11 @@ if args.undoItemName: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending undo of shared item: ' + args.undoItemName) sendUndoShareViaServer(baseDir, session, @@ -1356,7 +1459,7 @@ if args.undoItemName: httpPrefix, args.undoItemName, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1405,6 +1508,11 @@ if args.wantedItemName: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending wanted item: ' + args.wantedItemName) sendWantedViaServer(baseDir, session, @@ -1421,7 +1529,8 @@ if args.wantedItemName: args.duration, cachedWebfingers, personCache, debug, __version__, - args.itemPrice, args.itemCurrency) + args.itemPrice, args.itemCurrency, + signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1442,6 +1551,11 @@ if args.undoWantedItemName: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending undo of wanted item: ' + args.undoWantedItemName) sendUndoWantedViaServer(baseDir, session, @@ -1450,7 +1564,7 @@ if args.undoWantedItemName: httpPrefix, args.undoWantedItemName, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1471,6 +1585,11 @@ if args.like: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending like of ' + args.like) sendLikeViaServer(baseDir, session, @@ -1478,7 +1597,7 @@ if args.like: domain, port, httpPrefix, args.like, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1499,6 +1618,11 @@ if args.undolike: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending undo like of ' + args.undolike) sendUndoLikeViaServer(baseDir, session, @@ -1506,7 +1630,8 @@ if args.undolike: domain, port, httpPrefix, args.undolike, cachedWebfingers, personCache, - True, __version__) + True, __version__, + signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1527,6 +1652,11 @@ if args.bookmark: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending bookmark of ' + args.bookmark) sendBookmarkViaServer(baseDir, session, @@ -1534,7 +1664,8 @@ if args.bookmark: domain, port, httpPrefix, args.bookmark, cachedWebfingers, personCache, - True, __version__) + True, __version__, + signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1555,6 +1686,11 @@ if args.unbookmark: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending undo bookmark of ' + args.unbookmark) sendUndoBookmarkViaServer(baseDir, session, @@ -1562,7 +1698,7 @@ if args.unbookmark: domain, port, httpPrefix, args.unbookmark, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1583,6 +1719,11 @@ if args.delete: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending delete request of ' + args.delete) sendDeleteViaServer(baseDir, session, @@ -1590,7 +1731,7 @@ if args.delete: domain, port, httpPrefix, args.delete, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1623,6 +1764,11 @@ if args.follow: followHttpPrefix = httpPrefix if args.follow.startswith('https'): followHttpPrefix = 'https' + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) sendFollowRequestViaServer(baseDir, session, args.nickname, args.password, @@ -1630,7 +1776,7 @@ if args.follow: followNickname, followDomain, followPort, httpPrefix, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, signingPrivateKeyPem) for t in range(20): time.sleep(1) # TODO some method to know if it worked @@ -1664,6 +1810,11 @@ if args.unfollow: followHttpPrefix = httpPrefix if args.follow.startswith('https'): followHttpPrefix = 'https' + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) sendUnfollowRequestViaServer(baseDir, session, args.nickname, args.password, @@ -1671,7 +1822,7 @@ if args.unfollow: followNickname, followDomain, followPort, httpPrefix, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, signingPrivateKeyPem) for t in range(20): time.sleep(1) # TODO some method to know if it worked @@ -1694,6 +1845,11 @@ if args.followingList: personCache = {} cachedWebfingers = {} followHttpPrefix = httpPrefix + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) followingJson = \ getFollowingViaServer(baseDir, session, @@ -1701,7 +1857,7 @@ if args.followingList: domain, port, httpPrefix, args.pageNumber, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, signingPrivateKeyPem) if followingJson: pprint(followingJson) sys.exit() @@ -1722,6 +1878,11 @@ if args.followersList: personCache = {} cachedWebfingers = {} followHttpPrefix = httpPrefix + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) followersJson = \ getFollowersViaServer(baseDir, session, @@ -1729,7 +1890,8 @@ if args.followersList: domain, port, httpPrefix, args.pageNumber, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, + signingPrivateKeyPem) if followersJson: pprint(followersJson) sys.exit() @@ -1750,6 +1912,11 @@ if args.followRequestsList: personCache = {} cachedWebfingers = {} followHttpPrefix = httpPrefix + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) followRequestsJson = \ getFollowRequestsViaServer(baseDir, session, @@ -1757,7 +1924,7 @@ if args.followRequestsList: domain, port, httpPrefix, args.pageNumber, cachedWebfingers, personCache, - debug, __version__) + debug, __version__, signingPrivateKeyPem) if followRequestsJson: pprint(followRequestsJson) sys.exit() @@ -1797,9 +1964,14 @@ if args.migrations: httpPrefix = 'https' port = 443 session = createSession(proxyType) + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) ctr = migrateAccounts(baseDir, session, httpPrefix, cachedWebfingers, - True) + True, signingPrivateKeyPem) if ctr == 0: print('No followed accounts have moved') else: @@ -1807,7 +1979,17 @@ if args.migrations: sys.exit() if args.actor: - getActorJson(args.domain, args.actor, args.http, args.gnunet, debug) + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) + if debug: + print('baseDir: ' + str(baseDir)) + if signingPrivateKeyPem: + print('Obtained instance actor signing key') + else: + print('Did not obtain instance actor key for ' + domain) + getActorJson(domain, args.actor, args.http, args.gnunet, + debug, False, signingPrivateKeyPem) sys.exit() if args.followers: @@ -1882,10 +2064,17 @@ if args.followers: if nickname == 'inbox': nickname = domain + hostDomain = None + if args.domain: + hostDomain = args.domain handle = nickname + '@' + domain + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - None, __version__, debug, False) + hostDomain, __version__, debug, False, + signingPrivateKeyPem) if not wfRequest: print('Unable to webfinger ' + handle) sys.exit() @@ -1927,9 +2116,12 @@ if args.followers: asHeader = { 'Accept': 'application/ld+json; profile="' + profileStr + '"' } - + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) followersList = \ - downloadFollowCollection('followers', session, + downloadFollowCollection(signingPrivateKeyPem, + 'followers', session, httpPrefix, personUrl, 1, 3) if followersList: for actor in followersList: @@ -2179,6 +2371,11 @@ if args.skill: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending ' + args.skill + ' skill level ' + str(args.skillLevelPercent) + ' for ' + nickname) @@ -2188,7 +2385,7 @@ if args.skill: httpPrefix, args.skill, args.skillLevelPercent, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -2209,6 +2406,11 @@ if args.availability: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending availability status of ' + nickname + ' as ' + args.availability) @@ -2217,7 +2419,7 @@ if args.availability: httpPrefix, args.availability, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -2318,13 +2520,18 @@ if args.block: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending block of ' + args.block) sendBlockViaServer(baseDir, session, nickname, args.password, domain, port, httpPrefix, args.block, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -2345,13 +2552,18 @@ if args.mute: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending mute of ' + args.mute) sendMuteViaServer(baseDir, session, nickname, args.password, domain, port, httpPrefix, args.mute, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -2372,13 +2584,18 @@ if args.unmute: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending undo mute of ' + args.unmute) sendUndoMuteViaServer(baseDir, session, nickname, args.password, domain, port, httpPrefix, args.unmute, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -2411,13 +2628,18 @@ if args.unblock: session = createSession(proxyType) personCache = {} cachedWebfingers = {} + if not domain: + domain = getConfigParam(baseDir, 'domain') + signingPrivateKeyPem = None + if args.secureMode: + signingPrivateKeyPem = getInstanceActorKey(baseDir, domain) print('Sending undo block of ' + args.unblock) sendUndoBlockViaServer(baseDir, session, nickname, args.password, domain, port, httpPrefix, args.unblock, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -2803,6 +3025,15 @@ if YTDomain: if '.' in YTDomain: args.YTReplacementDomain = YTDomain +twitterDomain = getConfigParam(baseDir, 'twitterdomain') +if twitterDomain: + if '://' in twitterDomain: + twitterDomain = twitterDomain.split('://')[1] + if '/' in twitterDomain: + twitterDomain = twitterDomain.split('/')[0] + if '.' in twitterDomain: + args.twitterReplacementDomain = twitterDomain + if setTheme(baseDir, themeName, domain, args.allowLocalNetworkAccess, args.language): print('Theme set to ' + themeName) @@ -2833,7 +3064,8 @@ if args.defaultCurrency: print('Default currency set to ' + args.defaultCurrency) if __name__ == "__main__": - runDaemon(args.lowBandwidth, args.maxLikeCount, + runDaemon(args.defaultReplyIntervalHours, + args.lowBandwidth, args.maxLikeCount, sharedItemsFederatedDomains, userAgentsBlocked, args.logLoginFailures, @@ -2869,9 +3101,10 @@ if __name__ == "__main__": instanceId, args.client, baseDir, domain, onionDomain, i2pDomain, args.YTReplacementDomain, + args.twitterReplacementDomain, port, proxyPort, httpPrefix, federationList, args.maxMentions, - args.maxEmoji, args.authenticatedFetch, + args.maxEmoji, args.secureMode, proxyType, args.maxReplies, args.domainMaxPostsPerDay, args.accountMaxPostsPerDay, diff --git a/feeds.py b/feeds.py index 310243981..794f327c9 100644 --- a/feeds.py +++ b/feeds.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "RSS Feeds" diff --git a/filters.py b/filters.py index 875fb5503..20efe8a5b 100644 --- a/filters.py +++ b/filters.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Moderation" diff --git a/follow.py b/follow.py index b81786db3..b0aa8f3c5 100644 --- a/follow.py +++ b/follow.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" @@ -211,6 +211,12 @@ def isFollowerOfPerson(baseDir: str, nickname: str, domain: str, followerNickname: str, followerDomain: str) -> bool: """is the given nickname a follower of followerNickname? """ + if not followerDomain: + print('No followerDomain') + return False + if not followerNickname: + print('No followerNickname for ' + followerDomain) + return False domain = removeDomainPort(domain) followersFile = acctDir(baseDir, nickname, domain) + '/followers.txt' if not os.path.isfile(followersFile): @@ -308,7 +314,10 @@ def clearFollows(baseDir: str, nickname: str, domain: str, os.mkdir(baseDir + '/accounts/' + handle) filename = baseDir + '/accounts/' + handle + '/' + followFile if os.path.isfile(filename): - os.remove(filename) + try: + os.remove(filename) + except BaseException: + pass def clearFollowers(baseDir: str, nickname: str, domain: str) -> None: @@ -631,7 +640,8 @@ def receiveFollowRequest(session, baseDir: str, httpPrefix: str, cachedWebfingers: {}, personCache: {}, messageJson: {}, federationList: [], debug: bool, projectVersion: str, - maxFollowers: int, onionDomain: str) -> bool: + maxFollowers: int, onionDomain: str, + signingPrivateKeyPem: str) -> bool: """Receives a follow request within the POST section of HTTPServer """ if not messageJson['type'].startswith('Follow'): @@ -743,7 +753,8 @@ def receiveFollowRequest(session, baseDir: str, httpPrefix: str, print('Obtaining the following actor: ' + messageJson['actor']) if not getPersonPubKey(baseDir, session, messageJson['actor'], personCache, debug, projectVersion, - httpPrefix, domainToFollow, onionDomain): + httpPrefix, domainToFollow, onionDomain, + signingPrivateKeyPem): if debug: print('Unable to obtain following actor: ' + messageJson['actor']) @@ -779,7 +790,8 @@ def receiveFollowRequest(session, baseDir: str, httpPrefix: str, print('Obtaining the following actor: ' + messageJson['actor']) if not getPersonPubKey(baseDir, session, messageJson['actor'], personCache, debug, projectVersion, - httpPrefix, domainToFollow, onionDomain): + httpPrefix, domainToFollow, onionDomain, + signingPrivateKeyPem): if debug: print('Unable to obtain following actor: ' + messageJson['actor']) @@ -824,7 +836,8 @@ def receiveFollowRequest(session, baseDir: str, httpPrefix: str, messageJson['actor'], federationList, messageJson, sendThreads, postLog, cachedWebfingers, personCache, - debug, projectVersion, True) + debug, projectVersion, True, + signingPrivateKeyPem) def followedAccountAccepts(session, baseDir: str, httpPrefix: str, @@ -835,7 +848,8 @@ def followedAccountAccepts(session, baseDir: str, httpPrefix: str, followJson: {}, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, debug: bool, projectVersion: str, - removeFollowActivity: bool): + removeFollowActivity: bool, + signingPrivateKeyPem: str): """The person receiving a follow request accepts the new follower and sends back an Accept activity """ @@ -884,7 +898,8 @@ def followedAccountAccepts(session, baseDir: str, httpPrefix: str, federationList, sendThreads, postLog, cachedWebfingers, personCache, debug, projectVersion, None, - groupAccount) + groupAccount, signingPrivateKeyPem, + 7856837) def followedAccountRejects(session, baseDir: str, httpPrefix: str, @@ -894,7 +909,8 @@ def followedAccountRejects(session, baseDir: str, httpPrefix: str, federationList: [], sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str): + debug: bool, projectVersion: str, + signingPrivateKeyPem: str): """The person receiving a follow request rejects the new follower and sends back a Reject activity """ @@ -949,7 +965,8 @@ def followedAccountRejects(session, baseDir: str, httpPrefix: str, federationList, sendThreads, postLog, cachedWebfingers, personCache, debug, projectVersion, None, - groupAccount) + groupAccount, signingPrivateKeyPem, + 6393063) def sendFollowRequest(session, baseDir: str, @@ -960,9 +977,12 @@ def sendFollowRequest(session, baseDir: str, clientToServer: bool, federationList: [], sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, debug: bool, - projectVersion: str) -> {}: + projectVersion: str, signingPrivateKeyPem: str) -> {}: """Gets the json object for sending a follow request """ + if not signingPrivateKeyPem: + print('WARN: follow request without signing key') + if not domainPermitted(followDomain, federationList): print('You are not permitted to follow the domain ' + followDomain) return None @@ -1016,7 +1036,8 @@ def sendFollowRequest(session, baseDir: str, httpPrefix, True, clientToServer, federationList, sendThreads, postLog, cachedWebfingers, personCache, - debug, projectVersion, None, groupAccount) + debug, projectVersion, None, groupAccount, + signingPrivateKeyPem, 8234389) return newFollowJson @@ -1028,7 +1049,8 @@ def sendFollowRequestViaServer(baseDir: str, session, followPort: int, httpPrefix: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a follow request via c2s """ if not session: @@ -1057,7 +1079,8 @@ def sendFollowRequestViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: follow request webfinger failed for ' + handle) @@ -1070,11 +1093,13 @@ def sendFollowRequestViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle + originDomain = fromDomain (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, personCache, - projectVersion, httpPrefix, fromNickname, - fromDomain, postToBox, 52025) + displayName, _) = getPersonBox(signingPrivateKeyPem, originDomain, + baseDir, session, wfRequest, personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 52025) if not inboxUrl: if debug: @@ -1114,7 +1139,8 @@ def sendUnfollowRequestViaServer(baseDir: str, session, followPort: int, httpPrefix: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a unfollow request via c2s """ if not session: @@ -1147,7 +1173,8 @@ def sendUnfollowRequestViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: unfollow webfinger failed for ' + handle) @@ -1160,14 +1187,16 @@ def sendUnfollowRequestViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, - wfRequest, personCache, - projectVersion, httpPrefix, - fromNickname, - fromDomain, postToBox, - 76536) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, + wfRequest, personCache, + projectVersion, httpPrefix, + fromNickname, + fromDomain, postToBox, + 76536) if not inboxUrl: if debug: @@ -1205,7 +1234,8 @@ def getFollowingViaServer(baseDir: str, session, domain: str, port: int, httpPrefix: str, pageNumber: int, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Gets a page from the following collection as json """ if not session: @@ -1227,9 +1257,8 @@ def getFollowingViaServer(baseDir: str, session, pageNumber = 1 url = followActor + '/following?page=' + str(pageNumber) followingJson = \ - getJson(session, url, headers, {}, debug, - __version__, httpPrefix, - domain, 10, True) + getJson(signingPrivateKeyPem, session, url, headers, {}, debug, + __version__, httpPrefix, domain, 10, True) if not followingJson: if debug: print('DEBUG: GET following list failed for c2s to ' + url) @@ -1246,7 +1275,8 @@ def getFollowersViaServer(baseDir: str, session, domain: str, port: int, httpPrefix: str, pageNumber: int, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Gets a page from the followers collection as json """ if not session: @@ -1268,7 +1298,7 @@ def getFollowersViaServer(baseDir: str, session, pageNumber = 1 url = followActor + '/followers?page=' + str(pageNumber) followersJson = \ - getJson(session, url, headers, {}, debug, + getJson(signingPrivateKeyPem, session, url, headers, {}, debug, __version__, httpPrefix, domain, 10, True) if not followersJson: if debug: @@ -1286,7 +1316,8 @@ def getFollowRequestsViaServer(baseDir: str, session, domain: str, port: int, httpPrefix: str, pageNumber: int, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Gets a page from the follow requests collection as json """ if not session: @@ -1308,7 +1339,7 @@ def getFollowRequestsViaServer(baseDir: str, session, pageNumber = 1 url = followActor + '/followrequests?page=' + str(pageNumber) followersJson = \ - getJson(session, url, headers, {}, debug, + getJson(signingPrivateKeyPem, session, url, headers, {}, debug, __version__, httpPrefix, domain, 10, True) if not followersJson: if debug: @@ -1326,7 +1357,8 @@ def approveFollowRequestViaServer(baseDir: str, session, domain: str, port: int, httpPrefix: str, approveHandle: int, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> str: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> str: """Approves a follow request This is not exactly via c2s though. It simulates pressing the Approve button on the web interface @@ -1348,7 +1380,7 @@ def approveFollowRequestViaServer(baseDir: str, session, url = actor + '/followapprove=' + approveHandle approveHtml = \ - getJson(session, url, headers, {}, debug, + getJson(signingPrivateKeyPem, session, url, headers, {}, debug, __version__, httpPrefix, domain, 10, True) if not approveHtml: if debug: @@ -1366,7 +1398,8 @@ def denyFollowRequestViaServer(baseDir: str, session, domain: str, port: int, httpPrefix: str, denyHandle: int, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> str: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> str: """Denies a follow request This is not exactly via c2s though. It simulates pressing the Deny button on the web interface @@ -1388,7 +1421,7 @@ def denyFollowRequestViaServer(baseDir: str, session, url = actor + '/followdeny=' + denyHandle denyHtml = \ - getJson(session, url, headers, {}, debug, + getJson(signingPrivateKeyPem, session, url, headers, {}, debug, __version__, httpPrefix, domain, 10, True) if not denyHtml: if debug: diff --git a/followingCalendar.py b/followingCalendar.py index e5453eb2b..5782f801e 100644 --- a/followingCalendar.py +++ b/followingCalendar.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Calendar" diff --git a/gemini/EN/install.gmi b/gemini/EN/install.gmi index 23f342289..6994083b3 100644 --- a/gemini/EN/install.gmi +++ b/gemini/EN/install.gmi @@ -62,8 +62,6 @@ Create a web server configuration: And paste the following: - proxy_cache_path /var/www/cache levels=1:2 keys_zone=my_cache:10m max_size=10g - inactive=60m use_temp_path=off; server { listen 80; listen [::]:80; @@ -118,8 +116,6 @@ And paste the following: location / { proxy_http_version 1.1; client_max_body_size 31M; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forward-For $proxy_add_x_forwarded_for; @@ -135,10 +131,6 @@ And paste the following: proxy_redirect off; proxy_request_buffering off; proxy_buffering off; - location ~ ^/accounts/(avatars|headers)/(.*).(png|jpg|gif|webp|svg) { - expires 1d; - proxy_pass http://localhost:7156; - } proxy_pass http://localhost:7156; } } @@ -146,7 +138,6 @@ And paste the following: Enable the site: ln -s /etc/nginx/sites-available/YOUR_DOMAIN /etc/nginx/sites-enabled/ - mkdir /var/www/cache Forward port 443 from your internet router to your server. If you have dynamic DNS make sure its configured. Add a TLS certificate: diff --git a/git.py b/git.py index 7556d4840..fa51a39bd 100644 --- a/git.py +++ b/git.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/happening.py b/happening.py index a3d877528..fa8d85a98 100644 --- a/happening.py +++ b/happening.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" diff --git a/httpsig.py b/httpsig.py index 50d897e3c..1086508bc 100644 --- a/httpsig.py +++ b/httpsig.py @@ -4,7 +4,7 @@ __credits__ = ['lamia'] __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Security" @@ -24,6 +24,7 @@ from time import gmtime, strftime import datetime from utils import getFullDomain from utils import getSHA256 +from utils import getSHA512 from utils import localActorUrl @@ -39,7 +40,8 @@ def signPostHeaders(dateStr: str, privateKeyPem: str, toDomain: str, toPort: int, path: str, httpPrefix: str, - messageBodyJsonStr: str) -> str: + messageBodyJsonStr: str, + contentType: str) -> str: """Returns a raw signature string that can be plugged into a header and used to verify the authenticity of an HTTP transmission. """ @@ -49,13 +51,18 @@ def signPostHeaders(dateStr: str, privateKeyPem: str, if not dateStr: dateStr = strftime("%a, %d %b %Y %H:%M:%S %Z", gmtime()) - keyID = localActorUrl(httpPrefix, nickname, domain) + '#main-key' + if nickname != domain and nickname.lower() != 'actor': + keyID = localActorUrl(httpPrefix, nickname, domain) + else: + # instance actor + keyID = httpPrefix + '://' + domain + '/actor' + keyID += '#main-key' if not messageBodyJsonStr: headers = { - '(request-target)': f'post {path}', + '(request-target)': f'get {path}', 'host': toDomain, 'date': dateStr, - 'content-type': 'application/json' + 'accept': contentType } else: bodyDigest = messageContentDigest(messageBodyJsonStr) @@ -78,7 +85,8 @@ def signPostHeaders(dateStr: str, privateKeyPem: str, signedHeaderText = '' for headerKey in signedHeaderKeys: signedHeaderText += f'{headerKey}: {headers[headerKey]}\n' - signedHeaderText = signedHeaderText.strip() + # strip the trailing linefeed + signedHeaderText = signedHeaderText.rstrip('\n') # signedHeaderText.encode('ascii') matches headerDigest = getSHA256(signedHeaderText.encode('ascii')) # print('headerDigest2: ' + str(headerDigest)) @@ -155,11 +163,18 @@ def signPostHeadersNew(dateStr: str, privateKeyPem: str, for headerKey in signedHeaderKeys: signedHeaderText += f'{headerKey}: {headers[headerKey]}\n' signedHeaderText = signedHeaderText.strip() - headerDigest = getSHA256(signedHeaderText.encode('ascii')) # Sign the digest. Potentially other signing algorithms can be added here. signature = '' - if algorithm == 'rsa-sha256': + if algorithm == 'rsa-sha512': + headerDigest = getSHA512(signedHeaderText.encode('ascii')) + rawSignature = key.sign(headerDigest, + padding.PKCS1v15(), + hazutils.Prehashed(hashes.SHA512())) + signature = base64.b64encode(rawSignature).decode('ascii') + else: + # default sha256 + headerDigest = getSHA256(signedHeaderText.encode('ascii')) rawSignature = key.sign(headerDigest, padding.PKCS1v15(), hazutils.Prehashed(hashes.SHA256())) @@ -184,27 +199,35 @@ def signPostHeadersNew(dateStr: str, privateKeyPem: str, return signatureIndexHeader, signatureHeader -def createSignedHeader(privateKeyPem: str, nickname: str, +def createSignedHeader(dateStr: str, privateKeyPem: str, nickname: str, domain: str, port: int, toDomain: str, toPort: int, path: str, httpPrefix: str, withDigest: bool, - messageBodyJsonStr: str) -> {}: + messageBodyJsonStr: str, + contentType: str) -> {}: """Note that the domain is the destination, not the sender """ - contentType = 'application/activity+json' headerDomain = getFullDomain(toDomain, toPort) - dateStr = strftime("%a, %d %b %Y %H:%M:%S %Z", gmtime()) + # if no date is given then create one + if not dateStr: + dateStr = strftime("%a, %d %b %Y %H:%M:%S %Z", gmtime()) + + # Content-Type or Accept header + if not contentType: + contentType = 'application/activity+json' + if not withDigest: headers = { - '(request-target)': f'post {path}', + '(request-target)': f'get {path}', 'host': headerDomain, - 'date': dateStr + 'date': dateStr, + 'accept': contentType } signatureHeader = \ signPostHeaders(dateStr, privateKeyPem, nickname, domain, port, toDomain, toPort, - path, httpPrefix, None) + path, httpPrefix, None, contentType) else: bodyDigest = messageContentDigest(messageBodyJsonStr) contentLength = len(messageBodyJsonStr) @@ -220,7 +243,8 @@ def createSignedHeader(privateKeyPem: str, nickname: str, signPostHeaders(dateStr, privateKeyPem, nickname, domain, port, toDomain, toPort, - path, httpPrefix, messageBodyJsonStr) + path, httpPrefix, messageBodyJsonStr, + contentType) headers['signature'] = signatureHeader return headers @@ -302,9 +326,13 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, for k, v in [i.split('=', 1) for i in signatureHeader.split(',')] } + if debug: + print('signatureDict: ' + str(signatureDict)) + # Unpack the signed headers and set values based on current headers and # body (if a digest was included) signedHeaderList = [] + algorithm = 'rsa-sha256' for signedHeader in signatureDict[requestTargetKey].split(fieldSep2): signedHeader = signedHeader.strip() if debug: @@ -323,6 +351,9 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, # if ')' in appendStr: # appendStr = appendStr.split(')')[0] signedHeaderList.append(appendStr) + elif signedHeader == 'algorithm': + if headers.get(signedHeader): + algorithm = headers[signedHeader] elif signedHeader == 'digest': if messageBodyDigest: bodyDigest = messageBodyDigest @@ -333,19 +364,17 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, if headers.get(signedHeader): appendStr = f'content-length: {headers[signedHeader]}' signedHeaderList.append(appendStr) + elif headers.get('Content-Length'): + contentLength = headers['Content-Length'] + signedHeaderList.append(f'content-length: {contentLength}') + elif headers.get('Content-length'): + contentLength = headers['Content-length'] + appendStr = f'content-length: {contentLength}' + signedHeaderList.append(appendStr) else: - if headers.get('Content-Length'): - contentLength = headers['Content-Length'] - signedHeaderList.append(f'content-length: {contentLength}') - else: - if headers.get('Content-length'): - contentLength = headers['Content-length'] - appendStr = f'content-length: {contentLength}' - signedHeaderList.append(appendStr) - else: - if debug: - print('DEBUG: verifyPostHeaders ' + signedHeader + - ' not found in ' + str(headers)) + if debug: + print('DEBUG: verifyPostHeaders ' + signedHeader + + ' not found in ' + str(headers)) else: if headers.get(signedHeader): if signedHeader == 'date' and not noRecencyCheck: @@ -395,11 +424,10 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, signedHeaderList.append( f'{signedHeader}: {headers[signedHeaderCap]}') - if debug: - print('DEBUG: signedHeaderList: ' + str(signedHeaderList)) # Now we have our header data digest signedHeaderText = '\n'.join(signedHeaderList) - headerDigest = getSHA256(signedHeaderText.encode('ascii')) + if debug: + print('signedHeaderText:\n' + signedHeaderText + 'END') # Get the signature, verify with public key, return result signature = None @@ -415,15 +443,29 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, else: # Original Mastodon signature signature = base64.b64decode(signatureDict['signature']) + if debug: + print('signature: ' + algorithm + ' ' + + signatureDict['signature']) + + # If extra signing algorithms need to be added then do it here + if algorithm == 'rsa-sha256': + headerDigest = getSHA256(signedHeaderText.encode('ascii')) + paddingStr = padding.PKCS1v15() + alg = hazutils.Prehashed(hashes.SHA256()) + elif algorithm == 'rsa-sha512': + headerDigest = getSHA512(signedHeaderText.encode('ascii')) + paddingStr = padding.PKCS1v15() + alg = hazutils.Prehashed(hashes.SHA512()) + else: + print('Unknown http signature algorithm: ' + algorithm) + paddingStr = padding.PKCS1v15() + alg = hazutils.Prehashed(hashes.SHA256()) + headerDigest = '' try: - pubkey.verify( - signature, - headerDigest, - padding.PKCS1v15(), - hazutils.Prehashed(hashes.SHA256())) + pubkey.verify(signature, headerDigest, paddingStr, alg) return True except BaseException: if debug: print('DEBUG: verifyPostHeaders pkcs1_15 verify failure') - return False + return False diff --git a/i2pdomain b/i2pdomain index 9eeea2e5d..f4c7d9f74 100755 --- a/i2pdomain +++ b/i2pdomain @@ -5,7 +5,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.1.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" import base64, hashlib, sys diff --git a/img/search_banner.png b/img/search_banner.png new file mode 100644 index 000000000..5860c6b9c Binary files /dev/null and b/img/search_banner.png differ diff --git a/inbox.py b/inbox.py index 3decbf5ba..5b16fe16b 100644 --- a/inbox.py +++ b/inbox.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -14,6 +14,9 @@ import time import random from linked_data_sig import verifyJsonSignature from languages import understoodPostLanguage +from like import updateLikesCollection +from utils import getReplyIntervalHours +from utils import canReplyTo from utils import getUserPaths from utils import getBaseContentFromPost from utils import acctDir @@ -43,7 +46,6 @@ from utils import deletePost from utils import removeModerationPostFromIndex from utils import loadJson from utils import saveJson -from utils import updateLikesCollection from utils import undoLikesCollectionEntry from utils import hasGroupType from utils import localActorUrl @@ -51,6 +53,7 @@ from categories import getHashtagCategories from categories import setHashtagCategory from httpsig import verifyPostHeaders from session import createSession +from follow import followerApprovalActive from follow import isFollowingActor from follow import receiveFollowRequest from follow import getFollowersOfActor @@ -71,6 +74,8 @@ from utils import dangerousMarkup from utils import isDM from utils import isReply from httpsig import messageContentDigest +from posts import savePostToBox +from posts import isCreateInsideAnnounce from posts import createDirectMessagePost from posts import validContentWarning from posts import downloadAnnounce @@ -81,6 +86,7 @@ from posts import sendToFollowersThread from webapp_post import individualPostAsHtml from question import questionUpdateVotes from media import replaceYouTube +from media import replaceTwitter from git import isGitPatch from git import receiveGitPatch from followingCalendar import receivingCalendarEvents @@ -93,6 +99,7 @@ from announce import isSelfAnnounce from announce import createAnnounce from notifyOnPost import notifyWhenPersonPosts from conversation import updateConversation +from content import validHashTag def storeHashTags(baseDir: str, nickname: str, postJsonObject: {}) -> None: @@ -128,6 +135,8 @@ def storeHashTags(baseDir: str, nickname: str, postJsonObject: {}) -> None: if not tag.get('name'): continue tagName = tag['name'].replace('#', '').strip() + if not validHashTag(tagName): + continue tagsFilename = tagsDir + '/' + tagName + '.txt' postUrl = removeIdEnding(postJsonObject['id']) postUrl = postUrl.replace('/', '#') @@ -170,7 +179,8 @@ def _inboxStorePostToHtmlCache(recentPostsCache: {}, maxRecentPosts: int, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> None: + maxLikeCount: int, + signingPrivateKeyPem: str) -> None: """Converts the json post into html and stores it in a cache This enables the post to be quickly displayed later """ @@ -179,18 +189,22 @@ def _inboxStorePostToHtmlCache(recentPostsCache: {}, maxRecentPosts: int, if boxname != 'outbox': boxname = 'inbox' - individualPostAsHtml(True, recentPostsCache, maxRecentPosts, + notDM = not isDM(postJsonObject) + YTReplacementDomain = getConfigParam(baseDir, 'youtubedomain') + twitterReplacementDomain = getConfigParam(baseDir, 'twitterdomain') + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, pageNumber, baseDir, session, cachedWebfingers, personCache, nickname, domain, port, postJsonObject, avatarUrl, True, allowDeletion, - httpPrefix, __version__, boxname, None, + httpPrefix, __version__, boxname, + YTReplacementDomain, twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, - not isDM(postJsonObject), - True, True, False, True) + notDM, True, True, False, True, False) def validInbox(baseDir: str, nickname: str, domain: str) -> bool: @@ -225,17 +239,26 @@ def validInboxFilenames(baseDir: str, nickname: str, domain: str, return True expectedStr = expectedDomain + ':' + str(expectedPort) expectedFound = False + ctr = 0 for subdir, dirs, files in os.walk(inboxDir): for f in files: filename = os.path.join(subdir, f) + ctr += 1 if not os.path.isfile(filename): print('filename: ' + filename) return False if expectedStr in filename: expectedFound = True break + if ctr == 0: + return True if not expectedFound: print('Expected file was not found: ' + expectedStr) + for subdir, dirs, files in os.walk(inboxDir): + for f in files: + filename = os.path.join(subdir, f) + print(filename) + break return False return True @@ -254,6 +277,7 @@ def inboxMessageHasParams(messageJson: {}) -> bool: if not isinstance(messageJson['actor'], str): print('WARN: actor should be a string, but is actually: ' + str(messageJson['actor'])) + pprint(messageJson) return False # type should be a string @@ -808,7 +832,10 @@ def _receiveUpdateToQuestion(recentPostsCache: {}, messageJson: {}, getCachedPostFilename(baseDir, nickname, domain, messageJson) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass # remove from memory cache removePostFromCache(messageJson, recentPostsCache) @@ -852,25 +879,6 @@ def _receiveUpdate(recentPostsCache: {}, session, baseDir: str, print('DEBUG: Question update was received') return True - if messageJson['type'] == 'Person': - if messageJson.get('url') and messageJson.get('id'): - if debug: - print('Request to update actor unwrapped: ' + - str(messageJson)) - updateNickname = getNicknameFromActor(messageJson['id']) - if updateNickname: - updateDomain, updatePort = \ - getDomainFromActor(messageJson['id']) - if _personReceiveUpdate(baseDir, domain, port, - updateNickname, updateDomain, - updatePort, messageJson, - personCache, debug): - if debug: - print('DEBUG: ' + - 'Unwrapped profile update was received for ' + - messageJson['url']) - return True - if messageJson['object']['type'] == 'Person' or \ messageJson['object']['type'] == 'Application' or \ messageJson['object']['type'] == 'Group' or \ @@ -889,6 +897,7 @@ def _receiveUpdate(recentPostsCache: {}, session, baseDir: str, updatePort, messageJson['object'], personCache, debug): + print('Person Update: ' + str(messageJson)) if debug: print('DEBUG: Profile update was received for ' + messageJson['object']['url']) @@ -902,7 +911,16 @@ def _receiveLike(recentPostsCache: {}, onionDomain: str, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, messageJson: {}, federationList: [], - debug: bool) -> bool: + debug: bool, + signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int) -> bool: """Receives a Like activity within the POST section of HTTPServer """ if messageJson['type'] != 'Like': @@ -959,6 +977,41 @@ def _receiveLike(recentPostsCache: {}, updateLikesCollection(recentPostsCache, baseDir, postFilename, postLikedId, messageJson['actor'], handleName, domain, debug) + # regenerate the html + likedPostJson = loadJson(postFilename, 0, 1) + if likedPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, handleName, domain, + likedPostJson) + print('Liked post json: ' + str(likedPostJson)) + print('Liked post nickname: ' + handleName + ' ' + domain) + print('Liked post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, handleName, domain) + notDM = not isDM(likedPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + handleName, domain, port, likedPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) + return True @@ -967,7 +1020,16 @@ def _receiveUndoLike(recentPostsCache: {}, httpPrefix: str, domain: str, port: int, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, messageJson: {}, federationList: [], - debug: bool) -> bool: + debug: bool, + signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int) -> bool: """Receives an undo like activity within the POST section of HTTPServer """ if messageJson['type'] != 'Undo': @@ -1017,6 +1079,40 @@ def _receiveUndoLike(recentPostsCache: {}, undoLikesCollectionEntry(recentPostsCache, baseDir, postFilename, messageJson['object'], messageJson['actor'], domain, debug) + # regenerate the html + likedPostJson = loadJson(postFilename, 0, 1) + if likedPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, handleName, domain, + likedPostJson) + print('Unliked post json: ' + str(likedPostJson)) + print('Unliked post nickname: ' + handleName + ' ' + domain) + print('Unliked post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, handleName, domain) + notDM = not isDM(likedPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + handleName, domain, port, likedPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) return True @@ -1025,7 +1121,15 @@ def _receiveBookmark(recentPostsCache: {}, httpPrefix: str, domain: str, port: int, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, messageJson: {}, federationList: [], - debug: bool) -> bool: + debug: bool, signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int) -> bool: """Receives a bookmark activity within the POST section of HTTPServer """ if not messageJson.get('type'): @@ -1091,6 +1195,40 @@ def _receiveBookmark(recentPostsCache: {}, updateBookmarksCollection(recentPostsCache, baseDir, postFilename, messageJson['object']['url'], messageJson['actor'], domain, debug) + # regenerate the html + bookmarkedPostJson = loadJson(postFilename, 0, 1) + if bookmarkedPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, domain, + bookmarkedPostJson) + print('Bookmarked post json: ' + str(bookmarkedPostJson)) + print('Bookmarked post nickname: ' + nickname + ' ' + domain) + print('Bookmarked post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, nickname, domain) + notDM = not isDM(bookmarkedPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + nickname, domain, port, bookmarkedPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) return True @@ -1099,7 +1237,15 @@ def _receiveUndoBookmark(recentPostsCache: {}, httpPrefix: str, domain: str, port: int, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, messageJson: {}, federationList: [], - debug: bool) -> bool: + debug: bool, signingPrivateKeyPem: str, + maxRecentPosts: int, translate: {}, + allowDeletion: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, + peertubeInstances: [], + allowLocalNetworkAccess: bool, + themeName: str, systemLanguage: str, + maxLikeCount: int) -> bool: """Receives an undo bookmark activity within the POST section of HTTPServer """ if not messageJson.get('type'): @@ -1166,6 +1312,40 @@ def _receiveUndoBookmark(recentPostsCache: {}, undoBookmarksCollectionEntry(recentPostsCache, baseDir, postFilename, messageJson['object']['url'], messageJson['actor'], domain, debug) + # regenerate the html + bookmarkedPostJson = loadJson(postFilename, 0, 1) + if bookmarkedPostJson: + if debug: + cachedPostFilename = \ + getCachedPostFilename(baseDir, nickname, domain, + bookmarkedPostJson) + print('Unbookmarked post json: ' + str(bookmarkedPostJson)) + print('Unbookmarked post nickname: ' + nickname + ' ' + domain) + print('Unbookmarked post cache: ' + str(cachedPostFilename)) + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, nickname, domain) + notDM = not isDM(bookmarkedPostJson) + individualPostAsHtml(signingPrivateKeyPem, False, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + nickname, domain, port, bookmarkedPostJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) return True @@ -1260,8 +1440,14 @@ def _receiveAnnounce(recentPostsCache: {}, personCache: {}, messageJson: {}, federationList: [], debug: bool, translate: {}, YTReplacementDomain: str, + twitterReplacementDomain: str, allowLocalNetworkAccess: bool, - themeName: str, systemLanguage: str) -> bool: + themeName: str, systemLanguage: str, + signingPrivateKeyPem: str, + maxRecentPosts: int, + allowDeletion: bool, + peertubeInstances: [], + maxLikeCount: int) -> bool: """Receives an announce activity within the POST section of HTTPServer """ if messageJson['type'] != 'Announce': @@ -1305,6 +1491,7 @@ def _receiveAnnounce(recentPostsCache: {}, messageJson['type']) return False + blockedCache = {} prefixes = getProtocolPrefixes() # is the domain of the announce actor blocked? objectDomain = messageJson['object'] @@ -1328,6 +1515,16 @@ def _receiveAnnounce(recentPostsCache: {}, actorNickname + '@' + actorDomain) return False + # also check the actor for the url being announced + announcedActorNickname = getNicknameFromActor(messageJson['object']) + announcedActorDomain, announcedActorPort = \ + getDomainFromActor(messageJson['object']) + if isBlocked(baseDir, nickname, domain, + announcedActorNickname, announcedActorDomain): + print('Receive announce object blocked for actor: ' + + announcedActorNickname + '@' + announcedActorDomain) + return False + # is this post in the outbox of the person? postFilename = locatePost(baseDir, nickname, domain, messageJson['object']) @@ -1342,17 +1539,57 @@ def _receiveAnnounce(recentPostsCache: {}, print('DEBUG: Downloading announce post ' + messageJson['actor'] + ' -> ' + messageJson['object']) domainFull = getFullDomain(domain, port) + + # Generate html. This also downloads the announced post. + pageNumber = 1 + showPublishedDateOnly = False + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, nickname, domain) + notDM = True + if debug: + print('Generating html for announce ' + messageJson['id']) + announceHtml = \ + individualPostAsHtml(signingPrivateKeyPem, True, + recentPostsCache, maxRecentPosts, + translate, pageNumber, baseDir, + session, cachedWebfingers, personCache, + nickname, domain, port, messageJson, + None, True, allowDeletion, + httpPrefix, __version__, + 'inbox', + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount, notDM, + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, False) + if not announceHtml: + print('WARN: Unable to generate html for announce ' + + str(messageJson)) + else: + if debug: + print('Generated announce html ' + announceHtml.replace('\n', '')) + postJsonObject = downloadAnnounce(session, baseDir, httpPrefix, nickname, domain, messageJson, __version__, translate, YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, recentPostsCache, debug, systemLanguage, - domainFull, personCache) + domainFull, personCache, + signingPrivateKeyPem, + blockedCache) if not postJsonObject: + print('WARN: unable to download announce: ' + str(messageJson)) notInOnion = True if onionDomain: if onionDomain in messageJson['object']: @@ -1360,7 +1597,10 @@ def _receiveAnnounce(recentPostsCache: {}, if domain not in messageJson['object'] and notInOnion: if os.path.isfile(postFilename): # if the announce can't be downloaded then remove it - os.remove(postFilename) + try: + os.remove(postFilename) + except BaseException: + pass else: if debug: print('DEBUG: Announce post downloaded for ' + @@ -1402,7 +1642,8 @@ def _receiveAnnounce(recentPostsCache: {}, getPersonPubKey(baseDir, session, lookupActor, personCache, debug, __version__, httpPrefix, - domain, onionDomain) + domain, onionDomain, + signingPrivateKeyPem) if pubKey: if debug: print('DEBUG: public key obtained for announce: ' + @@ -1469,7 +1710,10 @@ def _receiveUndoAnnounce(recentPostsCache: {}, undoAnnounceCollectionEntry(recentPostsCache, baseDir, postFilename, messageJson['actor'], domain, debug) if os.path.isfile(postFilename): - os.remove(postFilename) + try: + os.remove(postFilename) + except BaseException: + pass return True @@ -1598,6 +1842,7 @@ def _validPostContent(baseDir: str, nickname: str, domain: str, if not validPostDate(messageJson['object']['published'], 90, debug): return False + summary = None if messageJson['object'].get('summary'): summary = messageJson['object']['summary'] if not isinstance(summary, str): @@ -1607,9 +1852,10 @@ def _validPostContent(baseDir: str, nickname: str, domain: str, print('WARN: invalid content warning ' + summary) return False + # check for patches before dangeousMarkup, which excludes code if isGitPatch(baseDir, nickname, domain, messageJson['object']['type'], - messageJson['object']['summary'], + summary, messageJson['object']['content']): return True @@ -1673,7 +1919,8 @@ def _validPostContent(baseDir: str, nickname: str, domain: str, def _obtainAvatarForReplyPost(session, baseDir: str, httpPrefix: str, domain: str, onionDomain: str, personCache: {}, - postJsonObject: {}, debug: bool) -> None: + postJsonObject: {}, debug: bool, + signingPrivateKeyPem: str) -> None: """Tries to obtain the actor for the person being replied to so that their avatar can later be shown """ @@ -1704,7 +1951,7 @@ def _obtainAvatarForReplyPost(session, baseDir: str, httpPrefix: str, getPersonPubKey(baseDir, session, lookupActor, personCache, debug, __version__, httpPrefix, - domain, onionDomain) + domain, onionDomain, signingPrivateKeyPem) if pubKey: if debug: print('DEBUG: public key obtained for reply: ' + lookupActor) @@ -1883,7 +2130,8 @@ def _sendToGroupMembers(session, baseDir: str, handle: str, port: int, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, debug: bool, systemLanguage: str, - onionDomain: str, i2pDomain: str) -> None: + onionDomain: str, i2pDomain: str, + signingPrivateKeyPem: str) -> None: """When a post arrives for a group send it out to the group members """ if debug: @@ -1919,18 +2167,22 @@ def _sendToGroupMembers(session, baseDir: str, handle: str, port: int, cc = '' nickname = handle.split('@')[0].replace('!', '') + # save to the group outbox so that replies will be to the group + # rather than the original sender + savePostToBox(baseDir, httpPrefix, None, + nickname, domain, postJsonObject, 'outbox') + + postId = removeIdEnding(postJsonObject['object']['id']) if debug: - print('Group announce: ' + postJsonObject['object']['id']) + print('Group announce: ' + postId) announceJson = \ createAnnounce(session, baseDir, federationList, nickname, domain, port, groupActor + '/followers', cc, - httpPrefix, - postJsonObject['object']['id'], - False, False, + httpPrefix, postId, False, False, sendThreads, postLog, personCache, cachedWebfingers, - debug, __version__) + debug, __version__, signingPrivateKeyPem) sendToFollowersThread(session, baseDir, nickname, domain, onionDomain, i2pDomain, port, @@ -1939,7 +2191,8 @@ def _sendToGroupMembers(session, baseDir: str, handle: str, port: int, cachedWebfingers, personCache, announceJson, debug, __version__, sharedItemsFederatedDomains, - sharedItemFederationTokens) + sharedItemFederationTokens, + signingPrivateKeyPem) def _inboxUpdateCalendar(baseDir: str, handle: str, @@ -1996,6 +2249,7 @@ def inboxUpdateIndex(boxname: str, baseDir: str, handle: str, if '/' in destinationFilename: destinationFilename = destinationFilename.split('/')[-1] + written = False if os.path.isfile(indexFilename): try: with open(indexFilename, 'r+') as indexFile: @@ -2003,6 +2257,7 @@ def inboxUpdateIndex(boxname: str, baseDir: str, handle: str, if destinationFilename + '\n' not in content: indexFile.seek(0, 0) indexFile.write(destinationFilename + '\n' + content) + written = True return True except Exception as e: print('WARN: Failed to write entry to index ' + str(e)) @@ -2010,10 +2265,11 @@ def inboxUpdateIndex(boxname: str, baseDir: str, handle: str, try: with open(indexFilename, 'w+') as indexFile: indexFile.write(destinationFilename + '\n') + written = True except Exception as e: print('WARN: Failed to write initial entry to index ' + str(e)) - return False + return written def _updateLastSeen(baseDir: str, handle: str, actor: str) -> None: @@ -2053,7 +2309,8 @@ def _bounceDM(senderPostId: str, session, httpPrefix: str, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, translate: {}, debug: bool, - lastBounceMessage: [], systemLanguage: str) -> bool: + lastBounceMessage: [], systemLanguage: str, + signingPrivateKeyPem: str) -> bool: """Sends a bounce message back to the sending handle if a DM has been rejected """ @@ -2123,7 +2380,8 @@ def _bounceDM(senderPostId: str, session, httpPrefix: str, senderNickname, senderDomain, senderPort, cc, httpPrefix, False, False, federationList, sendThreads, postLog, cachedWebfingers, - personCache, debug, __version__, None, groupAccount) + personCache, debug, __version__, None, groupAccount, + signingPrivateKeyPem, 7238634) return True @@ -2136,7 +2394,8 @@ def _isValidDM(baseDir: str, nickname: str, domain: str, port: int, personCache: {}, translate: {}, debug: bool, lastBounceMessage: [], - handle: str, systemLanguage: str) -> bool: + handle: str, systemLanguage: str, + signingPrivateKeyPem: str) -> bool: """Is the given message a valid DM? """ if nickname == 'inbox': @@ -2201,7 +2460,8 @@ def _isValidDM(baseDir: str, nickname: str, domain: str, port: int, obj = postJsonObject['object'] if isinstance(obj, dict): if not obj.get('inReplyTo'): - _bounceDM(postJsonObject['id'], + bouncedId = removeIdEnding(postJsonObject['id']) + _bounceDM(bouncedId, session, httpPrefix, baseDir, nickname, domain, @@ -2212,7 +2472,8 @@ def _isValidDM(baseDir: str, nickname: str, domain: str, port: int, personCache, translate, debug, lastBounceMessage, - systemLanguage) + systemLanguage, + signingPrivateKeyPem) return False # dm index will be updated @@ -2233,13 +2494,17 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, queueFilename: str, destinationFilename: str, maxReplies: int, allowDeletion: bool, maxMentions: int, maxEmoji: int, translate: {}, - unitTest: bool, YTReplacementDomain: str, + unitTest: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, allowLocalNetworkAccess: bool, peertubeInstances: [], lastBounceMessage: [], themeName: str, systemLanguage: str, - maxLikeCount: int) -> bool: + maxLikeCount: int, + signingPrivateKeyPem: str, + defaultReplyIntervalHours: int) -> bool: """ Anything which needs to be done after initial checks have passed """ actor = keyId @@ -2261,7 +2526,15 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, personCache, messageJson, federationList, - debug): + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount): if debug: print('DEBUG: Like accepted from ' + actor) return False @@ -2275,7 +2548,15 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, personCache, messageJson, federationList, - debug): + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount): if debug: print('DEBUG: Undo like accepted from ' + actor) return False @@ -2289,7 +2570,15 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, personCache, messageJson, federationList, - debug): + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount): if debug: print('DEBUG: Bookmark accepted from ' + actor) return False @@ -2303,11 +2592,22 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, personCache, messageJson, federationList, - debug): + debug, signingPrivateKeyPem, + maxRecentPosts, translate, + allowDeletion, + YTReplacementDomain, + twitterReplacementDomain, + peertubeInstances, + allowLocalNetworkAccess, + themeName, systemLanguage, + maxLikeCount): if debug: print('DEBUG: Undo bookmark accepted from ' + actor) return False + if isCreateInsideAnnounce(messageJson): + messageJson = messageJson['object'] + if _receiveAnnounce(recentPostsCache, session, handle, isGroup, baseDir, httpPrefix, @@ -2319,8 +2619,14 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, federationList, debug, translate, YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, - themeName, systemLanguage): + themeName, systemLanguage, + signingPrivateKeyPem, + maxRecentPosts, + allowDeletion, + peertubeInstances, + maxLikeCount): if debug: print('DEBUG: Announce accepted from ' + actor) @@ -2406,6 +2712,10 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, # replace YouTube links, so they get less tracking data replaceYouTube(postJsonObject, YTReplacementDomain, systemLanguage) + # replace twitter link domains, so that you can view twitter posts + # without having an account + replaceTwitter(postJsonObject, twitterReplacementDomain, + systemLanguage) # list of indexes to be updated updateIndexList = ['inbox'] @@ -2445,7 +2755,8 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, postJsonObject, debug, __version__, sharedItemsFederatedDomains, - sharedItemFederationTokens) + sharedItemFederationTokens, + signingPrivateKeyPem) isReplyToMutedPost = False @@ -2462,7 +2773,8 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, personCache, translate, debug, lastBounceMessage, - handle, systemLanguage): + handle, systemLanguage, + signingPrivateKeyPem): return False # get the actor being replied to @@ -2485,20 +2797,40 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, if isinstance(inReplyTo, str): if not isMuted(baseDir, nickname, domain, inReplyTo, conversationId): - actUrl = \ - localActorUrl(httpPrefix, - nickname, domain) - _replyNotify(baseDir, handle, - actUrl + '/tlreplies') + # check if the reply is within the allowed + # time period after publication + hrs = defaultReplyIntervalHours + replyIntervalHours = \ + getReplyIntervalHours(baseDir, + nickname, + domain, hrs) + if canReplyTo(baseDir, nickname, domain, + inReplyTo, + replyIntervalHours): + actUrl = \ + localActorUrl(httpPrefix, + nickname, domain) + _replyNotify(baseDir, handle, + actUrl + '/tlreplies') + else: + if debug: + print('Reply to ' + inReplyTo + + ' is outside of the ' + + 'permitted interval of ' + + str(replyIntervalHours) + + ' hours') + return False else: isReplyToMutedPost = True if isImageMedia(session, baseDir, httpPrefix, nickname, domain, postJsonObject, - translate, YTReplacementDomain, + translate, + YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, recentPostsCache, debug, systemLanguage, - domainFull, personCache): + domainFull, personCache, signingPrivateKeyPem): # media index will be updated updateIndexList.append('tlmedia') if isBlogPost(postJsonObject): @@ -2508,7 +2840,8 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, # get the avatar for a reply/announce _obtainAvatarForReplyPost(session, baseDir, httpPrefix, domain, onionDomain, - personCache, postJsonObject, debug) + personCache, postJsonObject, debug, + signingPrivateKeyPem) # save the post to file if saveJson(postJsonObject, destinationFilename): @@ -2573,7 +2906,8 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, - maxLikeCount) + maxLikeCount, + signingPrivateKeyPem) if debug: timeDiff = \ str(int((time.time() - htmlCacheStartTime) * @@ -2596,7 +2930,8 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, httpPrefix, federationList, sendThreads, postLog, cachedWebfingers, personCache, debug, systemLanguage, - onionDomain, i2pDomain) + onionDomain, i2pDomain, + signingPrivateKeyPem) # if the post wasn't saved if not os.path.isfile(destinationFilename): @@ -2831,12 +3166,14 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, allowDeletion: bool, debug: bool, maxMentions: int, maxEmoji: int, translate: {}, unitTest: bool, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, maxFollowers: int, allowLocalNetworkAccess: bool, peertubeInstances: [], verifyAllSignatures: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> None: + maxLikeCount: int, signingPrivateKeyPem: str, + defaultReplyIntervalHours: int) -> None: """Processes received items and moves them to the appropriate directories """ @@ -2987,7 +3324,7 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, getPersonPubKey(baseDir, session, keyId, personCache, debug, projectVersion, httpPrefix, - domain, onionDomain) + domain, onionDomain, signingPrivateKeyPem) if pubKey: if debug: print('DEBUG: public key: ' + str(pubKey)) @@ -3002,7 +3339,10 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, if debug: print('Queue: public key could not be obtained from ' + keyId) if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) continue @@ -3050,7 +3390,10 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, if httpSignatureFailed or verifyAllSignatures: if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) continue @@ -3067,7 +3410,10 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, print('WARN: jsonld inbox signature check failed ' + keyId) if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) continue @@ -3081,7 +3427,7 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, # set the id to the same as the post filename # This makes the filename and the id consistent # if queueJson['post'].get('id'): - # queueJson['post']['id']=queueJson['id'] + # queueJson['post']['id'] = queueJson['id'] if _receiveUndo(session, baseDir, httpPrefix, port, @@ -3093,7 +3439,10 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, debug): print('Queue: Undo accepted from ' + keyId) if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) continue @@ -3108,9 +3457,13 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, queueJson['post'], federationList, debug, projectVersion, - maxFollowers, onionDomain): + maxFollowers, onionDomain, + signingPrivateKeyPem): if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) print('Queue: Follow activity for ' + keyId + @@ -3128,7 +3481,10 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, federationList, debug): print('Queue: Accept/Reject received from ' + keyId) if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) continue @@ -3146,7 +3502,10 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, if debug: print('Queue: Update accepted from ' + keyId) if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) continue @@ -3161,7 +3520,10 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, print('Queue: no recipients were resolved ' + 'for post arriving in inbox') if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) continue @@ -3220,16 +3582,22 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, maxMentions, maxEmoji, translate, unitTest, YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, allowLocalNetworkAccess, peertubeInstances, lastBounceMessage, themeName, systemLanguage, - maxLikeCount) + maxLikeCount, + signingPrivateKeyPem, + defaultReplyIntervalHours) if debug: pprint(queueJson['post']) print('Queue: Queue post accepted') if os.path.isfile(queueFilename): - os.remove(queueFilename) + try: + os.remove(queueFilename) + except BaseException: + pass if len(queue) > 0: queue.pop(0) diff --git a/jami.py b/jami.py index d0a20008b..9f9e8c76a 100644 --- a/jami.py +++ b/jami.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/languages.py b/languages.py index 9d0d4022b..0bd5996d7 100644 --- a/languages.py +++ b/languages.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" diff --git a/like.py b/like.py index 1eddf571d..466f1060c 100644 --- a/like.py +++ b/like.py @@ -3,10 +3,12 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" +import os +from pprint import pprint from utils import removeDomainPort from utils import hasObjectDict from utils import hasUsersPath @@ -16,10 +18,13 @@ from utils import urlPermitted from utils import getNicknameFromActor from utils import getDomainFromActor from utils import locatePost -from utils import updateLikesCollection from utils import undoLikesCollectionEntry from utils import hasGroupType from utils import localActorUrl +from utils import loadJson +from utils import saveJson +from utils import removePostFromCache +from utils import getCachedPostFilename from posts import sendSignedJson from session import postJson from webfinger import webfingerHandle @@ -62,7 +67,8 @@ def _like(recentPostsCache: {}, clientToServer: bool, sendThreads: [], postLog: [], personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a like actor is the person doing the liking 'to' might be a specific person (actor) whose post was liked @@ -122,7 +128,8 @@ def _like(recentPostsCache: {}, 'https://www.w3.org/ns/activitystreams#Public', httpPrefix, True, clientToServer, federationList, sendThreads, postLog, cachedWebfingers, personCache, - debug, projectVersion, None, groupAccount) + debug, projectVersion, None, groupAccount, + signingPrivateKeyPem, 7367374) return newLikeJson @@ -135,7 +142,8 @@ def likePost(recentPostsCache: {}, likeStatusNumber: int, clientToServer: bool, sendThreads: [], postLog: [], personCache: {}, cachedWebfingers: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Likes a given status post. This is only used by unit tests """ likeDomain = getFullDomain(likeDomain, likePort) @@ -147,7 +155,7 @@ def likePost(recentPostsCache: {}, session, baseDir, federationList, nickname, domain, port, ccList, httpPrefix, objectUrl, actorLiked, clientToServer, sendThreads, postLog, personCache, cachedWebfingers, - debug, projectVersion) + debug, projectVersion, signingPrivateKeyPem) def sendLikeViaServer(baseDir: str, session, @@ -155,7 +163,8 @@ def sendLikeViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, likeUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a like via c2s """ if not session: @@ -178,7 +187,8 @@ def sendLikeViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: like webfinger failed for ' + handle) @@ -191,12 +201,15 @@ def sendLikeViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - fromNickname, fromDomain, - postToBox, 72873) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + fromNickname, fromDomain, + postToBox, 72873) if not inboxUrl: if debug: @@ -233,7 +246,8 @@ def sendUndoLikeViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, likeUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Undo a like via c2s """ if not session: @@ -260,7 +274,8 @@ def sendUndoLikeViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: unlike webfinger failed for ' + handle) @@ -274,12 +289,15 @@ def sendUndoLikeViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, fromNickname, - fromDomain, postToBox, - 72625) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, fromNickname, + fromDomain, postToBox, + 72625) if not inboxUrl: if debug: @@ -398,3 +416,68 @@ def outboxUndoLike(recentPostsCache: {}, domain, debug) if debug: print('DEBUG: post undo liked via c2s - ' + postFilename) + + +def updateLikesCollection(recentPostsCache: {}, + baseDir: str, postFilename: str, + objectUrl: str, actor: str, + nickname: str, domain: str, debug: bool) -> None: + """Updates the likes collection within a post + """ + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return + + # remove any cached version of this post so that the + # like icon is changed + removePostFromCache(postJsonObject, recentPostsCache) + cachedPostFilename = getCachedPostFilename(baseDir, nickname, + domain, postJsonObject) + if cachedPostFilename: + if os.path.isfile(cachedPostFilename): + try: + os.remove(cachedPostFilename) + except BaseException: + pass + + if not hasObjectDict(postJsonObject): + if debug: + pprint(postJsonObject) + print('DEBUG: post ' + objectUrl + ' has no object') + return + if not objectUrl.endswith('/likes'): + objectUrl = objectUrl + '/likes' + if not postJsonObject['object'].get('likes'): + if debug: + print('DEBUG: Adding initial like to ' + objectUrl) + likesJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': objectUrl, + 'type': 'Collection', + "totalItems": 1, + 'items': [{ + 'type': 'Like', + 'actor': actor + }] + } + postJsonObject['object']['likes'] = likesJson + else: + if not postJsonObject['object']['likes'].get('items'): + postJsonObject['object']['likes']['items'] = [] + for likeItem in postJsonObject['object']['likes']['items']: + if likeItem.get('actor'): + if likeItem['actor'] == actor: + # already liked + return + newLike = { + 'type': 'Like', + 'actor': actor + } + postJsonObject['object']['likes']['items'].append(newLike) + itlen = len(postJsonObject['object']['likes']['items']) + postJsonObject['object']['likes']['totalItems'] = itlen + + if debug: + print('DEBUG: saving post with likes added') + pprint(postJsonObject) + saveJson(postJsonObject, postFilename) diff --git a/linked_data_sig.py b/linked_data_sig.py index b1acbc7da..f6234fcc2 100644 --- a/linked_data_sig.py +++ b/linked_data_sig.py @@ -5,7 +5,7 @@ __credits__ = ['Based on ' + __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Security" diff --git a/manualapprove.py b/manualapprove.py index 907cd4b82..e4180d100 100644 --- a/manualapprove.py +++ b/manualapprove.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" @@ -26,7 +26,8 @@ def manualDenyFollowRequest(session, baseDir: str, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, debug: bool, - projectVersion: str) -> None: + projectVersion: str, + signingPrivateKeyPem: str) -> None: """Manually deny a follow request """ accountsDir = acctDir(baseDir, nickname, domain) @@ -60,7 +61,8 @@ def manualDenyFollowRequest(session, baseDir: str, federationList, sendThreads, postLog, cachedWebfingers, personCache, - debug, projectVersion) + debug, projectVersion, + signingPrivateKeyPem) print('Follow request from ' + denyHandle + ' was denied.') @@ -87,7 +89,8 @@ def manualApproveFollowRequest(session, baseDir: str, sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, debug: bool, - projectVersion: str) -> None: + projectVersion: str, + signingPrivateKeyPem: str) -> None: """Manually approve a follow request """ handle = nickname + '@' + domain @@ -176,7 +179,8 @@ def manualApproveFollowRequest(session, baseDir: str, cachedWebfingers, personCache, debug, - projectVersion, False) + projectVersion, False, + signingPrivateKeyPem) updateApprovedFollowers = True else: # this isn't the approved follow so it will remain @@ -218,6 +222,12 @@ def manualApproveFollowRequest(session, baseDir: str, # remove the .follow file if followActivityfilename: if os.path.isfile(followActivityfilename): - os.remove(followActivityfilename) + try: + os.remove(followActivityfilename) + except BaseException: + pass else: - os.remove(approveFollowsFilename + '.new') + try: + os.remove(approveFollowsFilename + '.new') + except BaseException: + pass diff --git a/markdown.py b/markdown.py index eb0cbf2d7..8996cf408 100644 --- a/markdown.py +++ b/markdown.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" diff --git a/mastoapiv1.py b/mastoapiv1.py index b8b85c04f..b099685f9 100644 --- a/mastoapiv1.py +++ b/mastoapiv1.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "API" diff --git a/matrix.py b/matrix.py index 0d493b0dc..39310288c 100644 --- a/matrix.py +++ b/matrix.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/media.py b/media.py index b7382f4b2..c78d7cee4 100644 --- a/media.py +++ b/media.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -28,10 +28,10 @@ from shutil import move from city import spoofGeolocation -def replaceYouTube(postJsonObject: {}, replacementDomain: str, - systemLanguage: str) -> None: - """Replace YouTube with a replacement domain - This denies Google some, but not all, tracking data +def _replaceSiloDomain(postJsonObject: {}, + siloDomain: str, replacementDomain: str, + systemLanguage: str) -> None: + """Replace a silo domain with a replacement domain """ if not replacementDomain: return @@ -40,14 +40,32 @@ def replaceYouTube(postJsonObject: {}, replacementDomain: str, if not postJsonObject['object'].get('content'): return contentStr = getBaseContentFromPost(postJsonObject, systemLanguage) - if 'www.youtube.com' not in contentStr: + if siloDomain not in contentStr: return - contentStr = contentStr.replace('www.youtube.com', replacementDomain) + contentStr = contentStr.replace(siloDomain, replacementDomain) postJsonObject['object']['content'] = contentStr if postJsonObject['object'].get('contentMap'): postJsonObject['object']['contentMap'][systemLanguage] = contentStr +def replaceYouTube(postJsonObject: {}, replacementDomain: str, + systemLanguage: str) -> None: + """Replace YouTube with a replacement domain + This denies Google some, but not all, tracking data + """ + _replaceSiloDomain(postJsonObject, 'www.youtube.com', + replacementDomain, systemLanguage) + + +def replaceTwitter(postJsonObject: {}, replacementDomain: str, + systemLanguage: str) -> None: + """Replace Twitter with a replacement domain + This allows you to view twitter posts without having a twitter account + """ + _replaceSiloDomain(postJsonObject, 'twitter.com', + replacementDomain, systemLanguage) + + def _removeMetaData(imageFilename: str, outputFilename: str) -> None: """Attempts to do this with pure python didn't work well, so better to use a dedicated tool if one is installed diff --git a/metadata.py b/metadata.py index 4f55f1cc7..bfbf76d62 100644 --- a/metadata.py +++ b/metadata.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Metadata" diff --git a/migrate.py b/migrate.py index a093cc8fe..5298200e3 100644 --- a/migrate.py +++ b/migrate.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" @@ -23,7 +23,8 @@ from person import getActorJson def _moveFollowingHandlesForAccount(baseDir: str, nickname: str, domain: str, session, httpPrefix: str, cachedWebfingers: {}, - debug: bool) -> int: + debug: bool, + signingPrivateKeyPem: str) -> int: """Goes through all follows for an account and updates any that have moved """ ctr = 0 @@ -38,14 +39,14 @@ def _moveFollowingHandlesForAccount(baseDir: str, nickname: str, domain: str, _updateMovedHandle(baseDir, nickname, domain, followHandle, session, httpPrefix, cachedWebfingers, - debug) + debug, signingPrivateKeyPem) return ctr def _updateMovedHandle(baseDir: str, nickname: str, domain: str, handle: str, session, httpPrefix: str, cachedWebfingers: {}, - debug: bool) -> int: + debug: bool, signingPrivateKeyPem: str) -> int: """Check if an account has moved, and if so then alter following.txt for each account. Returns 1 if moved, 0 otherwise @@ -59,7 +60,8 @@ def _updateMovedHandle(baseDir: str, nickname: str, domain: str, handle = handle[1:] wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - None, __version__, debug, False) + domain, __version__, debug, False, + signingPrivateKeyPem) if not wfRequest: print('updateMovedHandle unable to webfinger ' + handle) return ctr @@ -83,7 +85,8 @@ def _updateMovedHandle(baseDir: str, nickname: str, domain: str, if httpPrefix == 'gnunet': gnunet = True personJson = \ - getActorJson(domain, personUrl, httpPrefix, gnunet, debug) + getActorJson(domain, personUrl, httpPrefix, gnunet, debug, False, + signingPrivateKeyPem) if not personJson: return ctr if not personJson.get('movedTo'): @@ -172,7 +175,7 @@ def _updateMovedHandle(baseDir: str, nickname: str, domain: str, def migrateAccounts(baseDir: str, session, httpPrefix: str, cachedWebfingers: {}, - debug: bool) -> int: + debug: bool, signingPrivateKeyPem: str) -> int: """If followed accounts change then this modifies the following lists for each account accordingly. Returns the number of accounts migrated @@ -188,6 +191,7 @@ def migrateAccounts(baseDir: str, session, ctr += \ _moveFollowingHandlesForAccount(baseDir, nickname, domain, session, httpPrefix, - cachedWebfingers, debug) + cachedWebfingers, debug, + signingPrivateKeyPem) break return ctr diff --git a/newsdaemon.py b/newsdaemon.py index 6e529471b..c3497cea6 100644 --- a/newsdaemon.py +++ b/newsdaemon.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface Columns" @@ -526,6 +526,7 @@ def _convertRSStoActivityPub(baseDir: str, httpPrefix: str, """Converts rss items in a newswire into posts """ if not newswire: + print('No newswire to convert') return basePath = baseDir + '/accounts/news@' + domain + '/outbox' @@ -542,9 +543,18 @@ def _convertRSStoActivityPub(baseDir: str, httpPrefix: str, dateStr = dateStr.replace(' ', 'T') dateStr = dateStr.replace('+00:00', 'Z') else: - dateStrWithOffset = \ - datetime.datetime.strptime(dateStr, "%Y-%m-%d %H:%M:%S%z") - dateStr = dateStrWithOffset.strftime("%Y-%m-%dT%H:%M:%SZ") + try: + dateStrWithOffset = \ + datetime.datetime.strptime(dateStr, "%Y-%m-%d %H:%M:%S%z") + except BaseException: + print('Newswire strptime failed ' + str(dateStr)) + continue + try: + dateStr = dateStrWithOffset.strftime("%Y-%m-%dT%H:%M:%SZ") + except BaseException: + print('Newswire dateStrWithOffset failed ' + + str(dateStrWithOffset)) + continue statusNumber, published = getStatusNumber(dateStr) newPostId = \ @@ -702,7 +712,10 @@ def _convertRSStoActivityPub(baseDir: str, httpPrefix: str, blog['object']['arrived']) else: if os.path.isfile(filename + '.arrived'): - os.remove(filename + '.arrived') + try: + os.remove(filename + '.arrived') + except BaseException: + pass # setting the url here links to the activitypub object # stored locally @@ -750,6 +763,7 @@ def runNewswireDaemon(baseDir: str, httpd, print('Newswire daemon session established') # try to update the feeds + print('Updating newswire feeds') newNewswire = \ getDictFromNewswire(httpd.session, baseDir, domain, httpd.maxNewswirePostsPerSource, @@ -761,16 +775,22 @@ def runNewswireDaemon(baseDir: str, httpd, httpd.systemLanguage) if not httpd.newswire: + print('Newswire feeds not updated') if os.path.isfile(newswireStateFilename): + print('Loading newswire from file') httpd.newswire = loadJson(newswireStateFilename) + print('Merging with previous newswire') _mergeWithPreviousNewswire(httpd.newswire, newNewswire) httpd.newswire = newNewswire if newNewswire: saveJson(httpd.newswire, newswireStateFilename) print('Newswire updated') + else: + print('No new newswire') + print('Converting newswire to activitypub format') _convertRSStoActivityPub(baseDir, httpPrefix, domain, port, newNewswire, translate, @@ -792,6 +812,7 @@ def runNewswireDaemon(baseDir: str, httpd, archiveDir = baseDir + '/archive' archiveSubdir = \ archiveDir + '/accounts/news@' + domain + '/outbox' + print('Archiving news posts') archivePostsForPerson(httpPrefix, 'news', domain, baseDir, 'outbox', archiveSubdir, diff --git a/newswire.py b/newswire.py index 5f8a1ba0b..3d886dbeb 100644 --- a/newswire.py +++ b/newswire.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface Columns" @@ -192,9 +192,9 @@ def parseFeedDate(pubDate: str) -> str: formats = ("%a, %d %b %Y %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S EST", "%a, %d %b %Y %H:%M:%S UT", + "%a, %d %b %Y %H:%M:%S GMT", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%z") - publishedDate = None for dateFormat in formats: if ',' in pubDate and ',' not in dateFormat: @@ -207,6 +207,8 @@ def parseFeedDate(pubDate: str) -> str: continue if 'EST' not in pubDate and 'EST' in dateFormat: continue + if 'GMT' not in pubDate and 'GMT' in dateFormat: + continue if 'EST' in pubDate and 'EST' not in dateFormat: continue if 'UT' not in pubDate and 'UT' in dateFormat: @@ -218,8 +220,6 @@ def parseFeedDate(pubDate: str) -> str: publishedDate = \ datetime.strptime(pubDate, dateFormat) except BaseException: - print('WARN: unrecognized date format: ' + - pubDate + ' ' + dateFormat) continue if publishedDate: @@ -238,6 +238,8 @@ def parseFeedDate(pubDate: str) -> str: pubDateStr = str(publishedDate) if not pubDateStr.endswith('+00:00'): pubDateStr += '+00:00' + else: + print('WARN: unrecognized date format: ' + pubDate) return pubDateStr @@ -1028,7 +1030,10 @@ def _addBlogsToNewswire(baseDir: str, domain: str, newswire: {}, else: # remove the file if there is nothing to moderate if os.path.isfile(newswireModerationFilename): - os.remove(newswireModerationFilename) + try: + os.remove(newswireModerationFilename) + except BaseException: + pass def getDictFromNewswire(session, baseDir: str, domain: str, diff --git a/notifyOnPost.py b/notifyOnPost.py index e967e5d93..fdbe879f3 100644 --- a/notifyOnPost.py +++ b/notifyOnPost.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Calendar" diff --git a/ontology/DFC/DFC_BusinessOntology.owl b/ontology/DFC/DFC_BusinessOntology.owl new file mode 100644 index 000000000..d0d799b17 --- /dev/null +++ b/ontology/DFC/DFC_BusinessOntology.owl @@ -0,0 +1,5127 @@ + + + + + + + + + + + + + + + + + http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl + http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl + + + https://www.gnu.org/licenses/agpl-3.0.en.html + + + + http://static.datafoodconsortium.org/data/publication.rdf#rachelA + + + + http://static.datafoodconsortium.org/data/publication.rdf#simonL + + + + http://static.datafoodconsortium.org/data/publication.rdf#bernardC + + + + A common vocabulary for digital food platforms (Business Part) + + + + 2018-05-28 + + + + 2019-10-21 + + + + http://static.datafoodconsortium.org/data/publication.rdf#dataFoodConsortium + + + + Data Food Consortium Business + + + + dfc-b + + + + A common vocabulary for digital food platforms (Business Part) + + + + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #Address + Address + + + + #Address + Adresse + + + + #Agent + Agent + + + + #Agent + Agent + + + + #AsPlannedConsumptionFlow + As planned consumption flow + + + + #AsPlannedConsumptionFlow + Flux de consommation planifié (théorique) + + + + #AsPlannedLocalConsumptionFlow + As planned local consumption flow + + + + #AsPlannedLocalConsumptionFlow + Flux de consommation planifié (localisé) + + + + #AsPlannedLocalProductionFlow + As planned local production flow + + + + #AsPlannedLocalProductionFlow + Flux de production planifié (localisé) + + + + #AsPlannedLocalTransformation + As planned local transformation + + + + #AsPlannedLocalTransformation + Transformation planifiée (localisée) + + + + #AsPlannedProductionFlow + As planned production flow + + + + #AsPlannedProductionFlow + Flux de production planifié (théorique) + + + + #AsPlannedTransformation + As planned transformation + + + + #AsPlannedTransformation + Transformation planifiée (théorique) + + + + #AsRealizedConsumptionFlow + As realized consumption flow + + + + #AsRealizedConsumptionFlow + Flux de consommation réalisé + + + + #AsRealizedProductionFlow + As realized production flow + + + + #AsRealizedProductionFlow + Flux de production réalisé + + + + #AsRealizedTransformation + As realized transformation + + + + #AsRealizedTransformation + Transformation réalisée + + + + #CatalogItem + Catalog item + + + + #CatalogItem + Référence catalogue + + + + #ConsumptionFlow + Consumption flow + + + + #ConsumptionFlow + Flux de consommation + + + + #Coordination + Coordination + + + + #Coordination + Coordination + + + + #CustomerCategory + Catégorie client + + + + #CustomerCategory + Customer category + + + + #DFC_BusinessOntology_Relation + Relation de l'ontologie Data Food Consortium + + + + #DFC_BusinessOntology_Relation + Relation of Data Food Consortium ontology + + + + #DFC_BusinessOntology_Subject + Subject of Data Food Consortium ontology + + + + #DFC_BusinessOntology_Subject + Sujet de l'ontologie Data Food Consortium + + + + #DFC_How_Subject + Subject of Data Food Consortium ontology answering question "How?" + + + + #DFC_How_Subject + Sujet de l'ontologie DFC répondant à la question "Comment?" + + + + #DFC_What_Subject + Subject of Data Food Consortium ontology answering question "What?" + + + + #DFC_What_Subject + Sujet de l'ontologie DFC répondant à la question "Quoi?" + + + + #DFC_Where_Subject + Subject of Data Food Consortium ontology answering question "Where?" + + + + #DFC_Where_Subject + Sujet de l'ontologie DFC répondant à la question "Où?" + + + + #DFC_Who_Subject + Subject of Data Food Consortium ontology answering question "Who?" + + + + #DFC_Who_Subject + Sujet de l'ontologie DFC répondant à la question "Qui ?" + + + + #DefinedProduct + Defined product + + + + #DefinedProduct + Produit défini + + + + #DeliveryOption + Delivery option + + + + #DeliveryOption + Option de livraison + + + + #Enterprise + Enterprise + + + + #Enterprise + Entreprise + + + + #FunctionalProduct + Functional product + + + + #FunctionalProduct + Produit fonctionnel + + + + #Image + image + + + + #Image + image + + + + #InstagramPage + Instagram page + + + + #InstagramPage + page Instagram + + + + #LinkedinPage + LinkedIn page + + + + #LinkedinPage + page LinkedIn + + + + #LocalizedProduct + Localized product + + + + #LocalizedProduct + Produit localisé + + + + #Offer + Offer + + + + #Offer + Offre + + + + #Order + Commande + + + + #Order + Order + + + + #OrderLine + Ligne de commande + + + + #OrderLine + Order line + + + + #PaymentMethod + Méthode de payment + + + + #PaymentMethod + Payment method + + + + #Person + Person + + + + #Person + Personne + + + + #PhysicalPlace + Lieu physique + + + + #PhysicalPlace + Physical place + + + + #PhysicalProduct + Physical product + + + + #PhysicalProduct + Produit physique + + + + #PickupOption + Option de retrait + + + + #PickupOption + Pickup option + + + + #Place + Lieu + + + + #Place + Place + + + + #ProductBatch + Lot du produit + + + + #ProductBatch + Product batch + + + + #ProductionFlow + Fux de production + + + + #ProductionFlow + Production flow + + + + #RealStock + Real stock + + + + #RealStock + Stock réel + + + + #Repository + Repository + + + + #Repository + Référentiel + + + + #Repository_1 + La Ruche Qui Dit Oui + + + + #Repository_2 + Panier Local + + + + #Repository_3 + Cagette + + + + #Repository_4 + Open Food France + + + + #Repository_5 + Open Food Facts + + + + #Repository_6 + Elzeard + + + + #SaleSession + Sales session + + + + #SaleSession + Session de vente + + + + #ShippingOption + Option d'expédition + + + + #ShippingOption + Shipping option + + + + #Stock + Stock + + + + #Stock + Stock + + + + #SuppliedProduct + Produit fourni + + + + #SuppliedProduct + Supplied product + + + + #TechnicalProduct + Produit technique + + + + #TechnicalProduct + Technical product + + + + #TheoriticalStock + Stock théorique + + + + #TheoriticalStock + Theoretical stock + + + + #Transaction + Transaction + + + + #Transaction + Transaction + + + + #Transformation + Transformation + + + + #Transformation + Transformation + + + + #TwitterPage + Compte Twitter + + + + #TwitterPage + Twitter page + + + + #URL + URL + + + + #URL + URL + + + + #VATnumber + VAT number + + + + #VATnumber + n° de TVA intracommunautaire + + + + #VATstatus + Charges VAT? + + + + #VATstatus + Soumis à la TVA? + + + + #VirtualPlace + Lieu virtuel + + + + #VirtualPlace + Virtual place + + + + #addressOf + address of + + + + #addressOf + adresse de + + + + #affiliatedBy + géré par + + + + #affiliatedBy + managed by + + + + #affiliates + gère + + + + #affiliates + manages + + + + #batchNumber + batch number + + + + #batchNumber + numéro de lot + + + + #belongsTo + appartient à + + + + #belongsTo + belongs to + + + + #brand + brand + + + + #brand + marque + + + + #certificateOf + certificat/label de + + + + #certificateOf + certificate of + + + + #city + city + + + + #city + ville + + + + #claim + affirmation produit + + + + #claim + product claim + + + + #concernedBy + concerned by + + + + #concernedBy + concerné par + + + + #concerns + concerne + + + + #concerns + concerns + + + + #constituedBy + constituted by + + + + #constituedBy + constitué par + + + + #constitutes + constitue + + + + #constitutes + constitutes + + + + #consumedBy + consommé par + + + + #consumedBy + consumed by + + + + #consumes + consomme + + + + #consumes + consumes + + + + #coordinatedBy + coordinated by + + + + #coordinatedBy + coordonné par + + + + #coordinates + coordinates + + + + #coordinates + coordonne + + + + #cost + cost + + + + #cost + coût + + + + #country + country + + + + #country + pays + + + + #date + date + + + + #date + date + + + + #definedBy + defined by + + + + #definedBy + defini par + + + + #defines + defines + + + + #defines + définit + + + + #description + Description + + + + #description + Description + + + + #email + email + + + + #email + email + + + + #endDate + Date de fin + + + + #endDate + End date + + + + #enterpriseID + enterprise unique ID number + + + + #enterpriseID + n° SIRET + + + + #expiryDate + date de péremption + + + + #expiryDate + expiry date + + + + #facebookPage + Facebook page + + + + #facebookPage + Page Facebook + + + + #facetOf + facet of + + + + #facetOf + facette de + + + + #familyName + family name + + + + #familyName + nom de famille + + + + #fee + Fee + + + + #fee + Frais + + + + #firstName + first name + + + + #firstName + prénom + + + + #from + from + + + + #from + vient de + + + + #geographicalOriginOf + geographical origin of + + + + #geographicalOriginOf + origine géographique de + + + + #hasAddress + a pour adresse + + + + #hasAddress + has address + + + + #hasCertification + a pour certification/label + + + + #hasCertification + has certification + + + + #hasFacet + a pour facette descriptive + + + + #hasFacet + has facet + + + + #hasGeographicalOrigin + + + + + #hasGeographicalOrigin + a pour origine géographique + + + + #hasGeographicalOrigin + has geographical origin + + + + #hasIncome + a en entrée + + + + #hasIncome + has input + + + + #hasMainContact + a pour contact principal + + + + #hasMainContact + has for main contact + + + + #hasNatureOrigin + a pour source / origine naturelle ou vivante + + + + #hasNatureOrigin + has for nature origin + + + + #hasObject + a pour objet + + + + #hasObject + has object + + + + #hasOption + a pour option + + + + #hasOption + has option + + + + #hasOutcome + a en sortie + + + + #hasOutcome + has output + + + + #hasPart + a pour partie + + + + #hasPart + has part + + + + #hasPartOrigin + a pour partie d'origine au sein de la source + + + + #hasPartOrigin + has for part origin + + + + #hasProcess + a subi comme procédé + + + + #hasProcess + has process + + + + #hasReference + a pour référence + + + + #hasReference + has reference + + + + #hasType + a pour type + + + + #hasType + has type + + + + #hasUnit + a pour unité + + + + #hasUnit + has unit + + + + #hostedAt + a lieu à + + + + #hostedAt + hosted at + + + + #hosts + hosts + + + + #hosts + héberge + + + + #identifiedBy + identified by + + + + #identifiedBy + identifié par + + + + #identifies + identifie + + + + #identifies + identifies + + + + #incomeOf + Input of + + + + #incomeOf + Source de + + + + #industrializedBy + Industrialized by + + + + #industrializedBy + Indutrialisé par + + + + #industrializes + Industrializes + + + + #industrializes + Indutrialise + + + + #invoiceNumber + invoice number + + + + #invoiceNumber + numéro de facture + + + + #latitude + latitude + + + + #latitude + latitude + + + + #lifetime + durée de vie (en nombre de jours) + + + + #lifetime + lifetime (in number of days) + + + + #listedIn + listed in + + + + #listedIn + listé dans + + + + #lists + iste + + + + #lists + lists + + + + #localizedBy + localisé par + + + + #localizedBy + localized by + + + + #localizes + localise + + + + #localizes + localizes + + + + #logo + logo + + + + #logo + logo + + + + #longitude + longitude + + + + #longitude + longitude + + + + #mainContactOf + contact principal pour + + + + #mainContactOf + main contact of + + + + #maintainedBy + maintained by + + + + #maintainedBy + maintenu par + + + + #maintains + maintains + + + + #maintains + maintient + + + + #managedBy + géré par + + + + #managedBy + managed by + + + + #manages + gère + + + + #manages + manages + + + + #marginPercent + Marge + + + + #marginPercent + Markup + + + + #natureOriginOf + nature origin of + + + + #natureOriginOf + origine naturelle (source) de + + + + #objectOf + object of + + + + #objectOf + objet de + + + + #offeredThrough + offered through + + + + #offeredThrough + offert via + + + + #offers + offers + + + + #offers + offre + + + + #offersTo + offers to + + + + #offersTo + offert à + + + + #optionOf + option de + + + + #optionOf + option of + + + + #orderNumber + numéro de commande + + + + #orderNumber + order number + + + + #orderedBy + commandé par + + + + #orderedBy + ordered by + + + + #orders + commande + + + + #orders + orders + + + + #outcomeOf + Résultat de + + + + #outcomeOf + output of + + + + #ownedBy + owned by + + + + #ownedBy + possédé par + + + + #owns + owns + + + + #owns + possède + + + + #partOf + part of + + + + #partOf + partie de + + + + #partOriginOf + part of origin (source) utilisée + + + + #partOriginOf + partie de la source utilisée + + + + #paymentMethodProvider + fournisseur de la méthode de paiement + + + + #paymentMethodProvider + payment method provider + + + + #paymentMethodType + payment method type + + + + #paymentMethodType + type de méthode de paiement + + + + #phoneNumber + numéro de téléphone + + + + #phoneNumber + phone number + + + + #physicalCharacteristics + charactéristique physique + + + + #physicalCharacteristics + physical characteristics + + + + #postcode + code postal + + + + #postcode + post code + + + + #price + price + + + + #price + prix + + + + #processOf + process of + + + + #processOf + procédé utilisé pour + + + + #producedBy + produced by + + + + #producedBy + produit par + + + + #produces + produces + + + + #produces + produit + + + + #productionDate + date de production + + + + #productionDate + production date + + + + #proposedBy + proposed by + + + + #proposedBy + proposé par + + + + #proposes + propose + + + + #proposes + proposes + + + + #quantity + quantity + + + + #quantity + quantité + + + + #referenceOf + reference of + + + + #referenceOf + référence de + + + + #referencedBy + referenced by + + + + #referencedBy + référencé par + + + + #references + references + + + + #references + référence + + + + #refersTo + fait référence à + + + + #refersTo + refers to + + + + #representedBy + represented by + + + + #representedBy + représenté par + + + + #represents + represents + + + + #represents + représente + + + + #requestedBy + demandé par + + + + #requestedBy + requested by + + + + #requests + demande + + + + #requests + requests + + + + #satisfiedBy + satisfait par + + + + #satisfiedBy + satisfied by + + + + #satisfies + satisfait + + + + #satisfies + satisfies + + + + #selects + selects + + + + #selects + sélectionne + + + + #sku + Only a general "SKU" id is proposed for now. Agents using GTIN could use GTIN as SKU but other IDs than GTIN can be used. + + + + #sku + Référence produit + + + + #sku + SKU + + + + #startDate + Date de début + + + + #startDate + Start date + + + + #stockLimitation + limitation de stock + + + + #stockLimitation + stock limitation + + + + #storedIn + stocké dans + + + + #storedIn + stored in + + + + #stores + stocke + + + + #stores + stores + + + + #street + n°, rue + + + + #street + street, n° + + + + #suppliedBy + fourni par + + + + #suppliedBy + supplied by + + + + #supplies + fournit + + + + #supplies + supplies + + + + #to + to + + + + #to + à + + + + #totalTheoriticalStock + stock theorique total + + + + #totalTheoriticalStock + total theoretical stock + + + + #tracedBy + contained in + + + + #tracedBy + contenu dans + + + + #traces + contains + + + + #traces + contient + + + + #transformedBy + transformed by + + + + #transformedBy + transformé par + + + + #transforms + transforme + + + + #transforms + transforms + + + + #typeOf + type of + + + + #typeOf + type pour + + + + #uses + uses + + + + #uses + utilise + + + + + + diff --git a/ontology/DFC/DFC_BusinessOntology.rdf b/ontology/DFC/DFC_BusinessOntology.rdf new file mode 100644 index 000000000..ad5e9343e --- /dev/null +++ b/ontology/DFC/DFC_BusinessOntology.rdf @@ -0,0 +1,3270 @@ + + + + + dfc-b + http://static.datafoodconsortium.org/ontologies/DFC_BusinessOntology.owl# + + + + + + + A common vocabulary for digital food platforms (Business Part) + 2018-05-28 + 2019-10-21 + + Data Food Consortium Business + A common vocabulary for digital food platforms (Business Part) + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + address of + adresse de + + + + + + + + + + + géré par + managed by + + + + + + + + + + gère + manages + + + + + + + + + + + + + + + + + appartient à + belongs to + + + + + + + + + + + + certificat/label de + certificate of + + + + + + + + + + + + + + + + + + concerned by + concerné par + + + + + + + + + + + + + + + + + concerne + concerns + + + + + + + + + + + + + + + + + + constituted by + constitué par + + + + + + + + + + + + + + + + + constitue + constitutes + + + + + + + + + + + + + + + + + + + consommé par + consumed by + + + + + + + + + + consomme + consumes + + + + + + + + + + + coordinated by + coordonné par + + + + + + + + + + coordinates + coordonne + + + + + + + + + + + defined by + defini par + + + + + + + + + + defines + définit + + + + + + + + + + + facet of + facette de + + + + + + + + + + from + vient de + + + + + + + + + + + geographical origin of + origine géographique de + + + + + + + + + + + + + + + + + a pour adresse + has address + + + + + + + + + + + a pour certification/label + has certification + + + + + + + + + + a pour facette descriptive + has facet + + + + + + + + + + + + a pour origine géographique + has geographical origin + + + + + + + + + + + + + + + + + + + a en entrée + has input + + + + + + + + + + + a pour contact principal + has for main contact + + + + + + + + + + + a pour source / origine naturelle ou vivante + has for nature origin + + + + + + + + + + + a pour objet + has object + + + + + + + + + + + a pour option + has option + + + + + + + + + + + + + + + + + + + a en sortie + has output + + + + + + + + + + + a pour partie + has part + + + + + + + + + + + a pour partie d'origine au sein de la source + has for part origin + + + + + + + + + + + + a subi comme procédé + has process + + + + + + + + + + + a pour référence + has reference + + + + + + + + + + + + a pour type + has type + + + + + + + + + + a pour unité + has unit + + + + + + + + + + + a lieu à + hosted at + + + + + + + + + + hosts + héberge + + + + + + + + + + + identified by + identifié par + + + + + + + + + + identifie + identifies + + + + + + + + + + Input of + Source de + + + + + + + + + + + Industrialized by + Indutrialisé par + + + + + + + + + + Industrializes + Indutrialise + + + + + + + + + + + + + + + + + + listed in + listé dans + + + + + + + + + + + + + + + + + iste + lists + + + + + + + + + + + localisé par + localized by + + + + + + + + + + localise + localizes + + + + + + + + + + contact principal pour + main contact of + + + + + + + + + + + maintained by + maintenu par + + + + + + + + + + maintains + maintient + + + + + + + + + + + géré par + managed by + + + + + + + + + + gère + manages + + + + + + + + + nature origin of + origine naturelle (source) de + + + + + + + + + + object of + objet de + + + + + + + + + + + offered through + offert via + + + + + + + + + + offers + offre + + + + + + + + + + offers to + offert à + + + + + + + + + + option de + option of + + + + + + + + + + + commandé par + ordered by + + + + + + + + + + commande + orders + + + + + + + + + + Résultat de + output of + + + + + + + + + + + owned by + possédé par + + + + + + + + + + owns + possède + + + + + + + + + + part of + partie de + + + + + + + + + part of origin (source) utilisée + partie de la source utilisée + + + + + + + + + + + process of + procédé utilisé pour + + + + + + + + + + + + + + + + + + + produced by + produit par + + + + + + + + + + produces + produit + + + + + + + + + + + proposed by + proposé par + + + + + + + + + + propose + proposes + + + + + + + + + + reference of + référence de + + + + + + + + + + + + referenced by + référencé par + + + + + + + + + + references + référence + + + + + + + + + + fait référence à + refers to + + + + + + + + + + + represented by + représenté par + + + + + + + + + + represents + représente + + + + + + + + + + + demandé par + requested by + + + + + + + + + + demande + requests + + + + + + + + + + + satisfait par + satisfied by + + + + + + + + + + satisfait + satisfies + + + + + + + + + + selects + sélectionne + + + + + + + + + + + stocké dans + stored in + + + + + + + + + + stocke + stores + + + + + + + + + + + fourni par + supplied by + + + + + + + + + + fournit + supplies + + + + + + + + + + to + à + + + + + + + + + + + contained in + contenu dans + + + + + + + + + + contains + contient + + + + + + + + + + + transformed by + transformé par + + + + + + + + + + transforme + transforms + + + + + + + + + type of + type pour + + + + + + + + + + + + + + + + + + uses + utilise + + + + + + + + + + + + + + + + + + + + + + + image + image + + + + + + + + + + Instagram page + page Instagram + + + + + + + + + + LinkedIn page + page LinkedIn + + + + + + + + + + Compte Twitter + Twitter page + + + + + + + + + + + URL + URL + + + + + + + + + + + VAT number + n° de TVA intracommunautaire + + + + + + + + + + + Charges VAT? + Soumis à la TVA? + + + + + + + + + + + batch number + numéro de lot + + + + + + + + + + brand + marque + + + + + + + + + + + city + ville + + + + + + + + + + + affirmation produit + product claim + + + + + + + + + + + + + + + + + + + cost + coût + + + + + + + + + + + country + pays + + + + + + + + + + + date + date + + + + + + + + + + + Description + Description + + + + + + + + + + + email + email + + + + + + + + + + + + + + + + + + + + Date de fin + End date + + + + + + + + + + + enterprise unique ID number + n° SIRET + + + + + + + + + + + date de péremption + expiry date + + + + + + + + + + Facebook page + Page Facebook + + + + + + + + + + + family name + nom de famille + + + + + + + + + + + Fee + Frais + + + + + + + + + + + first name + prénom + + + + + + + + + + + invoice number + numéro de facture + + + + + + + + + + + latitude + latitude + + + + + + + + + + durée de vie (en nombre de jours) + lifetime (in number of days) + + + + + + + + + + + logo + logo + + + + + + + + + + + longitude + longitude + + + + + + + + + + + Marge + Markup + + + + + + + + + + + numéro de commande + order number + + + + + + + + + + + fournisseur de la méthode de paiement + payment method provider + + + + + + + + + + + payment method type + type de méthode de paiement + + + + + + + + + + numéro de téléphone + phone number + + + + + + + + + + charactéristique physique + physical characteristics + + + + + + + + + + + code postal + post code + + + + + + + + + + + + + + + + + + + price + prix + + + + + + + + + + + date de production + production date + + + + + + + + + + + + + + + + + + + + + + + + + + quantity + quantité + + + + + + + + + + + Only a general "SKU" id is proposed for now. Agents using GTIN could use GTIN as SKU but other IDs than GTIN can be used. + Référence produit + SKU + + + + + + + + + + + + + + + + + + + + Date de début + Start date + + + + + + + + + + + + + + + + limitation de stock + stock limitation + + + + + + + + + + + n°, rue + street, n° + + + + + + + + + stock theorique total + total theoretical stock + + + + + + + + + + + + + + + + + + + + + + + + + + Address + Adresse + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Agent + Agent + + + + + + + + + + + + + + + + + + + + + As planned consumption flow + Flux de consommation planifié (théorique) + + + + + + + + + + + + + + + + + + + + + As planned local consumption flow + Flux de consommation planifié (localisé) + + + + + + + + + + + + + + + + + + + + + As planned local production flow + Flux de production planifié (localisé) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + As planned local transformation + Transformation planifiée (localisée) + + + + + + + + + + + + + + + + + + + + + As planned production flow + Flux de production planifié (théorique) + + + + + + + + + + + + + + + + + + + + + As planned transformation + Transformation planifiée (théorique) + + + + + + + + + + + + + + + + + + + + + As realized consumption flow + Flux de consommation réalisé + + + + + + + + + + + + + + + + + + + + + As realized production flow + Flux de production réalisé + + + + + + + + + + + + + + + + + + + + + As realized transformation + Transformation réalisée + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + + + + 1 + + + + + + 1 + + + Catalog item + Référence catalogue + + + + + + + + + + + + 1 + + + + + + 1 + + + Consumption flow + Flux de consommation + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + Coordination + Coordination + + + + + + + + + + + + + + + + + + 1 + + + Catégorie client + Customer category + + + + + + + + + Relation de l'ontologie Data Food Consortium + Relation of Data Food Consortium ontology + + + + + + + + + Subject of Data Food Consortium ontology + Sujet de l'ontologie Data Food Consortium + + + + + + + + + Subject of Data Food Consortium ontology answering question "How?" + Sujet de l'ontologie DFC répondant à la question "Comment?" + + + + + + + + + Subject of Data Food Consortium ontology answering question "What?" + Sujet de l'ontologie DFC répondant à la question "Quoi?" + + + + + + + + + Subject of Data Food Consortium ontology answering question "Where?" + Sujet de l'ontologie DFC répondant à la question "Où?" + + + + + + + + + Subject of Data Food Consortium ontology answering question "Who?" + Sujet de l'ontologie DFC répondant à la question "Qui ?" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + Defined product + Produit défini + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + Delivery option + Option de livraison + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + Enterprise + Entreprise + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + Functional product + Produit fonctionnel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Localized product + Produit localisé + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + + + + 1 + + + Offer + Offre + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + + + + 1 + + + + + + 1 + + + Commande + Order + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + Ligne de commande + Order line + + + + + + + + + Méthode de payment + Payment method + + + + + + + + + + + + + + + + + + + + + Person + Personne + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + Lieu physique + Physical place + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Physical product + Produit physique + + + + + + + + + + + + + + + + + + 1 + + + Option de retrait + Pickup option + + + + + + + + + + + + + + + Lieu + Place + + + + + + + + + + + + + + + + + + + + + Lot du produit + Product batch + + + + + + + + + + + + 1 + + + + + + 1 + + + Fux de production + Production flow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + + + + 1 + + + Real stock + Stock réel + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + Repository + Référentiel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sales session + Session de vente + + + + + + + + + + + + + + + Option d'expédition + Shipping option + + + + + + + + + Stock + Stock + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + Produit fourni + Supplied product + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + Produit technique + Technical product + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + Stock théorique + Theoretical stock + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + Transaction + Transaction + + + + + + + + + Transformation + Transformation + + + + + + + + + Lieu virtuel + Virtual place + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + La Ruche Qui Dit Oui + + + + + + + + + Panier Local + + + + + + + + + Cagette + + + + + + + + + Open Food France + + + + + + + + + Open Food Facts + + + + + + + + + Elzeard + + + + + + diff --git a/ontology/DFC/DFC_FullModel.owl b/ontology/DFC/DFC_FullModel.owl new file mode 100644 index 000000000..7158e2085 --- /dev/null +++ b/ontology/DFC/DFC_FullModel.owl @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + http://static.datafoodconsortium.org/ontologies/DFC_BusinessOntology.owl + http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl + http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl + + + https://www.gnu.org/licenses/agpl-3.0.en.html + + + + http://static.datafoodconsortium.org/data/publication.rdf#rachelA + + + + http://static.datafoodconsortium.org/data/publication.rdf#simonL + + + + http://static.datafoodconsortium.org/data/publication.rdf#bernardC + + + + A common vocabulary for digital food platforms + + + + 2018-05-28 + + + + 2019-10-21 + + + + http://static.datafoodconsortium.org/data/publication.rdf#dataFoodConsortium + + + + Data Food Consortium + + + + dfc + + + + A common vocabulary for digital food platforms + + + + 4.0 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ontology/DFC/DFC_FullModel.rdf b/ontology/DFC/DFC_FullModel.rdf new file mode 100644 index 000000000..3487ce20e --- /dev/null +++ b/ontology/DFC/DFC_FullModel.rdf @@ -0,0 +1,84 @@ + + + + + + dfc + http://static.datafoodconsortium.org/ontologies/DFC_FullModel.owl# + + + + + + + + A common vocabulary for digital food platforms + 2018-05-28 + 2019-10-21 + + Data Food Consortium + A common vocabulary for digital food platforms + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ontology/DFC/DFC_ProductGlossary.owl b/ontology/DFC/DFC_ProductGlossary.owl new file mode 100644 index 000000000..e62c5f2cd --- /dev/null +++ b/ontology/DFC/DFC_ProductGlossary.owl @@ -0,0 +1,549 @@ + + + + + + + + + + + + + + + + + http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl + + + https://www.gnu.org/licenses/agpl-3.0.en.html + + + + http://static.datafoodconsortium.org/data/publication.rdf#rachelA + + + + http://static.datafoodconsortium.org/data/publication.rdf#simonL + + + + http://static.datafoodconsortium.org/data/publication.rdf#bernardC + + + + A common vocabulary for digital food platforms (Product Glossary Part) + + + + 2018-05-28 + + + + 2019-10-21 + + + + http://static.datafoodconsortium.org/data/publication.rdf#dataFoodConsortium + + + + Data Food Consortium Product + + + + dfc-p + + + + A common vocabulary for digital food platforms (Product Glossary Part) + + + + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #Certification + certification + + + + #Certification + certification + + + + #DFC_ProductGlossary_Facet + Subject of the facets thesaurus + + + + #DFC_ProductGlossary_Facet + Sujet du Thésaurus à Facettes + + + + #DFC_ProductGlossary_Measure + thesaurus des unités de mesure + + + + #DFC_ProductGlossary_Measure + unit of measures thesaurus + + + + #Dimension + dimension + + + + #Dimension + dimension + + + + #GlobalGenericOrigin + Global generic origin + + + + #GlobalGenericOrigin + Origines génériques globales + + + + #NatureOrigin + natural "living" origin + + + + #NatureOrigin + source "vivante" d'origine + + + + #PartOrigin + part of natural "living" origin concerned + + + + #PartOrigin + partie de la source "vivante" d'origine concernée + + + + #Process + process applied + + + + #Process + procédé appliqué + + + + #ProductType + product type (general taxonomy) + + + + #ProductType + type de produit (classification générale) + + + + #TerritorialOrigin + origine territoriale + + + + #TerritorialOrigin + territorial origin + + + + #Unit + unit + + + + #Unit + unité + + + + #generalizes + generalizes + + + + #generalizes + généralise + + + + #measuredBy + measured by + + + + #measuredBy + mesuré en + + + + #measures + measures + + + + #measures + mesure + + + + #specializes + specializes + + + + #specializes + spécialise + + + + + + + diff --git a/ontology/DFC/DFC_ProductGlossary.rdf b/ontology/DFC/DFC_ProductGlossary.rdf new file mode 100644 index 000000000..355a5bfb8 --- /dev/null +++ b/ontology/DFC/DFC_ProductGlossary.rdf @@ -0,0 +1,428 @@ + + + + + dfc-p + http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl# + + + + + + A common vocabulary for digital food platforms (Product Glossary Part) + 2018-05-28 + 2019-10-21 + + Data Food Consortium Product + A common vocabulary for digital food platforms (Product Glossary Part) + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + generalizes + généralise + + + + + + + + + + + measured by + mesuré en + + + + + + + + + + measures + mesure + + + + + + + + + + specializes + spécialise + + + + + + + + + + + + + + + + + + + + + + + + + + certification + certification + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + Subject of the facets thesaurus + Sujet du Thésaurus à Facettes + + + + + + + + + thesaurus des unités de mesure + unit of measures thesaurus + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + dimension + dimension + + + + + + + + + + + + + + + + + + + + + Global generic origin + Origines génériques globales + + + + + + + + + + + + + + + + + + + + + natural "living" origin + source "vivante" d'origine + + + + + + + + + + + + + + + + + + + + + part of natural "living" origin concerned + partie de la source "vivante" d'origine concernée + + + + + + + + + + + + + + + + + + + + + process applied + procédé appliqué + + + + + + + + + + + + + + + + + + + + + product type (general taxonomy) + type de produit (classification générale) + + + + + + + + + + + + + + + + + + + + + origine territoriale + territorial origin + + + + + + + + + + + + + + + + + + 1 + + + unit + unité + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ontology/DFC/DFC_TechnicalOntology.owl b/ontology/DFC/DFC_TechnicalOntology.owl new file mode 100644 index 000000000..6f9ba7b24 --- /dev/null +++ b/ontology/DFC/DFC_TechnicalOntology.owl @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + https://www.gnu.org/licenses/agpl-3.0.en.html + + + + http://static.datafoodconsortium.org/data/publication.rdf#simonL + + + + A common vocabulary for digital food platforms (Technical Part) + + + + 2018-05-28 + + + + 2019-10-21 + + + + http://static.datafoodconsortium.org/data/publication.rdf#dataFoodConsortium + + + + Data Food Consortium Technical + + + + dfc-t + + + + A common vocabulary for digital food platforms (Technical Part) + + + + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #DFC_DitributedRepresentation + Concepts de réconciliation de représentation distribuée + + + + #DFC_DitributedRepresentation + ditributed représentation reconcialition concepts + + + + #Platform + Organisation qui heberge la donnée + + + + #Platform + Plateforme + + + + #RepresentationPivot + Permet de designer tous les RepresentatedThing qui sont équivalents et d'etre désigné par un RepresentedThing pour connaitre ses équivalence par transitivité + + + + #RepresentationPivot + Pivot de représentation + + + + #RepresentedThing + Chose représentée sur une platefome posadant des equivalences sur d'autres plateformes + + + + #RepresentedThing + Chose représentée + + + + #hasPivot + possède un point pivot + + + + #hostedBy + hébergé par + + + + #represent + représente + + + + + + + diff --git a/ontology/DFC/DFC_TechnicalOntology.rdf b/ontology/DFC/DFC_TechnicalOntology.rdf new file mode 100644 index 000000000..eb68ebbb4 --- /dev/null +++ b/ontology/DFC/DFC_TechnicalOntology.rdf @@ -0,0 +1,291 @@ + + + + + dfc-t + http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl# + + + A common vocabulary for digital food platforms (Technical Part) + 2018-05-28 + 2019-10-21 + + Data Food Consortium Technical + A common vocabulary for digital food platforms (Technical Part) + 4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + possède un point pivot + + + + + + + + + hébergé par + + + + + + + + + représente + + + + + + + + + + + + + Concepts de réconciliation de représentation distribuée + ditributed représentation reconcialition concepts + + + + + + + + + + + + + + + Organisation qui heberge la donnée + Plateforme + + + + + + + + + + + + + + + + + + 1 + + + + Permet de designer tous les RepresentatedThing qui sont équivalents et d'etre désigné par un RepresentedThing pour connaitre ses équivalence par transitivité + Pivot de représentation + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + 1 + + + + Chose représentée sur une platefome posadant des equivalences sur d'autres plateformes + Chose représentée + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ontology/accommodationTypes.json b/ontology/accommodationTypes.json new file mode 100644 index 000000000..565da1a26 --- /dev/null +++ b/ontology/accommodationTypes.json @@ -0,0 +1,989 @@ +{ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "dfc-b": "http://static.datafoodconsortium.org/ontologies/DFC_BusinessOntology.owl#", + "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl#", + "dfc-t": "http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl#", + "dfc-u": "http://static.datafoodconsortium.org/data/units.json#", + "dfc-p:specialize": { + "@type": "@id" + } + }, + "@graph": [ + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#EntirePlace", + "rdfs:label": [ + { + "@value": "Entire Place", + "@language": "en" + }, + { + "@value": "Entire Place", + "@language": "ar" + }, + { + "@value": "Entire Place", + "@language": "ku" + }, + { + "@value": "Entire Place", + "@language": "es" + }, + { + "@value": "Posto intero", + "@language": "it" + }, + { + "@value": "Gesamter Ort", + "@language": "de" + }, + { + "@value": "Entire Place", + "@language": "sw" + }, + { + "@value": "Lugar completo", + "@language": "pt" + }, + { + "@value": "Entire Place", + "@language": "oc" + }, + { + "@value": "Все место", + "@language": "ru" + }, + { + "@value": "Entire Place", + "@language": "cy" + }, + { + "@value": "集合場所", + "@language": "ja" + }, + { + "@value": "Áit Eintire", + "@language": "ga" + }, + { + "@value": "जगह", + "@language": "hi" + }, + { + "@value": "入口", + "@language": "zh" + }, + { + "@value": "Entire Place", + "@language": "fr" + }, + { + "@value": "Entire Place", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#EntirePlace", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#PrivateRoom", + "rdfs:label": [ + { + "@value": "Private Room", + "@language": "en" + }, + { + "@value": "الغرفة الخاصة", + "@language": "ar" + }, + { + "@value": "Private Room", + "@language": "ku" + }, + { + "@value": "Habitación privada", + "@language": "es" + }, + { + "@value": "Stanza privata", + "@language": "it" + }, + { + "@value": "Privatzimmer", + "@language": "de" + }, + { + "@value": "Private Room", + "@language": "sw" + }, + { + "@value": "Quarto privado", + "@language": "pt" + }, + { + "@value": "Private Room", + "@language": "oc" + }, + { + "@value": "Частная комната", + "@language": "ru" + }, + { + "@value": "Private Room", + "@language": "cy" + }, + { + "@value": "プライベートルーム", + "@language": "ja" + }, + { + "@value": "Seomra na nDaoine", + "@language": "ga" + }, + { + "@value": "निजी कक्ष", + "@language": "hi" + }, + { + "@value": "私人会议室", + "@language": "zh" + }, + { + "@value": "Salle privée", + "@language": "fr" + }, + { + "@value": "Private Room", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#PrivateRoom", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#HotelRoom", + "rdfs:label": [ + { + "@value": "Hotel Room", + "@language": "en" + }, + { + "@value": "فندق", + "@language": "ar" + }, + { + "@value": "Hotel Room", + "@language": "ku" + }, + { + "@value": "Hotel Room", + "@language": "es" + }, + { + "@value": "Camera dell'hotel", + "@language": "it" + }, + { + "@value": "Hotelzimmer", + "@language": "de" + }, + { + "@value": "Hotel Room", + "@language": "sw" + }, + { + "@value": "Quarto de Hotel", + "@language": "pt" + }, + { + "@value": "Hotel Room", + "@language": "oc" + }, + { + "@value": "Номер в отеле", + "@language": "ru" + }, + { + "@value": "Hotel Room", + "@language": "cy" + }, + { + "@value": "ホテル ルーム", + "@language": "ja" + }, + { + "@value": "Seomra Óstán", + "@language": "ga" + }, + { + "@value": "होटल", + "@language": "hi" + }, + { + "@value": "旅馆", + "@language": "zh" + }, + { + "@value": "Hotel Room", + "@language": "fr" + }, + { + "@value": "Hotel Room", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#HotelRoom", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#SharedRoom", + "rdfs:label": [ + { + "@value": "Shared Room", + "@language": "en" + }, + { + "@value": "الغرفة المشتركة", + "@language": "ar" + }, + { + "@value": "Shared Room", + "@language": "ku" + }, + { + "@value": "Habitación compartida", + "@language": "es" + }, + { + "@value": "Camera condivisa", + "@language": "it" + }, + { + "@value": "Zimmer", + "@language": "de" + }, + { + "@value": "Shared Room", + "@language": "sw" + }, + { + "@value": "Quarto compartilhado", + "@language": "pt" + }, + { + "@value": "Shared Room", + "@language": "oc" + }, + { + "@value": "Общая комната", + "@language": "ru" + }, + { + "@value": "Shared Room", + "@language": "cy" + }, + { + "@value": "シェアルーム", + "@language": "ja" + }, + { + "@value": "Seomra Comhroinnte", + "@language": "ga" + }, + { + "@value": "साझा कक्ष", + "@language": "hi" + }, + { + "@value": "共有会议室", + "@language": "zh" + }, + { + "@value": "Salle partagée", + "@language": "fr" + }, + { + "@value": "Shared Room", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#SharedRoom", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Sofa", + "rdfs:label": [ + { + "@value": "Sofa", + "@language": "en" + }, + { + "@value": "Sofa", + "@language": "ar" + }, + { + "@value": "Sofa", + "@language": "ku" + }, + { + "@value": "Sofa", + "@language": "es" + }, + { + "@value": "Divano", + "@language": "it" + }, + { + "@value": "Sofa", + "@language": "de" + }, + { + "@value": "Sofa", + "@language": "sw" + }, + { + "@value": "Sofá", + "@language": "pt" + }, + { + "@value": "Sofa", + "@language": "oc" + }, + { + "@value": "Диван", + "@language": "ru" + }, + { + "@value": "Sofa", + "@language": "cy" + }, + { + "@value": "ソファ", + "@language": "ja" + }, + { + "@value": "Toir agus Crainn", + "@language": "ga" + }, + { + "@value": "सोफा", + "@language": "hi" + }, + { + "@value": "Sofa", + "@language": "zh" + }, + { + "@value": "Sofa", + "@language": "fr" + }, + { + "@value": "Sofa", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Sofa", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Boat", + "rdfs:label": [ + { + "@value": "Boat", + "@language": "en" + }, + { + "@value": "Boat", + "@language": "ar" + }, + { + "@value": "Boat", + "@language": "ku" + }, + { + "@value": "El barco", + "@language": "es" + }, + { + "@value": "Barca", + "@language": "it" + }, + { + "@value": "Boote", + "@language": "de" + }, + { + "@value": "Boat", + "@language": "sw" + }, + { + "@value": "Barco", + "@language": "pt" + }, + { + "@value": "Boat", + "@language": "oc" + }, + { + "@value": "Лодка", + "@language": "ru" + }, + { + "@value": "Boat", + "@language": "cy" + }, + { + "@value": "ボート", + "@language": "ja" + }, + { + "@value": "taiseachas aeir: fliuch", + "@language": "ga" + }, + { + "@value": "नाव", + "@language": "hi" + }, + { + "@value": "B. 博塔", + "@language": "zh" + }, + { + "@value": "Boat", + "@language": "fr" + }, + { + "@value": "Boat", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Boat", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Barge", + "rdfs:label": [ + { + "@value": "Barge", + "@language": "en" + }, + { + "@value": "Barge", + "@language": "ar" + }, + { + "@value": "Barge", + "@language": "ku" + }, + { + "@value": "Barge", + "@language": "es" + }, + { + "@value": "Barge", + "@language": "it" + }, + { + "@value": "Barrel", + "@language": "de" + }, + { + "@value": "Barge", + "@language": "sw" + }, + { + "@value": "Barco", + "@language": "pt" + }, + { + "@value": "Barge", + "@language": "oc" + }, + { + "@value": "Барж", + "@language": "ru" + }, + { + "@value": "Barge", + "@language": "cy" + }, + { + "@value": "バージ", + "@language": "ja" + }, + { + "@value": "Toir agus Crainn", + "@language": "ga" + }, + { + "@value": "बार्ज", + "@language": "hi" + }, + { + "@value": "律师协会", + "@language": "zh" + }, + { + "@value": "Barge", + "@language": "fr" + }, + { + "@value": "Barge", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Boat", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Tent", + "rdfs:label": [ + { + "@value": "Tent", + "@language": "en" + }, + { + "@value": "الخيمة", + "@language": "ar" + }, + { + "@value": "Tent", + "@language": "ku" + }, + { + "@value": "Tent", + "@language": "es" + }, + { + "@value": "Tenda", + "@language": "it" + }, + { + "@value": "Zelt", + "@language": "de" + }, + { + "@value": "Tent", + "@language": "sw" + }, + { + "@value": "Tenda", + "@language": "pt" + }, + { + "@value": "Tent", + "@language": "oc" + }, + { + "@value": "Тент", + "@language": "ru" + }, + { + "@value": "Tent", + "@language": "cy" + }, + { + "@value": "テント", + "@language": "ja" + }, + { + "@value": "Tent", + "@language": "ga" + }, + { + "@value": "टेंट", + "@language": "hi" + }, + { + "@value": "答辩", + "@language": "zh" + }, + { + "@value": "Tent", + "@language": "fr" + }, + { + "@value": "Tent", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Tent", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Caravan", + "rdfs:label": [ + { + "@value": "Caravan", + "@language": "en" + }, + { + "@value": "Caravan", + "@language": "ar" + }, + { + "@value": "Caravan", + "@language": "ku" + }, + { + "@value": "Caravan", + "@language": "es" + }, + { + "@value": "Caravan", + "@language": "it" + }, + { + "@value": "Wohnwagen", + "@language": "de" + }, + { + "@value": "Caravan", + "@language": "sw" + }, + { + "@value": "Caravana", + "@language": "pt" + }, + { + "@value": "Caravan", + "@language": "oc" + }, + { + "@value": "Караван", + "@language": "ru" + }, + { + "@value": "Caravan", + "@language": "cy" + }, + { + "@value": "キャラバン", + "@language": "ja" + }, + { + "@value": "Amharc ar gach eolas", + "@language": "ga" + }, + { + "@value": "कारवां", + "@language": "hi" + }, + { + "@value": "车队", + "@language": "zh" + }, + { + "@value": "Caravan", + "@language": "fr" + }, + { + "@value": "Caravan", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Caravan", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Hostel", + "rdfs:label": [ + { + "@value": "Hostel", + "@language": "en" + }, + { + "@value": "Hostel", + "@language": "ar" + }, + { + "@value": "Hostel", + "@language": "ku" + }, + { + "@value": "Hostel", + "@language": "es" + }, + { + "@value": "Ostello", + "@language": "it" + }, + { + "@value": "Hostel", + "@language": "de" + }, + { + "@value": "Hostel", + "@language": "sw" + }, + { + "@value": "Albergue", + "@language": "pt" + }, + { + "@value": "Hostel", + "@language": "oc" + }, + { + "@value": "Хостел", + "@language": "ru" + }, + { + "@value": "Hostel", + "@language": "cy" + }, + { + "@value": "ホステル", + "@language": "ja" + }, + { + "@value": "brú", + "@language": "ga" + }, + { + "@value": "छात्रावास", + "@language": "hi" + }, + { + "@value": "人质", + "@language": "zh" + }, + { + "@value": "Hostel", + "@language": "fr" + }, + { + "@value": "Hostel", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Hostel", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Yurt", + "rdfs:label": [ + { + "@value": "Yurt", + "@language": "en" + }, + { + "@value": "يوت", + "@language": "ar" + }, + { + "@value": "Yurt", + "@language": "ku" + }, + { + "@value": "Yurt", + "@language": "es" + }, + { + "@value": "Yurt", + "@language": "it" + }, + { + "@value": "Rind", + "@language": "de" + }, + { + "@value": "Yurt", + "@language": "sw" + }, + { + "@value": "Yurt.", + "@language": "pt" + }, + { + "@value": "Yurt", + "@language": "oc" + }, + { + "@value": "Юрт", + "@language": "ru" + }, + { + "@value": "Yurt", + "@language": "cy" + }, + { + "@value": "ユルト", + "@language": "ja" + }, + { + "@value": "taiseachas aeir: fliuch", + "@language": "ga" + }, + { + "@value": "युर्ट", + "@language": "hi" + }, + { + "@value": "导 言", + "@language": "zh" + }, + { + "@value": "Yurt", + "@language": "fr" + }, + { + "@value": "Yurt", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Yurt", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Tipi", + "rdfs:label": [ + { + "@value": "Tipi", + "@language": "en" + }, + { + "@value": "Tipi", + "@language": "ar" + }, + { + "@value": "Tipi", + "@language": "ku" + }, + { + "@value": "Tipi", + "@language": "es" + }, + { + "@value": "Tipi di", + "@language": "it" + }, + { + "@value": "Tipi", + "@language": "de" + }, + { + "@value": "Tipi", + "@language": "sw" + }, + { + "@value": "Sugestões", + "@language": "pt" + }, + { + "@value": "Tipi", + "@language": "oc" + }, + { + "@value": "Советы", + "@language": "ru" + }, + { + "@value": "Tipi", + "@language": "cy" + }, + { + "@value": "ログイン", + "@language": "ja" + }, + { + "@value": "An tSeapáin", + "@language": "ga" + }, + { + "@value": "टीका", + "@language": "hi" + }, + { + "@value": "注", + "@language": "zh" + }, + { + "@value": "Tipi", + "@language": "fr" + }, + { + "@value": "Tipi", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#Tipi", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/accommodationTypes.json#RV", + "rdfs:label": [ + { + "@value": "RV", + "@language": "en" + }, + { + "@value": "RV", + "@language": "ar" + }, + { + "@value": "RV", + "@language": "ku" + }, + { + "@value": "RV", + "@language": "es" + }, + { + "@value": "RV", + "@language": "it" + }, + { + "@value": "RV", + "@language": "de" + }, + { + "@value": "RV", + "@language": "sw" + }, + { + "@value": "RV", + "@language": "pt" + }, + { + "@value": "RV", + "@language": "oc" + }, + { + "@value": "РВ", + "@language": "ru" + }, + { + "@value": "RV", + "@language": "cy" + }, + { + "@value": "RVの特長", + "@language": "ja" + }, + { + "@value": "RV", + "@language": "ga" + }, + { + "@value": "आरवी", + "@language": "hi" + }, + { + "@value": "RV", + "@language": "zh" + }, + { + "@value": "RV", + "@language": "fr" + }, + { + "@value": "RV", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/accommodationTypes.json#RV", + "@type": "dfc-p:ProductType" + } + ] +} diff --git a/ontology/clothesTypes.json b/ontology/clothesTypes.json index 7f0eb517f..52eb58739 100644 --- a/ontology/clothesTypes.json +++ b/ontology/clothesTypes.json @@ -2,16 +2,16 @@ "@context": { "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "dfc-b": "http://static.datafoodconsortium.org/ontologies/DFC_BusinessOntology.owl#", - "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductOntology.owl#", + "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl#", "dfc-t": "http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl#", - "dfc-u": "http://static.datafoodconsortium.org/data/units.rdf#", + "dfc-u": "http://static.datafoodconsortium.org/data/units.json#", "dfc-p:specialize": { "@type": "@id" } }, "@graph": [ { - "@id": "https://clothes/data/clothesTypes.rdf#shirt", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#shirt", "rdfs:label": [ { "@value": "La chemise", @@ -82,11 +82,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/clothesTypes.rdf#shirt", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/clothesTypes.json#shirt", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#belt", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#belt", "rdfs:label": [ { "@value": "Ceinture", @@ -157,11 +157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#belt", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#belt", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#childrens-clothing", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#childrens-clothing", "rdfs:label": [ { "@value": "Vêtements pour enfants", @@ -232,11 +232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#childrens-clothing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#childrens-clothing", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#coat", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#coat", "rdfs:label": [ { "@value": "Manteau", @@ -307,11 +307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#coat", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#coat", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#dress", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#dress", "rdfs:label": [ { "@value": "Robe", @@ -382,11 +382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#womens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#womens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#shoes", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#shoes", "rdfs:label": [ { "@value": "Des chaussures", @@ -457,11 +457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#footwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#footwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#boots", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#boots", "rdfs:label": [ { "@value": "Bottes", @@ -532,11 +532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#footwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#footwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#gown", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#gown", "rdfs:label": [ { "@value": "Robe", @@ -607,11 +607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#gown", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#gown", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#hat", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#hat", "rdfs:label": [ { "@value": "Chapeau", @@ -682,11 +682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#hat", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#hat", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#hosiery‎", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#hosiery‎", "rdfs:label": [ { "@value": "Hosiery", @@ -757,11 +757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#hosiery‎", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#hosiery‎", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#jacket", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#jacket", "rdfs:label": [ { "@value": "Veste", @@ -832,11 +832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#jacket", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#jacket", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#jeans", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#jeans", "rdfs:label": [ { "@value": "Jeans", @@ -907,11 +907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#jeans", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#jeans", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#mask", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#mask", "rdfs:label": [ { "@value": "Masquer", @@ -982,11 +982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#mask", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#mask", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#neckwear", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#neckwear", "rdfs:label": [ { "@value": "Vêtements de cou", @@ -1057,11 +1057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#neckwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#neckwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#scarf", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#scarf", "rdfs:label": [ { "@value": "Écharpe", @@ -1132,11 +1132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#neckwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#neckwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#suit", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#suit", "rdfs:label": [ { "@value": "Costume", @@ -1207,11 +1207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#suit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#suit", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#poncho", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#poncho", "rdfs:label": [ { "@value": "Poncho", @@ -1282,11 +1282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#poncho", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#poncho", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#cloak", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#cloak", "rdfs:label": [ { "@value": "Manteau", @@ -1357,11 +1357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#cloak", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#cloak", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#sari", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#sari", "rdfs:label": [ { "@value": "Sari", @@ -1432,11 +1432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#womens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#womens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#sash", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#sash", "rdfs:label": [ { "@value": "Ceinture", @@ -1507,11 +1507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#sash", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#sash", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#shawl", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#shawl", "rdfs:label": [ { "@value": "Châle", @@ -1582,11 +1582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#womens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#womens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#skirt", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#skirt", "rdfs:label": [ { "@value": "Jupe", @@ -1657,11 +1657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#womens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#womens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#trousers", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#trousers", "rdfs:label": [ { "@value": "Pantalon", @@ -1732,11 +1732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#trousers", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#trousers", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#shorts", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#shorts", "rdfs:label": [ { "@value": "Shorts", @@ -1807,11 +1807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#shorts", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#shorts", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#underwear", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#underwear", "rdfs:label": [ { "@value": "Sous-vêtement", @@ -1882,11 +1882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#underwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#underwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#socks", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#socks", "rdfs:label": [ { "@value": "Des chaussettes", @@ -1957,11 +1957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#footwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#footwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#helmet", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#helmet", "rdfs:label": [ { "@value": "Casque", @@ -2032,11 +2032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#helmet", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#helmet", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#gloves", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#gloves", "rdfs:label": [ { "@value": "Gants", @@ -2107,11 +2107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#gloves", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#gloves", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#kurta", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#kurta", "rdfs:label": [ { "@value": "Kurta", @@ -2182,11 +2182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#kurta", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#kurta", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#sherwani", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#sherwani", "rdfs:label": [ { "@value": "Sherwani", @@ -2257,11 +2257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#mens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#mens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#shalwar-kameez", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#shalwar-kameez", "rdfs:label": [ { "@value": "Shalwar Kameez", @@ -2332,11 +2332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#womens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#womens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#cheongsam", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#cheongsam", "rdfs:label": [ { "@value": "Cheongsam", @@ -2407,11 +2407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#womens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#womens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#áo-bà-ba", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#áo-bà-ba", "rdfs:label": [ { "@value": "Áo bà ba", @@ -2482,11 +2482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#áo-bà-ba", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#áo-bà-ba", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#áo-dài", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#áo-dài", "rdfs:label": [ { "@value": "Áo dài", @@ -2557,11 +2557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#áo-dài", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#áo-dài", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#halter-top", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#halter-top", "rdfs:label": [ { "@value": "Halter haut", @@ -2632,11 +2632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#womens", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#womens", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#sandals", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#sandals", "rdfs:label": [ { "@value": "Des sandales", @@ -2707,11 +2707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#footwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#footwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#slippers", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#slippers", "rdfs:label": [ { "@value": "Chaussons", @@ -2782,11 +2782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#footwear", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#footwear", "@type": "dfc-p:ProductType" }, { - "@id": "https://clothes/data/clothesTypes.rdf#kilt", + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#kilt", "rdfs:label": [ { "@value": "Kilt", @@ -2857,7 +2857,157 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://clothes/data/toolTypes.rdf#kilt", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#kilt", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#apron", + "rdfs:label": [ + { + "@value": "Apron", + "@language": "en" + }, + { + "@value": "Apron", + "@language": "ar" + }, + { + "@value": "Apron", + "@language": "ku" + }, + { + "@value": "Apron", + "@language": "es" + }, + { + "@value": "Grembiule", + "@language": "it" + }, + { + "@value": "Apres", + "@language": "de" + }, + { + "@value": "Apron", + "@language": "sw" + }, + { + "@value": "Avental", + "@language": "pt" + }, + { + "@value": "Apron", + "@language": "oc" + }, + { + "@value": "Абон", + "@language": "ru" + }, + { + "@value": "Apron", + "@language": "cy" + }, + { + "@value": "エプロン", + "@language": "ja" + }, + { + "@value": "An tAthrú", + "@language": "ga" + }, + { + "@value": "एप्रन", + "@language": "hi" + }, + { + "@value": "环境", + "@language": "zh" + }, + { + "@value": "Apron", + "@language": "fr" + }, + { + "@value": "Apron", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#apron", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/clothesTypes.json#corset", + "rdfs:label": [ + { + "@value": "Corset", + "@language": "en" + }, + { + "@value": "Corset", + "@language": "ar" + }, + { + "@value": "Corset", + "@language": "ku" + }, + { + "@value": "Corset", + "@language": "es" + }, + { + "@value": "Corse", + "@language": "it" + }, + { + "@value": "Korsett", + "@language": "de" + }, + { + "@value": "Corset", + "@language": "sw" + }, + { + "@value": "Espartilho", + "@language": "pt" + }, + { + "@value": "Corset", + "@language": "oc" + }, + { + "@value": "Корсет", + "@language": "ru" + }, + { + "@value": "Corset", + "@language": "cy" + }, + { + "@value": "コルセット", + "@language": "ja" + }, + { + "@value": "Sraith", + "@language": "ga" + }, + { + "@value": "कोर्सेट", + "@language": "hi" + }, + { + "@value": "Cset", + "@language": "zh" + }, + { + "@value": "Corset", + "@language": "fr" + }, + { + "@value": "Corset", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.rdf#corset", "@type": "dfc-p:ProductType" } ] diff --git a/ontology/foodTypes.json b/ontology/foodTypes.json index 75eb0d6ae..f769adbc6 100644 --- a/ontology/foodTypes.json +++ b/ontology/foodTypes.json @@ -2,16 +2,16 @@ "@context": { "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "dfc-b": "http://static.datafoodconsortium.org/ontologies/DFC_BusinessOntology.owl#", - "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductOntology.owl#", + "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl#", "dfc-t": "http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl#", - "dfc-u": "http://static.datafoodconsortium.org/data/units.rdf#", + "dfc-u": "http://static.datafoodconsortium.org/data/units.json#", "dfc-p:specialize": { "@type": "@id" } }, "@graph": [ { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#soft-drink", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#soft-drink", "rdfs:label": [ { "@value": "Boisson non alcoolisée", @@ -82,11 +82,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#drink", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#drink", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "rdfs:label": [ { "@value": "Salade", @@ -157,11 +157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#egg", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#egg", "rdfs:label": [ { "@value": "Oeuf", @@ -232,11 +232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#old-variety-squash", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#old-variety-squash", "rdfs:label": [ { "@value": "Variété ancienne", @@ -307,11 +307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#squash", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#squash", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fig", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fig", "rdfs:label": [ { "@value": "Figue", @@ -382,11 +382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#rocket", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#rocket", "rdfs:label": [ { "@value": "Roquette", @@ -457,11 +457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#beef", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#beef", "rdfs:label": [ { "@value": "Viande bovine", @@ -532,11 +532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-mature-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-mature-cheese", "rdfs:label": [ { "@value": "Fromage affinés", @@ -607,11 +607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-milk", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-milk", "rdfs:label": [ { "@value": "Lait", @@ -682,11 +682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chanterelle-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chanterelle-mushroom", "rdfs:label": [ { "@value": "Chanterelle", @@ -757,11 +757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-sweet-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-sweet-yogurt", "rdfs:label": [ { "@value": "Yaourt sucré", @@ -832,11 +832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#rabbit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#rabbit", "rdfs:label": [ { "@value": "Lapin", @@ -907,11 +907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pie-pastry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pie-pastry", "rdfs:label": [ { "@value": "Pâte à tarte", @@ -982,11 +982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#jam", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#jam", "rdfs:label": [ { "@value": "Confiture", @@ -1057,11 +1057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sweet-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sweet-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fourth-range-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fourth-range-vegetable", "rdfs:label": [ { "@value": "Légume quatrième gamme", @@ -1132,11 +1132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "rdfs:label": [ { "@value": "Champignon", @@ -1207,11 +1207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#beer", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#beer", "rdfs:label": [ { "@value": "Bière", @@ -1282,11 +1282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#alcoholic-beverage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#alcoholic-beverage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chilli-pepper", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chilli-pepper", "rdfs:label": [ { "@value": "Piment", @@ -1357,11 +1357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#non-local-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#non-local-vegetable", "rdfs:label": [ { "@value": "Légume non local", @@ -1432,11 +1432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chicory", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chicory", "rdfs:label": [ { "@value": "Chicorée", @@ -1507,11 +1507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#other-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#other-cheese", "rdfs:label": [ { "@value": "Fromage", @@ -1582,11 +1582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#other-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#other-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cream-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cream-cheese", "rdfs:label": [ { "@value": "Fromage blanc", @@ -1657,11 +1657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#peach", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#peach", "rdfs:label": [ { "@value": "Pêche", @@ -1732,11 +1732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#almond", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#almond", "rdfs:label": [ { "@value": "Amande", @@ -1807,11 +1807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#nut", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#nut", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#milk", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#milk", "rdfs:label": [ { "@value": "Lait", @@ -1882,11 +1882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#romanesco-cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#romanesco-cabbage", "rdfs:label": [ { "@value": "Chou romanesco", @@ -1957,11 +1957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen", "rdfs:label": [ { "@value": "Surgelé", @@ -2036,7 +2036,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fresh-meat", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fresh-meat", "rdfs:label": [ { "@value": "Viande fraîche", @@ -2107,11 +2107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#pork", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#pork", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#semolina", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#semolina", "rdfs:label": [ { "@value": "Semoule", @@ -2182,11 +2182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#turkey", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#turkey", "rdfs:label": [ { "@value": "Dinde", @@ -2257,11 +2257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#plant", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#plant", "rdfs:label": [ { "@value": "Plant", @@ -2332,11 +2332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#inedible", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#inedible", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#kale-cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#kale-cabbage", "rdfs:label": [ { "@value": "Chou kale", @@ -2407,11 +2407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#asparagus", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#asparagus", "rdfs:label": [ { "@value": "Asperge", @@ -2482,11 +2482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-yogurt-with-fruits", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-yogurt-with-fruits", "rdfs:label": [ { "@value": "Yaourt aux fruits", @@ -2557,11 +2557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#wine", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#wine", "rdfs:label": [ { "@value": "Vin", @@ -2632,11 +2632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#alcoholic-beverage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#alcoholic-beverage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-yogurt-on-a-bed-of-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-yogurt-on-a-bed-of-fruit", "rdfs:label": [ { "@value": "Yaourt sur lit de fruit", @@ -2707,11 +2707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#crepe-and-galette", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#crepe-and-galette", "rdfs:label": [ { "@value": "Crêpe et galette", @@ -2782,11 +2782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "rdfs:label": [ { "@value": "Produits laitiers de chèvre", @@ -2857,11 +2857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#oyster-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#oyster-mushroom", "rdfs:label": [ { "@value": "Pleurote", @@ -2932,11 +2932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#seashell", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#seashell", "rdfs:label": [ { "@value": "Coquillage", @@ -3007,11 +3007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fishery-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fishery-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#radish", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#radish", "rdfs:label": [ { "@value": "Radis", @@ -3082,11 +3082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#tomato", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#tomato", "rdfs:label": [ { "@value": "Tomate", @@ -3157,11 +3157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pork", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pork", "rdfs:label": [ { "@value": "Porc", @@ -3232,11 +3232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#thyme", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#thyme", "rdfs:label": [ { "@value": "Thym", @@ -3307,11 +3307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cider", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cider", "rdfs:label": [ { "@value": "Cidre", @@ -3382,11 +3382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#alcoholic-beverage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#alcoholic-beverage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#mint", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#mint", "rdfs:label": [ { "@value": "Menthe", @@ -3457,11 +3457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#parsley", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#parsley", "rdfs:label": [ { "@value": "Persil", @@ -3532,11 +3532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-fresh-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-fresh-cheese", "rdfs:label": [ { "@value": "Fromage frais", @@ -3607,11 +3607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "rdfs:label": [ { "@value": "Produit laitier de vache", @@ -3682,11 +3682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#jerusalem-artichoke", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#jerusalem-artichoke", "rdfs:label": [ { "@value": "Topinambour", @@ -3757,11 +3757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#ripe", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#ripe", "rdfs:label": [ { "@value": "Mûre", @@ -3832,11 +3832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#berry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#berry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad-mix", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#salad-mix", "rdfs:label": [ { "@value": "Mélange salades", @@ -3907,11 +3907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#parsnip", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#parsnip", "rdfs:label": [ { "@value": "Panais", @@ -3982,11 +3982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#broccoli-cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#broccoli-cabbage", "rdfs:label": [ { "@value": "Chou brocoli", @@ -4057,11 +4057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#salt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#salt", "rdfs:label": [ { "@value": "Sel", @@ -4132,11 +4132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#yam", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#yam", "rdfs:label": [ { "@value": "Patate douce", @@ -4207,11 +4207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-yogurt-with-fruits", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-yogurt-with-fruits", "rdfs:label": [ { "@value": "Yaourt aux fruits", @@ -4282,11 +4282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#green-garlic", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#green-garlic", "rdfs:label": [ { "@value": "Aillet", @@ -4357,11 +4357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chestnut", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chestnut", "rdfs:label": [ { "@value": "Marron", @@ -4432,11 +4432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#nut", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#nut", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chard", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chard", "rdfs:label": [ { "@value": "Blette", @@ -4507,11 +4507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#flower", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#flower", "rdfs:label": [ { "@value": "Fleur", @@ -4582,11 +4582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#inedible", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#inedible", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fennel", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fennel", "rdfs:label": [ { "@value": "Fenouil", @@ -4657,11 +4657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fish", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fish", "rdfs:label": [ { "@value": "Poisson", @@ -4732,11 +4732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fishery-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fishery-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#shallot", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#shallot", "rdfs:label": [ { "@value": "Échalote", @@ -4807,11 +4807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#dried-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#dried-fruit", "rdfs:label": [ { "@value": "Fruit séché", @@ -4882,11 +4882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#kiwi", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#kiwi", "rdfs:label": [ { "@value": "Kiwi", @@ -4957,11 +4957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#berry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#berry", "rdfs:label": [ { "@value": "Petit fruit", @@ -5032,11 +5032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#inedible", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#inedible", "rdfs:label": [ { "@value": "Non alimentaire", @@ -5111,7 +5111,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chewed-up", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chewed-up", "rdfs:label": [ { "@value": "Mâche", @@ -5182,11 +5182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#endive", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#endive", "rdfs:label": [ { "@value": "Endive", @@ -5257,11 +5257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#celeriac", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#celeriac", "rdfs:label": [ { "@value": "Céleri-rave", @@ -5332,11 +5332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#kohlrabi", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#kohlrabi", "rdfs:label": [ { "@value": "Chou rave", @@ -5407,11 +5407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#dandelion", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#dandelion", "rdfs:label": [ { "@value": "Pissenlit", @@ -5482,11 +5482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#guinea-fowl", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#guinea-fowl", "rdfs:label": [ { "@value": "Pintade", @@ -5557,11 +5557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cassis", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cassis", "rdfs:label": [ { "@value": "Cassis", @@ -5632,11 +5632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#berry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#berry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#duck", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#duck", "rdfs:label": [ { "@value": "Canard", @@ -5707,11 +5707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#deaths-trumpet", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#deaths-trumpet", "rdfs:label": [ { "@value": "Trompette de la mort", @@ -5782,11 +5782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cosmetic", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cosmetic", "rdfs:label": [ { "@value": "Cosmétique", @@ -5857,11 +5857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#inedible", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#inedible", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#festive-poultry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#festive-poultry", "rdfs:label": [ { "@value": "Volaille festive", @@ -5932,11 +5932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#melon", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#melon", "rdfs:label": [ { "@value": "Melon", @@ -6007,11 +6007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sweet-groceries", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sweet-groceries", "rdfs:label": [ { "@value": "Epicerie sucrée", @@ -6082,11 +6082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#local-grocery-store", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#local-grocery-store", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#smoothie", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#smoothie", "rdfs:label": [ { "@value": "Smoothie", @@ -6157,11 +6157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#soft-drink", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#soft-drink", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#blueberry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#blueberry", "rdfs:label": [ { "@value": "Myrtille", @@ -6232,11 +6232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#berry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#berry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#gooseberry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#gooseberry", "rdfs:label": [ { "@value": "Groseille à maquereau", @@ -6307,11 +6307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#berry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#berry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#girolle-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#girolle-mushroom", "rdfs:label": [ { "@value": "Girolle", @@ -6382,11 +6382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#grape", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#grape", "rdfs:label": [ { "@value": "Raisin", @@ -6457,11 +6457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "rdfs:label": [ { "@value": "Aromate", @@ -6532,11 +6532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-fresh-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-fresh-cheese", "rdfs:label": [ { "@value": "Fromage frais", @@ -6607,11 +6607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-flavored-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-flavored-yogurt", "rdfs:label": [ { "@value": "Yaourt aromatisé", @@ -6682,11 +6682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#quinoa", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#quinoa", "rdfs:label": [ { "@value": "Quinoa", @@ -6757,11 +6757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dried-vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dried-vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#ready-meal", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#ready-meal", "rdfs:label": [ { "@value": "Plat cuisiné", @@ -6832,11 +6832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#local-grocery-store", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#local-grocery-store", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#rhubarb", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#rhubarb", "rdfs:label": [ { "@value": "Rhubarbe", @@ -6907,11 +6907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#rosemary", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#rosemary", "rdfs:label": [ { "@value": "Romarin", @@ -6982,11 +6982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chinese-cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chinese-cabbage", "rdfs:label": [ { "@value": "Chou chinois", @@ -7057,11 +7057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#brussels-sprouts", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#brussels-sprouts", "rdfs:label": [ { "@value": "Chou de Bruxelles", @@ -7132,11 +7132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#alcoholic-beverage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#alcoholic-beverage", "rdfs:label": [ { "@value": "Boisson alcoolisée", @@ -7207,11 +7207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#drink", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#drink", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#paris-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#paris-mushroom", "rdfs:label": [ { "@value": "Champignon de Paris", @@ -7282,11 +7282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#bottled-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#bottled-fruit", "rdfs:label": [ { "@value": "Fruit en bocal", @@ -7357,11 +7357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pepper", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pepper", "rdfs:label": [ { "@value": "Poivron", @@ -7432,11 +7432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#round-tomato", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#round-tomato", "rdfs:label": [ { "@value": "Tomate ronde", @@ -7507,11 +7507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#tomato", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#tomato", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goose", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goose", "rdfs:label": [ { "@value": "Oie", @@ -7582,11 +7582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#bottled-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#bottled-vegetable", "rdfs:label": [ { "@value": "Légume en bocal", @@ -7657,11 +7657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cooked-meat", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cooked-meat", "rdfs:label": [ { "@value": "Viande cuite", @@ -7732,11 +7732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#pork", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#pork", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "rdfs:label": [ { "@value": "Légume", @@ -7811,7 +7811,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#quail", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#quail", "rdfs:label": [ { "@value": "Caille", @@ -7882,11 +7882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#mature-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#mature-cheese", "rdfs:label": [ { "@value": "Fromage affiné", @@ -7957,11 +7957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#lemon", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#lemon", "rdfs:label": [ { "@value": "Citron", @@ -8032,11 +8032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pear", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pear", "rdfs:label": [ { "@value": "Poire", @@ -8107,11 +8107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen-fruit", "rdfs:label": [ { "@value": "Fruit surgelé", @@ -8182,11 +8182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#peas", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#peas", "rdfs:label": [ { "@value": "Pois", @@ -8257,11 +8257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dried-vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dried-vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#salting", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#salting", "rdfs:label": [ { "@value": "Salaison", @@ -8332,11 +8332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#pork", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#pork", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#honey", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#honey", "rdfs:label": [ { "@value": "Miel", @@ -8407,11 +8407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sweet-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sweet-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen-meal", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen-meal", "rdfs:label": [ { "@value": "Plat surgelé", @@ -8482,11 +8482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pasta", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pasta", "rdfs:label": [ { "@value": "Pâte", @@ -8557,11 +8557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cherry-tomato", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cherry-tomato", "rdfs:label": [ { "@value": "Tomate cerise", @@ -8632,11 +8632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#tomato", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#tomato", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen-meat", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen-meat", "rdfs:label": [ { "@value": "Viande surgelée", @@ -8707,11 +8707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#non-local-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#non-local-fruit", "rdfs:label": [ { "@value": "Fruit non local", @@ -8782,11 +8782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#clementine", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#clementine", "rdfs:label": [ { "@value": "Clémentine", @@ -8857,11 +8857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#other-milk", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#other-milk", "rdfs:label": [ { "@value": "Lait", @@ -8932,11 +8932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#other-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#other-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#dill", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#dill", "rdfs:label": [ { "@value": "Aneth", @@ -9007,11 +9007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#lemonade", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#lemonade", "rdfs:label": [ { "@value": "Limonade", @@ -9082,11 +9082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#soft-drink", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#soft-drink", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#rice", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#rice", "rdfs:label": [ { "@value": "Riz", @@ -9157,11 +9157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#snails", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#snails", "rdfs:label": [ { "@value": "Escargots", @@ -9232,11 +9232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fifth-range-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fifth-range-vegetable", "rdfs:label": [ { "@value": "Légume cinquième gamme", @@ -9307,11 +9307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#turnip", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#turnip", "rdfs:label": [ { "@value": "Navet", @@ -9382,11 +9382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cluster-tomato", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cluster-tomato", "rdfs:label": [ { "@value": "Tomate grappe", @@ -9457,11 +9457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#tomato", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#tomato", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "rdfs:label": [ { "@value": "Chou", @@ -9532,11 +9532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit-in-compote", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit-in-compote", "rdfs:label": [ { "@value": "Fruit en compote", @@ -9607,11 +9607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#digestive", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#digestive", "rdfs:label": [ { "@value": "Digestif", @@ -9682,11 +9682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#alcoholic-beverage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#alcoholic-beverage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#truffle", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#truffle", "rdfs:label": [ { "@value": "Truffe", @@ -9757,11 +9757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-yogurt-on-a-bed-of-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-yogurt-on-a-bed-of-fruit", "rdfs:label": [ { "@value": "Yaourt sur lit de fruit", @@ -9832,11 +9832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#flour", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#flour", "rdfs:label": [ { "@value": "Farine", @@ -9907,11 +9907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#eggplant", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#eggplant", "rdfs:label": [ { "@value": "Aubergine", @@ -9982,11 +9982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cress", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cress", "rdfs:label": [ { "@value": "Cresson", @@ -10057,11 +10057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#quince", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#quince", "rdfs:label": [ { "@value": "Coing", @@ -10132,11 +10132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sweet-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sweet-yogurt", "rdfs:label": [ { "@value": "Yaourt sucré", @@ -10207,11 +10207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#laurel", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#laurel", "rdfs:label": [ { "@value": "Laurier", @@ -10282,11 +10282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-dessert", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-dessert", "rdfs:label": [ { "@value": "Desserts lactés", @@ -10357,11 +10357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#raspberry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#raspberry", "rdfs:label": [ { "@value": "Framboise", @@ -10432,11 +10432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#berry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#berry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#hazelnut", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#hazelnut", "rdfs:label": [ { "@value": "Noisette", @@ -10507,11 +10507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#nut", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#nut", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#delicatessen", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#delicatessen", "rdfs:label": [ { "@value": "Charcuterie", @@ -10582,11 +10582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#pork", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#pork", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-natural-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-natural-yogurt", "rdfs:label": [ { "@value": "Yaourt nature", @@ -10657,11 +10657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#bakery", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#bakery", "rdfs:label": [ { "@value": "Boulangerie", @@ -10736,7 +10736,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#lettuce", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#lettuce", "rdfs:label": [ { "@value": "Laitue", @@ -10807,11 +10807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#veal", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#veal", "rdfs:label": [ { "@value": "Veau", @@ -10882,11 +10882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#drink", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#drink", "rdfs:label": [ { "@value": "Boisson", @@ -10961,7 +10961,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#tarragon", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#tarragon", "rdfs:label": [ { "@value": "Estragon", @@ -11032,11 +11032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "rdfs:label": [ { "@value": "Produit carné", @@ -11111,7 +11111,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#natural-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#natural-yogurt", "rdfs:label": [ { "@value": "Yaourt nature", @@ -11182,11 +11182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-dessert", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-dessert", "rdfs:label": [ { "@value": "Dessert lacté", @@ -11257,11 +11257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#milky-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#milky-mushroom", "rdfs:label": [ { "@value": "Lactaire", @@ -11332,11 +11332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cherry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cherry", "rdfs:label": [ { "@value": "Cerise", @@ -11407,11 +11407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#flavored-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#flavored-yogurt", "rdfs:label": [ { "@value": "Yaourt aromatisé", @@ -11482,11 +11482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#squash-melon", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#squash-melon", "rdfs:label": [ { "@value": "Pâtisson", @@ -11557,11 +11557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#squash", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#squash", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chervil", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chervil", "rdfs:label": [ { "@value": "Cerfeuil", @@ -11632,11 +11632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "rdfs:label": [ { "@value": "Epicerie salée", @@ -11707,11 +11707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#local-grocery-store", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#local-grocery-store", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#local-grocery-store", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#local-grocery-store", "rdfs:label": [ { "@value": "Epicerie locale", @@ -11786,7 +11786,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-fruit", "rdfs:label": [ { "@value": "Fruit transformé", @@ -11857,11 +11857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#local-grocery-store", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#local-grocery-store", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#other-dairy-product", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#other-dairy-product", "rdfs:label": [ { "@value": "Produit laitier autre", @@ -11932,11 +11932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#potato", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#potato", "rdfs:label": [ { "@value": "Pomme de terre", @@ -12007,11 +12007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#coriander", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#coriander", "rdfs:label": [ { "@value": "Coriandre", @@ -12082,11 +12082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#beans", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#beans", "rdfs:label": [ { "@value": "Fèves", @@ -12157,11 +12157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dried-vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dried-vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#viennoiserie-", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#viennoiserie-", "rdfs:label": [ { "@value": "Viennoiserie", @@ -12232,11 +12232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#bakery", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#bakery", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-flavored-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-flavored-yogurt", "rdfs:label": [ { "@value": "Yaourt aromatisé", @@ -12307,11 +12307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#currant", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#currant", "rdfs:label": [ { "@value": "Groseille", @@ -12382,11 +12382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#berry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#berry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#nectarine", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#nectarine", "rdfs:label": [ { "@value": "Nectarine", @@ -12457,11 +12457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#bread", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#bread", "rdfs:label": [ { "@value": "Pain", @@ -12532,11 +12532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#bakery", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#bakery", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-sweet-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-sweet-yogurt", "rdfs:label": [ { "@value": "Yaourt sucré", @@ -12607,11 +12607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#smooth-cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#smooth-cabbage", "rdfs:label": [ { "@value": "Chou lisse", @@ -12682,11 +12682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#courgette", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#courgette", "rdfs:label": [ { "@value": "Courgette", @@ -12757,11 +12757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#strawberry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#strawberry", "rdfs:label": [ { "@value": "Fraise", @@ -12832,11 +12832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-natural-yogurt", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-natural-yogurt", "rdfs:label": [ { "@value": "Yaourt nature", @@ -12907,11 +12907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pastry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pastry", "rdfs:label": [ { "@value": "Pâtisserie", @@ -12982,11 +12982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sweet-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sweet-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fresh-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fresh-cheese", "rdfs:label": [ { "@value": "Fromage frais", @@ -13057,11 +13057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#canned-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#canned-vegetable", "rdfs:label": [ { "@value": "Légume en conserve", @@ -13132,11 +13132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#yogurt-on-a-bed-of-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#yogurt-on-a-bed-of-fruit", "rdfs:label": [ { "@value": "Yaourt sur lit de fruit", @@ -13207,11 +13207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#aperitif", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#aperitif", "rdfs:label": [ { "@value": "Apéritif", @@ -13282,11 +13282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#alcoholic-beverage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#alcoholic-beverage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#kale", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#kale", "rdfs:label": [ { "@value": "Chou frisé", @@ -13357,11 +13357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#grilling-meat", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#grilling-meat", "rdfs:label": [ { "@value": "Viande à griller", @@ -13432,11 +13432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#beef", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#beef", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-vegetable", "rdfs:label": [ { "@value": "Légume transformé", @@ -13507,11 +13507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#local-grocery-store", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#local-grocery-store", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#spinach", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#spinach", "rdfs:label": [ { "@value": "Épinard", @@ -13582,11 +13582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#morel", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#morel", "rdfs:label": [ { "@value": "Morille", @@ -13657,11 +13657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cucumber", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cucumber", "rdfs:label": [ { "@value": "Concombre", @@ -13732,11 +13732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#onion", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#onion", "rdfs:label": [ { "@value": "Oignon", @@ -13807,11 +13807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#artichoke", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#artichoke", "rdfs:label": [ { "@value": "Artichaut", @@ -13882,11 +13882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-milk", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-milk", "rdfs:label": [ { "@value": "Lait", @@ -13957,11 +13957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#nut", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#nut", "rdfs:label": [ { "@value": "Fruit à coque", @@ -14032,11 +14032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#dairy-product", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#dairy-product", "rdfs:label": [ { "@value": "Produit laitier", @@ -14111,7 +14111,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#red-kuri-squash", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#red-kuri-squash", "rdfs:label": [ { "@value": "Potimarron", @@ -14182,11 +14182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#squash", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#squash", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#squash", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#squash", "rdfs:label": [ { "@value": "Courge", @@ -14257,11 +14257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen-vegetable", "rdfs:label": [ { "@value": "Légume surgelé", @@ -14332,11 +14332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#frozen", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#frozen", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sage", "rdfs:label": [ { "@value": "Sauge", @@ -14407,11 +14407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#salsify", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#salsify", "rdfs:label": [ { "@value": "Salsifis", @@ -14482,11 +14482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#bean", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#bean", "rdfs:label": [ { "@value": "Haricot", @@ -14557,11 +14557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#plum", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#plum", "rdfs:label": [ { "@value": "Prune", @@ -14632,11 +14632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pigeon", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pigeon", "rdfs:label": [ { "@value": "Pigeon", @@ -14707,11 +14707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#basil", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#basil", "rdfs:label": [ { "@value": "Basilic", @@ -14782,11 +14782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#red-cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#red-cabbage", "rdfs:label": [ { "@value": "Chou rouge", @@ -14857,11 +14857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#butternut", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#butternut", "rdfs:label": [ { "@value": "Butternut", @@ -14932,11 +14932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#squash", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#squash", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#celery-branch", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#celery-branch", "rdfs:label": [ { "@value": "Céleri-branche", @@ -15007,11 +15007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#coulemelle-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#coulemelle-mushroom", "rdfs:label": [ { "@value": "Coulemelle", @@ -15082,11 +15082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#apple-cabbage", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#apple-cabbage", "rdfs:label": [ { "@value": "Chou pomme", @@ -15157,11 +15157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cereal", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cereal", "rdfs:label": [ { "@value": "Céréale", @@ -15232,11 +15232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#canned-fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#canned-fruit", "rdfs:label": [ { "@value": "Fruit en conserve", @@ -15307,11 +15307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#apples", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#apples", "rdfs:label": [ { "@value": "Pomme", @@ -15382,11 +15382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#rutabaga", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#rutabaga", "rdfs:label": [ { "@value": "Rutabaga", @@ -15457,11 +15457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#mousseron", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#mousseron", "rdfs:label": [ { "@value": "Mousseron", @@ -15532,11 +15532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#cauliflower", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#cauliflower", "rdfs:label": [ { "@value": "Chou-fleur", @@ -15607,11 +15607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cabbage", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cabbage", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chicken", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chicken", "rdfs:label": [ { "@value": "Poulet", @@ -15682,11 +15682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#soup", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#soup", "rdfs:label": [ { "@value": "Soupe", @@ -15757,11 +15757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#processed-vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#processed-vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "rdfs:label": [ { "@value": "Fruit", @@ -15836,7 +15836,7 @@ "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#confectionery", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#confectionery", "rdfs:label": [ { "@value": "Confiserie", @@ -15907,11 +15907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sweet-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sweet-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#oil", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#oil", "rdfs:label": [ { "@value": "Huile", @@ -15982,11 +15982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#savory-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#savory-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fresh-cream", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fresh-cream", "rdfs:label": [ { "@value": "Crème Fraîche", @@ -16057,11 +16057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheep-dairy-product", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheep-dairy-product", "rdfs:label": [ { "@value": "Produits laitiers de brebis", @@ -16132,11 +16132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#mandarin", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#mandarin", "rdfs:label": [ { "@value": "Mandarine", @@ -16207,11 +16207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#garlic", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#garlic", "rdfs:label": [ { "@value": "Ail", @@ -16282,11 +16282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#yogurt-with-fruits", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#yogurt-with-fruits", "rdfs:label": [ { "@value": "Yaourt aux fruits", @@ -16357,11 +16357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#butter", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#butter", "rdfs:label": [ { "@value": "Beurre", @@ -16432,11 +16432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#poultry", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#poultry", "rdfs:label": [ { "@value": "Volaille", @@ -16507,11 +16507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fishery-product", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fishery-product", "rdfs:label": [ { "@value": "Produit de la pêche", @@ -16582,11 +16582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#porcini", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#porcini", "rdfs:label": [ { "@value": "Cèpe", @@ -16657,11 +16657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#bluefoot-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#bluefoot-mushroom", "rdfs:label": [ { "@value": "Pied-bleu", @@ -16732,11 +16732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#dairy-dessert", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#dairy-dessert", "rdfs:label": [ { "@value": "Dessert lacté", @@ -16807,11 +16807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#cow-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#cow-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#leek", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#leek", "rdfs:label": [ { "@value": "Poireau", @@ -16882,11 +16882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#beetroot", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#beetroot", "rdfs:label": [ { "@value": "Betterave rouge", @@ -16957,11 +16957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#lentils", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#lentils", "rdfs:label": [ { "@value": "Lentilles", @@ -17032,11 +17032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#dried-vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#dried-vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#medlar", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#medlar", "rdfs:label": [ { "@value": "Nèfle", @@ -17107,11 +17107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#nut", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#nut", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#chive", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#chive", "rdfs:label": [ { "@value": "Ciboulette", @@ -17182,11 +17182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#aromatic", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#aromatic", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#apricot", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#apricot", "rdfs:label": [ { "@value": "Abricot", @@ -17257,11 +17257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#orange", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#orange", "rdfs:label": [ { "@value": "Orange", @@ -17332,11 +17332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#old-variety-tomato", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#old-variety-tomato", "rdfs:label": [ { "@value": "Tomate ancienne", @@ -17407,11 +17407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#tomato", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#tomato", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#walnut", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#walnut", "rdfs:label": [ { "@value": "Noix", @@ -17482,11 +17482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#nut", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#nut", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#shellfish", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#shellfish", "rdfs:label": [ { "@value": "Crustacé", @@ -17557,11 +17557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fishery-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fishery-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#sheepfoot-mushroom", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#sheepfoot-mushroom", "rdfs:label": [ { "@value": "Pied de mouton", @@ -17632,11 +17632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#mushroom", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#mushroom", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#prune", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#prune", "rdfs:label": [ { "@value": "Pruneau", @@ -17707,11 +17707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#simmering-meat", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#simmering-meat", "rdfs:label": [ { "@value": "Viande à mijoter", @@ -17782,11 +17782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#beef", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#beef", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#dried-vegetable", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#dried-vegetable", "rdfs:label": [ { "@value": "Légume sec", @@ -17857,11 +17857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#lamb", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#lamb", "rdfs:label": [ { "@value": "Agneau", @@ -17932,11 +17932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#meat-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#meat-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#fruit-juice", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#fruit-juice", "rdfs:label": [ { "@value": "Jus de fruit", @@ -18007,11 +18007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#soft-drink", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#soft-drink", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-mature-cheese", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-mature-cheese", "rdfs:label": [ { "@value": "Fromage affinés", @@ -18082,11 +18082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#goat-dairy-product", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#goat-dairy-product", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#biscuit", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#biscuit", "rdfs:label": [ { "@value": "Biscuit", @@ -18157,11 +18157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#sweet-groceries", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#sweet-groceries", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#pumpkin", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#pumpkin", "rdfs:label": [ { "@value": "Potiron", @@ -18232,11 +18232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#squash", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#squash", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#mesclun", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#mesclun", "rdfs:label": [ { "@value": "Mesclun", @@ -18307,11 +18307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#salad", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#salad", "@type": "dfc-p:ProductType" }, { - "@id": "http://static.datafoodconsortium.org/data/productTypes.rdf#carrot", + "@id": "http://static.datafoodconsortium.org/data/foodTypes.json#carrot", "rdfs:label": [ { "@value": "Carotte", @@ -18382,7 +18382,7 @@ "@language": "ca" } ], - "dfc-p:specialize": "http://static.datafoodconsortium.org/data/productTypes.rdf#vegetable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/foodTypes.json#vegetable", "@type": "dfc-p:ProductType" } ] diff --git a/ontology/medicalTypes.json b/ontology/medicalTypes.json index 8e7e84689..e5597073f 100644 --- a/ontology/medicalTypes.json +++ b/ontology/medicalTypes.json @@ -2,16 +2,16 @@ "@context": { "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "dfc-b": "http://static.datafoodconsortium.org/ontologies/DFC_BusinessOntology.owl#", - "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductOntology.owl#", + "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl#", "dfc-t": "http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl#", - "dfc-u": "http://static.datafoodconsortium.org/data/units.rdf#", + "dfc-u": "http://static.datafoodconsortium.org/data/units.json#", "dfc-p:specialize": { "@type": "@id" } }, "@graph": [ { - "@id": "https://medical/data/medicalTypes.rdf#gas-mask", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#gas-mask", "rdfs:label": [ { "@value": "Gas Mask", @@ -82,11 +82,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#body-protection", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#body-protection", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#gas-mask-filter", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#gas-mask-filter", "rdfs:label": [ { "@value": "Gas Mask Filter", @@ -157,11 +157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#body-protection", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#body-protection", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#bandages", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "rdfs:label": [ { "@value": "Bandages", @@ -232,11 +232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#gauze-wrap", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#gauze-wrap", "rdfs:label": [ { "@value": "Gauze Wrap", @@ -307,11 +307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#gauze-pad", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#gauze-pad", "rdfs:label": [ { "@value": "Gauze Pad", @@ -382,11 +382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#nonstick-pad", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#nonstick-pad", "rdfs:label": [ { "@value": "Nonstick Pad", @@ -457,11 +457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#triangle-bandage", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#triangle-bandage", "rdfs:label": [ { "@value": "Triangle Bandage", @@ -532,11 +532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#wound-closure-strip", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#wound-closure-strip", "rdfs:label": [ { "@value": "Wound Closure Strip", @@ -607,11 +607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#paper-tape", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#paper-tape", "rdfs:label": [ { "@value": "Paper Tape", @@ -682,11 +682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#tape", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#tape", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#plastic-tape", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#plastic-tape", "rdfs:label": [ { "@value": "Plastic Tape", @@ -757,11 +757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#tape", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#tape", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#examination-gloves", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#examination-gloves", "rdfs:label": [ { "@value": "Examination Gloves", @@ -832,11 +832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#gloves", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#gloves", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#stick-on-bandage", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#stick-on-bandage", "rdfs:label": [ { "@value": "Stick-on Bandage", @@ -907,11 +907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#saline-solution", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#saline-solution", "rdfs:label": [ { "@value": "Saline Solution", @@ -982,11 +982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#fluid", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#fluid", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#antibiotic-ointment", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#antibiotic-ointment", "rdfs:label": [ { "@value": "Antibiotic Ointment", @@ -1057,11 +1057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#medicine", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#medicine", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#anti-hemorrhagic-agent", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#anti-hemorrhagic-agent", "rdfs:label": [ { "@value": "Anti-hemorrhagic Agent", @@ -1132,11 +1132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#fluids", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#fluids", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#sunblock", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#sunblock", "rdfs:label": [ { "@value": "Sunblock", @@ -1207,11 +1207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#body-protection", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#body-protection", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#bandage-shears", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandage-shears", "rdfs:label": [ { "@value": "Bandage Shears", @@ -1282,11 +1282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bandages", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandages", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#tweezers", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#tweezers", "rdfs:label": [ { "@value": "Tweezers", @@ -1357,11 +1357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#medical-tools", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#medical-tools", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#protein-bar", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#protein-bar", "rdfs:label": [ { "@value": "Protein Bar", @@ -1432,11 +1432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#energy", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#energy", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#bandanna", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#bandanna", "rdfs:label": [ { "@value": "Bandanna", @@ -1507,11 +1507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#body-protection", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#body-protection", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#water-bottle", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#water-bottle", "rdfs:label": [ { "@value": "Water Bottle", @@ -1582,11 +1582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#fluids", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#fluids", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#ice-pack", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#ice-pack", "rdfs:label": [ { "@value": "Ice Pack", @@ -1657,11 +1657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#anti-inflamatory", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#anti-inflamatory", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#messenger-bag", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#messenger-bag", "rdfs:label": [ { "@value": "Messenger Bag", @@ -1732,11 +1732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#bag", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#bag", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#glucose-tablets", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#glucose-tablets", "rdfs:label": [ { "@value": "Glucose tablets", @@ -1807,11 +1807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#energy", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#energy", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#liquid-antacid-water-mixture", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#liquid-antacid-water-mixture", "rdfs:label": [ { "@value": "Liquid Antacid Water Mixture", @@ -1882,11 +1882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#fluids", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#fluids", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#re-hydration-mixture", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#re-hydration-mixture", "rdfs:label": [ { "@value": "Re-hydration Mixture", @@ -1957,11 +1957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#fluids", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#fluids", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#ear-plugs", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#ear-plugs", "rdfs:label": [ { "@value": "Ear Plugs", @@ -2032,11 +2032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#body-protection", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#body-protection", "@type": "dfc-p:ProductType" }, { - "@id": "https://medical/data/medicalTypes.rdf#cpr-mask", + "@id": "http://static.datafoodconsortium.org/data/medicalTypes.json#cpr-mask", "rdfs:label": [ { "@value": "CPR Mask", @@ -2107,7 +2107,7 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://medical/data/medicalTypes.rdf#mask", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/medicalTypes.json#mask", "@type": "dfc-p:ProductType" } ] diff --git a/ontology/publication.rdf b/ontology/publication.rdf new file mode 100644 index 000000000..f6f6aec7e --- /dev/null +++ b/ontology/publication.rdf @@ -0,0 +1,23 @@ + + + + + Louvet + Simon + + + + Arnould + Rachel + + + Chabot + Bernard + + + + Data Food Consortium + + diff --git a/ontology/toolTypes.json b/ontology/toolTypes.json index 94d17e455..de11533a9 100644 --- a/ontology/toolTypes.json +++ b/ontology/toolTypes.json @@ -2,16 +2,16 @@ "@context": { "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "dfc-b": "http://static.datafoodconsortium.org/ontologies/DFC_BusinessOntology.owl#", - "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductOntology.owl#", + "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl#", "dfc-t": "http://static.datafoodconsortium.org/ontologies/DFC_TechnicalOntology.owl#", - "dfc-u": "http://static.datafoodconsortium.org/data/units.rdf#", + "dfc-u": "http://static.datafoodconsortium.org/data/units.json#", "dfc-p:specialize": { "@type": "@id" } }, "@graph": [ { - "@id": "https://tools/data/toolTypes.rdf#garden-trowel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#garden-trowel", "rdfs:label": [ { "@value": "Truelle de jardin", @@ -82,11 +82,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#watering-can", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#watering-can", "rdfs:label": [ { "@value": "Arrosoir", @@ -157,11 +157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#auger", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#auger", "rdfs:label": [ { "@value": "Tailleuse", @@ -232,11 +232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#backpack-sprayer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#backpack-sprayer", "rdfs:label": [ { "@value": "Pulvérisateur à dos", @@ -307,11 +307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sprayer", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sprayer", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#border-spade", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#border-spade", "rdfs:label": [ { "@value": "Pelle frontalière", @@ -382,11 +382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bow-rake", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bow-rake", "rdfs:label": [ { "@value": "Râteau d'arc", @@ -457,11 +457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#broadfork", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#broadfork", "rdfs:label": [ { "@value": "Larges", @@ -532,11 +532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#budding-knife", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#budding-knife", "rdfs:label": [ { "@value": "Couteau en herbe", @@ -607,11 +607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cutting-tool", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bulb-planter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bulb-planter", "rdfs:label": [ { "@value": "Planteur d'ampoule", @@ -682,11 +682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#compost-bin", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#compost-bin", "rdfs:label": [ { "@value": "Bac à compost", @@ -757,11 +757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#container", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#container", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#compost-fork", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#compost-fork", "rdfs:label": [ { "@value": "Fourchette de compost", @@ -832,11 +832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#core-aerator", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#core-aerator", "rdfs:label": [ { "@value": "Aérateur de noyau", @@ -907,11 +907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#drum-aerators", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#drum-aerators", "rdfs:label": [ { "@value": "Aérateurs de tambour", @@ -982,11 +982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#edging-shears", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#edging-shears", "rdfs:label": [ { "@value": "Édition de cisailles", @@ -1057,11 +1057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#electric-edger", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#electric-edger", "rdfs:label": [ { "@value": "Edger électrique", @@ -1132,11 +1132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#flat-rake", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#flat-rake", "rdfs:label": [ { "@value": "Râteau plat", @@ -1207,11 +1207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#garden-fork", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#garden-fork", "rdfs:label": [ { "@value": "Fourche à bêcher", @@ -1282,11 +1282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#garden-hoe", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#garden-hoe", "rdfs:label": [ { "@value": "Jardin houe", @@ -1357,11 +1357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#garden-shovel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#garden-shovel", "rdfs:label": [ { "@value": "Pelle de jardin", @@ -1432,11 +1432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shovel", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shovel", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#gas-powered-lawn-edger", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#gas-powered-lawn-edger", "rdfs:label": [ { "@value": "Edger à gaz à gaz", @@ -1507,11 +1507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#gardening-gloves", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening-gloves", "rdfs:label": [ { "@value": "Gants de jardinage", @@ -1582,11 +1582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hand-cultivator", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hand-cultivator", "rdfs:label": [ { "@value": "Cultivateur de mains", @@ -1657,11 +1657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#handheld-sprayer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#handheld-sprayer", "rdfs:label": [ { "@value": "Pulvérisateur de poche", @@ -1732,11 +1732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hand-seeder", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hand-seeder", "rdfs:label": [ { "@value": "Semoir à la main", @@ -1807,11 +1807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hedge-shears", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hedge-shears", "rdfs:label": [ { "@value": "Cisailles de haie", @@ -1882,11 +1882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hoe", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hoe", "rdfs:label": [ { "@value": "Houe", @@ -1957,11 +1957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#kneeler", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#kneeler", "rdfs:label": [ { "@value": "Agenouilloir", @@ -2032,11 +2032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#lawn-mower", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#lawn-mower", "rdfs:label": [ { "@value": "Tondeuse à gazon", @@ -2107,11 +2107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#leaf-blower", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#leaf-blower", "rdfs:label": [ { "@value": "Souffleur de feuilles", @@ -2182,11 +2182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#leaf-rake", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#leaf-rake", "rdfs:label": [ { "@value": "Râteau de feuilles", @@ -2257,11 +2257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#machete", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#machete", "rdfs:label": [ { "@value": "Machette", @@ -2332,11 +2332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cutting-tool", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#manual-edger", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#manual-edger", "rdfs:label": [ { "@value": "Edger manuel", @@ -2407,11 +2407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pick-mattock", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pick-mattock", "rdfs:label": [ { "@value": "Choisi le mittock", @@ -2482,11 +2482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pitchfork", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pitchfork", "rdfs:label": [ { "@value": "Pittoresque", @@ -2557,11 +2557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#pitchfork", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#pitchfork", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#planting-dibble", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#planting-dibble", "rdfs:label": [ { "@value": "Planter DiBble", @@ -2632,11 +2632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pointed-shovel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pointed-shovel", "rdfs:label": [ { "@value": "Pelle pointue", @@ -2707,11 +2707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shovel", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shovel", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pole-pruner", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pole-pruner", "rdfs:label": [ { "@value": "Châtrice", @@ -2782,11 +2782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cutting-tool", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#post-hole-pincer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#post-hole-pincer", "rdfs:label": [ { "@value": "Pincer Pincer", @@ -2857,11 +2857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#potato-fork", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#potato-fork", "rdfs:label": [ { "@value": "Fourchette de pommes de terre", @@ -2932,11 +2932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#powered-chainsaw", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#powered-chainsaw", "rdfs:label": [ { "@value": "Tronçonneuse alimenté", @@ -3007,11 +3007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#saw", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#saw", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#powered-edger", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#powered-edger", "rdfs:label": [ { "@value": "Edger alimenté", @@ -3082,11 +3082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pruning-knife", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pruning-knife", "rdfs:label": [ { "@value": "Coup de couteau", @@ -3157,11 +3157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cutting-tool", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pruning-saw", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pruning-saw", "rdfs:label": [ { "@value": "Scie à élagité", @@ -3232,11 +3232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#saw", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#saw", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pruning-shears", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pruning-shears", "rdfs:label": [ { "@value": "Cisailles de taille", @@ -3307,11 +3307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cutting-tool", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#rake", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#rake", "rdfs:label": [ { "@value": "Râteau", @@ -3382,11 +3382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#rotary-tiller", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#rotary-tiller", "rdfs:label": [ { "@value": "Taber rotatif", @@ -3457,11 +3457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#round-point-shovel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#round-point-shovel", "rdfs:label": [ { "@value": "Pelle à point rond", @@ -3532,11 +3532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shovel", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shovel", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#scoop-shovel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#scoop-shovel", "rdfs:label": [ { "@value": "Pelleteuse", @@ -3607,11 +3607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shovel", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shovel", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#scuffle-hoe", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#scuffle-hoe", "rdfs:label": [ { "@value": "Bracelet houe", @@ -3682,11 +3682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#gardening", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#gardening", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#seeder-row-planter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#seeder-row-planter", "rdfs:label": [ { "@value": "Planteur de rangée de semis", @@ -3757,11 +3757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#seeder-row-planter", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#seeder-row-planter", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#shredder", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#shredder", "rdfs:label": [ { "@value": "Déchiqueteuse", @@ -3832,11 +3832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shredder", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shredder", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#soil-scoop", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#soil-scoop", "rdfs:label": [ { "@value": "Écosserie", @@ -3907,11 +3907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#soil-scoop", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#soil-scoop", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#spading-fork", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#spading-fork", "rdfs:label": [ { "@value": "Fourchette", @@ -3982,11 +3982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#spading-fork", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#spading-fork", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#spike-aerator", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#spike-aerator", "rdfs:label": [ { "@value": "Aérateur de pic", @@ -4057,11 +4057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#spike-aerator", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#spike-aerator", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#spiked-aerating-shoes", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#spiked-aerating-shoes", "rdfs:label": [ { "@value": "Chaussures d'aération à pointes", @@ -4132,11 +4132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shoes", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shoes", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#spreader", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#spreader", "rdfs:label": [ { "@value": "Épandeuse", @@ -4207,11 +4207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#spreader", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#spreader", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#sprinkler", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#sprinkler", "rdfs:label": [ { "@value": "Arroseuse", @@ -4282,11 +4282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sprinkler", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sprinkler", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#square-point-shovel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#square-point-shovel", "rdfs:label": [ { "@value": "Pelle à point carré", @@ -4357,11 +4357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shovel", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shovel", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#step-edger", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#step-edger", "rdfs:label": [ { "@value": "Step Edger", @@ -4432,11 +4432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#step-edger", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#step-edger", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#string-trimmer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#string-trimmer", "rdfs:label": [ { "@value": "Tondeuse", @@ -4507,11 +4507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#string-trimmer", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#string-trimmer", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#trailer-sprayer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#trailer-sprayer", "rdfs:label": [ { "@value": "Pulvérisateur de remorque", @@ -4582,11 +4582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sprayer", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sprayer", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#transplant-spade", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#transplant-spade", "rdfs:label": [ { "@value": "Pache de transplantation", @@ -4657,11 +4657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shovel", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shovel", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#trench-shovel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#trench-shovel", "rdfs:label": [ { "@value": "Tranchée", @@ -4732,11 +4732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#shovel", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#shovel", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#tree-pruner", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#tree-pruner", "rdfs:label": [ { "@value": "Arbre", @@ -4807,11 +4807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#tree-pruner", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#tree-pruner", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#twist-tiller", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#twist-tiller", "rdfs:label": [ { "@value": "Torsadeur", @@ -4882,11 +4882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#twist-tiller", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#twist-tiller", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#warren-hoe", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#warren-hoe", "rdfs:label": [ { "@value": "Warren houe", @@ -4957,11 +4957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#warren-hoe", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#warren-hoe", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#water-hose", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#water-hose", "rdfs:label": [ { "@value": "Tuyau d'eau", @@ -5032,11 +5032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#water-hose", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#water-hose", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#weeder", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#weeder", "rdfs:label": [ { "@value": "Weeder", @@ -5107,11 +5107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#weeder", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#weeder", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#wheelbarrow", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#wheelbarrow", "rdfs:label": [ { "@value": "Brouette", @@ -5182,11 +5182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#wheelbarrow", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#wheelbarrow", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#wheel-edger", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#wheel-edger", "rdfs:label": [ { "@value": "Edger de roue", @@ -5257,11 +5257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#wheel-edger", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#wheel-edger", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#laser-cutter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#laser-cutter", "rdfs:label": [ { "@value": "Coupeur laser", @@ -5332,11 +5332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#laser-cutter", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#laser-cutter", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bandsaw", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bandsaw", "rdfs:label": [ { "@value": "Scie à ruban", @@ -5407,11 +5407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#saw", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#saw", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bench-grinder", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bench-grinder", "rdfs:label": [ { "@value": "Broyeur", @@ -5482,11 +5482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#bench-grinder", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bench-grinder", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bench-scroll-saw", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bench-scroll-saw", "rdfs:label": [ { "@value": "Scie de défilement bancaire", @@ -5557,11 +5557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#saw", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#saw", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#cnc-mill", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cnc-mill", "rdfs:label": [ { "@value": "Moulin à commande numérique", @@ -5632,11 +5632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cnc-mill", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cnc-mill", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#dremels", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#dremels", "rdfs:label": [ { "@value": "Dramelle", @@ -5707,11 +5707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#dremels", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#dremels", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#floor-standing-pillar-drill", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#floor-standing-pillar-drill", "rdfs:label": [ { "@value": "Porte debout au sol", @@ -5782,11 +5782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#drill", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#drill", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#lathe", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#lathe", "rdfs:label": [ { "@value": "Tour", @@ -5857,11 +5857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#lathe", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#lathe", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#table-saw", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#table-saw", "rdfs:label": [ { "@value": "Banc de scie", @@ -5932,11 +5932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#saw", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#saw", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#mitre-saw", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#mitre-saw", "rdfs:label": [ { "@value": "Scie à onglet", @@ -6007,11 +6007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#saw", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#saw", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#wire-bending-tool", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#wire-bending-tool", "rdfs:label": [ { "@value": "Outil de pliage de fil", @@ -6082,11 +6082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#wire-bending-tool", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#wire-bending-tool", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#wood-vice", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#wood-vice", "rdfs:label": [ { "@value": "Vice du bois", @@ -6157,11 +6157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#vice", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#vice", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#metal-vice", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#metal-vice", "rdfs:label": [ { "@value": "Vice du métal", @@ -6232,11 +6232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#vice", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#vice", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#woodturning-lathe", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#woodturning-lathe", "rdfs:label": [ { "@value": "Tour de bois", @@ -6307,11 +6307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#lathe", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#lathe", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#multimeter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#multimeter", "rdfs:label": [ { "@value": "Multimètre", @@ -6382,11 +6382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#electrical", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#electrical", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bench-power-supply", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bench-power-supply", "rdfs:label": [ { "@value": "Alimentation électrique", @@ -6457,11 +6457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#electrical", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#electrical", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#capacitance-meter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#capacitance-meter", "rdfs:label": [ { "@value": "Compteur de capacités", @@ -6532,11 +6532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#electrical", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#electrical", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#oscilloscope", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#oscilloscope", "rdfs:label": [ { "@value": "Oscilloscope", @@ -6607,11 +6607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#electrical", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#electrical", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#oscilloscope-probes", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#oscilloscope-probes", "rdfs:label": [ { "@value": "Sondes d'oscilloscope", @@ -6682,11 +6682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#electrical", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#electrical", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#signal-generator", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#signal-generator", "rdfs:label": [ { "@value": "Générateur de signal", @@ -6757,11 +6757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#signal-generator", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#signal-generator", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#mains-transformer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#mains-transformer", "rdfs:label": [ { "@value": "Transformateur principal", @@ -6832,11 +6832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#electrical", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#electrical", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hot-air-gun", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hot-air-gun", "rdfs:label": [ { "@value": "Pistolet à air chaud", @@ -6907,11 +6907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#heater", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#heater", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bench-pillar-drill", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bench-pillar-drill", "rdfs:label": [ { "@value": "Percaire de banc", @@ -6982,11 +6982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#drill", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#drill", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bench-magnifier-lamp", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bench-magnifier-lamp", "rdfs:label": [ { "@value": "Lampe de loupe de banc", @@ -7057,11 +7057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#bench-magnifier-lamp", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bench-magnifier-lamp", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#electronic-hotplate", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#electronic-hotplate", "rdfs:label": [ { "@value": "Plaque de feu électronique", @@ -7132,11 +7132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#heater", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#heater", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#microscope", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#microscope", "rdfs:label": [ { "@value": "Microscope", @@ -7207,11 +7207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#microscope", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#microscope", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#soldering-iron", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#soldering-iron", "rdfs:label": [ { "@value": "Fer à souder", @@ -7282,11 +7282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#soldering", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#soldering", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#solder-reflow-oven", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#solder-reflow-oven", "rdfs:label": [ { "@value": "Four de refusion à souder", @@ -7357,11 +7357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#heater", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#heater", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#spot-welder", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#spot-welder", "rdfs:label": [ { "@value": "Soudeur tache", @@ -7432,11 +7432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#welding", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#welding", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#flat-bed-pen-plotter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#flat-bed-pen-plotter", "rdfs:label": [ { "@value": "Traceur de stylo plat", @@ -7507,11 +7507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#flat-bed-pen-plotter", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#flat-bed-pen-plotter", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#printer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#printer", "rdfs:label": [ { "@value": "Imprimante", @@ -7582,11 +7582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#printer", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#printer", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#laminator", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#laminator", "rdfs:label": [ { "@value": "Laminateur", @@ -7657,11 +7657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#laminator", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#laminator", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#thermal-camera", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#thermal-camera", "rdfs:label": [ { "@value": "Caméra thermique", @@ -7732,11 +7732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#camera", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#camera", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#glue-gun", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#glue-gun", "rdfs:label": [ { "@value": "Pistolet à colle", @@ -7807,11 +7807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#glue-gun", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#glue-gun", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hot-air-gun", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hot-air-gun", "rdfs:label": [ { "@value": "Pistolet à air chaud", @@ -7882,11 +7882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#hot-air-gun", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#hot-air-gun", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#label-printer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#label-printer", "rdfs:label": [ { "@value": "Imprimante d'étiquettes", @@ -7957,11 +7957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#label-printer", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#label-printer", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#sewing-machine", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#sewing-machine", "rdfs:label": [ { "@value": "Machine à coudre", @@ -8032,11 +8032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sewing-machine", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sewing-machine", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#laptop", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#laptop", "rdfs:label": [ { "@value": "Portable", @@ -8107,11 +8107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#desktop-computer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#desktop-computer", "rdfs:label": [ { "@value": "Ordinateur de bureau", @@ -8182,11 +8182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#computer-monitor", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-monitor", "rdfs:label": [ { "@value": "Moniteur d'ordinateur", @@ -8257,11 +8257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#computer-mouse", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-mouse", "rdfs:label": [ { "@value": "Souris d'ordinateur", @@ -8332,11 +8332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#computer-trackball", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-trackball", "rdfs:label": [ { "@value": "Computer Shueball", @@ -8407,11 +8407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#computer-drawing-tablet", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-drawing-tablet", "rdfs:label": [ { "@value": "Tablette de dessin informatique", @@ -8482,11 +8482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computer-drawing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-drawing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#computer-webcam", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-webcam", "rdfs:label": [ { "@value": "Webcam", @@ -8557,11 +8557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#computer-microphone", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-microphone", "rdfs:label": [ { "@value": "Microphone informatique", @@ -8632,11 +8632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#computer-keyboard", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#computer-keyboard", "rdfs:label": [ { "@value": "Clavier d'ordinateur", @@ -8707,11 +8707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#mobile-phone", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#mobile-phone", "rdfs:label": [ { "@value": "Téléphone mobile", @@ -8782,11 +8782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#telecoms", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#telecoms", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#dect-phone", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#dect-phone", "rdfs:label": [ { "@value": "Téléphone de Dec", @@ -8857,11 +8857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#telecoms", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#telecoms", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#dect-base-station", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#dect-base-station", "rdfs:label": [ { "@value": "Station de base de Dec", @@ -8932,11 +8932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#telecoms", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#telecoms", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#network-router", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#network-router", "rdfs:label": [ { "@value": "Routeur de réseau", @@ -9007,11 +9007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#wifi-router", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#wifi-router", "rdfs:label": [ { "@value": "Routeur Wi-Fi", @@ -9082,11 +9082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#outdoor-router", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#outdoor-router", "rdfs:label": [ { "@value": "Outdoor Router", @@ -9157,11 +9157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#poe-adapter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#poe-adapter", "rdfs:label": [ { "@value": "Power Over Ethernet Adapter", @@ -9232,11 +9232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#network-adapter", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#network-adapter", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#network-adapter", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#network-adapter", "rdfs:label": [ { "@value": "Network Adapter", @@ -9307,11 +9307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#cable-crimper", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cable-crimper", "rdfs:label": [ { "@value": "Cable Crimper", @@ -9382,11 +9382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#cable-stripper", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cable-stripper", "rdfs:label": [ { "@value": "Cable Stripper", @@ -9457,11 +9457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#cable-tester", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cable-tester", "rdfs:label": [ { "@value": "Cable Tester", @@ -9532,11 +9532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#scissors", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#scissors", "rdfs:label": [ { "@value": "Scissors", @@ -9607,11 +9607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cutting-tool", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#tweesers", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#tweesers", "rdfs:label": [ { "@value": "Tweesers", @@ -9682,11 +9682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#tweesers", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#tweesers", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#electrical-tape", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#electrical-tape", "rdfs:label": [ { "@value": "Electrical Tape", @@ -9757,11 +9757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#tape", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#tape", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#masking-tape", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#masking-tape", "rdfs:label": [ { "@value": "Masking Tape", @@ -9832,11 +9832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#tape", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#tape", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#plumbers-tape", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#plumbers-tape", "rdfs:label": [ { "@value": "Plumbers Tape", @@ -9907,11 +9907,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#tape", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#tape", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#duct-tape", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#duct-tape", "rdfs:label": [ { "@value": "Duct Tape", @@ -9982,11 +9982,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#tape", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#tape", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#chordless-drill", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#chordless-drill", "rdfs:label": [ { "@value": "Chordless Drill", @@ -10057,11 +10057,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#drill", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#drill", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#nails", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#nails", "rdfs:label": [ { "@value": "Nails", @@ -10132,11 +10132,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#screws", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#screws", "rdfs:label": [ { "@value": "Screws", @@ -10207,11 +10207,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bolts", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bolts", "rdfs:label": [ { "@value": "Bolts", @@ -10282,11 +10282,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#battery", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#battery", "rdfs:label": [ { "@value": "Batterie", @@ -10357,11 +10357,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#battery", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#battery", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#cable", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cable", "rdfs:label": [ { "@value": "Câble", @@ -10432,11 +10432,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cable", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#microcontroller", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#microcontroller", "rdfs:label": [ { "@value": "Microcontrôleur", @@ -10507,11 +10507,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#single-board-computer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#single-board-computer", "rdfs:label": [ { "@value": "Ordinateur unique", @@ -10582,11 +10582,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#computing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#computing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#screwdriver", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#screwdriver", "rdfs:label": [ { "@value": "Tournevis", @@ -10657,11 +10657,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#screwdriver", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#screwdriver", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#spanner", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#spanner", "rdfs:label": [ { "@value": "Clé", @@ -10732,11 +10732,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#spanner", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#spanner", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#vise-grips", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#vise-grips", "rdfs:label": [ { "@value": "Vise Grips", @@ -10807,11 +10807,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#pliers", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#pliers", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#adjustable-spanner", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#adjustable-spanner", "rdfs:label": [ { "@value": "Adjustable Spanner", @@ -10882,11 +10882,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#spanner", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#spanner", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hose-clamp", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hose-clamp", "rdfs:label": [ { "@value": "Hose Clamp", @@ -10957,11 +10957,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#antenna-mast", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#antenna-mast", "rdfs:label": [ { "@value": "Antenna Mast", @@ -11032,11 +11032,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#networking", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#networking", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#washers", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#washers", "rdfs:label": [ { "@value": "Washers", @@ -11107,11 +11107,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#zip-ties", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#zip-ties", "rdfs:label": [ { "@value": "Zip Ties", @@ -11182,11 +11182,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#cable-staples", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cable-staples", "rdfs:label": [ { "@value": "Cable Staples", @@ -11257,11 +11257,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#cable-clip", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cable-clip", "rdfs:label": [ { "@value": "Cable Fastener Clip", @@ -11332,11 +11332,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#fixing", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#fixing", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hand-truck", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hand-truck", "rdfs:label": [ { "@value": "Hand Truck", @@ -11407,11 +11407,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#trolley", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#trolley", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#hammer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hammer", "rdfs:label": [ { "@value": "Marteau", @@ -11482,11 +11482,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#hammer", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#hammer", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#drill-bit", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#drill-bit", "rdfs:label": [ { "@value": "Foreuse", @@ -11557,11 +11557,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#drill", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#drill", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#socket-set", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#socket-set", "rdfs:label": [ { "@value": "Prise de courant", @@ -11632,11 +11632,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#socket-set", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#socket-set", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#paintbrush", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#paintbrush", "rdfs:label": [ { "@value": "Pinceau", @@ -11707,11 +11707,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#painting", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#painting", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#paint-roller", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#paint-roller", "rdfs:label": [ { "@value": "Rouleau de peinture", @@ -11782,11 +11782,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#painting", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#painting", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#water-barrel", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#water-barrel", "rdfs:label": [ { "@value": "Baril d'eau", @@ -11857,11 +11857,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#container", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#container", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#thermostat", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#thermostat", "rdfs:label": [ { "@value": "Thermostat", @@ -11932,11 +11932,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sensor", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sensor", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#thermometer", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#thermometer", "rdfs:label": [ { "@value": "Thermomètre", @@ -12007,11 +12007,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sensor", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sensor", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#gas-sensor", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#gas-sensor", "rdfs:label": [ { "@value": "Capteur de gaz", @@ -12082,11 +12082,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sensor", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sensor", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#fire-alarm", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#fire-alarm", "rdfs:label": [ { "@value": "Alarme incendie", @@ -12157,11 +12157,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#sensor", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#sensor", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#kettle", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#kettle", "rdfs:label": [ { "@value": "Bouilloire", @@ -12232,11 +12232,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#kitchen", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#kitchen", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#coffee-machine", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#coffee-machine", "rdfs:label": [ { "@value": "Cafetière", @@ -12307,11 +12307,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#kitchen", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#kitchen", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#electric-heater", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#electric-heater", "rdfs:label": [ { "@value": "Chauffage électrique", @@ -12382,11 +12382,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#heater", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#heater", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#fire-extinguisher", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#fire-extinguisher", "rdfs:label": [ { "@value": "Extincteur d'incendie", @@ -12457,11 +12457,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#safety", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#safety", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#blow-torch", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#blow-torch", "rdfs:label": [ { "@value": "Coup de flèche", @@ -12532,11 +12532,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#heater", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#heater", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#bunsen-burner", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bunsen-burner", "rdfs:label": [ { "@value": "Bec Bunsen", @@ -12607,11 +12607,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#heater", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#heater", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#oven", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#oven", "rdfs:label": [ { "@value": "Four", @@ -12682,11 +12682,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#heater", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#heater", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#pliers", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#pliers", "rdfs:label": [ { "@value": "Pinces", @@ -12757,11 +12757,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#pliers", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#pliers", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#solder-sucker", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#solder-sucker", "rdfs:label": [ { "@value": "Ventouse de soudure", @@ -12832,11 +12832,11 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#soldering", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#soldering", "@type": "dfc-p:ProductType" }, { - "@id": "https://tools/data/toolTypes.rdf#mains-extension-cable", + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#mains-extension-cable", "rdfs:label": [ { "@value": "Câble d'extension secteur", @@ -12907,7 +12907,1732 @@ "@language": "ca" } ], - "dfc-p:specialize": "https://tools/data/toolTypes.rdf#cable", + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cable", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-pump", + "rdfs:label": [ + { + "@value": "Bicycle Pump", + "@language": "en" + }, + { + "@value": "مضخة دراجة", + "@language": "ar" + }, + { + "@value": "Bicycle Pump", + "@language": "ku" + }, + { + "@value": "Bomba de bicicletas", + "@language": "es" + }, + { + "@value": "Pompa per bicicletta", + "@language": "it" + }, + { + "@value": "Fahrradpumpe", + "@language": "de" + }, + { + "@value": "Bicycle Pump", + "@language": "sw" + }, + { + "@value": "Bomba de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Pump", + "@language": "oc" + }, + { + "@value": "Велосипедный насос", + "@language": "ru" + }, + { + "@value": "Bicycle Pump", + "@language": "cy" + }, + { + "@value": "自転車ポンプ", + "@language": "ja" + }, + { + "@value": "Caidéal Rothar", + "@language": "ga" + }, + { + "@value": "साइकिल पम्प", + "@language": "hi" + }, + { + "@value": "B. 自行车", + "@language": "zh" + }, + { + "@value": "Pompe à vélo", + "@language": "fr" + }, + { + "@value": "Bicycle Pump", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-chain-tool", + "rdfs:label": [ + { + "@value": "Bicycle Chain Tool", + "@language": "en" + }, + { + "@value": "دراجة شاين تول", + "@language": "ar" + }, + { + "@value": "Bicycle Chain Tool", + "@language": "ku" + }, + { + "@value": "Herramienta de cadena de bicicletas", + "@language": "es" + }, + { + "@value": "Strumento per la catena della bicicletta", + "@language": "it" + }, + { + "@value": "Fahrradkette Werkzeug", + "@language": "de" + }, + { + "@value": "Bicycle Chain Tool", + "@language": "sw" + }, + { + "@value": "Ferramenta de cadeia de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Chain Tool", + "@language": "oc" + }, + { + "@value": "Велосипедная цепь Инструмент", + "@language": "ru" + }, + { + "@value": "Bicycle Chain Tool", + "@language": "cy" + }, + { + "@value": "自転車チェーンツール", + "@language": "ja" + }, + { + "@value": "Rothar Slabhra Uirlis", + "@language": "ga" + }, + { + "@value": "साइकिल चेन उपकरण", + "@language": "hi" + }, + { + "@value": "拜多尔", + "@language": "zh" + }, + { + "@value": "Bicycle Chain Tool", + "@language": "fr" + }, + { + "@value": "Bicycle Chain Tool", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-tire-lever", + "rdfs:label": [ + { + "@value": "Bicycle Tire Lever", + "@language": "en" + }, + { + "@value": "إطار الدراجة", + "@language": "ar" + }, + { + "@value": "Bicycle Tire Lever", + "@language": "ku" + }, + { + "@value": "Bicycle Tire Lever", + "@language": "es" + }, + { + "@value": "Bicicletta Tire Lever", + "@language": "it" + }, + { + "@value": "Fahrradreifen Lever", + "@language": "de" + }, + { + "@value": "Bicycle Tire Lever", + "@language": "sw" + }, + { + "@value": "Alavanca de pneus de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Tire Lever", + "@language": "oc" + }, + { + "@value": "Велосипед Tire Lever", + "@language": "ru" + }, + { + "@value": "Bicycle Tire Lever", + "@language": "cy" + }, + { + "@value": "自転車タイヤレバー", + "@language": "ja" + }, + { + "@value": "Rothar Tire Lever", + "@language": "ga" + }, + { + "@value": "साइकिल टायर लीवर", + "@language": "hi" + }, + { + "@value": "B. 拜 循环", + "@language": "zh" + }, + { + "@value": "Pneus de vélo Lever", + "@language": "fr" + }, + { + "@value": "Bicycle Tire Lever", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-cleaner", + "rdfs:label": [ + { + "@value": "Bicycle Cleaner", + "@language": "en" + }, + { + "@value": "منظف الدراجات", + "@language": "ar" + }, + { + "@value": "Bicycle Cleaner", + "@language": "ku" + }, + { + "@value": "Limpiador de bicicletas", + "@language": "es" + }, + { + "@value": "Pulizia della bicicletta", + "@language": "it" + }, + { + "@value": "Fahrradreiniger", + "@language": "de" + }, + { + "@value": "Bicycle Cleaner", + "@language": "sw" + }, + { + "@value": "Limpeza de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Cleaner", + "@language": "oc" + }, + { + "@value": "Уборка велосипедов", + "@language": "ru" + }, + { + "@value": "Bicycle Cleaner", + "@language": "cy" + }, + { + "@value": "自転車クリーナー", + "@language": "ja" + }, + { + "@value": "Glantóir Rothar", + "@language": "ga" + }, + { + "@value": "साइकिल क्लीनर", + "@language": "hi" + }, + { + "@value": "B. 双周期清洁", + "@language": "zh" + }, + { + "@value": "Bicycle Cleaner", + "@language": "fr" + }, + { + "@value": "Bicycle Cleaner", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-repair-stand", + "rdfs:label": [ + { + "@value": "Bicycle Repair Stand", + "@language": "en" + }, + { + "@value": "اعادة تدوير الدراجات", + "@language": "ar" + }, + { + "@value": "Bicycle Repair Stand", + "@language": "ku" + }, + { + "@value": "Bicycle Repair Stand", + "@language": "es" + }, + { + "@value": "Stand di riparazione della bicicletta", + "@language": "it" + }, + { + "@value": "Fahrrad-Reparaturständer", + "@language": "de" + }, + { + "@value": "Bicycle Repair Stand", + "@language": "sw" + }, + { + "@value": "Suporte de reparação de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Repair Stand", + "@language": "oc" + }, + { + "@value": "Велосипедный ремонтный стенд", + "@language": "ru" + }, + { + "@value": "Bicycle Repair Stand", + "@language": "cy" + }, + { + "@value": "自転車修理スタンド", + "@language": "ja" + }, + { + "@value": "Staid Deisiúchán Rothar", + "@language": "ga" + }, + { + "@value": "साइकिल मरम्मत स्टैंड", + "@language": "hi" + }, + { + "@value": "B. 双周期内", + "@language": "zh" + }, + { + "@value": "Support de réparation de vélos", + "@language": "fr" + }, + { + "@value": "Bicycle Repair Stand", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#torque-wrench", + "rdfs:label": [ + { + "@value": "Torque Wrench", + "@language": "en" + }, + { + "@value": "Torque Wrench", + "@language": "ar" + }, + { + "@value": "Torque Wrench", + "@language": "ku" + }, + { + "@value": "Torque Wrench", + "@language": "es" + }, + { + "@value": "Avvolgitore di coppia", + "@language": "it" + }, + { + "@value": "Torque Wrench", + "@language": "de" + }, + { + "@value": "Torque Wrench", + "@language": "sw" + }, + { + "@value": "Chave de torção", + "@language": "pt" + }, + { + "@value": "Torque Wrench", + "@language": "oc" + }, + { + "@value": "Торке Воскрес", + "@language": "ru" + }, + { + "@value": "Torque Wrench", + "@language": "cy" + }, + { + "@value": "トルクレンチ", + "@language": "ja" + }, + { + "@value": "riachtanais uisce: measartha", + "@language": "ga" + }, + { + "@value": "टॉर्क रिंच", + "@language": "hi" + }, + { + "@value": "Torque Wrench", + "@language": "zh" + }, + { + "@value": "Clé de torsion", + "@language": "fr" + }, + { + "@value": "Torque Wrench", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#wrench", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#y-wrench", + "rdfs:label": [ + { + "@value": "Y Wrench", + "@language": "en" + }, + { + "@value": "Y Wrench", + "@language": "ar" + }, + { + "@value": "Y Wrench", + "@language": "ku" + }, + { + "@value": "Y Wrench", + "@language": "es" + }, + { + "@value": "Y Wrench", + "@language": "it" + }, + { + "@value": "Y Wrench", + "@language": "de" + }, + { + "@value": "Y Wrench", + "@language": "sw" + }, + { + "@value": "Y Wrench", + "@language": "pt" + }, + { + "@value": "Y Wrench", + "@language": "oc" + }, + { + "@value": "Y Воланч", + "@language": "ru" + }, + { + "@value": "Y Wrench", + "@language": "cy" + }, + { + "@value": "Yレンチ", + "@language": "ja" + }, + { + "@value": "Y Wrench", + "@language": "ga" + }, + { + "@value": "Y Wrench", + "@language": "hi" + }, + { + "@value": "Y Wrench", + "@language": "zh" + }, + { + "@value": "Y Wrench", + "@language": "fr" + }, + { + "@value": "Y Wrench", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#wrench", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-chain-wear-checker", + "rdfs:label": [ + { + "@value": "Bicycle Chain Wear Checker", + "@language": "en" + }, + { + "@value": "دفتر أحذية", + "@language": "ar" + }, + { + "@value": "Bicycle Chain Wear Checker", + "@language": "ku" + }, + { + "@value": "Bicycle Chain Wear Checker", + "@language": "es" + }, + { + "@value": "Biciclette a catena", + "@language": "it" + }, + { + "@value": "Fahrradkettenbekleidung Checker", + "@language": "de" + }, + { + "@value": "Bicycle Chain Wear Checker", + "@language": "sw" + }, + { + "@value": "Verificador de desgaste de cadeia de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Chain Wear Checker", + "@language": "oc" + }, + { + "@value": "Велосипед Цепь Носить Checker", + "@language": "ru" + }, + { + "@value": "Bicycle Chain Wear Checker", + "@language": "cy" + }, + { + "@value": "自転車チェーン ウェア チェッカー", + "@language": "ja" + }, + { + "@value": "Rothar Seain Sheiceáil Caith", + "@language": "ga" + }, + { + "@value": "साइकिल चेन पहनें चेकर", + "@language": "hi" + }, + { + "@value": "比拜多·查宁·韦尔·查克尔", + "@language": "zh" + }, + { + "@value": "Bicycle Chain Wear Checker", + "@language": "fr" + }, + { + "@value": "Bicycle Chain Wear Checker", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cable-cutter", + "rdfs:label": [ + { + "@value": "Cable Cutter", + "@language": "en" + }, + { + "@value": "Cable Cutter", + "@language": "ar" + }, + { + "@value": "Cable Cutter", + "@language": "ku" + }, + { + "@value": "Cable Cutter", + "@language": "es" + }, + { + "@value": "Cutter del cavo", + "@language": "it" + }, + { + "@value": "Kabelschneider", + "@language": "de" + }, + { + "@value": "Cable Cutter", + "@language": "sw" + }, + { + "@value": "Cortador de cabo", + "@language": "pt" + }, + { + "@value": "Cable Cutter", + "@language": "oc" + }, + { + "@value": "Кабель Cutter", + "@language": "ru" + }, + { + "@value": "Cable Cutter", + "@language": "cy" + }, + { + "@value": "ケーブルカッター", + "@language": "ja" + }, + { + "@value": "Cábla cutter", + "@language": "ga" + }, + { + "@value": "केबल कटर", + "@language": "hi" + }, + { + "@value": "物质", + "@language": "zh" + }, + { + "@value": "Cutter de câble", + "@language": "fr" + }, + { + "@value": "Cable Cutter", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-chain-cleaner", + "rdfs:label": [ + { + "@value": "Bicycle Chain Cleaner", + "@language": "en" + }, + { + "@value": "منظف الدراجات النارية", + "@language": "ar" + }, + { + "@value": "Bicycle Chain Cleaner", + "@language": "ku" + }, + { + "@value": "Limpiador de cadena de bicicletas", + "@language": "es" + }, + { + "@value": "Pulitore di catena per biciclette", + "@language": "it" + }, + { + "@value": "Fahrradkettenreiniger", + "@language": "de" + }, + { + "@value": "Bicycle Chain Cleaner", + "@language": "sw" + }, + { + "@value": "Limpador de corrente de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Chain Cleaner", + "@language": "oc" + }, + { + "@value": "Велосипед Цепной Уборка", + "@language": "ru" + }, + { + "@value": "Bicycle Chain Cleaner", + "@language": "cy" + }, + { + "@value": "自転車チェーンクリーナー", + "@language": "ja" + }, + { + "@value": "Gluaisrothar Glantóir Slabhra", + "@language": "ga" + }, + { + "@value": "साइकिल चेन क्लीनर", + "@language": "hi" + }, + { + "@value": "B. 比循环 Chain 清洁", + "@language": "zh" + }, + { + "@value": "Nettoyeur de chaîne de vélo", + "@language": "fr" + }, + { + "@value": "Bicycle Chain Cleaner", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#tape-measure", + "rdfs:label": [ + { + "@value": "Tape Measure", + "@language": "en" + }, + { + "@value": "قياس التايب", + "@language": "ar" + }, + { + "@value": "Tape Measure", + "@language": "ku" + }, + { + "@value": "Medida de cinta", + "@language": "es" + }, + { + "@value": "Misura del nastro", + "@language": "it" + }, + { + "@value": "Bandmessung", + "@language": "de" + }, + { + "@value": "Tape Measure", + "@language": "sw" + }, + { + "@value": "Medida de fita", + "@language": "pt" + }, + { + "@value": "Tape Measure", + "@language": "oc" + }, + { + "@value": "Лента Measure", + "@language": "ru" + }, + { + "@value": "Tape Measure", + "@language": "cy" + }, + { + "@value": "テープ測定", + "@language": "ja" + }, + { + "@value": "Tomhais Téip", + "@language": "ga" + }, + { + "@value": "टेप उपाय", + "@language": "hi" + }, + { + "@value": "塔佩克测量", + "@language": "zh" + }, + { + "@value": "Tape Measure", + "@language": "fr" + }, + { + "@value": "Tape Measure", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#measuring-tool", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-chain-whip", + "rdfs:label": [ + { + "@value": "Bicycle Chain Whip", + "@language": "en" + }, + { + "@value": "درّاجة (شاين ويب)", + "@language": "ar" + }, + { + "@value": "Bicycle Chain Whip", + "@language": "ku" + }, + { + "@value": "Bicycle Chain Whip", + "@language": "es" + }, + { + "@value": "Bicicletta catena Whip", + "@language": "it" + }, + { + "@value": "Fahrrad-Kette Whip", + "@language": "de" + }, + { + "@value": "Bicycle Chain Whip", + "@language": "sw" + }, + { + "@value": "Balope de corrente de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Chain Whip", + "@language": "oc" + }, + { + "@value": "Велосипедная цепь Whip", + "@language": "ru" + }, + { + "@value": "Bicycle Chain Whip", + "@language": "cy" + }, + { + "@value": "自転車チェーンホイップ", + "@language": "ja" + }, + { + "@value": "Aoire Slabhra Rothar", + "@language": "ga" + }, + { + "@value": "साइकिल चेन विप", + "@language": "hi" + }, + { + "@value": "比 循环 Chain Whip", + "@language": "zh" + }, + { + "@value": "chaîne de vélo Whip", + "@language": "fr" + }, + { + "@value": "Bicycle Chain Whip", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#cassette-wrench", + "rdfs:label": [ + { + "@value": "Cassette Wrench", + "@language": "en" + }, + { + "@value": "Kaette Wrench", + "@language": "ar" + }, + { + "@value": "Cassette Wrench", + "@language": "ku" + }, + { + "@value": "Cassette Wrench", + "@language": "es" + }, + { + "@value": "Avvolgica per cassette", + "@language": "it" + }, + { + "@value": "Das ist ein toller", + "@language": "de" + }, + { + "@value": "Cassette Wrench", + "@language": "sw" + }, + { + "@value": "Chave de fenda", + "@language": "pt" + }, + { + "@value": "Cassette Wrench", + "@language": "oc" + }, + { + "@value": "Кассета Wrench", + "@language": "ru" + }, + { + "@value": "Cassette Wrench", + "@language": "cy" + }, + { + "@value": "カセットレンチ", + "@language": "ja" + }, + { + "@value": "cliceáil grianghraf a mhéadú", + "@language": "ga" + }, + { + "@value": "कैसेट रिंच", + "@language": "hi" + }, + { + "@value": "Cassette Wrench", + "@language": "zh" + }, + { + "@value": "Cassette Wrench", + "@language": "fr" + }, + { + "@value": "Cassette Wrench", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#wrench", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle-chain-keeper", + "rdfs:label": [ + { + "@value": "Bicycle Chain Keeper", + "@language": "en" + }, + { + "@value": "حامض الدراجات النارية", + "@language": "ar" + }, + { + "@value": "Bicycle Chain Keeper", + "@language": "ku" + }, + { + "@value": "Ciclo de cadena de mantenimiento", + "@language": "es" + }, + { + "@value": "Cuscinetto della catena della bicicletta", + "@language": "it" + }, + { + "@value": "Fahrrad Kettenhalter", + "@language": "de" + }, + { + "@value": "Bicycle Chain Keeper", + "@language": "sw" + }, + { + "@value": "Guardador de corrente de bicicleta", + "@language": "pt" + }, + { + "@value": "Bicycle Chain Keeper", + "@language": "oc" + }, + { + "@value": "Велосипедная цепь Keeper", + "@language": "ru" + }, + { + "@value": "Bicycle Chain Keeper", + "@language": "cy" + }, + { + "@value": "自転車チェーン Keeper", + "@language": "ja" + }, + { + "@value": "Rothar Slabhra Coimeádaí", + "@language": "ga" + }, + { + "@value": "साइकिल चेन कीपर", + "@language": "hi" + }, + { + "@value": "比周期", + "@language": "zh" + }, + { + "@value": "Bicycle Chain Keeper", + "@language": "fr" + }, + { + "@value": "Bicycle Chain Keeper", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#bicycle", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#battery-terminal-puller", + "rdfs:label": [ + { + "@value": "Battery Terminal Puller", + "@language": "en" + }, + { + "@value": "Battery Terminal Puller", + "@language": "ar" + }, + { + "@value": "Battery Terminal Puller", + "@language": "ku" + }, + { + "@value": "Terminal de batería", + "@language": "es" + }, + { + "@value": "Caricabatterie", + "@language": "it" + }, + { + "@value": "Batterie Klemmen Puller", + "@language": "de" + }, + { + "@value": "Battery Terminal Puller", + "@language": "sw" + }, + { + "@value": "Puxador de terminal de bateria", + "@language": "pt" + }, + { + "@value": "Battery Terminal Puller", + "@language": "oc" + }, + { + "@value": "Батарея Терминал Puller", + "@language": "ru" + }, + { + "@value": "Battery Terminal Puller", + "@language": "cy" + }, + { + "@value": "バッテリーターミナルプルア", + "@language": "ja" + }, + { + "@value": "Uirlisí ilchuspóireacha", + "@language": "ga" + }, + { + "@value": "बैटरी टर्मिनल पुलर", + "@language": "hi" + }, + { + "@value": "Bttery用语Puller", + "@language": "zh" + }, + { + "@value": "Battery Terminal Puller", + "@language": "fr" + }, + { + "@value": "Battery Terminal Puller", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#battery", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#jumper-cables", + "rdfs:label": [ + { + "@value": "Jumper cables/leads", + "@language": "en" + }, + { + "@value": "الكابلات/الرحلات", + "@language": "ar" + }, + { + "@value": "Jumper cables/leads", + "@language": "ku" + }, + { + "@value": "Cables de salto / cuentas", + "@language": "es" + }, + { + "@value": "Cavi di salto/cavo", + "@language": "it" + }, + { + "@value": "Jumper Kabel/Stecker", + "@language": "de" + }, + { + "@value": "Jumper cables/leads", + "@language": "sw" + }, + { + "@value": "Cabos de ligação/mangueiras", + "@language": "pt" + }, + { + "@value": "Jumper cables/leads", + "@language": "oc" + }, + { + "@value": "Прыжок кабели/лиды", + "@language": "ru" + }, + { + "@value": "Jumper cables/leads", + "@language": "cy" + }, + { + "@value": "ジャンパーケーブル/鉛", + "@language": "ja" + }, + { + "@value": "cáblaí / luaidhe Léim", + "@language": "ga" + }, + { + "@value": "जम्पर केबल/लीड", + "@language": "hi" + }, + { + "@value": "Jumper电缆/铅", + "@language": "zh" + }, + { + "@value": "Câbles de saut/perles", + "@language": "fr" + }, + { + "@value": "Jumper cables/leads", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#battery", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#tube-cutter", + "rdfs:label": [ + { + "@value": "Hose / Tube Cutter", + "@language": "en" + }, + { + "@value": "Hose/ Tube Cutter", + "@language": "ar" + }, + { + "@value": "Hose / Tube Cutter", + "@language": "ku" + }, + { + "@value": "Hose / Tube Cutter", + "@language": "es" + }, + { + "@value": "Tubo / Tubo", + "@language": "it" + }, + { + "@value": "Schlauch / Rohrschneider", + "@language": "de" + }, + { + "@value": "Hose / Tube Cutter", + "@language": "sw" + }, + { + "@value": "Mangueira / Cortador de tubos", + "@language": "pt" + }, + { + "@value": "Hose / Tube Cutter", + "@language": "oc" + }, + { + "@value": "Шланг / Tube Cutter", + "@language": "ru" + }, + { + "@value": "Hose / Tube Cutter", + "@language": "cy" + }, + { + "@value": "ホース/管カッター", + "@language": "ja" + }, + { + "@value": "Hose / Tube Cutter", + "@language": "ga" + }, + { + "@value": "नली / ट्यूब कटर", + "@language": "hi" + }, + { + "@value": "Hose/ Tube Cutter", + "@language": "zh" + }, + { + "@value": "Cutter à l ' ossature", + "@language": "fr" + }, + { + "@value": "Hose / Tube Cutter", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#moisture-meter", + "rdfs:label": [ + { + "@value": "Moisture Meter", + "@language": "en" + }, + { + "@value": "قياس الحركة", + "@language": "ar" + }, + { + "@value": "Moisture Meter", + "@language": "ku" + }, + { + "@value": "Moisture Meter", + "@language": "es" + }, + { + "@value": "Moisture Meter", + "@language": "it" + }, + { + "@value": "Feuchtemesser", + "@language": "de" + }, + { + "@value": "Moisture Meter", + "@language": "sw" + }, + { + "@value": "Medidor de umidade", + "@language": "pt" + }, + { + "@value": "Moisture Meter", + "@language": "oc" + }, + { + "@value": "Метр влаги", + "@language": "ru" + }, + { + "@value": "Moisture Meter", + "@language": "cy" + }, + { + "@value": "湿気のメートル", + "@language": "ja" + }, + { + "@value": "Méadar taise", + "@language": "ga" + }, + { + "@value": "नमी मीटर", + "@language": "hi" + }, + { + "@value": "摩尔·梅雷", + "@language": "zh" + }, + { + "@value": "Metteur en température", + "@language": "fr" + }, + { + "@value": "Moisture Meter", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#measuring-tool", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#retrieval-tool", + "rdfs:label": [ + { + "@value": "Retrieval Tool", + "@language": "en" + }, + { + "@value": "Retrieval Tool", + "@language": "ar" + }, + { + "@value": "Retrieval Tool", + "@language": "ku" + }, + { + "@value": "Herramienta de recuperación", + "@language": "es" + }, + { + "@value": "Strumento di recupero", + "@language": "it" + }, + { + "@value": "Abrufwerkzeug", + "@language": "de" + }, + { + "@value": "Retrieval Tool", + "@language": "sw" + }, + { + "@value": "Ferramenta de recuperação", + "@language": "pt" + }, + { + "@value": "Retrieval Tool", + "@language": "oc" + }, + { + "@value": "Инструмент для поиска", + "@language": "ru" + }, + { + "@value": "Retrieval Tool", + "@language": "cy" + }, + { + "@value": "リトリバルツール", + "@language": "ja" + }, + { + "@value": "Uirlisí Retrival", + "@language": "ga" + }, + { + "@value": "पुनर्प्राप्ति उपकरण", + "@language": "hi" + }, + { + "@value": "雷瓦尔·托维斯", + "@language": "zh" + }, + { + "@value": "Retrieval Tool", + "@language": "fr" + }, + { + "@value": "Retrieval Tool", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#retrieval-tool", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#hatchet", + "rdfs:label": [ + { + "@value": "Hatchet", + "@language": "en" + }, + { + "@value": "Hatchet", + "@language": "ar" + }, + { + "@value": "Hatchet", + "@language": "ku" + }, + { + "@value": "Hatchet", + "@language": "es" + }, + { + "@value": "Hatchet", + "@language": "it" + }, + { + "@value": "Hasche", + "@language": "de" + }, + { + "@value": "Hatchet", + "@language": "sw" + }, + { + "@value": "Hatchet", + "@language": "pt" + }, + { + "@value": "Hatchet", + "@language": "oc" + }, + { + "@value": "Хатчет", + "@language": "ru" + }, + { + "@value": "Hatchet", + "@language": "cy" + }, + { + "@value": "ハチェット", + "@language": "ja" + }, + { + "@value": "cineál gas: in airde", + "@language": "ga" + }, + { + "@value": "हैचेट", + "@language": "hi" + }, + { + "@value": "Hatchet", + "@language": "zh" + }, + { + "@value": "Hatchet", + "@language": "fr" + }, + { + "@value": "Hatchet", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#top-snapper", + "rdfs:label": [ + { + "@value": "Top Snapper", + "@language": "en" + }, + { + "@value": "أعلى", + "@language": "ar" + }, + { + "@value": "Top Snapper", + "@language": "ku" + }, + { + "@value": "Top Snapper", + "@language": "es" + }, + { + "@value": "Top Snapper", + "@language": "it" + }, + { + "@value": "Top Snapper", + "@language": "de" + }, + { + "@value": "Top Snapper", + "@language": "sw" + }, + { + "@value": "Top Snapper", + "@language": "pt" + }, + { + "@value": "Top Snapper", + "@language": "oc" + }, + { + "@value": "Топ Snapper", + "@language": "ru" + }, + { + "@value": "Top Snapper", + "@language": "cy" + }, + { + "@value": "トップスナッパー", + "@language": "ja" + }, + { + "@value": "Barr an leathanaigh", + "@language": "ga" + }, + { + "@value": "शीर्ष स्नैपर", + "@language": "hi" + }, + { + "@value": "例 例", + "@language": "zh" + }, + { + "@value": "Top Snapper", + "@language": "fr" + }, + { + "@value": "Top Snapper", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#top-snapper", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#rope-cutter", + "rdfs:label": [ + { + "@value": "Rope Cutter", + "@language": "en" + }, + { + "@value": "Rope Cutter", + "@language": "ar" + }, + { + "@value": "Rope Cutter", + "@language": "ku" + }, + { + "@value": "Rope Cutter", + "@language": "es" + }, + { + "@value": "Cutter di corda", + "@language": "it" + }, + { + "@value": "Der Kopf", + "@language": "de" + }, + { + "@value": "Rope Cutter", + "@language": "sw" + }, + { + "@value": "Cortador de corda", + "@language": "pt" + }, + { + "@value": "Rope Cutter", + "@language": "oc" + }, + { + "@value": "Веревка Cutter", + "@language": "ru" + }, + { + "@value": "Rope Cutter", + "@language": "cy" + }, + { + "@value": "ロープカッター", + "@language": "ja" + }, + { + "@value": "Rope cutter", + "@language": "ga" + }, + { + "@value": "रस्सी कटर", + "@language": "hi" + }, + { + "@value": "求 力", + "@language": "zh" + }, + { + "@value": "Rope Cutter", + "@language": "fr" + }, + { + "@value": "Rope Cutter", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#cutting-tool", + "@type": "dfc-p:ProductType" + }, + { + "@id": "http://static.datafoodconsortium.org/data/toolTypes.json#adjustable-spanner-wrench", + "rdfs:label": [ + { + "@value": "Adjustable Spanner Wrench", + "@language": "en" + }, + { + "@value": "Spanner Wrench", + "@language": "ar" + }, + { + "@value": "Adjustable Spanner Wrench", + "@language": "ku" + }, + { + "@value": "Wrench de Spanner ajustable", + "@language": "es" + }, + { + "@value": "Avvitatore di ricambio regolabile", + "@language": "it" + }, + { + "@value": "Einstellbare Spannschraube", + "@language": "de" + }, + { + "@value": "Adjustable Spanner Wrench", + "@language": "sw" + }, + { + "@value": "Chave de spanner ajustável", + "@language": "pt" + }, + { + "@value": "Adjustable Spanner Wrench", + "@language": "oc" + }, + { + "@value": "Регулируемый Spanner Wrench", + "@language": "ru" + }, + { + "@value": "Adjustable Spanner Wrench", + "@language": "cy" + }, + { + "@value": "調節可能なスパナーレンチ", + "@language": "ja" + }, + { + "@value": "Inoiriúnaithe Spanner Wrench", + "@language": "ga" + }, + { + "@value": "समायोज्य स्पैनर रिंच", + "@language": "hi" + }, + { + "@value": "A. 理应", + "@language": "zh" + }, + { + "@value": "Ajustable Spanner Wrench", + "@language": "fr" + }, + { + "@value": "Adjustable Spanner Wrench", + "@language": "ca" + } + ], + "dfc-p:specialize": "http://static.datafoodconsortium.org/data/toolTypes.json#wrench", "@type": "dfc-p:ProductType" } ] diff --git a/ontology/units.json b/ontology/units.json index 066fecd65..03c775758 100644 --- a/ontology/units.json +++ b/ontology/units.json @@ -1,6 +1,6 @@ { "@context":{ - "dfc-p": "http://static.datafoodconsortium.org/ontologies/dfc_ProductGlossary.owl#", + "dfc-p": "http://static.datafoodconsortium.org/ontologies/DFC_ProductGlossary.owl#", "dfc-u":"http://static.datafoodconsortium.org/data/units.rdf#" }, "@graph":[ diff --git a/ontology/units.rdf b/ontology/units.rdf new file mode 100644 index 000000000..3848adc3d --- /dev/null +++ b/ontology/units.rdf @@ -0,0 +1,23 @@ + + + + + kilogramme + + + + unité + + + + gramme + + + + litre + + + diff --git a/outbox.py b/outbox.py index b8b6fb98d..fee962c06 100644 --- a/outbox.py +++ b/outbox.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -34,12 +34,14 @@ from blocking import outboxUndoBlock from blocking import outboxMute from blocking import outboxUndoMute from media import replaceYouTube +from media import replaceTwitter from media import getMediaPath from media import createMediaDirs from inbox import inboxUpdateIndex from announce import outboxAnnounce from announce import outboxUndoAnnounce from follow import outboxUndoFollow +from follow import followerApprovalActive from skills import outboxSkills from availability import outboxAvailability from like import outboxLike @@ -49,6 +51,7 @@ from bookmarks import outboxUndoBookmark from delete import outboxDelete from shares import outboxShareUpload from shares import outboxUndoShareUpload +from webapp_post import individualPostAsHtml def _outboxPersonReceiveUpdate(recentPostsCache: {}, @@ -189,12 +192,17 @@ def postMessageToOutbox(session, translate: {}, personCache: {}, allowDeletion: bool, proxyType: str, version: str, debug: bool, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, allowLocalNetworkAccess: bool, city: str, systemLanguage: str, sharedItemsFederatedDomains: [], sharedItemFederationTokens: {}, - lowBandwidth: bool) -> bool: + lowBandwidth: bool, + signingPrivateKeyPem: str, + peertubeInstances: str, theme: str, + maxLikeCount: int, + maxRecentPosts: int) -> bool: """post is received by the outbox Client to server message post https://www.w3.org/TR/activitypub/#client-to-server-outbox-delivery @@ -281,6 +289,9 @@ def postMessageToOutbox(session, translate: {}, return False # replace youtube, so that google gets less tracking data replaceYouTube(messageJson, YTReplacementDomain, systemLanguage) + # replace twitter, so that twitter posts can be shown without + # having a twitter account + replaceTwitter(messageJson, twitterReplacementDomain, systemLanguage) # https://www.w3.org/TR/activitypub/#create-activity-outbox messageJson['object']['attributedTo'] = messageJson['actor'] if messageJson['object'].get('attachment'): @@ -318,7 +329,7 @@ def postMessageToOutbox(session, translate: {}, # generate a path for the uploaded image mPath = getMediaPath() mediaPath = mPath + '/' + \ - createPassword(32) + '.' + fileExtension + createPassword(16).lower() + '.' + fileExtension createMediaDirs(baseDir, mPath) mediaFilename = baseDir + '/' + mediaPath # move the uploaded image to its new path @@ -384,7 +395,10 @@ def postMessageToOutbox(session, translate: {}, baseDir + '/accounts/' + \ postToNickname + '@' + domain + '/.citations.txt' if os.path.isfile(citationsFilename): - os.remove(citationsFilename) + try: + os.remove(citationsFilename) + except BaseException: + pass # The following activity types get added to the index files indexedActivities = ( @@ -404,10 +418,13 @@ def postMessageToOutbox(session, translate: {}, if isImageMedia(session, baseDir, httpPrefix, postToNickname, domain, messageJson, - translate, YTReplacementDomain, + translate, + YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, recentPostsCache, debug, systemLanguage, - domainFull, personCache): + domainFull, personCache, + signingPrivateKeyPem): inboxUpdateIndex('tlmedia', baseDir, postToNickname + '@' + domain, savedFilename, debug) @@ -423,6 +440,37 @@ def postMessageToOutbox(session, translate: {}, inboxUpdateIndex(boxNameIndex, baseDir, postToNickname + '@' + domain, savedFilename, debug) + + # regenerate the html + useCacheOnly = False + pageNumber = 1 + showIndividualPostIcons = True + manuallyApproveFollowers = \ + followerApprovalActive(baseDir, postToNickname, domain) + individualPostAsHtml(signingPrivateKeyPem, + False, recentPostsCache, + maxRecentPosts, + translate, pageNumber, + baseDir, session, + cachedWebfingers, + personCache, + postToNickname, domain, port, + messageJson, None, True, + allowDeletion, + httpPrefix, __version__, + boxNameIndex, + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, + peertubeInstances, + allowLocalNetworkAccess, + theme, systemLanguage, + maxLikeCount, + boxNameIndex != 'dm', + showIndividualPostIcons, + manuallyApproveFollowers, + False, True, useCacheOnly) + if outboxAnnounce(recentPostsCache, baseDir, messageJson, debug): if debug: @@ -468,7 +516,8 @@ def postMessageToOutbox(session, translate: {}, messageJson, debug, version, sharedItemsFederatedDomains, - sharedItemFederationTokens) + sharedItemFederationTokens, + signingPrivateKeyPem) followersThreads.append(followersThread) if debug: @@ -592,5 +641,6 @@ def postMessageToOutbox(session, translate: {}, messageJson, debug, version, sharedItemsFederatedDomains, - sharedItemFederationTokens) + sharedItemFederationTokens, + signingPrivateKeyPem) return True diff --git a/person.py b/person.py index 985286d8e..b853d9172 100644 --- a/person.py +++ b/person.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" @@ -37,6 +37,7 @@ from roles import setRole from roles import setRolesFromList from roles import getActorRolesList from media import processMetaData +from utils import replaceUsersWithAt from utils import removeLineEndings from utils import removeDomainPort from utils import getStatusNumber @@ -55,6 +56,7 @@ from utils import acctDir from utils import getUserPaths from utils import getGroupPaths from utils import localActorUrl +from utils import dangerousSVG from session import createSession from session import getJson from webfinger import webfingerHandle @@ -185,6 +187,117 @@ def randomizeActorImages(personJson: {}) -> None: '/image' + randStr + '.' + existingExtension +def getActorUpdateJson(actorJson: {}) -> {}: + """Returns the json for an Person Update + """ + pubNumber, _ = getStatusNumber() + manuallyApprovesFollowers = actorJson['manuallyApprovesFollowers'] + return { + '@context': [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": + { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": + { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": + { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": + { + "@id": "as:movedTo", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "IdentityProof": "toot:IdentityProof", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": + { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": + { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": + { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": + { + "@type": "@id", + "@id": "toot:devices" + }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText", + "suspended": "toot:suspended", + "focalPoint": + { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + 'id': actorJson['id'] + '#updates/' + pubNumber, + 'type': 'Update', + 'actor': actorJson['id'], + 'to': ['https://www.w3.org/ns/activitystreams#Public'], + 'cc': [actorJson['id'] + '/followers'], + 'object': { + 'id': actorJson['id'], + 'type': actorJson['type'], + 'icon': { + 'type': 'Image', + 'url': actorJson['icon']['url'] + }, + 'image': { + 'type': 'Image', + 'url': actorJson['image']['url'] + }, + 'attachment': actorJson['attachment'], + 'following': actorJson['id'] + '/following', + 'followers': actorJson['id'] + '/followers', + 'inbox': actorJson['id'] + '/inbox', + 'outbox': actorJson['id'] + '/outbox', + 'featured': actorJson['id'] + '/collections/featured', + 'featuredTags': actorJson['id'] + '/collections/tags', + 'preferredUsername': actorJson['preferredUsername'], + 'name': actorJson['name'], + 'summary': actorJson['summary'], + 'url': actorJson['url'], + 'manuallyApprovesFollowers': manuallyApprovesFollowers, + 'discoverable': actorJson['discoverable'], + 'published': actorJson['published'], + 'devices': actorJson['devices'], + "publicKey": actorJson['publicKey'], + } + } + + def getDefaultPersonContext() -> str: """Gets the default actor context """ @@ -702,7 +815,7 @@ def personUpgradeActor(baseDir: str, personJson: {}, # update domain/@nickname in actors cache actorCacheFilename = \ baseDir + '/accounts/cache/actors/' + \ - personJson['id'].replace('/users/', '/@').replace('/', '#') + \ + replaceUsersWithAt(personJson['id']).replace('/', '#') + \ '.json' if os.path.isfile(actorCacheFilename): saveJson(personJson, actorCacheFilename) @@ -717,7 +830,7 @@ def personLookup(domain: str, path: str, baseDir: str) -> {}: isSharedInbox = False if path == '/inbox' or path == '/users/inbox' or path == '/sharedInbox': # shared inbox actor on @domain@domain - path = '/users/' + domain + path = '/users/inbox' isSharedInbox = True else: notPersonLookup = ('/inbox', '/outbox', '/outboxarchive', @@ -741,7 +854,8 @@ def personLookup(domain: str, path: str, baseDir: str) -> {}: if not os.path.isfile(filename): return None personJson = loadJson(filename) - personUpgradeActor(baseDir, personJson, handle, filename) + if not isSharedInbox: + personUpgradeActor(baseDir, personJson, handle, filename) # if not personJson: # personJson={"user": "unknown"} return personJson @@ -917,10 +1031,16 @@ def suspendAccount(baseDir: str, nickname: str, domain: str) -> None: saltFilename = acctDir(baseDir, nickname, domain) + '/.salt' if os.path.isfile(saltFilename): - os.remove(saltFilename) + try: + os.remove(saltFilename) + except BaseException: + pass tokenFilename = acctDir(baseDir, nickname, domain) + '/.token' if os.path.isfile(tokenFilename): - os.remove(tokenFilename) + try: + os.remove(tokenFilename) + except BaseException: + pass suspendedFilename = baseDir + '/accounts/suspended.txt' if os.path.isfile(suspendedFilename): @@ -1023,17 +1143,32 @@ def removeAccount(baseDir: str, nickname: str, if os.path.isdir(baseDir + '/accounts/' + handle): shutil.rmtree(baseDir + '/accounts/' + handle) if os.path.isfile(baseDir + '/accounts/' + handle + '.json'): - os.remove(baseDir + '/accounts/' + handle + '.json') + try: + os.remove(baseDir + '/accounts/' + handle + '.json') + except BaseException: + pass if os.path.isfile(baseDir + '/wfendpoints/' + handle + '.json'): - os.remove(baseDir + '/wfendpoints/' + handle + '.json') + try: + os.remove(baseDir + '/wfendpoints/' + handle + '.json') + except BaseException: + pass if os.path.isfile(baseDir + '/keys/private/' + handle + '.key'): - os.remove(baseDir + '/keys/private/' + handle + '.key') + try: + os.remove(baseDir + '/keys/private/' + handle + '.key') + except BaseException: + pass if os.path.isfile(baseDir + '/keys/public/' + handle + '.pem'): - os.remove(baseDir + '/keys/public/' + handle + '.pem') + try: + os.remove(baseDir + '/keys/public/' + handle + '.pem') + except BaseException: + pass if os.path.isdir(baseDir + '/sharefiles/' + nickname): shutil.rmtree(baseDir + '/sharefiles/' + nickname) if os.path.isfile(baseDir + '/wfdeactivated/' + handle + '.json'): - os.remove(baseDir + '/wfdeactivated/' + handle + '.json') + try: + os.remove(baseDir + '/wfdeactivated/' + handle + '.json') + except BaseException: + pass if os.path.isdir(baseDir + '/sharefilesdeactivated/' + nickname): shutil.rmtree(baseDir + '/sharefilesdeactivated/' + nickname) @@ -1215,7 +1350,8 @@ def _detectUsersPath(url: str) -> str: def getActorJson(hostDomain: str, handle: str, http: bool, gnunet: bool, - debug: bool, quiet: bool = False) -> ({}, {}): + debug: bool, quiet: bool, + signingPrivateKeyPem: str) -> ({}, {}): """Returns the actor json """ if debug: @@ -1302,52 +1438,68 @@ def getActorJson(hostDomain: str, handle: str, http: bool, gnunet: bool, if nickname == 'inbox': nickname = domain - handle = nickname + '@' + domain - wfRequest = webfingerHandle(session, handle, - httpPrefix, cachedWebfingers, - None, __version__, debug, - groupAccount) - if not wfRequest: - if not quiet: - print('getActorJson Unable to webfinger ' + handle) - return None, None - if not isinstance(wfRequest, dict): - if not quiet: - print('getActorJson Webfinger for ' + handle + - ' did not return a dict. ' + str(wfRequest)) - return None, None - - if not quiet: - pprint(wfRequest) - personUrl = None - if wfRequest.get('errors'): - if not quiet or debug: - print('getActorJson wfRequest error: ' + - str(wfRequest['errors'])) - if hasUsersPath(handle): - personUrl = originalActor - else: - if debug: - print('No users path in ' + handle) + wfRequest = None + + if '://' in originalActor and \ + originalActor.lower().endswith('/actor'): + if debug: + print(originalActor + ' is an instance actor') + personUrl = originalActor + elif '://' in originalActor and groupAccount: + if debug: + print(originalActor + ' is a group actor') + personUrl = originalActor + else: + handle = nickname + '@' + domain + wfRequest = webfingerHandle(session, handle, + httpPrefix, cachedWebfingers, + hostDomain, __version__, debug, + groupAccount, signingPrivateKeyPem) + if not wfRequest: + if not quiet: + print('getActorJson Unable to webfinger ' + handle) return None, None + if not isinstance(wfRequest, dict): + if not quiet: + print('getActorJson Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return None, None + + if not quiet: + pprint(wfRequest) + + if wfRequest.get('errors'): + if not quiet or debug: + print('getActorJson wfRequest error: ' + + str(wfRequest['errors'])) + if hasUsersPath(handle): + personUrl = originalActor + else: + if debug: + print('No users path in ' + handle) + return None, None profileStr = 'https://www.w3.org/ns/activitystreams' headersList = ( "activity+json", "ld+json", "jrd+json" ) - if not personUrl: + if not personUrl and wfRequest: personUrl = getUserUrl(wfRequest, 0, debug) if nickname == domain: paths = getUserPaths() for userPath in paths: personUrl = personUrl.replace(userPath, '/actor/') + if not personUrl and groupAccount: + personUrl = httpPrefix + '://' + domain + '/c/' + nickname if not personUrl: # try single user instance personUrl = httpPrefix + '://' + domain + '/' + nickname headersList = ( "ld+json", "jrd+json", "activity+json" ) + if debug: + print('Trying single user instance ' + personUrl) if '/channel/' in personUrl or '/accounts/' in personUrl: headersList = ( "ld+json", "jrd+json", "activity+json" @@ -1360,7 +1512,7 @@ def getActorJson(hostDomain: str, handle: str, http: bool, gnunet: bool, 'Accept': headerMimeType + '; profile="' + profileStr + '"' } personJson = \ - getJson(session, personUrl, asHeader, None, + getJson(signingPrivateKeyPem, session, personUrl, asHeader, None, debug, __version__, httpPrefix, hostDomain, 20, quiet) if personJson: if not quiet: @@ -1386,12 +1538,24 @@ def getPersonAvatarUrl(baseDir: str, personUrl: str, personCache: {}, imageExtension = getImageExtensions() for ext in imageExtension: - if os.path.isfile(avatarImagePath + '.' + ext): - return '/avatars/' + actorStr + '.' + ext - elif os.path.isfile(avatarImagePath.lower() + '.' + ext): - return '/avatars/' + actorStr.lower() + '.' + ext + imFilename = avatarImagePath + '.' + ext + imPath = '/avatars/' + actorStr + '.' + ext + if not os.path.isfile(imFilename): + imFilename = avatarImagePath.lower() + '.' + ext + imPath = '/avatars/' + actorStr.lower() + '.' + ext + if not os.path.isfile(imFilename): + continue + if ext != 'svg': + return imPath + else: + content = '' + with open(imFilename, 'r') as fp: + content = fp.read() + if not dangerousSVG(content, False): + return imPath if personJson.get('icon'): if personJson['icon'].get('url'): - return personJson['icon']['url'] + if '.svg' not in personJson['icon']['url'].lower(): + return personJson['icon']['url'] return None diff --git a/petnames.py b/petnames.py index 6346c22f5..e0230c1be 100644 --- a/petnames.py +++ b/petnames.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" diff --git a/pgp.py b/pgp.py index 5abf6856d..193b50951 100644 --- a/pgp.py +++ b/pgp.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" @@ -16,6 +16,7 @@ from utils import isPGPEncrypted from utils import getFullDomain from utils import getStatusNumber from utils import localActorUrl +from utils import replaceUsersWithAt from webfinger import webfingerHandle from posts import getPersonBox from auth import createBasicAuthHeader @@ -333,14 +334,16 @@ def _pgpEncrypt(content: str, recipientPubKey: str) -> str: return encryptResult -def _getPGPPublicKeyFromActor(domain: str, handle: str, +def _getPGPPublicKeyFromActor(signingPrivateKeyPem: str, + domain: str, handle: str, actorJson: {} = None) -> str: """Searches tags on the actor to see if there is any PGP public key specified """ if not actorJson: actorJson, asHeader = \ - getActorJson(domain, handle, False, False, False, True) + getActorJson(domain, handle, False, False, False, True, + signingPrivateKeyPem) if not actorJson: return None if not actorJson.get('attachment'): @@ -372,18 +375,21 @@ def hasLocalPGPkey() -> bool: return False -def pgpEncryptToActor(domain: str, content: str, toHandle: str) -> str: +def pgpEncryptToActor(domain: str, content: str, toHandle: str, + signingPrivateKeyPem: str) -> str: """PGP encrypt a message to the given actor or handle """ # get the actor and extract the pgp public key from it - recipientPubKey = _getPGPPublicKeyFromActor(domain, toHandle) + recipientPubKey = \ + _getPGPPublicKeyFromActor(signingPrivateKeyPem, domain, toHandle) if not recipientPubKey: return None # encrypt using the recipient public key return _pgpEncrypt(content, recipientPubKey) -def pgpDecrypt(domain: str, content: str, fromHandle: str) -> str: +def pgpDecrypt(domain: str, content: str, fromHandle: str, + signingPrivateKeyPem: str) -> str: """ Encrypt using your default pgp key to the given recipient fromHandle can be a handle or actor url """ @@ -394,7 +400,9 @@ def pgpDecrypt(domain: str, content: str, fromHandle: str) -> str: if containsPGPPublicKey(content): pubKey = extractPGPPublicKey(content) else: - pubKey = _getPGPPublicKeyFromActor(domain, content, fromHandle) + pubKey = \ + _getPGPPublicKeyFromActor(signingPrivateKeyPem, + domain, content, fromHandle) if pubKey: _pgpImportPubKey(pubKey) @@ -449,7 +457,8 @@ def pgpPublicKeyUpload(baseDir: str, session, domain: str, port: int, httpPrefix: str, cachedWebfingers: {}, personCache: {}, - debug: bool, test: str) -> {}: + debug: bool, test: str, + signingPrivateKeyPem: str) -> {}: if debug: print('pgpPublicKeyUpload') @@ -481,7 +490,8 @@ def pgpPublicKeyUpload(baseDir: str, session, print('Getting actor for ' + handle) actorJson, asHeader = \ - getActorJson(domain, handle, False, False, debug, True) + getActorJson(domainFull, handle, False, False, debug, True, + signingPrivateKeyPem) if not actorJson: if debug: print('No actor returned for ' + handle) @@ -491,7 +501,7 @@ def pgpPublicKeyUpload(baseDir: str, session, print('Actor for ' + handle + ' obtained') actor = localActorUrl(httpPrefix, nickname, domainFull) - handle = actor.replace('/users/', '/@') + handle = replaceUsersWithAt(actor) # check that this looks like the correct actor if not actorJson.get('id'): @@ -548,7 +558,8 @@ def pgpPublicKeyUpload(baseDir: str, session, # lookup the inbox for the To handle wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, __version__, debug, False) + domain, __version__, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: pgp actor update webfinger failed for ' + @@ -563,11 +574,12 @@ def pgpPublicKeyUpload(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, personCache, - __version__, httpPrefix, nickname, - domain, postToBox, 52025) + originDomain = domain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, originDomain, + baseDir, session, wfRequest, personCache, + __version__, httpPrefix, nickname, + domain, postToBox, 35725) if not inboxUrl: if debug: diff --git a/posts.py b/posts.py index 3593f7ed3..3511a7065 100644 --- a/posts.py +++ b/posts.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" @@ -32,6 +32,8 @@ from webfinger import webfingerHandle from httpsig import createSignedHeader from siteactive import siteIsActive from languages import understoodPostLanguage +from utils import removeIdEnding +from utils import replaceUsersWithAt from utils import hasGroupType from utils import getBaseContentFromPost from utils import removeDomainPort @@ -66,6 +68,7 @@ from utils import acctDir from utils import localActorUrl from media import attachMedia from media import replaceYouTube +from media import replaceTwitter from content import limitRepeatedWords from content import tagExists from content import removeLongWords @@ -79,6 +82,7 @@ from filters import isFiltered from git import convertPostToPatch from linked_data_sig import generateJsonSignature from petnames import resolvePetnames +from video import convertVideoToNote def isModerator(baseDir: str, nickname: str) -> bool: @@ -129,19 +133,53 @@ def noOfFollowersOnDomain(baseDir: str, handle: str, return ctr +def _getLocalPrivateKey(baseDir: str, nickname: str, domain: str) -> str: + """Returns the private key for a local account + """ + if not domain or not nickname: + return None + handle = nickname + '@' + domain + keyFilename = baseDir + '/keys/private/' + handle.lower() + '.key' + if not os.path.isfile(keyFilename): + return None + with open(keyFilename, 'r') as pemFile: + return pemFile.read() + return None + + +def getInstanceActorKey(baseDir: str, domain: str) -> str: + """Returns the private key for the instance actor used for + signing GET posts + """ + return _getLocalPrivateKey(baseDir, 'inbox', domain) + + +def _getLocalPublicKey(baseDir: str, nickname: str, domain: str) -> str: + """Returns the public key for a local account + """ + if not domain or not nickname: + return None + handle = nickname + '@' + domain + keyFilename = baseDir + '/keys/public/' + handle.lower() + '.key' + if not os.path.isfile(keyFilename): + return None + with open(keyFilename, 'r') as pemFile: + return pemFile.read() + return None + + def _getPersonKey(nickname: str, domain: str, baseDir: str, keyType: str = 'public', debug: bool = False): """Returns the public or private key of a person """ - handle = nickname + '@' + domain - keyFilename = baseDir + '/keys/' + keyType + '/' + handle.lower() + '.key' - if not os.path.isfile(keyFilename): + if keyType == 'private': + keyPem = _getLocalPrivateKey(baseDir, nickname, domain) + else: + keyPem = _getLocalPublicKey(baseDir, nickname, domain) + if not keyPem: if debug: - print('DEBUG: private key file not found: ' + keyFilename) + print('DEBUG: ' + keyType + ' key file not found') return '' - keyPem = '' - with open(keyFilename, 'r') as pemFile: - keyPem = pemFile.read() if len(keyPem) < 20: if debug: print('DEBUG: private key was too short: ' + keyPem) @@ -183,9 +221,10 @@ def getUserUrl(wfRequest: {}, sourceId: int = 0, debug: bool = False) -> str: return None -def parseUserFeed(session, feedUrl: str, asHeader: {}, +def parseUserFeed(signingPrivateKeyPem: str, + session, feedUrl: str, asHeader: {}, projectVersion: str, httpPrefix: str, - domain: str, debug: bool, depth: int = 0) -> []: + originDomain: str, debug: bool, depth: int = 0) -> []: if depth > 10: if debug: print('Maximum search depth reached') @@ -194,8 +233,21 @@ def parseUserFeed(session, feedUrl: str, asHeader: {}, if debug: print('Getting user feed for ' + feedUrl) print('User feed header ' + str(asHeader)) - feedJson = getJson(session, feedUrl, asHeader, None, - False, projectVersion, httpPrefix, domain) + print('httpPrefix ' + str(httpPrefix)) + print('originDomain ' + str(originDomain)) + + feedJson = getJson(signingPrivateKeyPem, session, feedUrl, asHeader, None, + debug, projectVersion, httpPrefix, originDomain) + if not feedJson: + profileStr = 'https://www.w3.org/ns/activitystreams' + acceptStr = 'application/ld+json; profile="' + profileStr + '"' + if asHeader['Accept'] != acceptStr: + asHeader = { + 'Accept': acceptStr + } + feedJson = getJson(signingPrivateKeyPem, session, feedUrl, + asHeader, None, debug, projectVersion, + httpPrefix, originDomain) if not feedJson: if debug: print('No user feed was returned') @@ -207,6 +259,8 @@ def parseUserFeed(session, feedUrl: str, asHeader: {}, if 'orderedItems' in feedJson: return feedJson['orderedItems'] + elif 'items' in feedJson: + return feedJson['items'] nextUrl = None if 'first' in feedJson: @@ -221,23 +275,28 @@ def parseUserFeed(session, feedUrl: str, asHeader: {}, if isinstance(nextUrl, str): if '?max_id=0' not in nextUrl: userFeed = \ - parseUserFeed(session, nextUrl, asHeader, + parseUserFeed(signingPrivateKeyPem, + session, nextUrl, asHeader, projectVersion, httpPrefix, - domain, debug, depth + 1) + originDomain, debug, depth + 1) if userFeed: return userFeed elif isinstance(nextUrl, dict): userFeed = nextUrl if userFeed.get('orderedItems'): return userFeed['orderedItems'] + elif userFeed.get('items'): + return userFeed['items'] return None def _getPersonBoxActor(session, baseDir: str, actor: str, profileStr: str, asHeader: {}, debug: bool, projectVersion: str, - httpPrefix: str, domain: str, - personCache: {}) -> {}: + httpPrefix: str, originDomain: str, + personCache: {}, + signingPrivateKeyPem: str, + sourceId: int) -> {}: """Returns the actor json for the given actor url """ personJson = \ @@ -249,27 +308,29 @@ def _getPersonBoxActor(session, baseDir: str, actor: str, asHeader = { 'Accept': 'application/ld+json; profile="' + profileStr + '"' } - personJson = getJson(session, actor, asHeader, None, - debug, projectVersion, httpPrefix, domain) + personJson = getJson(signingPrivateKeyPem, session, actor, asHeader, None, + debug, projectVersion, httpPrefix, originDomain) if personJson: return personJson asHeader = { 'Accept': 'application/ld+json; profile="' + profileStr + '"' } - personJson = getJson(session, actor, asHeader, None, - debug, projectVersion, httpPrefix, domain) + personJson = getJson(signingPrivateKeyPem, session, actor, asHeader, None, + debug, projectVersion, httpPrefix, originDomain) if personJson: return personJson - print('Unable to get actor for ' + actor) + print('Unable to get actor for ' + actor + ' ' + str(sourceId)) + if not signingPrivateKeyPem: + print('No signing key provided when getting actor') return None -def getPersonBox(baseDir: str, session, wfRequest: {}, - personCache: {}, +def getPersonBox(signingPrivateKeyPem: str, originDomain: str, + baseDir: str, session, wfRequest: {}, personCache: {}, projectVersion: str, httpPrefix: str, nickname: str, domain: str, boxName: str = 'inbox', - sourceId=0) -> (str, str, str, str, str, str, str, str): + sourceId=0) -> (str, str, str, str, str, str, str, bool): debug = False profileStr = 'https://www.w3.org/ns/activitystreams' asHeader = { @@ -277,7 +338,7 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, } if not wfRequest: print('No webfinger given') - return None, None, None, None, None, None, None + return None, None, None, None, None, None, None, None # get the actor / personUrl if not wfRequest.get('errors'): @@ -295,17 +356,23 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, # the final fallback is a mastodon style url personUrl = localActorUrl(httpPrefix, nickname, domain) if not personUrl: - return None, None, None, None, None, None, None + return None, None, None, None, None, None, None, None # get the actor json from the url personJson = \ _getPersonBoxActor(session, baseDir, personUrl, profileStr, asHeader, debug, projectVersion, - httpPrefix, domain, - personCache) + httpPrefix, originDomain, + personCache, signingPrivateKeyPem, + sourceId) if not personJson: - return None, None, None, None, None, None, None + return None, None, None, None, None, None, None, None + + isGroup = False + if personJson.get('type'): + if personJson['type'] == 'Group': + isGroup = True # get the url for the box/collection boxJson = None @@ -316,7 +383,7 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, else: boxJson = personJson[boxName] if not boxJson: - return None, None, None, None, None, None, None + return None, None, None, None, None, None, None, None personId = None if personJson.get('id'): @@ -355,7 +422,85 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, storePersonInCache(baseDir, personUrl, personJson, personCache, True) return boxJson, pubKeyId, pubKey, personId, sharedInbox, \ - avatarUrl, displayName + avatarUrl, displayName, isGroup + + +def _isPublicFeedPost(item: {}, personPosts: {}, debug: bool) -> bool: + """Is the given post a public feed post? + """ + if not isinstance(item, dict): + if debug: + print('item object is not a dict') + pprint(item) + return False + if not item.get('id'): + if debug: + print('No id') + return False + if not item.get('type'): + if debug: + print('No type') + return False + if item['type'] != 'Create' and item['type'] != 'Announce': + if debug: + print('Not Create type') + return False + if item.get('object'): + if isinstance(item['object'], dict): + if not item['object'].get('published'): + if debug: + print('No published attribute') + return False + elif isinstance(item['object'], str): + if not item.get('published'): + if debug: + print('No published attribute') + return False + else: + if debug: + print('object is not a dict or string') + return False + if not personPosts.get(item['id']): + # check that this is a public post + # #Public should appear in the "to" list + if isinstance(item['object'], dict): + if item['object'].get('to'): + isPublic = False + for recipient in item['object']['to']: + if recipient.endswith('#Public'): + isPublic = True + break + if not isPublic: + return False + elif isinstance(item['object'], str): + if item.get('to'): + isPublic = False + for recipient in item['to']: + if recipient.endswith('#Public'): + isPublic = True + break + if not isPublic: + return False + return True + + +def isCreateInsideAnnounce(item: {}) -> bool: + """ is this a Create inside of an Announce? + eg. lemmy feed item + """ + if not isinstance(item, dict): + return False + if item['type'] != 'Announce': + return False + if not item.get('object'): + return False + if not isinstance(item['object'], dict): + return False + if not item['object'].get('type'): + return False + if item['object']['type'] != 'Create': + return False + return True def _getPosts(session, outboxUrl: str, maxPosts: int, @@ -365,7 +510,8 @@ def _getPosts(session, outboxUrl: str, maxPosts: int, personCache: {}, raw: bool, simple: bool, debug: bool, projectVersion: str, httpPrefix: str, - domain: str, systemLanguage: str) -> {}: + originDomain: str, systemLanguage: str, + signingPrivateKeyPem: str) -> {}: """Gets public posts from an outbox """ if debug: @@ -374,20 +520,28 @@ def _getPosts(session, outboxUrl: str, maxPosts: int, if not outboxUrl: return personPosts profileStr = 'https://www.w3.org/ns/activitystreams' + acceptStr = \ + 'application/activity+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' + 'Accept': acceptStr } if '/outbox/' in outboxUrl: + acceptStr = \ + 'application/ld+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' + 'Accept': acceptStr } if raw: if debug: print('Returning the raw feed') result = [] i = 0 - userFeed = parseUserFeed(session, outboxUrl, asHeader, - projectVersion, httpPrefix, domain, debug) + userFeed = parseUserFeed(signingPrivateKeyPem, + session, outboxUrl, asHeader, + projectVersion, httpPrefix, + originDomain, debug) for item in userFeed: result.append(item) i += 1 @@ -398,140 +552,95 @@ def _getPosts(session, outboxUrl: str, maxPosts: int, if debug: print('Returning a human readable version of the feed') - userFeed = parseUserFeed(session, outboxUrl, asHeader, - projectVersion, httpPrefix, domain, debug) + userFeed = parseUserFeed(signingPrivateKeyPem, + session, outboxUrl, asHeader, + projectVersion, httpPrefix, + originDomain, debug) if not userFeed: return personPosts i = 0 for item in userFeed: - if not item.get('id'): - if debug: - print('No id') - continue - if not item.get('type'): - if debug: - print('No type') - continue - if item['type'] != 'Create' and item['type'] != 'Announce': - if debug: - print('Not Create type') - continue - if not isinstance(item, dict): - if debug: - print('item object is not a dict') - pprint(item) - continue - if item.get('object'): - if isinstance(item['object'], dict): - if not item['object'].get('published'): - if debug: - print('No published attribute') - continue - elif isinstance(item['object'], str): - if not item.get('published'): - if debug: - print('No published attribute') - continue - else: - if debug: - print('object is not a dict or string') - continue - if not personPosts.get(item['id']): - # check that this is a public post - # #Public should appear in the "to" list - if isinstance(item['object'], dict): - if item['object'].get('to'): - isPublic = False - for recipient in item['object']['to']: - if recipient.endswith('#Public'): - isPublic = True - break - if not isPublic: - continue - elif isinstance(item['object'], str): - if item.get('to'): - isPublic = False - for recipient in item['to']: - if recipient.endswith('#Public'): - isPublic = True - break - if not isPublic: - continue + if isCreateInsideAnnounce(item): + item = item['object'] - content = getBaseContentFromPost(item, systemLanguage) - content = content.replace(''', "'") + if not _isPublicFeedPost(item, personPosts, debug): + continue - mentions = [] - emoji = {} - summary = '' - inReplyTo = '' - attachment = [] - sensitive = False - if isinstance(item['object'], dict): - if item['object'].get('tag'): - for tagItem in item['object']['tag']: - tagType = tagItem['type'].lower() - if tagType == 'emoji': - if tagItem.get('name') and tagItem.get('icon'): - if tagItem['icon'].get('url'): - # No emoji from non-permitted domains - if urlPermitted(tagItem['icon']['url'], - federationList): - emojiName = tagItem['name'] - emojiIcon = tagItem['icon']['url'] - emoji[emojiName] = emojiIcon - else: - if debug: - print('url not permitted ' + - tagItem['icon']['url']) - if tagType == 'mention': - if tagItem.get('name'): - if tagItem['name'] not in mentions: - mentions.append(tagItem['name']) - if len(mentions) > maxMentions: - if debug: - print('max mentions reached') - continue - if len(emoji) > maxEmoji: - if debug: - print('max emojis reached') - continue + content = getBaseContentFromPost(item, systemLanguage) + content = content.replace(''', "'") - if item['object'].get('summary'): - if item['object']['summary']: - summary = item['object']['summary'] - - if item['object'].get('inReplyTo'): - if item['object']['inReplyTo']: - if isinstance(item['object']['inReplyTo'], str): - # No replies to non-permitted domains - if not urlPermitted(item['object']['inReplyTo'], + mentions = [] + emoji = {} + summary = '' + inReplyTo = '' + attachment = [] + sensitive = False + if isinstance(item['object'], dict): + if item['object'].get('tag'): + for tagItem in item['object']['tag']: + tagType = tagItem['type'].lower() + if tagType == 'emoji': + if tagItem.get('name') and tagItem.get('icon'): + if tagItem['icon'].get('url'): + # No emoji from non-permitted domains + if urlPermitted(tagItem['icon']['url'], federationList): - if debug: - print('url not permitted ' + - item['object']['inReplyTo']) - continue - inReplyTo = item['object']['inReplyTo'] - - if item['object'].get('attachment'): - if item['object']['attachment']: - for attach in item['object']['attachment']: - if attach.get('name') and attach.get('url'): - # no attachments from non-permitted domains - if urlPermitted(attach['url'], - federationList): - attachment.append([attach['name'], - attach['url']]) + emojiName = tagItem['name'] + emojiIcon = tagItem['icon']['url'] + emoji[emojiName] = emojiIcon else: if debug: print('url not permitted ' + - attach['url']) + tagItem['icon']['url']) + if tagType == 'mention': + if tagItem.get('name'): + if tagItem['name'] not in mentions: + mentions.append(tagItem['name']) + if len(mentions) > maxMentions: + if debug: + print('max mentions reached') + continue + if len(emoji) > maxEmoji: + if debug: + print('max emojis reached') + continue - sensitive = False - if item['object'].get('sensitive'): - sensitive = item['object']['sensitive'] + if item['object'].get('summary'): + if item['object']['summary']: + summary = item['object']['summary'] + if item['object'].get('inReplyTo'): + if item['object']['inReplyTo']: + if isinstance(item['object']['inReplyTo'], str): + # No replies to non-permitted domains + if not urlPermitted(item['object']['inReplyTo'], + federationList): + if debug: + print('url not permitted ' + + item['object']['inReplyTo']) + continue + inReplyTo = item['object']['inReplyTo'] + + if item['object'].get('attachment'): + if item['object']['attachment']: + for attach in item['object']['attachment']: + if attach.get('name') and attach.get('url'): + # no attachments from non-permitted domains + if urlPermitted(attach['url'], + federationList): + attachment.append([attach['name'], + attach['url']]) + else: + if debug: + print('url not permitted ' + + attach['url']) + + sensitive = False + if item['object'].get('sensitive'): + sensitive = item['object']['sensitive'] + + if content: if simple: print(_cleanHtml(content) + '\n') else: @@ -546,10 +655,10 @@ def _getPosts(session, outboxUrl: str, maxPosts: int, "mentions": mentions, "emoji": emoji } - i += 1 + i += 1 - if i == maxPosts: - break + if i == maxPosts: + break return personPosts @@ -616,24 +725,32 @@ def getPostDomains(session, outboxUrl: str, maxPosts: int, projectVersion: str, httpPrefix: str, domain: str, wordFrequency: {}, - domainList: [], systemLanguage: str) -> []: + domainList: [], systemLanguage: str, + signingPrivateKeyPem: str) -> []: """Returns a list of domains referenced within public posts """ if not outboxUrl: return [] profileStr = 'https://www.w3.org/ns/activitystreams' + acceptStr = \ + 'application/activity+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' + 'Accept': acceptStr } if '/outbox/' in outboxUrl: + acceptStr = \ + 'application/ld+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' + 'Accept': acceptStr } postDomains = domainList i = 0 - userFeed = parseUserFeed(session, outboxUrl, asHeader, + userFeed = parseUserFeed(signingPrivateKeyPem, + session, outboxUrl, asHeader, projectVersion, httpPrefix, domain, debug) for item in userFeed: i += 1 @@ -671,24 +788,32 @@ def _getPostsForBlockedDomains(baseDir: str, personCache: {}, debug: bool, projectVersion: str, httpPrefix: str, - domain: str) -> {}: + domain: str, + signingPrivateKeyPem: str) -> {}: """Returns a dictionary of posts for blocked domains """ if not outboxUrl: return {} profileStr = 'https://www.w3.org/ns/activitystreams' + acceptStr = \ + 'application/activity+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' + 'Accept': acceptStr } if '/outbox/' in outboxUrl: + acceptStr = \ + 'application/ld+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' + 'Accept': acceptStr } blockedPosts = {} i = 0 - userFeed = parseUserFeed(session, outboxUrl, asHeader, + userFeed = parseUserFeed(signingPrivateKeyPem, + session, outboxUrl, asHeader, projectVersion, httpPrefix, domain, debug) for item in userFeed: i += 1 @@ -966,6 +1091,7 @@ def _createPostS2S(baseDir: str, nickname: str, domain: str, port: int, 'type': 'Collection', 'first': { 'type': 'CollectionPage', + 'next': idStr + '?only_other_accounts=true&page=true', 'partOf': idStr, 'items': [] } @@ -1029,6 +1155,7 @@ def _createPostC2S(baseDir: str, nickname: str, domain: str, port: int, 'type': 'Collection', 'first': { 'type': 'CollectionPage', + 'next': idStr + '?only_other_accounts=true&page=true', 'partOf': idStr, 'items': [] } @@ -1258,10 +1385,13 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, postContext = [ 'https://www.w3.org/ns/activitystreams', { - 'Hashtag': 'as:Hashtag', - 'sensitive': 'as:sensitive', - 'toot': 'http://joinmastodon.org/ns#', - 'votersCount': 'toot:votersCount' + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" } ] @@ -1425,7 +1555,10 @@ def undoPinnedPost(baseDir: str, nickname: str, domain: str) -> None: accountDir = acctDir(baseDir, nickname, domain) pinnedFilename = accountDir + '/pinToProfile.txt' if os.path.isfile(pinnedFilename): - os.remove(pinnedFilename) + try: + os.remove(pinnedFilename) + except BaseException: + pass def getPinnedPostAsJson(baseDir: str, httpPrefix: str, @@ -1463,7 +1596,7 @@ def getPinnedPostAsJson(baseDir: str, httpPrefix: str, 'tag': [], 'to': ['https://www.w3.org/ns/activitystreams#Public'], 'type': 'Note', - 'url': actor.replace('/users/', '/@') + '/pinned' + 'url': replaceUsersWithAt(actor) + '/pinned' } return pinnedPostJson @@ -1860,7 +1993,8 @@ def createDirectMessagePost(baseDir: str, messageJson['cc'] = [] messageJson['object']['cc'] = [] if schedulePost: - savePostToBox(baseDir, httpPrefix, messageJson['object']['id'], + postId = removeIdEnding(messageJson['object']['id']) + savePostToBox(baseDir, httpPrefix, postId, nickname, domain, messageJson, 'scheduled') return messageJson @@ -2024,7 +2158,7 @@ def threadSendPost(session, postJsonStr: str, federationList: [], tries += 1 -def sendPost(projectVersion: str, +def sendPost(signingPrivateKeyPem: str, projectVersion: str, session, baseDir: str, nickname: str, domain: str, port: int, toNickname: str, toDomain: str, toPort: int, cc: str, httpPrefix: str, content: str, followersOnly: bool, @@ -2056,7 +2190,8 @@ def sendPost(projectVersion: str, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: return 1 if not isinstance(wfRequest, dict): @@ -2072,13 +2207,15 @@ def sendPost(projectVersion: str, postToBox = 'tlblogs' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - toPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, postToBox, - 72533) + originDomain = domain + (inboxUrl, pubKeyId, pubKey, toPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, postToBox, + 72533) if not inboxUrl: return 3 @@ -2126,9 +2263,10 @@ def sendPost(projectVersion: str, # construct the http header, including the message body digest signatureHeaderJson = \ - createSignedHeader(privateKeyPem, nickname, domain, port, + createSignedHeader(None, privateKeyPem, nickname, domain, port, toDomain, toPort, - postPath, httpPrefix, withDigest, postJsonStr) + postPath, httpPrefix, withDigest, postJsonStr, + None) # if the "to" domain is within the shared items # federation list then send the token for this domain @@ -2170,7 +2308,7 @@ def sendPost(projectVersion: str, return 0 -def sendPostViaServer(projectVersion: str, +def sendPostViaServer(signingPrivateKeyPem: str, projectVersion: str, baseDir: str, session, fromNickname: str, password: str, fromDomain: str, fromPort: int, toNickname: str, toDomain: str, toPort: int, cc: str, @@ -2199,7 +2337,8 @@ def sendPostViaServer(projectVersion: str, # lookup the inbox for the To handle wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomainFull, projectVersion, debug, False) + fromDomainFull, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: post webfinger failed for ' + handle) @@ -2214,14 +2353,16 @@ def sendPostViaServer(projectVersion: str, postToBox = 'tlblogs' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - fromNickname, - fromDomainFull, postToBox, - 82796) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + fromNickname, + fromDomainFull, postToBox, + 82796) if not inboxUrl: if debug: print('DEBUG: post no ' + postToBox + @@ -2360,7 +2501,9 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str, federationList: [], sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, debug: bool, projectVersion: str, - sharedItemsToken: str, groupAccount: bool) -> int: + sharedItemsToken: str, groupAccount: bool, + signingPrivateKeyPem: str, + sourceId: int) -> int: """Sends a signed json object to an inbox/outbox """ if debug: @@ -2396,7 +2539,8 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, groupAccount) + domain, projectVersion, debug, groupAccount, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: webfinger for ' + handle + ' failed') @@ -2417,12 +2561,15 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str, postToBox = 'outbox' # get the actor inbox/outbox for the To handle + originDomain = domain (inboxUrl, pubKeyId, pubKey, toPersonId, sharedInboxUrl, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, postToBox, - 30873) + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, postToBox, + sourceId) print("inboxUrl: " + str(inboxUrl)) print("toPersonId: " + str(toPersonId)) @@ -2480,9 +2627,10 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str, # construct the http header, including the message body digest signatureHeaderJson = \ - createSignedHeader(privateKeyPem, nickname, domain, port, + createSignedHeader(None, privateKeyPem, nickname, domain, port, toDomain, toPort, - postPath, httpPrefix, withDigest, postJsonStr) + postPath, httpPrefix, withDigest, postJsonStr, + None) # optionally add a token so that the receiving instance may access # your shared items catalog if sharedItemsToken: @@ -2602,7 +2750,8 @@ def sendToNamedAddresses(session, baseDir: str, postJsonObject: {}, debug: bool, projectVersion: str, sharedItemsFederatedDomains: [], - sharedItemFederationTokens: {}) -> None: + sharedItemFederationTokens: {}, + signingPrivateKeyPem: str) -> None: """sends a post to the specific named addresses in to/cc """ if not session: @@ -2739,11 +2888,12 @@ def sendToNamedAddresses(session, baseDir: str, federationList, sendThreads, postLog, cachedWebfingers, personCache, debug, projectVersion, - sharedItemsToken, groupAccount) + sharedItemsToken, groupAccount, + signingPrivateKeyPem, 34436782) def _hasSharedInbox(session, httpPrefix: str, domain: str, - debug: bool) -> bool: + debug: bool, signingPrivateKeyPem: str) -> bool: """Returns true if the given domain has a shared inbox This tries the new and the old way of webfingering the shared inbox """ @@ -2753,7 +2903,8 @@ def _hasSharedInbox(session, httpPrefix: str, domain: str, tryHandles.append('inbox@' + domain) for handle in tryHandles: wfRequest = webfingerHandle(session, handle, httpPrefix, {}, - None, __version__, debug, False) + domain, __version__, debug, False, + signingPrivateKeyPem) if wfRequest: if isinstance(wfRequest, dict): if not wfRequest.get('errors'): @@ -2789,7 +2940,8 @@ def sendToFollowers(session, baseDir: str, postJsonObject: {}, debug: bool, projectVersion: str, sharedItemsFederatedDomains: [], - sharedItemFederationTokens: {}) -> None: + sharedItemFederationTokens: {}, + signingPrivateKeyPem: str) -> None: """sends a post to the followers of the given nickname """ print('sendToFollowers') @@ -2847,8 +2999,9 @@ def sendToFollowers(session, baseDir: str, print('Sending post to followers domain is active: ' + followerDomainUrl) - withSharedInbox = _hasSharedInbox(session, httpPrefix, - followerDomain, debug) + withSharedInbox = \ + _hasSharedInbox(session, httpPrefix, followerDomain, debug, + signingPrivateKeyPem) if debug: if withSharedInbox: print(followerDomain + ' has shared inbox') @@ -2908,7 +3061,8 @@ def sendToFollowers(session, baseDir: str, federationList, sendThreads, postLog, cachedWebfingers, personCache, debug, projectVersion, - sharedItemsToken, groupAccount) + sharedItemsToken, groupAccount, + signingPrivateKeyPem, 639342) else: # send to individual followers without using a shared inbox for handle in followerHandles: @@ -2936,7 +3090,8 @@ def sendToFollowers(session, baseDir: str, federationList, sendThreads, postLog, cachedWebfingers, personCache, debug, projectVersion, - sharedItemsToken, groupAccount) + sharedItemsToken, groupAccount, + signingPrivateKeyPem, 634219) time.sleep(4) @@ -2958,7 +3113,8 @@ def sendToFollowersThread(session, baseDir: str, postJsonObject: {}, debug: bool, projectVersion: str, sharedItemsFederatedDomains: [], - sharedItemFederationTokens: {}): + sharedItemFederationTokens: {}, + signingPrivateKeyPem: str): """Returns a thread used to send a post to followers """ sendThread = \ @@ -2972,7 +3128,8 @@ def sendToFollowersThread(session, baseDir: str, postJsonObject.copy(), debug, projectVersion, sharedItemsFederatedDomains, - sharedItemFederationTokens), daemon=True) + sharedItemFederationTokens, + signingPrivateKeyPem), daemon=True) try: sendThread.start() except SocketError as e: @@ -3145,22 +3302,28 @@ def isImageMedia(session, baseDir: str, httpPrefix: str, nickname: str, domain: str, postJsonObject: {}, translate: {}, YTReplacementDomain: str, + twitterReplacementDomain: str, allowLocalNetworkAccess: bool, recentPostsCache: {}, debug: bool, systemLanguage: str, - domainFull: str, personCache: {}) -> bool: + domainFull: str, personCache: {}, + signingPrivateKeyPem: str) -> bool: """Returns true if the given post has attached image media """ if postJsonObject['type'] == 'Announce': + blockedCache = {} postJsonAnnounce = \ downloadAnnounce(session, baseDir, httpPrefix, nickname, domain, postJsonObject, __version__, translate, YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, recentPostsCache, debug, systemLanguage, - domainFull, personCache) + domainFull, personCache, + signingPrivateKeyPem, + blockedCache) if postJsonAnnounce: postJsonObject = postJsonAnnounce if postJsonObject['type'] != 'Create': @@ -3392,6 +3555,7 @@ def _createBoxIndexed(recentPostsCache: {}, } postsInBox = [] + postUrlsInBox = [] indexFilename = \ acctDir(baseDir, timelineNickname, originalDomain) + \ @@ -3431,6 +3595,9 @@ def _createBoxIndexed(recentPostsCache: {}, postFilename.replace('\n', '').replace('\r', '') postUrl = postUrl.replace('.json', '').strip() + if postUrl in postUrlsInBox: + continue + # is the post cached in memory? if recentPostsCache.get('index'): if postUrl in recentPostsCache['index']: @@ -3441,6 +3608,7 @@ def _createBoxIndexed(recentPostsCache: {}, boxActor): totalPostsCount += 1 postsAddedToTimeline += 1 + postUrlsInBox.append(postUrl) continue else: print('Post not added to timeline') @@ -3458,6 +3626,7 @@ def _createBoxIndexed(recentPostsCache: {}, postsInBox, boxActor): postsAddedToTimeline += 1 totalPostsCount += 1 + postUrlsInBox.append(postUrl) else: print('WARN: Unable to add post ' + postUrl + ' nickname ' + nickname + @@ -3473,6 +3642,7 @@ def _createBoxIndexed(recentPostsCache: {}, postsInBox, boxActor): postsAddedToTimeline += 1 totalPostsCount += 1 + postUrlsInBox.append(postUrl) else: print('WARN: Unable to add features post ' + postUrl + ' nickname ' + nickname + @@ -3703,7 +3873,10 @@ def archivePostsForPerson(httpPrefix: str, nickname: str, domain: str, postCacheFilename = \ os.path.join(postCacheDir, postFilename).replace('.json', '.html') if os.path.isfile(postCacheFilename): - os.remove(postCacheFilename) + try: + os.remove(postCacheFilename) + except BaseException: + pass noOfPosts -= 1 removeCtr += 1 @@ -3723,9 +3896,17 @@ def getPublicPostsOfPerson(baseDir: str, nickname: str, domain: str, raw: bool, simple: bool, proxyType: str, port: int, httpPrefix: str, debug: bool, projectVersion: str, - systemLanguage: str) -> None: + systemLanguage: str, + signingPrivateKeyPem: str, + originDomain: str) -> None: """ This is really just for test purposes """ + if debug: + if signingPrivateKeyPem: + print('Signing key available') + else: + print('Signing key missing') + print('Starting new session for getting public posts') session = createSession(proxyType) if not session: @@ -3744,7 +3925,8 @@ def getPublicPostsOfPerson(baseDir: str, nickname: str, domain: str, wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, groupAccount) + originDomain, projectVersion, debug, groupAccount, + signingPrivateKeyPem) if not wfRequest: if debug: print('No webfinger result was returned for ' + handle) @@ -3756,15 +3938,18 @@ def getPublicPostsOfPerson(baseDir: str, nickname: str, domain: str, if debug: print('Getting the outbox for ' + handle) - (personUrl, pubKeyId, pubKey, - personId, shaedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, 'outbox', - 62524) + (personUrl, pubKeyId, pubKey, personId, shaedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, 'outbox', + 62524) if debug: - print('Actor url: ' + personId) + print('Actor url: ' + str(personId)) + if not personId: + return maxMentions = 10 maxEmoji = 10 @@ -3772,14 +3957,17 @@ def getPublicPostsOfPerson(baseDir: str, nickname: str, domain: str, _getPosts(session, personUrl, 30, maxMentions, maxEmoji, maxAttachments, federationList, personCache, raw, simple, debug, - projectVersion, httpPrefix, domain, systemLanguage) + projectVersion, httpPrefix, originDomain, systemLanguage, + signingPrivateKeyPem) def getPublicPostDomains(session, baseDir: str, nickname: str, domain: str, + originDomain: str, proxyType: str, port: int, httpPrefix: str, debug: bool, projectVersion: str, wordFrequency: {}, domainList: [], - systemLanguage: str) -> []: + systemLanguage: str, + signingPrivateKeyPem: str) -> []: """ Returns a list of domains referenced within public posts """ if not session: @@ -3794,7 +3982,8 @@ def getPublicPostDomains(session, baseDir: str, nickname: str, domain: str, handle = httpPrefix + "://" + domainFull + "/@" + nickname wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: return domainList if not isinstance(wfRequest, dict): @@ -3802,13 +3991,14 @@ def getPublicPostDomains(session, baseDir: str, nickname: str, domain: str, str(wfRequest)) return domainList - (personUrl, pubKeyId, pubKey, - personId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, 'outbox', - 92522) + (personUrl, pubKeyId, pubKey, personId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, 'outbox', + 92522) maxMentions = 99 maxEmoji = 99 maxAttachments = 5 @@ -3817,12 +4007,14 @@ def getPublicPostDomains(session, baseDir: str, nickname: str, domain: str, maxAttachments, federationList, personCache, debug, projectVersion, httpPrefix, domain, - wordFrequency, domainList, systemLanguage) + wordFrequency, domainList, systemLanguage, + signingPrivateKeyPem) postDomains.sort() return postDomains -def downloadFollowCollection(followType: str, +def downloadFollowCollection(signingPrivateKeyPem: str, + followType: str, session, httpPrefix: str, actor: str, pageNumber: int = 1, noOfPages: int = 1, debug: bool = False) -> []: @@ -3831,24 +4023,34 @@ def downloadFollowCollection(followType: str, """ prof = 'https://www.w3.org/ns/activitystreams' if '/channel/' not in actor or '/accounts/' not in actor: + acceptStr = \ + 'application/activity+json; ' + \ + 'profile="' + prof + '"' sessionHeaders = { - 'Accept': 'application/activity+json; profile="' + prof + '"' + 'Accept': acceptStr } else: + acceptStr = \ + 'application/ld+json; ' + \ + 'profile="' + prof + '"' sessionHeaders = { - 'Accept': 'application/ld+json; profile="' + prof + '"' + 'Accept': acceptStr } result = [] for pageCtr in range(noOfPages): url = actor + '/' + followType + '?page=' + str(pageNumber + pageCtr) followersJson = \ - getJson(session, url, sessionHeaders, None, + getJson(signingPrivateKeyPem, session, url, sessionHeaders, None, debug, __version__, httpPrefix, None) if followersJson: if followersJson.get('orderedItems'): for followerActor in followersJson['orderedItems']: if followerActor not in result: result.append(followerActor) + elif followersJson.get('items'): + for followerActor in followersJson['items']: + if followerActor not in result: + result.append(followerActor) else: break else: @@ -3857,9 +4059,11 @@ def downloadFollowCollection(followType: str, def getPublicPostInfo(session, baseDir: str, nickname: str, domain: str, + originDomain: str, proxyType: str, port: int, httpPrefix: str, debug: bool, projectVersion: str, - wordFrequency: {}, systemLanguage: str) -> []: + wordFrequency: {}, systemLanguage: str, + signingPrivateKeyPem: str) -> []: """ Returns a dict of domains referenced within public posts """ if not session: @@ -3874,7 +4078,8 @@ def getPublicPostInfo(session, baseDir: str, nickname: str, domain: str, handle = httpPrefix + "://" + domainFull + "/@" + nickname wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: return {} if not isinstance(wfRequest, dict): @@ -3882,13 +4087,14 @@ def getPublicPostInfo(session, baseDir: str, nickname: str, domain: str, str(wfRequest)) return {} - (personUrl, pubKeyId, pubKey, - personId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, 'outbox', - 13863) + (personUrl, pubKeyId, pubKey, personId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, 'outbox', + 13863) maxMentions = 99 maxEmoji = 99 maxAttachments = 5 @@ -3898,7 +4104,7 @@ def getPublicPostInfo(session, baseDir: str, nickname: str, domain: str, maxAttachments, federationList, personCache, debug, projectVersion, httpPrefix, domain, - wordFrequency, [], systemLanguage) + wordFrequency, [], systemLanguage, signingPrivateKeyPem) postDomains.sort() domainsInfo = {} for d in postDomains: @@ -3913,7 +4119,7 @@ def getPublicPostInfo(session, baseDir: str, nickname: str, domain: str, personCache, debug, projectVersion, httpPrefix, - domain) + domain, signingPrivateKeyPem) for blockedDomain, postUrlList in blockedPosts.items(): domainsInfo[blockedDomain] += postUrlList @@ -3925,15 +4131,19 @@ def getPublicPostDomainsBlocked(session, baseDir: str, proxyType: str, port: int, httpPrefix: str, debug: bool, projectVersion: str, wordFrequency: {}, domainList: [], - systemLanguage: str) -> []: + systemLanguage: str, + signingPrivateKeyPem: str) -> []: """ Returns a list of domains referenced within public posts which are globally blocked on this instance """ + originDomain = domain postDomains = \ getPublicPostDomains(session, baseDir, nickname, domain, + originDomain, proxyType, port, httpPrefix, debug, projectVersion, - wordFrequency, domainList, systemLanguage) + wordFrequency, domainList, systemLanguage, + signingPrivateKeyPem) if not postDomains: return [] @@ -3982,7 +4192,8 @@ def checkDomains(session, baseDir: str, proxyType: str, port: int, httpPrefix: str, debug: bool, projectVersion: str, maxBlockedDomains: int, singleCheck: bool, - systemLanguage: str) -> None: + systemLanguage: str, + signingPrivateKeyPem: str) -> None: """Checks follower accounts for references to globally blocked domains """ wordFrequency = {} @@ -4011,7 +4222,8 @@ def checkDomains(session, baseDir: str, proxyType, port, httpPrefix, debug, projectVersion, wordFrequency, [], - systemLanguage) + systemLanguage, + signingPrivateKeyPem) if blockedDomains: if len(blockedDomains) > maxBlockedDomains: followerWarningStr += handle + '\n' @@ -4032,7 +4244,8 @@ def checkDomains(session, baseDir: str, proxyType, port, httpPrefix, debug, projectVersion, wordFrequency, [], - systemLanguage) + systemLanguage, + signingPrivateKeyPem) if blockedDomains: print(handle) for d in blockedDomains: @@ -4128,11 +4341,15 @@ def _rejectAnnounce(announceFilename: str, def downloadAnnounce(session, baseDir: str, httpPrefix: str, nickname: str, domain: str, postJsonObject: {}, projectVersion: str, - translate: {}, YTReplacementDomain: str, + translate: {}, + YTReplacementDomain: str, + twitterReplacementDomain: str, allowLocalNetworkAccess: bool, recentPostsCache: {}, debug: bool, systemLanguage: str, - domainFull: str, personCache: {}) -> {}: + domainFull: str, personCache: {}, + signingPrivateKeyPem: str, + blockedCache: {}) -> {}: """Download the post referenced by an announce """ if not postJsonObject.get('object'): @@ -4150,7 +4367,7 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, postId = None if postJsonObject.get('id'): - postId = postJsonObject['id'] + postId = removeIdEnding(postJsonObject['id']) announceFilename = \ announceCacheDir + '/' + \ postJsonObject['object'].replace('/', '#') + '.json' @@ -4167,13 +4384,19 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, return postJsonObject else: profileStr = 'https://www.w3.org/ns/activitystreams' + acceptStr = \ + 'application/activity+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' + 'Accept': acceptStr } if '/channel/' in postJsonObject['actor'] or \ '/accounts/' in postJsonObject['actor']: + acceptStr = \ + 'application/ld+json; ' + \ + 'profile="' + profileStr + '"' asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' + 'Accept': acceptStr } actorNickname = getNicknameFromActor(postJsonObject['actor']) actorDomain, actorPort = getDomainFromActor(postJsonObject['actor']) @@ -4205,8 +4428,8 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, print('Downloading Announce content for ' + postJsonObject['object']) announcedJson = \ - getJson(session, postJsonObject['object'], asHeader, - None, debug, projectVersion, httpPrefix, domain) + getJson(signingPrivateKeyPem, session, postJsonObject['object'], + asHeader, None, debug, projectVersion, httpPrefix, domain) if not announcedJson: return None @@ -4223,6 +4446,18 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, baseDir, nickname, domain, postId, recentPostsCache) return None + if not announcedJson.get('type'): + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) + return None + if announcedJson['type'] == 'Video': + convertedJson = \ + convertVideoToNote(baseDir, nickname, domain, + systemLanguage, + announcedJson, blockedCache) + if convertedJson: + announcedJson = convertedJson if '/statuses/' not in announcedJson['id']: _rejectAnnounce(announceFilename, baseDir, nickname, domain, postId, @@ -4233,11 +4468,6 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, baseDir, nickname, domain, postId, recentPostsCache) return None - if not announcedJson.get('type'): - _rejectAnnounce(announceFilename, - baseDir, nickname, domain, postId, - recentPostsCache) - return None if announcedJson['type'] != 'Note' and \ announcedJson['type'] != 'Article': # You can only announce Note or Article types @@ -4322,6 +4552,8 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, return None postJsonObject = announcedJson replaceYouTube(postJsonObject, YTReplacementDomain, systemLanguage) + replaceTwitter(postJsonObject, twitterReplacementDomain, + systemLanguage) if saveJson(postJsonObject, announceFilename): return postJsonObject return None @@ -4350,7 +4582,8 @@ def sendBlockViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, blockedUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a block via c2s """ if not session: @@ -4377,7 +4610,8 @@ def sendBlockViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: block webfinger failed for ' + handle) @@ -4390,12 +4624,14 @@ def sendBlockViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, fromNickname, - fromDomain, postToBox, 72652) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 72652) if not inboxUrl: if debug: @@ -4430,7 +4666,8 @@ def sendMuteViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, mutedUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a mute via c2s """ if not session: @@ -4440,7 +4677,7 @@ def sendMuteViaServer(baseDir: str, session, fromDomainFull = getFullDomain(fromDomain, fromPort) actor = localActorUrl(httpPrefix, fromNickname, fromDomainFull) - handle = actor.replace('/users/', '/@') + handle = replaceUsersWithAt(actor) newMuteJson = { "@context": "https://www.w3.org/ns/activitystreams", @@ -4453,7 +4690,8 @@ def sendMuteViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: mute webfinger failed for ' + handle) @@ -4466,12 +4704,14 @@ def sendMuteViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, fromNickname, - fromDomain, postToBox, 72652) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 72652) if not inboxUrl: if debug: @@ -4506,7 +4746,8 @@ def sendUndoMuteViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, mutedUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Undoes a mute via c2s """ if not session: @@ -4516,7 +4757,7 @@ def sendUndoMuteViaServer(baseDir: str, session, fromDomainFull = getFullDomain(fromDomain, fromPort) actor = localActorUrl(httpPrefix, fromNickname, fromDomainFull) - handle = actor.replace('/users/', '/@') + handle = replaceUsersWithAt(actor) undoMuteJson = { "@context": "https://www.w3.org/ns/activitystreams", @@ -4534,7 +4775,8 @@ def sendUndoMuteViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: undo mute webfinger failed for ' + handle) @@ -4547,12 +4789,14 @@ def sendUndoMuteViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, fromNickname, - fromDomain, postToBox, 72652) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 72652) if not inboxUrl: if debug: @@ -4588,7 +4832,8 @@ def sendUndoBlockViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, blockedUrl: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Creates a block via c2s """ if not session: @@ -4619,7 +4864,8 @@ def sendUndoBlockViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: unblock webfinger failed for ' + handle) @@ -4632,11 +4878,13 @@ def sendUndoBlockViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, avatarUrl, - displayName) = getPersonBox(baseDir, session, wfRequest, personCache, - projectVersion, httpPrefix, fromNickname, - fromDomain, postToBox, 53892) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, personCache, + projectVersion, httpPrefix, fromNickname, + fromDomain, postToBox, 53892) if not inboxUrl: if debug: @@ -4671,24 +4919,30 @@ def postIsMuted(baseDir: str, nickname: str, domain: str, postJsonObject: {}, messageId: str) -> bool: """ Returns true if the given post is muted """ - isMuted = postJsonObject.get('muted') + isMuted = None + if 'muted' in postJsonObject: + isMuted = postJsonObject['muted'] if isMuted is True or isMuted is False: return isMuted + + isMuted = False postDir = acctDir(baseDir, nickname, domain) muteFilename = \ postDir + '/inbox/' + messageId.replace('/', '#') + '.json.muted' if os.path.isfile(muteFilename): - return True - muteFilename = \ - postDir + '/outbox/' + messageId.replace('/', '#') + '.json.muted' - if os.path.isfile(muteFilename): - return True - muteFilename = \ - baseDir + '/accounts/cache/announce/' + nickname + \ - '/' + messageId.replace('/', '#') + '.json.muted' - if os.path.isfile(muteFilename): - return True - return False + isMuted = True + else: + muteFilename = \ + postDir + '/outbox/' + messageId.replace('/', '#') + '.json.muted' + if os.path.isfile(muteFilename): + isMuted = True + else: + muteFilename = \ + baseDir + '/accounts/cache/announce/' + nickname + \ + '/' + messageId.replace('/', '#') + '.json.muted' + if os.path.isfile(muteFilename): + isMuted = True + return isMuted def c2sBoxJson(baseDir: str, session, @@ -4696,7 +4950,7 @@ def c2sBoxJson(baseDir: str, session, domain: str, port: int, httpPrefix: str, boxName: str, pageNumber: int, - debug: bool) -> {}: + debug: bool, signingPrivateKeyPem: str) -> {}: """C2S Authenticated GET of posts for a timeline """ if not session: @@ -4718,7 +4972,7 @@ def c2sBoxJson(baseDir: str, session, # GET json url = actor + '/' + boxName + '?page=' + str(pageNumber) - boxJson = getJson(session, url, headers, None, + boxJson = getJson(signingPrivateKeyPem, session, url, headers, None, debug, __version__, httpPrefix, None) if boxJson is not None and debug: diff --git a/question.py b/question.py index 9157aeea6..54f8f4485 100644 --- a/question.py +++ b/question.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" diff --git a/roles.py b/roles.py index 1dccf2073..8dc8466f9 100644 --- a/roles.py +++ b/roles.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/schedule.py b/schedule.py index 3395b47df..112bbfeef 100644 --- a/schedule.py +++ b/schedule.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Calendar" @@ -46,7 +46,10 @@ def _updatePostSchedule(baseDir: str, handle: str, httpd, if deleteSchedulePost: # delete extraneous scheduled posts if os.path.isfile(postFilename): - os.remove(postFilename) + try: + os.remove(postFilename) + except BaseException: + pass continue # create the new index file indexLines.append(line) @@ -110,14 +113,23 @@ def _updatePostSchedule(baseDir: str, handle: str, httpd, httpd.projectVersion, httpd.debug, httpd.YTReplacementDomain, + httpd.twitterReplacementDomain, httpd.showPublishedDateOnly, httpd.allowLocalNetworkAccess, httpd.city, httpd.systemLanguage, httpd.sharedItemsFederatedDomains, httpd.sharedItemFederationTokens, - httpd.lowBandwidth): + httpd.lowBandwidth, + httpd.signingPrivateKeyPem, + httpd.peertubeInstances, + httpd.themeName, + httpd.maxLikeCount, + httpd.maxRecentPosts): indexLines.remove(line) - os.remove(postFilename) + try: + os.remove(postFilename) + except BaseException: + pass continue # move to the outbox @@ -185,7 +197,10 @@ def removeScheduledPosts(baseDir: str, nickname: str, domain: str) -> None: scheduleIndexFilename = \ acctDir(baseDir, nickname, domain) + '/schedule.index' if os.path.isfile(scheduleIndexFilename): - os.remove(scheduleIndexFilename) + try: + os.remove(scheduleIndexFilename) + except BaseException: + pass # remove the scheduled posts scheduledDir = acctDir(baseDir, nickname, domain) + '/scheduled' if not os.path.isdir(scheduledDir): @@ -194,6 +209,9 @@ def removeScheduledPosts(baseDir: str, nickname: str, domain: str) -> None: filePath = os.path.join(scheduledDir, scheduledPostFilename) try: if os.path.isfile(filePath): - os.remove(filePath) + try: + os.remove(filePath) + except BaseException: + pass except BaseException: pass diff --git a/scripts/epicyon-notification b/scripts/epicyon-notification index 4b6205d63..a92db2b01 100755 --- a/scripts/epicyon-notification +++ b/scripts/epicyon-notification @@ -11,7 +11,7 @@ # License # ======= # -# Copyright (C) 2020-2021 Bob Mottram +# Copyright (C) 2020-2021 Bob Mottram # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by diff --git a/session.py b/session.py index f0e60db0b..ce1b6160e 100644 --- a/session.py +++ b/session.py @@ -3,14 +3,15 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" -__module_group__ = "Core" +__module_group__ = "Session" import os import requests from utils import urlPermitted from utils import isImageFile +from httpsig import createSignedHeader import json from socket import error as SocketError import errno @@ -84,43 +85,23 @@ def urlExists(session, url: str, timeoutSec: int = 3, return False -def getJson(session, url: str, headers: {}, params: {}, debug: bool, - version: str = '1.2.0', httpPrefix: str = 'https', - domain: str = 'testdomain', - timeoutSec: int = 20, quiet: bool = False) -> {}: - if not isinstance(url, str): - if debug and not quiet: - print('url: ' + str(url)) - print('ERROR: getJson failed, url should be a string') - return None - sessionParams = {} - sessionHeaders = {} - if headers: - sessionHeaders = headers - if params: - sessionParams = params - sessionHeaders['User-Agent'] = 'Epicyon/' + version - if domain: - sessionHeaders['User-Agent'] += \ - '; +' + httpPrefix + '://' + domain + '/' - if not session: - if not quiet: - print('WARN: getJson failed, no session specified for getJson') - return None - - if debug: - HTTPConnection.debuglevel = 1 - +def _getJsonRequest(session, url: str, domainFull: str, sessionHeaders: {}, + sessionParams: {}, timeoutSec: int, + signingPrivateKeyPem: str, quiet: bool, debug: bool) -> {}: + """http GET for json + """ try: result = session.get(url, headers=sessionHeaders, params=sessionParams, timeout=timeoutSec) if result.status_code != 200: if result.status_code == 401: - print('WARN: getJson Unauthorized url: ' + url) + print("WARN: getJson " + url + ' rejected by secure mode') elif result.status_code == 403: print('WARN: getJson Forbidden url: ' + url) elif result.status_code == 404: print('WARN: getJson Not Found url: ' + url) + elif result.status_code == 410: + print('WARN: getJson no longer available url: ' + url) else: print('WARN: getJson url: ' + url + ' failed with error code ' + @@ -151,6 +132,115 @@ def getJson(session, url: str, headers: {}, params: {}, debug: bool, return None +def _getJsonSigned(session, url: str, domainFull: str, sessionHeaders: {}, + sessionParams: {}, timeoutSec: int, + signingPrivateKeyPem: str, quiet: bool, debug: bool) -> {}: + """Authorized fetch - a signed version of GET + """ + if not domainFull: + if debug: + print('No sending domain for signed GET') + return None + if '://' not in url: + print('Invalid url: ' + url) + return None + httpPrefix = url.split('://')[0] + toDomainFull = url.split('://')[1] + if '/' in toDomainFull: + toDomainFull = toDomainFull.split('/')[0] + + if ':' in domainFull: + domain = domainFull.split(':')[0] + port = domainFull.split(':')[1] + else: + domain = domainFull + if httpPrefix == 'https': + port = 443 + else: + port = 80 + + if ':' in toDomainFull: + toDomain = toDomainFull.split(':')[0] + toPort = toDomainFull.split(':')[1] + else: + toDomain = toDomainFull + if httpPrefix == 'https': + toPort = 443 + else: + toPort = 80 + + if debug: + print('Signed GET domain: ' + domain + ' ' + str(port)) + print('Signed GET toDomain: ' + toDomain + ' ' + str(toPort)) + print('Signed GET url: ' + url) + print('Signed GET httpPrefix: ' + httpPrefix) + messageStr = '' + withDigest = False + if toDomainFull + '/' in url: + path = '/' + url.split(toDomainFull + '/')[1] + else: + path = '/actor' + contentType = 'application/activity+json' + if sessionHeaders.get('Accept'): + contentType = sessionHeaders['Accept'] + signatureHeaderJson = \ + createSignedHeader(None, signingPrivateKeyPem, 'actor', domain, port, + toDomain, toPort, path, httpPrefix, withDigest, + messageStr, contentType) + if debug: + print('Signed GET signatureHeaderJson ' + str(signatureHeaderJson)) + # update the session headers from the signature headers + sessionHeaders['Host'] = signatureHeaderJson['host'] + sessionHeaders['Date'] = signatureHeaderJson['date'] + sessionHeaders['Accept'] = signatureHeaderJson['accept'] + sessionHeaders['Signature'] = signatureHeaderJson['signature'] + sessionHeaders['Content-Length'] = '0' + # if debug: + print('Signed GET sessionHeaders ' + str(sessionHeaders)) + + return _getJsonRequest(session, url, domainFull, sessionHeaders, + sessionParams, timeoutSec, None, quiet, debug) + + +def getJson(signingPrivateKeyPem: str, + session, url: str, headers: {}, params: {}, debug: bool, + version: str = '1.2.0', httpPrefix: str = 'https', + domain: str = 'testdomain', + timeoutSec: int = 20, quiet: bool = False) -> {}: + if not isinstance(url, str): + if debug and not quiet: + print('url: ' + str(url)) + print('ERROR: getJson failed, url should be a string') + return None + sessionParams = {} + sessionHeaders = {} + if headers: + sessionHeaders = headers + if params: + sessionParams = params + sessionHeaders['User-Agent'] = 'Epicyon/' + version + if domain: + sessionHeaders['User-Agent'] += \ + '; +' + httpPrefix + '://' + domain + '/' + if not session: + if not quiet: + print('WARN: getJson failed, no session specified for getJson') + return None + + if debug: + HTTPConnection.debuglevel = 1 + + if signingPrivateKeyPem: + return _getJsonSigned(session, url, domain, + sessionHeaders, sessionParams, + timeoutSec, signingPrivateKeyPem, + quiet, debug) + else: + return _getJsonRequest(session, url, domain, sessionHeaders, + sessionParams, timeoutSec, + None, quiet, debug) + + def postJson(httpPrefix: str, domainFull: str, session, postJsonObject: {}, federationList: [], inboxUrl: str, headers: {}, timeoutSec: int = 60, diff --git a/setup.cfg b/setup.cfg index 2a7d568f6..bf4669a6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,9 +2,9 @@ name = epicyon version = 1.3.0 author = Bob Mottram -author_email = bob@freedombone.net +author_email = bob@libreserver.org maintainer = Bob Mottram -maintainer_email = bob@freedombone.net +maintainer_email = bob@libreserver.org description = A modern ActivityPub compliant server implementing both S2S and C2S protocols. long_description = file: README.md long_description_content_type = text/markdown diff --git a/shares.py b/shares.py index e4b7e4045..9865472b9 100644 --- a/shares.py +++ b/shares.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -47,7 +47,8 @@ from blocking import isBlocked def _loadDfcIds(baseDir: str, systemLanguage: str, - productType: str) -> {}: + productType: str, + httpPrefix: str, domainFull: str) -> {}: """Loads the product types ontology This is used to add an id to shared items """ @@ -92,7 +93,10 @@ def _loadDfcIds(baseDir: str, systemLanguage: str, if not label.get('@value'): continue if label['@language'] == systemLanguage: - dfcIds[label['@value'].lower()] = item['@id'] + itemId = \ + item['@id'].replace('http://static.datafoodconsortium.org', + httpPrefix + '://' + domainFull) + dfcIds[label['@value'].lower()] = itemId break return dfcIds @@ -142,7 +146,10 @@ def removeSharedItem(baseDir: str, nickname: str, domain: str, for ext in formats: if sharesJson[itemID]['imageUrl'].endswith('.' + ext): if os.path.isfile(itemIDfile + '.' + ext): - os.remove(itemIDfile + '.' + ext) + try: + os.remove(itemIDfile + '.' + ext) + except BaseException: + pass # remove the item itself del sharesJson[itemID] saveJson(sharesJson, sharesFilename) @@ -193,7 +200,9 @@ def _dfcProductTypeFromCategory(baseDir: str, def _getshareDfcId(baseDir: str, systemLanguage: str, itemType: str, itemCategory: str, - translate: {}, dfcIds: {} = None) -> str: + translate: {}, + httpPrefix: str, domainFull: str, + dfcIds: {} = None) -> str: """Attempts to obtain a DFC Id for the shared item, based upon productTypes ontology. See https://github.com/datafoodconsortium/ontology @@ -207,7 +216,8 @@ def _getshareDfcId(baseDir: str, systemLanguage: str, itemType = itemType.replace('.', '') return 'epicyon#' + itemType if not dfcIds: - dfcIds = _loadDfcIds(baseDir, systemLanguage, matchedProductType) + dfcIds = _loadDfcIds(baseDir, systemLanguage, matchedProductType, + httpPrefix, domainFull) if not dfcIds: return '' itemTypeLower = itemType.lower() @@ -316,7 +326,8 @@ def addShare(baseDir: str, actor = localActorUrl(httpPrefix, nickname, domainFull) itemID = _getValidSharedItemID(actor, displayName) dfcId = _getshareDfcId(baseDir, systemLanguage, - itemType, itemCategory, translate) + itemType, itemCategory, translate, + httpPrefix, domainFull) # has an image for this share been uploaded? imageUrl = None @@ -350,7 +361,10 @@ def addShare(baseDir: str, imageFilename, itemIDfile + '.' + ext, city) if moveImage: - os.remove(imageFilename) + try: + os.remove(imageFilename) + except BaseException: + pass imageUrl = \ httpPrefix + '://' + domainFull + \ '/sharefiles/' + nickname + '/' + itemID + '.' + ext @@ -419,7 +433,10 @@ def _expireSharesForAccount(baseDir: str, nickname: str, domain: str, formats = getImageExtensions() for ext in formats: if os.path.isfile(itemIDfile + '.' + ext): - os.remove(itemIDfile + '.' + ext) + try: + os.remove(itemIDfile + '.' + ext) + except BaseException: + pass saveJson(sharesJson, sharesFilename) @@ -535,7 +552,8 @@ def sendShareViaServer(baseDir, session, location: str, duration: str, cachedWebfingers: {}, personCache: {}, debug: bool, projectVersion: str, - itemPrice: str, itemCurrency: str) -> {}: + itemPrice: str, itemCurrency: str, + signingPrivateKeyPem: str) -> {}: """Creates an item share via c2s """ if not session: @@ -585,7 +603,8 @@ def sendShareViaServer(baseDir, session, wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: share webfinger failed for ' + handle) @@ -598,13 +617,15 @@ def sendShareViaServer(baseDir, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, fromNickname, - fromDomain, postToBox, - 83653) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, fromNickname, + fromDomain, postToBox, + 83653) if not inboxUrl: if debug: @@ -652,7 +673,8 @@ def sendUndoShareViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, displayName: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Undoes a share via c2s """ if not session: @@ -685,7 +707,8 @@ def sendUndoShareViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: unshare webfinger failed for ' + handle) @@ -698,13 +721,15 @@ def sendUndoShareViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, fromNickname, - fromDomain, postToBox, - 12663) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, fromNickname, + fromDomain, postToBox, + 12663) if not inboxUrl: if debug: @@ -747,7 +772,8 @@ def sendWantedViaServer(baseDir, session, location: str, duration: str, cachedWebfingers: {}, personCache: {}, debug: bool, projectVersion: str, - itemMaxPrice: str, itemCurrency: str) -> {}: + itemMaxPrice: str, itemCurrency: str, + signingPrivateKeyPem: str) -> {}: """Creates a wanted item via c2s """ if not session: @@ -797,7 +823,8 @@ def sendWantedViaServer(baseDir, session, wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: share webfinger failed for ' + handle) @@ -810,13 +837,15 @@ def sendWantedViaServer(baseDir, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, fromNickname, - fromDomain, postToBox, - 83653) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, fromNickname, + fromDomain, postToBox, + 23653) if not inboxUrl: if debug: @@ -864,7 +893,8 @@ def sendUndoWantedViaServer(baseDir: str, session, fromDomain: str, fromPort: int, httpPrefix: str, displayName: str, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Undoes a wanted item via c2s """ if not session: @@ -897,7 +927,8 @@ def sendUndoWantedViaServer(baseDir: str, session, # lookup the inbox for the To handle wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion, debug, False) + fromDomain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: unwant webfinger failed for ' + handle) @@ -910,13 +941,15 @@ def sendUndoWantedViaServer(baseDir: str, session, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, fromNickname, - fromDomain, postToBox, - 12663) + originDomain = fromDomain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, fromNickname, + fromDomain, postToBox, + 12693) if not inboxUrl: if debug: @@ -953,7 +986,8 @@ def sendUndoWantedViaServer(baseDir: str, session, def getSharedItemsCatalogViaServer(baseDir, session, nickname: str, password: str, domain: str, port: int, - httpPrefix: str, debug: bool) -> {}: + httpPrefix: str, debug: bool, + signingPrivateKeyPem: str) -> {}: """Returns the shared items catalog via c2s """ if not session: @@ -972,8 +1006,8 @@ def getSharedItemsCatalogViaServer(baseDir, session, url = localActorUrl(httpPrefix, nickname, domainFull) + '/catalog' if debug: print('Shared items catalog request to: ' + url) - catalogJson = getJson(session, url, headers, None, debug, - __version__, httpPrefix, None) + catalogJson = getJson(signingPrivateKeyPem, session, url, headers, None, + debug, __version__, httpPrefix, None) if not catalogJson: if debug: print('DEBUG: GET shared items catalog failed for c2s to ' + url) @@ -1129,12 +1163,14 @@ def sharesCatalogAccountEndpoint(baseDir: str, httpPrefix: str, sharesFileType: str) -> {}: """Returns the endpoint for the shares catalog of a particular account See https://github.com/datafoodconsortium/ontology + Also the subdirectory ontology/DFC """ today, minPrice, maxPrice, matchPattern = _sharesCatalogParams(path) dfcUrl = \ - "http://static.datafoodconsortium.org/ontologies/DFC_FullModel.owl#" + httpPrefix + '://' + domainFull + '/ontologies/DFC_FullModel.owl#' dfcPtUrl = \ - "http://static.datafoodconsortium.org/data/productTypes.rdf#" + httpPrefix + '://' + domainFull + \ + '/ontologies/DFC_ProductGlossary.rdf#' owner = localActorUrl(httpPrefix, nickname, domainFull) if sharesFileType == 'shares': dfcInstanceId = owner + '/catalog' @@ -1217,12 +1253,14 @@ def sharesCatalogEndpoint(baseDir: str, httpPrefix: str, path: str, sharesFileType: str) -> {}: """Returns the endpoint for the shares catalog for the instance See https://github.com/datafoodconsortium/ontology + Also the subdirectory ontology/DFC """ today, minPrice, maxPrice, matchPattern = _sharesCatalogParams(path) dfcUrl = \ - "http://static.datafoodconsortium.org/ontologies/DFC_FullModel.owl#" + httpPrefix + '://' + domainFull + '/ontologies/DFC_FullModel.owl#' dfcPtUrl = \ - "http://static.datafoodconsortium.org/data/productTypes.rdf#" + httpPrefix + '://' + domainFull + \ + '/ontologies/DFC_ProductGlossary.rdf#' dfcInstanceId = httpPrefix + '://' + domainFull + '/catalog' endpoint = { "@context": { @@ -1323,7 +1361,8 @@ def sharesCatalogCSVEndpoint(baseDir: str, httpPrefix: str, csvStr += str(item['DFC:quantity']) + ',' csvStr += item['DFC:price'].split(' ')[0] + ',' csvStr += '"' + item['DFC:price'].split(' ')[1] + '",' - csvStr += '"' + item['DFC:Image'] + '",' + if item.get('DFC:Image'): + csvStr += '"' + item['DFC:Image'] + '",' description = item['DFC:description'].replace('"', "'") csvStr += '"' + description + '",\n' return csvStr @@ -1550,7 +1589,8 @@ def _updateFederatedSharesCache(session, sharedItemsFederatedDomains: [], if saveJson(catalogJson, catalogFilename): print('Downloaded shared items catalog for ' + federatedDomainFull) sharesJson = _dfcToSharesFormat(catalogJson, - baseDir, systemLanguage) + baseDir, systemLanguage, + httpPrefix, domainFull) if sharesJson: sharesFilename = \ catalogsDir + '/' + federatedDomainFull + '.' + \ @@ -1709,7 +1749,8 @@ def runFederatedSharesDaemon(baseDir: str, httpd, httpPrefix: str, def _dfcToSharesFormat(catalogJson: {}, - baseDir: str, systemLanguage: str) -> {}: + baseDir: str, systemLanguage: str, + httpPrefix: str, domainFull: str) -> {}: """Converts DFC format into the internal formal used to store shared items. This simplifies subsequent search and display """ @@ -1720,7 +1761,8 @@ def _dfcToSharesFormat(catalogJson: {}, dfcIds = {} productTypesList = getCategoryTypes(baseDir) for productType in productTypesList: - dfcIds[productType] = _loadDfcIds(baseDir, systemLanguage, productType) + dfcIds[productType] = _loadDfcIds(baseDir, systemLanguage, productType, + httpPrefix, domainFull) currTime = int(time.time()) for item in catalogJson['DFC:supplies']: @@ -1731,7 +1773,6 @@ def _dfcToSharesFormat(catalogJson: {}, not item.get('DFC:expiryDate') or \ not item.get('DFC:quantity') or \ not item.get('DFC:price') or \ - not item.get('DFC:Image') or \ not item.get('DFC:description'): continue @@ -1780,10 +1821,13 @@ def _dfcToSharesFormat(catalogJson: {}, itemID = item['@id'] description = item['DFC:description'].split(':', 1)[1].strip() + imageUrl = '' + if item.get('DFC:Image'): + imageUrl = item['DFC:Image'] sharesJson[itemID] = { "displayName": item['DFC:description'].split(':')[0], "summary": description, - "imageUrl": item['DFC:Image'], + "imageUrl": imageUrl, "itemQty": float(item['DFC:quantity']), "dfcId": dfcId, "itemType": itemType, @@ -1795,3 +1839,17 @@ def _dfcToSharesFormat(catalogJson: {}, "itemCurrency": item['DFC:price'].split(' ')[1] } return sharesJson + + +def shareCategoryIcon(category: str) -> str: + """Returns unicode icon for the given category + """ + categoryIcons = { + 'accommodation': '🏠', + 'clothes': '👚', + 'tools': '🔧', + 'food': '🍏' + } + if categoryIcons.get(category): + return categoryIcons[category] + return '' diff --git a/siteactive.py b/siteactive.py index 3fb7173e9..171e43f90 100644 --- a/siteactive.py +++ b/siteactive.py @@ -4,7 +4,7 @@ __credits__ = ["webchk"] __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" diff --git a/skills.py b/skills.py index af8e125a4..fd2061807 100644 --- a/skills.py +++ b/skills.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" @@ -177,7 +177,8 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, httpPrefix: str, skill: str, skillLevelPercent: int, cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: + debug: bool, projectVersion: str, + signingPrivateKeyPem: str) -> {}: """Sets a skill for a person via c2s """ if not session: @@ -209,7 +210,8 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: if debug: print('DEBUG: skill webfinger failed for ' + handle) @@ -222,12 +224,14 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, postToBox = 'outbox' # get the actor inbox for the To handle - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, projectVersion, - httpPrefix, nickname, domain, - postToBox, 86725) + originDomain = domain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, projectVersion, + httpPrefix, nickname, domain, + postToBox, 76121) if not inboxUrl: if debug: diff --git a/socnet.py b/socnet.py index cd2d4da72..fb3a5143e 100644 --- a/socnet.py +++ b/socnet.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Moderation" @@ -18,7 +18,7 @@ def instancesGraph(baseDir: str, handles: str, proxyType: str, port: int, httpPrefix: str, debug: bool, projectVersion: str, - systemLanguage: str) -> str: + systemLanguage: str, signingPrivateKeyPem: str) -> str: """ Returns a dot graph of federating instances based upon a few sample handles. The handles argument should contain a comma separated list @@ -54,7 +54,8 @@ def instancesGraph(baseDir: str, handles: str, wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion, debug, False) + domain, projectVersion, debug, False, + signingPrivateKeyPem) if not wfRequest: return dotGraphStr + '}\n' if not isinstance(wfRequest, dict): @@ -62,20 +63,23 @@ def instancesGraph(baseDir: str, handles: str, str(wfRequest)) return dotGraphStr + '}\n' - (personUrl, pubKeyId, pubKey, - personId, shaedInbox, - avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, - personCache, - projectVersion, httpPrefix, - nickname, domain, 'outbox', - 27261) + originDomain = None + (personUrl, pubKeyId, pubKey, personId, shaedInbox, avatarUrl, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, wfRequest, + personCache, + projectVersion, httpPrefix, + nickname, domain, 'outbox', + 27261) wordFrequency = {} postDomains = \ getPostDomains(session, personUrl, 64, maxMentions, maxEmoji, maxAttachments, federationList, personCache, debug, projectVersion, httpPrefix, domain, - wordFrequency, [], systemLanguage) + wordFrequency, [], systemLanguage, + signingPrivateKeyPem) postDomains.sort() for fedDomain in postDomains: dotLineStr = ' "' + domain + '" -> "' + fedDomain + '";\n' diff --git a/speaker.py b/speaker.py index 7d81a48e9..341e63a91 100644 --- a/speaker.py +++ b/speaker.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Accessibility" @@ -11,6 +11,7 @@ import os import html import random import urllib.parse +from utils import removeIdEnding from utils import isDM from utils import isReply from utils import camelCaseSplit @@ -489,7 +490,7 @@ def _postToSpeakerJson(baseDir: str, httpPrefix: str, announcedHandle + '. ' + content postId = None if postJsonObject['object'].get('id'): - postId = postJsonObject['object']['id'] + postId = removeIdEnding(postJsonObject['object']['id']) followRequestsExist = False followRequestsList = [] diff --git a/ssb.py b/ssb.py index 2c2a3af7a..44a43a2aa 100644 --- a/ssb.py +++ b/ssb.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/tests.py b/tests.py index 4064b1a8a..efc908115 100644 --- a/tests.py +++ b/tests.py @@ -3,10 +3,17 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Testing" +import base64 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.primitives.serialization import load_pem_public_key +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric import utils as hazutils import time import os import shutil @@ -16,6 +23,7 @@ from shutil import copyfile from random import randint from time import gmtime, strftime from pprint import pprint +from httpsig import createSignedHeader from httpsig import signPostHeaders from httpsig import signPostHeadersNew from httpsig import verifyPostHeaders @@ -42,6 +50,9 @@ from follow import clearFollowers from follow import sendFollowRequestViaServer from follow import sendUnfollowRequestViaServer from siteactive import siteIsActive +from utils import getSHA256 +from utils import dangerousSVG +from utils import canReplyTo from utils import isGroupAccount from utils import getActorLanguagesList from utils import getCategoryTypes @@ -125,6 +136,7 @@ from content import removeTextFormatting from content import removeHtmlTag from theme import updateDefaultThemesList from theme import setCSSparam +from theme import scanThemesForScripts from linked_data_sig import generateJsonSignature from linked_data_sig import verifyJsonSignature from newsdaemon import hashtagRuleTree @@ -161,6 +173,216 @@ thrBob = None thrEve = None +def _testHttpSignedGET(baseDir: str): + print('testHttpSignedGET') + httpPrefix = 'https' + debug = True + + boxpath = "/users/Actor" + host = "epicyon.libreserver.org" + content_length = "0" + user_agent = "http.rb/4.4.1 (Mastodon/3.4.1; +https://octodon.social/)" + dateStr = 'Wed, 01 Sep 2021 16:11:10 GMT' + accept_encoding = 'gzip' + accept = \ + 'application/activity+json, application/ld+json' + signature = \ + 'keyId="https://octodon.social/actor#main-key",' + \ + 'algorithm="rsa-sha256",' + \ + 'headers="(request-target) host date accept",' + \ + 'signature="Fe53PS9A2OSP4x+W/svhA' + \ + 'jUKHBvnAR73Ez+H32au7DQklLk08Lvm8al' + \ + 'LS7pCor28yfyx+DfZADgq6G1mLLRZo0OOn' + \ + 'PFSog7DhdcygLhBUMS0KlT5KVGwUS0tw' + \ + 'jdiHv4OC83RiCr/ZySBgOv65YLHYmGCi5B' + \ + 'IqSZJRkqi8+SLmLGESlNOEzKu+jIxOBY' + \ + 'mEEdIpNrDeE5YrFKpfTC3vS2GnxGOo5J/4' + \ + 'lB2h+dlUpso+sv5rDz1d1FsqRWK8waV7' + \ + '4HUfLV+qbgYRceOTyZIi50vVqLvt9CTQes' + \ + 'KZHG3GrrPfaBuvoUbR4MCM3BUvpB7EzL' + \ + '9F17Y+Ea9mo8zjqzZm8HaZQ=="' + publicKeyPem = \ + '-----BEGIN PUBLIC KEY-----\n' + \ + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMII' + \ + 'BCgKCAQEA1XT+ov/i4LDYuaXCwh4r\n' + \ + '2rVfWtnz68wnFx3knwymwtRoAc/SFGzp9ye' + \ + '5ogG1uPcbe7MeirZHhaBICynPlL32\n' + \ + 's9OYootI7MsQWn+vu7azxiXO7qcTPByvGcl' + \ + '0vpLhtT/ApmlMintkRTVXdzBdJVM0\n' + \ + 'UsmYKg6U+IHNL+a1gURHGXep2Ih0BJMh4Aa' + \ + 'DbaID6jtpJZvbIkYgJ4IJucOe+A3T\n' + \ + 'YPMwkBA84ew+hso+vKQfTunyDInuPQbEzrA' + \ + 'zMJXEHS7IpBhdS4/cEox86BoDJ/q0\n' + \ + 'KOEOUpUDniFYWb9k1+9B387OviRDLIcLxNZ' + \ + 'nf+bNq8d+CwEXY2xGsToBle/q74d8\n' + \ + 'BwIDAQAB\n' + \ + '-----END PUBLIC KEY-----\n' + headers = { + "user-agent": user_agent, + "content-length": content_length, + "host": host, + "date": dateStr, + "accept": accept, + "accept-encoding": accept_encoding, + "signature": signature + } + GETmethod = True + messageBodyDigest = None + messageBodyJsonStr = '' + noRecencyCheck = True + assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, + boxpath, GETmethod, messageBodyDigest, + messageBodyJsonStr, debug, noRecencyCheck) + # Change a single character and the signature should fail + headers['date'] = headers['date'].replace(':10', ':11') + assert not verifyPostHeaders(httpPrefix, publicKeyPem, headers, + boxpath, GETmethod, messageBodyDigest, + messageBodyJsonStr, debug, noRecencyCheck) + + path = baseDir + '/.testHttpsigGET' + if os.path.isdir(path): + shutil.rmtree(path) + os.mkdir(path) + os.chdir(path) + + nickname = 'testactor' + hostDomain = 'someother.instance' + domain = 'argumentative.social' + httpPrefix = 'https' + port = 443 + withDigest = False + password = 'SuperSecretPassword' + noRecencyCheck = True + privateKeyPem, publicKeyPem, person, wfEndpoint = \ + createPerson(path, nickname, domain, port, httpPrefix, + False, False, password) + assert privateKeyPem + assert publicKeyPem + messageBodyJsonStr = '' + + headersDomain = getFullDomain(hostDomain, port) + + dateStr = 'Tue, 14 Sep 2021 16:19:00 GMT' + boxpath = '/inbox' + accept = 'application/json' +# accept = 'application/activity+json' + headers = { + 'user-agent': 'Epicyon/1.2.0; +https://' + domain + '/', + 'host': headersDomain, + 'date': dateStr, + 'accept': accept, + 'content-length': 0 + } + signatureHeader = createSignedHeader(dateStr, + privateKeyPem, nickname, + domain, port, + hostDomain, port, + boxpath, httpPrefix, False, + None, accept) + + headers['signature'] = signatureHeader['signature'] + GETmethod = not withDigest + assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, + boxpath, GETmethod, None, + messageBodyJsonStr, debug, noRecencyCheck) + if os.path.isdir(path): + shutil.rmtree(path) + + +def _testSignAndVerify() -> None: + print('testSignAndVerify') + publicKeyPem = \ + '-----BEGIN RSA PUBLIC KEY-----\n' + \ + 'MIIBCgKCAQEAhAKYdtoeoy8zcAcR874L8' + \ + 'cnZxKzAGwd7v36APp7Pv6Q2jdsPBRrw\n' + \ + 'WEBnez6d0UDKDwGbc6nxfEXAy5mbhgajz' + \ + 'rw3MOEt8uA5txSKobBpKDeBLOsdJKFq\n' + \ + 'MGmXCQvEG7YemcxDTRPxAleIAgYYRjTSd' + \ + '/QBwVW9OwNFhekro3RtlinV0a75jfZg\n' + \ + 'kne/YiktSvLG34lw2zqXBDTC5NHROUqGT' + \ + 'lML4PlNZS5Ri2U4aCNx2rUPRcKIlE0P\n' + \ + 'uKxI4T+HIaFpv8+rdV6eUgOrB2xeI1dSF' + \ + 'Fn/nnv5OoZJEIB+VmuKn3DCUcCZSFlQ\n' + \ + 'PSXSfBDiUGhwOw76WuSSsf1D4b/vLoJ10wIDAQAB\n' + \ + '-----END RSA PUBLIC KEY-----\n' + + privateKeyPem = \ + '-----BEGIN RSA PRIVATE KEY-----\n' + \ + 'MIIEqAIBAAKCAQEAhAKYdtoeoy8zcAcR8' + \ + '74L8cnZxKzAGwd7v36APp7Pv6Q2jdsP\n' + \ + 'BRrwWEBnez6d0UDKDwGbc6nxfEXAy5mbh' + \ + 'gajzrw3MOEt8uA5txSKobBpKDeBLOsd\n' + \ + 'JKFqMGmXCQvEG7YemcxDTRPxAleIAgYYR' + \ + 'jTSd/QBwVW9OwNFhekro3RtlinV0a75\n' + \ + 'jfZgkne/YiktSvLG34lw2zqXBDTC5NHRO' + \ + 'UqGTlML4PlNZS5Ri2U4aCNx2rUPRcKI\n' + \ + 'lE0PuKxI4T+HIaFpv8+rdV6eUgOrB2xeI' + \ + '1dSFFn/nnv5OoZJEIB+VmuKn3DCUcCZ\n' + \ + 'SFlQPSXSfBDiUGhwOw76WuSSsf1D4b/vL' + \ + 'oJ10wIDAQABAoIBAG/JZuSWdoVHbi56\n' + \ + 'vjgCgkjg3lkO1KrO3nrdm6nrgA9P9qaPj' + \ + 'xuKoWaKO1cBQlE1pSWp/cKncYgD5WxE\n' + \ + 'CpAnRUXG2pG4zdkzCYzAh1i+c34L6oZoH' + \ + 'sirK6oNcEnHveydfzJL5934egm6p8DW\n' + \ + '+m1RQ70yUt4uRc0YSor+q1LGJvGQHReF0' + \ + 'WmJBZHrhz5e63Pq7lE0gIwuBqL8SMaA\n' + \ + 'yRXtK+JGxZpImTq+NHvEWWCu09SCq0r83' + \ + '8ceQI55SvzmTkwqtC+8AT2zFviMZkKR\n' + \ + 'Qo6SPsrqItxZWRty2izawTF0Bf5S2VAx7' + \ + 'O+6t3wBsQ1sLptoSgX3QblELY5asI0J\n' + \ + 'YFz7LJECgYkAsqeUJmqXE3LP8tYoIjMIA' + \ + 'KiTm9o6psPlc8CrLI9CH0UbuaA2JCOM\n' + \ + 'cCNq8SyYbTqgnWlB9ZfcAm/cFpA8tYci9' + \ + 'm5vYK8HNxQr+8FS3Qo8N9RJ8d0U5Csw\n' + \ + 'DzMYfRghAfUGwmlWj5hp1pQzAuhwbOXFt' + \ + 'xKHVsMPhz1IBtF9Y8jvgqgYHLbmyiu1\n' + \ + 'mwJ5AL0pYF0G7x81prlARURwHo0Yf52kE' + \ + 'w1dxpx+JXER7hQRWQki5/NsUEtv+8RT\n' + \ + 'qn2m6qte5DXLyn83b1qRscSdnCCwKtKWU' + \ + 'ug5q2ZbwVOCJCtmRwmnP131lWRYfj67\n' + \ + 'B/xJ1ZA6X3GEf4sNReNAtaucPEelgR2ns' + \ + 'N0gKQKBiGoqHWbK1qYvBxX2X3kbPDkv\n' + \ + '9C+celgZd2PW7aGYLCHq7nPbmfDV0yHcW' + \ + 'jOhXZ8jRMjmANVR/eLQ2EfsRLdW69bn\n' + \ + 'f3ZD7JS1fwGnO3exGmHO3HZG+6AvberKY' + \ + 'VYNHahNFEw5TsAcQWDLRpkGybBcxqZo\n' + \ + '81YCqlqidwfeO5YtlO7etx1xLyqa2NsCe' + \ + 'G9A86UjG+aeNnXEIDk1PDK+EuiThIUa\n' + \ + '/2IxKzJKWl1BKr2d4xAfR0ZnEYuRrbeDQ' + \ + 'YgTImOlfW6/GuYIxKYgEKCFHFqJATAG\n' + \ + 'IxHrq1PDOiSwXd2GmVVYyEmhZnbcp8Cxa' + \ + 'EMQoevxAta0ssMK3w6UsDtvUvYvF22m\n' + \ + 'qQKBiD5GwESzsFPy3Ga0MvZpn3D6EJQLg' + \ + 'snrtUPZx+z2Ep2x0xc5orneB5fGyF1P\n' + \ + 'WtP+fG5Q6Dpdz3LRfm+KwBCWFKQjg7uTx' + \ + 'cjerhBWEYPmEMKYwTJF5PBG9/ddvHLQ\n' + \ + 'EQeNC8fHGg4UXU8mhHnSBt3EA10qQJfRD' + \ + 's15M38eG2cYwB1PZpDHScDnDA0=\n' + \ + '-----END RSA PRIVATE KEY-----' + + # sign + signedHeaderText = \ + '(request-target): get /actor\n' + \ + 'host: octodon.social\n' + \ + 'date: Tue, 14 Sep 2021 16:19:00 GMT\n' + \ + 'accept: application/json' + headerDigest = getSHA256(signedHeaderText.encode('ascii')) + key = load_pem_private_key(privateKeyPem.encode('utf-8'), + None, backend=default_backend()) + rawSignature = key.sign(headerDigest, + padding.PKCS1v15(), + hazutils.Prehashed(hashes.SHA256())) + signature1 = base64.b64encode(rawSignature).decode('ascii') + + # verify + paddingStr = padding.PKCS1v15() + alg = hazutils.Prehashed(hashes.SHA256()) + pubkey = load_pem_public_key(publicKeyPem.encode('utf-8'), + backend=default_backend()) + signature2 = base64.b64decode(signature1) + pubkey.verify(signature2, headerDigest, paddingStr, alg) + + def _testHttpSigNew(): print('testHttpSigNew') messageBodyJson = {"hello": "world"} @@ -330,10 +552,9 @@ def _testHttpSigNew(): '/aYa/GhW2pSrctDnAKIi4imj9joppr3CB8gqgXZOPQ==:' -def _testHttpsigBase(withDigest): +def _testHttpsigBase(withDigest: bool, baseDir: str): print('testHttpsig(' + str(withDigest) + ')') - baseDir = os.getcwd() path = baseDir + '/.testHttpsigBase' if os.path.isdir(path): shutil.rmtree(path) @@ -342,6 +563,7 @@ def _testHttpsigBase(withDigest): contentType = 'application/activity+json' nickname = 'socrates' + hostDomain = 'someother.instance' domain = 'argumentative.social' httpPrefix = 'https' port = 5576 @@ -350,14 +572,17 @@ def _testHttpsigBase(withDigest): createPerson(path, nickname, domain, port, httpPrefix, False, False, password) assert privateKeyPem - messageBodyJson = { - "a key": "a value", - "another key": "A string", - "yet another key": "Another string" - } - messageBodyJsonStr = json.dumps(messageBodyJson) + if withDigest: + messageBodyJson = { + "a key": "a value", + "another key": "A string", + "yet another key": "Another string" + } + messageBodyJsonStr = json.dumps(messageBodyJson) + else: + messageBodyJsonStr = '' - headersDomain = getFullDomain(domain, port) + headersDomain = getFullDomain(hostDomain, port) dateStr = strftime("%a, %d %b %Y %H:%M:%S %Z", gmtime()) boxpath = '/inbox' @@ -365,13 +590,13 @@ def _testHttpsigBase(withDigest): headers = { 'host': headersDomain, 'date': dateStr, - 'content-type': 'application/json' + 'accept': contentType } signatureHeader = \ signPostHeaders(dateStr, privateKeyPem, nickname, domain, port, - domain, port, - boxpath, httpPrefix, None) + hostDomain, port, + boxpath, httpPrefix, None, contentType) else: bodyDigest = messageContentDigest(messageBodyJsonStr) contentLength = len(messageBodyJsonStr) @@ -385,31 +610,33 @@ def _testHttpsigBase(withDigest): signatureHeader = \ signPostHeaders(dateStr, privateKeyPem, nickname, domain, port, - domain, port, - boxpath, httpPrefix, messageBodyJsonStr) + hostDomain, port, + boxpath, httpPrefix, messageBodyJsonStr, + contentType) headers['signature'] = signatureHeader + GETmethod = not withDigest assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, - boxpath, False, None, + boxpath, GETmethod, None, messageBodyJsonStr, False) if withDigest: # everything correct except for content-length headers['content-length'] = str(contentLength + 2) assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, - boxpath, False, None, + boxpath, GETmethod, None, messageBodyJsonStr, False) is False assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, - '/parambulator' + boxpath, False, None, + '/parambulator' + boxpath, GETmethod, None, messageBodyJsonStr, False) is False assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, - boxpath, True, None, + boxpath, not GETmethod, None, messageBodyJsonStr, False) is False if not withDigest: # fake domain headers = { 'host': 'bogon.domain', 'date': dateStr, - 'content-type': 'application/json' + 'content-type': contentType } else: # correct domain but fake message @@ -427,16 +654,16 @@ def _testHttpsigBase(withDigest): } headers['signature'] = signatureHeader assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, - boxpath, True, None, + boxpath, not GETmethod, None, messageBodyJsonStr, False) is False os.chdir(baseDir) shutil.rmtree(path) -def _testHttpsig(): - _testHttpsigBase(True) - _testHttpsigBase(False) +def _testHttpsig(baseDir: str): + _testHttpsigBase(True, baseDir) + _testHttpsigBase(False, baseDir) def _testCache(): @@ -585,8 +812,10 @@ def createServerAlice(path: str, domain: str, port: int, logLoginFailures = False userAgentsBlocked = [] maxLikeCount = 10 + defaultReplyIntervalHours = 9999999999 print('Server running: Alice') - runDaemon(lowBandwidth, maxLikeCount, + runDaemon(defaultReplyIntervalHours, + lowBandwidth, maxLikeCount, sharedItemsFederatedDomains, userAgentsBlocked, logLoginFailures, city, @@ -602,7 +831,7 @@ def createServerAlice(path: str, domain: str, port: int, 0, False, 1, False, False, False, 5, True, True, 'en', __version__, "instanceId", False, path, domain, - onionDomain, i2pDomain, None, port, port, + onionDomain, i2pDomain, None, None, port, port, httpPrefix, federationList, maxMentions, maxEmoji, False, proxyType, maxReplies, domainMaxPostsPerDay, accountMaxPostsPerDay, @@ -721,8 +950,10 @@ def createServerBob(path: str, domain: str, port: int, logLoginFailures = False userAgentsBlocked = [] maxLikeCount = 10 + defaultReplyIntervalHours = 9999999999 print('Server running: Bob') - runDaemon(lowBandwidth, maxLikeCount, + runDaemon(defaultReplyIntervalHours, + lowBandwidth, maxLikeCount, sharedItemsFederatedDomains, userAgentsBlocked, logLoginFailures, city, @@ -738,7 +969,7 @@ def createServerBob(path: str, domain: str, port: int, False, 1, False, False, False, 5, True, True, 'en', __version__, "instanceId", False, path, domain, - onionDomain, i2pDomain, None, port, port, + onionDomain, i2pDomain, None, None, port, port, httpPrefix, federationList, maxMentions, maxEmoji, False, proxyType, maxReplies, domainMaxPostsPerDay, accountMaxPostsPerDay, @@ -786,8 +1017,10 @@ def createServerEve(path: str, domain: str, port: int, federationList: [], userAgentsBlocked = [] maxLikeCount = 10 lowBandwidth = True + defaultReplyIntervalHours = 9999999999 print('Server running: Eve') - runDaemon(lowBandwidth, maxLikeCount, + runDaemon(defaultReplyIntervalHours, + lowBandwidth, maxLikeCount, sharedItemsFederatedDomains, userAgentsBlocked, logLoginFailures, city, @@ -803,7 +1036,7 @@ def createServerEve(path: str, domain: str, port: int, federationList: [], False, 1, False, False, False, 5, True, True, 'en', __version__, "instanceId", False, path, domain, - onionDomain, i2pDomain, None, port, port, + onionDomain, i2pDomain, None, None, port, port, httpPrefix, federationList, maxMentions, maxEmoji, False, proxyType, maxReplies, allowDeletion, True, True, False, sendThreads, False) @@ -853,8 +1086,10 @@ def createServerGroup(path: str, domain: str, port: int, userAgentsBlocked = [] maxLikeCount = 10 lowBandwidth = True + defaultReplyIntervalHours = 9999999999 print('Server running: Group') - runDaemon(lowBandwidth, maxLikeCount, + runDaemon(defaultReplyIntervalHours, + lowBandwidth, maxLikeCount, sharedItemsFederatedDomains, userAgentsBlocked, logLoginFailures, city, @@ -870,7 +1105,7 @@ def createServerGroup(path: str, domain: str, port: int, 0, False, 1, False, False, False, 5, True, True, 'en', __version__, "instanceId", False, path, domain, - onionDomain, i2pDomain, None, port, port, + onionDomain, i2pDomain, None, None, port, port, httpPrefix, federationList, maxMentions, maxEmoji, False, proxyType, maxReplies, domainMaxPostsPerDay, accountMaxPostsPerDay, @@ -878,7 +1113,7 @@ def createServerGroup(path: str, domain: str, port: int, False) -def testPostMessageBetweenServers(): +def testPostMessageBetweenServers(baseDir: str) -> None: print('Testing sending message from one server to the inbox of another') global testServerAliceRunning @@ -890,7 +1125,6 @@ def testPostMessageBetweenServers(): httpPrefix = 'http' proxyType = None - baseDir = os.getcwd() if os.path.isdir(baseDir + '/.tests'): shutil.rmtree(baseDir + '/.tests') os.mkdir(baseDir + '/.tests') @@ -978,8 +1212,9 @@ def testPostMessageBetweenServers(): assert len([name for name in os.listdir(outboxPath) if os.path.isfile(os.path.join(outboxPath, name))]) == 0 lowBandwidth = False + signingPrivateKeyPem = None sendResult = \ - sendPost(__version__, + sendPost(signingPrivateKeyPem, __version__, sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, 'bob', bobDomain, bobPort, ccUrl, httpPrefix, 'Why is a mouse when it spins? ' + @@ -1100,7 +1335,7 @@ def testPostMessageBetweenServers(): 'alice', aliceDomain, alicePort, [], statusNumber, False, bobSendThreads, bobPostLog, bobPersonCache, bobCachedWebfingers, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(20): if 'likes' in open(outboxPostFilename).read(): @@ -1135,7 +1370,7 @@ def testPostMessageBetweenServers(): objectUrl, False, bobSendThreads, bobPostLog, bobPersonCache, bobCachedWebfingers, - True, __version__) + True, __version__, signingPrivateKeyPem) announceMessageArrived = False outboxMessageArrived = False for i in range(10): @@ -1176,7 +1411,7 @@ def testPostMessageBetweenServers(): shutil.rmtree(bobDir) -def testFollowBetweenServers(): +def testFollowBetweenServers(baseDir: str) -> None: print('Testing sending a follow request from one server to another') global testServerAliceRunning @@ -1189,7 +1424,6 @@ def testFollowBetweenServers(): proxyType = None federationList = [] - baseDir = os.getcwd() if os.path.isdir(baseDir + '/.tests'): shutil.rmtree(baseDir + '/.tests') os.mkdir(baseDir + '/.tests') @@ -1270,6 +1504,7 @@ def testFollowBetweenServers(): aliceCachedWebfingers = {} alicePostLog = [] bobActor = httpPrefix + '://' + bobAddress + '/users/bob' + signingPrivateKeyPem = None sendResult = \ sendFollowRequest(sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, httpPrefix, @@ -1278,7 +1513,7 @@ def testFollowBetweenServers(): clientToServer, federationList, aliceSendThreads, alicePostLog, aliceCachedWebfingers, alicePersonCache, - True, __version__) + True, __version__, signingPrivateKeyPem) print('sendResult: ' + str(sendResult)) for t in range(16): @@ -1315,8 +1550,9 @@ def testFollowBetweenServers(): isArticle = False city = 'London, England' lowBandwidth = False + signingPrivateKeyPem = None sendResult = \ - sendPost(__version__, + sendPost(signingPrivateKeyPem, __version__, sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, 'bob', bobDomain, bobPort, ccUrl, httpPrefix, 'Alice message', followersOnly, saveToFile, @@ -1362,7 +1598,7 @@ def testFollowBetweenServers(): shutil.rmtree(baseDir + '/.tests') -def testSharedItemsFederation(): +def testSharedItemsFederation(baseDir: str) -> None: print('Testing federation of shared items between Alice and Bob') global testServerAliceRunning @@ -1375,7 +1611,6 @@ def testSharedItemsFederation(): proxyType = None federationList = [] - baseDir = os.getcwd() if os.path.isdir(baseDir + '/.tests'): shutil.rmtree(baseDir + '/.tests') os.mkdir(baseDir + '/.tests') @@ -1441,6 +1676,25 @@ def testSharedItemsFederation(): assert ctr <= 60 time.sleep(1) + signingPrivateKeyPem = None + sessionClient = createSession(proxyType) + + # Get Bob's instance actor + print('\n\n*********************************************************') + print("Test Bob's instance actor") + profileStr = 'https://www.w3.org/ns/activitystreams' + testHeaders = { + 'host': bobAddress, + 'Accept': 'application/ld+json; profile="' + profileStr + '"' + } + bobInstanceActorJson = \ + getJson(signingPrivateKeyPem, sessionClient, + 'http://' + bobAddress + '/@actor', testHeaders, {}, True, + __version__, 'http', 'somedomain.or.other', 10, True) + assert bobInstanceActorJson + pprint(bobInstanceActorJson) + assert bobInstanceActorJson['name'] == 'ACTOR' + # In the beginning all was calm and there were no follows print('\n\n*********************************************************') @@ -1474,7 +1728,7 @@ def testSharedItemsFederation(): clientToServer, federationList, aliceSendThreads, alicePostLog, aliceCachedWebfingers, alicePersonCache, - True, __version__) + True, __version__, signingPrivateKeyPem) print('sendResult: ' + str(sendResult)) for t in range(16): @@ -1512,11 +1766,16 @@ def testSharedItemsFederation(): bobDir + '/ontology/toolTypes.json') copyfile(baseDir + '/ontology/clothesTypes.json', bobDir + '/ontology/clothesTypes.json') + copyfile(baseDir + '/ontology/medicalTypes.json', + bobDir + '/ontology/medicalTypes.json') + copyfile(baseDir + '/ontology/accommodationTypes.json', + bobDir + '/ontology/accommodationTypes.json') assert os.path.isfile(bobDir + '/logo.png') assert os.path.isfile(bobDir + '/ontology/foodTypes.json') assert os.path.isfile(bobDir + '/ontology/toolTypes.json') assert os.path.isfile(bobDir + '/ontology/clothesTypes.json') - sessionBob = createSession(proxyType) + assert os.path.isfile(bobDir + '/ontology/medicalTypes.json') + assert os.path.isfile(bobDir + '/ontology/accommodationTypes.json') sharedItemName = 'cheddar' sharedItemDescription = 'Some cheese' sharedItemImageFilename = 'logo.png' @@ -1527,6 +1786,8 @@ def testSharedItemsFederation(): sharedItemDuration = "10 days" sharedItemPrice = "1.30" sharedItemCurrency = "EUR" + signingPrivateKeyPem = None + sessionBob = createSession(proxyType) shareJson = \ sendShareViaServer(bobDir, sessionBob, 'bob', bobPassword, @@ -1537,7 +1798,8 @@ def testSharedItemsFederation(): sharedItemLocation, sharedItemDuration, bobCachedWebfingers, bobPersonCache, True, __version__, - sharedItemPrice, sharedItemCurrency) + sharedItemPrice, sharedItemCurrency, + signingPrivateKeyPem) assert shareJson assert isinstance(shareJson, dict) sharedItemName = 'Epicyon T-shirt' @@ -1560,7 +1822,8 @@ def testSharedItemsFederation(): sharedItemLocation, sharedItemDuration, bobCachedWebfingers, bobPersonCache, True, __version__, - sharedItemPrice, sharedItemCurrency) + sharedItemPrice, sharedItemCurrency, + signingPrivateKeyPem) assert shareJson assert isinstance(shareJson, dict) sharedItemName = 'Soldering iron' @@ -1583,7 +1846,8 @@ def testSharedItemsFederation(): sharedItemLocation, sharedItemDuration, bobCachedWebfingers, bobPersonCache, True, __version__, - sharedItemPrice, sharedItemCurrency) + sharedItemPrice, sharedItemCurrency, + signingPrivateKeyPem) assert shareJson assert isinstance(shareJson, dict) @@ -1605,9 +1869,11 @@ def testSharedItemsFederation(): print('\n\n*********************************************************') print('Bob can read the shared items catalog on his own instance') + signingPrivateKeyPem = None catalogJson = \ getSharedItemsCatalogViaServer(bobDir, sessionBob, 'bob', bobPassword, - bobDomain, bobPort, httpPrefix, True) + bobDomain, bobPort, httpPrefix, True, + signingPrivateKeyPem) assert catalogJson pprint(catalogJson) assert 'DFC:supplies' in catalogJson @@ -1633,8 +1899,9 @@ def testSharedItemsFederation(): isArticle = False city = 'London, England' lowBandwidth = False + signingPrivateKeyPem = None sendResult = \ - sendPost(__version__, + sendPost(signingPrivateKeyPem, __version__, sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, 'bob', bobDomain, bobPort, ccUrl, httpPrefix, 'Alice message', followersOnly, saveToFile, @@ -1697,7 +1964,9 @@ def testSharedItemsFederation(): 'Accept': 'application/json' } url = httpPrefix + '://' + bobAddress + '/catalog' - catalogJson = getJson(sessionAlice, url, headers, None, True) + signingPrivateKeyPem = None + catalogJson = getJson(signingPrivateKeyPem, sessionAlice, url, headers, + None, True) assert catalogJson pprint(catalogJson) assert 'DFC:supplies' in catalogJson @@ -1723,7 +1992,7 @@ def testSharedItemsFederation(): 'Alice and Bob is complete') -def testGroupFollow(): +def testGroupFollow(baseDir: str) -> None: print('Testing following of a group') global testServerAliceRunning @@ -1739,7 +2008,6 @@ def testGroupFollow(): proxyType = None federationList = [] - baseDir = os.getcwd() if os.path.isdir(baseDir + '/.tests'): shutil.rmtree(baseDir + '/.tests') os.mkdir(baseDir + '/.tests') @@ -1836,8 +2104,9 @@ def testGroupFollow(): asHeader = { 'Accept': 'application/ld+json; profile="' + profileStr + '"' } - outboxJson = getJson(session, aliceOutbox, asHeader, None, - True, __version__, 'http', None) + signingPrivateKeyPem = None + outboxJson = getJson(signingPrivateKeyPem, session, aliceOutbox, asHeader, + None, True, __version__, 'http', None) assert outboxJson pprint(outboxJson) assert outboxJson['type'] == 'OrderedCollection' @@ -1847,8 +2116,8 @@ def testGroupFollow(): print('Alice outbox totalItems: ' + str(outboxJson['totalItems'])) assert outboxJson['totalItems'] == 3 - outboxJson = getJson(session, firstPage, asHeader, None, - True, __version__, 'http', None) + outboxJson = getJson(signingPrivateKeyPem, session, firstPage, asHeader, + None, True, __version__, 'http', None) assert outboxJson pprint(outboxJson) assert 'orderedItems' in outboxJson @@ -1879,6 +2148,7 @@ def testGroupFollow(): alicePostLog = [] # aliceActor = httpPrefix + '://' + aliceAddress + '/users/alice' testgroupActor = httpPrefix + '://' + testgroupAddress + '/users/testgroup' + signingPrivateKeyPem = None sendResult = \ sendFollowRequest(sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, httpPrefix, @@ -1887,7 +2157,7 @@ def testGroupFollow(): clientToServer, federationList, aliceSendThreads, alicePostLog, aliceCachedWebfingers, alicePersonCache, - True, __version__) + True, __version__, signingPrivateKeyPem) print('sendResult: ' + str(sendResult)) aliceFollowingFilename = \ @@ -1954,6 +2224,7 @@ def testGroupFollow(): bobPostLog = [] # bobActor = httpPrefix + '://' + bobAddress + '/users/bob' testgroupActor = httpPrefix + '://' + testgroupAddress + '/users/testgroup' + signingPrivateKeyPem = None sendResult = \ sendFollowRequest(sessionBob, bobDir, 'bob', bobDomain, bobPort, httpPrefix, @@ -1962,7 +2233,7 @@ def testGroupFollow(): clientToServer, federationList, bobSendThreads, bobPostLog, bobCachedWebfingers, bobPersonCache, - True, __version__) + True, __version__, signingPrivateKeyPem) print('sendResult: ' + str(sendResult)) bobFollowingFilename = \ @@ -2025,8 +2296,24 @@ def testGroupFollow(): isArticle = False city = 'London, England' lowBandwidth = False + signingPrivateKeyPem = None + + queuePath = \ + testgroupDir + '/accounts/testgroup@' + testgroupDomain + '/queue' + inboxPath = \ + testgroupDir + '/accounts/testgroup@' + testgroupDomain + '/inbox' + outboxPath = \ + testgroupDir + '/accounts/testgroup@' + testgroupDomain + '/outbox' + aliceMessageArrived = False + startPostsInbox = \ + len([name for name in os.listdir(inboxPath) + if os.path.isfile(os.path.join(inboxPath, name))]) + startPostsOutbox = \ + len([name for name in os.listdir(outboxPath) + if os.path.isfile(os.path.join(outboxPath, name))]) + sendResult = \ - sendPost(__version__, + sendPost(signingPrivateKeyPem, __version__, sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, 'testgroup', testgroupDomain, testgroupPort, ccUrl, httpPrefix, "Alice group message", followersOnly, @@ -2039,20 +2326,17 @@ def testGroupFollow(): inReplyTo, inReplyToAtomUri, subject) print('sendResult: ' + str(sendResult)) - queuePath = \ - testgroupDir + '/accounts/testgroup@' + testgroupDomain + '/queue' - inboxPath = \ - testgroupDir + '/accounts/testgroup@' + testgroupDomain + '/inbox' - aliceMessageArrived = False - startPosts = len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) for i in range(20): time.sleep(1) if os.path.isdir(inboxPath): - currPosts = \ + currPostsInbox = \ len([name for name in os.listdir(inboxPath) if os.path.isfile(os.path.join(inboxPath, name))]) - if currPosts > startPosts: + currPostsOutbox = \ + len([name for name in os.listdir(outboxPath) + if os.path.isfile(os.path.join(outboxPath, name))]) + if currPostsInbox > startPostsInbox and \ + currPostsOutbox > startPostsOutbox: aliceMessageArrived = True print('Alice post sent to test group!') break @@ -2078,6 +2362,20 @@ def testGroupFollow(): assert bobMessageArrived is True + # check that the received post has an id from the group, + # not from the original sender (alice) + groupIdChecked = False + for name in os.listdir(inboxPathBob): + filename = os.path.join(inboxPathBob, name) + if os.path.isfile(filename): + receivedJson = loadJson(filename) + assert receivedJson + print('Received group post ' + receivedJson['id']) + assert '/testgroup/statuses/' in receivedJson['id'] + groupIdChecked = True + break + assert groupIdChecked + # stop the servers thrAlice.kill() thrAlice.join() @@ -2101,9 +2399,9 @@ def testGroupFollow(): print('Testing following of a group is complete') -def _testFollowersOfPerson(): +def _testFollowersOfPerson(baseDir: str) -> None: print('testFollowersOfPerson') - currDir = os.getcwd() + currDir = baseDir nickname = 'mxpop' domain = 'diva.domain' password = 'birb' @@ -2150,9 +2448,9 @@ def _testFollowersOfPerson(): shutil.rmtree(baseDir) -def _testNoOfFollowersOnDomain(): +def _testNoOfFollowersOnDomain(baseDir: str) -> None: print('testNoOfFollowersOnDomain') - currDir = os.getcwd() + currDir = baseDir nickname = 'mxpop' domain = 'diva.domain' otherdomain = 'soup.dragon' @@ -2212,10 +2510,10 @@ def _testNoOfFollowersOnDomain(): shutil.rmtree(baseDir) -def _testGroupFollowers(): +def _testGroupFollowers(baseDir: str) -> None: print('testGroupFollowers') - currDir = os.getcwd() + currDir = baseDir nickname = 'test735' domain = 'mydomain.com' password = 'somepass' @@ -2257,9 +2555,9 @@ def _testGroupFollowers(): shutil.rmtree(baseDir) -def _testFollows(): +def _testFollows(baseDir: str) -> None: print('testFollows') - currDir = os.getcwd() + currDir = baseDir nickname = 'test529' domain = 'testdomain.com' password = 'mypass' @@ -2335,10 +2633,10 @@ def _testFollows(): shutil.rmtree(baseDir) -def _testCreatePerson(): +def _testCreatePerson(baseDir: str): print('testCreatePerson') systemLanguage = 'en' - currDir = os.getcwd() + currDir = baseDir nickname = 'test382' domain = 'badgerdomain.com' password = 'mypass' @@ -2391,9 +2689,21 @@ def _testCreatePerson(): shutil.rmtree(baseDir) -def _testAuthentication(): +def showTestBoxes(name: str, inboxPath: str, outboxPath: str) -> None: + inboxPosts = \ + len([name for name in os.listdir(inboxPath) + if os.path.isfile(os.path.join(inboxPath, name))]) + outboxPosts = \ + len([name for name in os.listdir(outboxPath) + if os.path.isfile(os.path.join(outboxPath, name))]) + print('EVENT: ' + name + + ' inbox has ' + str(inboxPosts) + ' posts and ' + + str(outboxPosts) + ' outbox posts') + + +def _testAuthentication(baseDir: str) -> None: print('testAuthentication') - currDir = os.getcwd() + currDir = baseDir nickname = 'test8743' password = 'SuperSecretPassword12345' @@ -2431,8 +2741,8 @@ def _testAuthentication(): shutil.rmtree(baseDir) -def testClientToServer(): - print('Testing sending a post via c2s') +def testClientToServer(baseDir: str): + print('EVENT: Testing sending a post via c2s') global testServerAliceRunning global testServerBobRunning @@ -2445,7 +2755,6 @@ def testClientToServer(): federationList = [] lowBandwidth = False - baseDir = os.getcwd() if os.path.isdir(baseDir + '/.tests'): shutil.rmtree(baseDir + '/.tests') os.mkdir(baseDir + '/.tests') @@ -2509,7 +2818,7 @@ def testClientToServer(): time.sleep(1) print('\n\n*******************************************************') - print('Alice sends to Bob via c2s') + print('EVENT: Alice sends to Bob via c2s') sessionAlice = createSession(proxyType) followersOnly = False @@ -2522,14 +2831,28 @@ def testClientToServer(): personCache = {} password = 'alicepass' conversationId = None + + aliceInboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/inbox' + aliceOutboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/outbox' + bobInboxPath = bobDir + '/accounts/bob@' + bobDomain + '/inbox' + bobOutboxPath = bobDir + '/accounts/bob@' + bobDomain + '/outbox' + outboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/outbox' inboxPath = bobDir + '/accounts/bob@' + bobDomain + '/inbox' - assert len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]) == 0 - assert len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) == 0 + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + assert len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 0 + assert len([name for name in os.listdir(aliceOutboxPath) + if os.path.isfile(os.path.join(aliceOutboxPath, name))]) == 0 + assert len([name for name in os.listdir(bobInboxPath) + if os.path.isfile(os.path.join(bobInboxPath, name))]) == 0 + assert len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) == 0 + print('EVENT: all inboxes and outboxes are empty') + signingPrivateKeyPem = None sendResult = \ - sendPostViaServer(__version__, + sendPostViaServer(signingPrivateKeyPem, __version__, aliceDir, sessionAlice, 'alice', password, aliceDomain, alicePort, 'bob', bobDomain, bobPort, None, @@ -2550,23 +2873,32 @@ def testClientToServer(): break time.sleep(1) - assert len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]) == 1 - print(">>> c2s post arrived in Alice's outbox") + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + assert len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 0 + assert len([name for name in os.listdir(aliceOutboxPath) + if os.path.isfile(os.path.join(aliceOutboxPath, name))]) == 1 + print(">>> c2s post arrived in Alice's outbox\n\n\n") for i in range(30): if os.path.isdir(inboxPath): - if len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) == 1: + if len([name for name in os.listdir(bobInboxPath) + if os.path.isfile(os.path.join(bobInboxPath, name))]) == 1: break time.sleep(1) - assert len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) == 1 - print(">>> s2s post arrived in Bob's inbox") - print("c2s send success") + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + assert len([name for name in os.listdir(bobInboxPath) + if os.path.isfile(os.path.join(bobInboxPath, name))]) == 1 + assert len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) == 0 - print('\n\nGetting message id for the post') + print(">>> s2s post arrived in Bob's inbox") + print("c2s send success\n\n\n") + + print('\n\nEVENT: Getting message id for the post') statusNumber = 0 outboxPostFilename = None outboxPostId = None @@ -2585,13 +2917,14 @@ def testClientToServer(): aliceDomain, alicePort) print('\n\nAlice follows Bob') + signingPrivateKeyPem = None sendFollowRequestViaServer(aliceDir, sessionAlice, 'alice', password, aliceDomain, alicePort, 'bob', bobDomain, bobPort, httpPrefix, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) alicePetnamesFilename = aliceDir + '/accounts/' + \ 'alice@' + aliceDomain + '/petnames.txt' aliceFollowingFilename = \ @@ -2626,14 +2959,14 @@ def testClientToServer(): assert validInboxFilenames(bobDir, 'bob', bobDomain, aliceDomain, alicePort) - print('\n\nBob follows Alice') + print('\n\nEVENT: Bob follows Alice') sendFollowRequestViaServer(aliceDir, sessionAlice, 'bob', 'bobpass', bobDomain, bobPort, 'alice', aliceDomain, alicePort, httpPrefix, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for t in range(10): if os.path.isfile(aliceDir + '/accounts/alice@' + aliceDomain + '/followers.txt'): @@ -2666,25 +2999,29 @@ def testClientToServer(): assert 'alice@' + aliceDomain + ':' + str(alicePort) in \ open(bobDir + '/accounts/bob@' + bobDomain + '/following.txt').read() - print('\n\nBob likes the post') sessionBob = createSession(proxyType) password = 'bobpass' outboxPath = bobDir + '/accounts/bob@' + bobDomain + '/outbox' inboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/inbox' - print(str(len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]))) - assert len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]) == 1 - print(str(len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]))) - assert len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) == 1 + print(str(len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]))) + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + assert len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) == 1 + print(str(len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]))) + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + assert len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 0 + print('\n\nEVENT: Bob likes the post') sendLikeViaServer(bobDir, sessionBob, 'bob', 'bobpass', bobDomain, bobPort, httpPrefix, outboxPostId, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(20): if os.path.isdir(outboxPath) and os.path.isdir(inboxPath): if len([name for name in os.listdir(outboxPath) @@ -2694,26 +3031,34 @@ def testClientToServer(): if test == 1: break time.sleep(1) - assert len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]) == 2 - assert len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) == 1 - print('Post liked') + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + assert len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) == 2 + assert len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 0 + print('EVENT: Post liked') - print('\n\nBob repeats the post') print(str(len([name for name in os.listdir(outboxPath) if os.path.isfile(os.path.join(outboxPath, name))]))) + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) assert len([name for name in os.listdir(outboxPath) if os.path.isfile(os.path.join(outboxPath, name))]) == 2 print(str(len([name for name in os.listdir(inboxPath) if os.path.isfile(os.path.join(inboxPath, name))]))) - assert len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) == 1 + assert len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 0 + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + print('\n\nEVENT: Bob repeats the post') + signingPrivateKeyPem = None sendAnnounceViaServer(bobDir, sessionBob, 'bob', password, bobDomain, bobPort, httpPrefix, outboxPostId, cachedWebfingers, - personCache, True, __version__) + personCache, True, __version__, + signingPrivateKeyPem) for i in range(20): if os.path.isdir(outboxPath) and os.path.isdir(inboxPath): if len([name for name in os.listdir(outboxPath) @@ -2724,25 +3069,27 @@ def testClientToServer(): break time.sleep(1) - assert len([name for name in os.listdir(outboxPath) - if os.path.isfile(os.path.join(outboxPath, name))]) == 3 - assert len([name for name in os.listdir(inboxPath) - if os.path.isfile(os.path.join(inboxPath, name))]) == 2 - print('Post repeated') + showTestBoxes('alice', aliceInboxPath, aliceOutboxPath) + showTestBoxes('bob', bobInboxPath, bobOutboxPath) + assert len([name for name in os.listdir(bobOutboxPath) + if os.path.isfile(os.path.join(bobOutboxPath, name))]) == 4 + assert len([name for name in os.listdir(aliceInboxPath) + if os.path.isfile(os.path.join(aliceInboxPath, name))]) == 1 + print('EVENT: Post repeated') inboxPath = bobDir + '/accounts/bob@' + bobDomain + '/inbox' outboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/outbox' postsBefore = \ len([name for name in os.listdir(inboxPath) if os.path.isfile(os.path.join(inboxPath, name))]) - print('\n\nAlice deletes her post: ' + outboxPostId + ' ' + + print('\n\nEVENT: Alice deletes her post: ' + outboxPostId + ' ' + str(postsBefore)) password = 'alicepass' sendDeleteViaServer(aliceDir, sessionAlice, 'alice', password, aliceDomain, alicePort, httpPrefix, outboxPostId, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for i in range(30): if os.path.isdir(inboxPath): test = len([name for name in os.listdir(inboxPath) @@ -2759,7 +3106,7 @@ def testClientToServer(): assert validInboxFilenames(bobDir, 'bob', bobDomain, aliceDomain, alicePort) - print('\n\nAlice unfollows Bob') + print('\n\nEVENT: Alice unfollows Bob') password = 'alicepass' sendUnfollowRequestViaServer(baseDir, sessionAlice, 'alice', password, @@ -2767,7 +3114,7 @@ def testClientToServer(): 'bob', bobDomain, bobPort, httpPrefix, cachedWebfingers, personCache, - True, __version__) + True, __version__, signingPrivateKeyPem) for t in range(10): if 'alice@' + aliceDomain + ':' + str(alicePort) not in \ open(bobFollowersFilename).read(): @@ -2977,7 +3324,7 @@ def _testWebLinks(): assert resultText == exampleText -def _testAddEmoji(): +def _testAddEmoji(baseDir: str): print('testAddEmoji') content = "Emoji :lemon: :strawberry: :banana:" httpPrefix = 'http' @@ -2986,8 +3333,7 @@ def _testAddEmoji(): port = 3682 recipients = [] hashtags = {} - baseDir = os.getcwd() - baseDirOriginal = os.getcwd() + baseDirOriginal = baseDir path = baseDir + '/.tests' if not os.path.isdir(path): os.mkdir(path) @@ -3048,7 +3394,10 @@ def _testJsonString() -> None: assert receivedJson['content'] == messageStr encodedStr = json.dumps(testJson, ensure_ascii=False) assert messageStr in encodedStr - os.remove(filename) + try: + os.remove(filename) + except BaseException: + pass def _testSaveLoadJson(): @@ -3059,7 +3408,10 @@ def _testSaveLoadJson(): } testFilename = '.epicyon_tests_testSaveLoadJson.json' if os.path.isfile(testFilename): - os.remove(testFilename) + try: + os.remove(testFilename) + except BaseException: + pass assert saveJson(testJson, testFilename) assert os.path.isfile(testFilename) testLoadJson = loadJson(testFilename) @@ -3068,7 +3420,10 @@ def _testSaveLoadJson(): assert testLoadJson.get('param2') assert testLoadJson['param1'] == 3 assert testLoadJson['param2'] == '"Crème brûlée यह एक परीक्षण ह"' - os.remove(testFilename) + try: + os.remove(testFilename) + except BaseException: + pass def _testTheme(): @@ -3228,9 +3583,8 @@ def _testRemoveHtml(): 'This string contains a url http://somesite.or.other') -def _testDangerousCSS(): +def _testDangerousCSS(baseDir: str) -> None: print('testDangerousCSS') - baseDir = os.getcwd() for subdir, dirs, files in os.walk(baseDir): for f in files: if not f.endswith('.css'): @@ -3239,6 +3593,37 @@ def _testDangerousCSS(): break +def _testDangerousSVG(baseDir: str) -> None: + print('testDangerousSVG') + svgContent = \ + ' ' + \ + ' ' + \ + '' + assert not dangerousSVG(svgContent, False) + svgContent = \ + ' ' + \ + ' ' + \ + '' + \ + ' ' + \ + '' + assert dangerousSVG(svgContent, False) + + assert not scanThemesForScripts(baseDir) + + def _testDangerousMarkup(): print('testDangerousMarkup') allowLocalNetworkAccess = False @@ -3401,9 +3786,8 @@ def _testValidContentWarning(): assert resultStr == 'Invalid content warning' -def _testTranslations(): +def _testTranslations(baseDir: str) -> None: print('testTranslations') - baseDir = os.getcwd() languagesStr = getSupportedLanguages(baseDir) assert languagesStr @@ -3744,10 +4128,8 @@ def _testGuessHashtagCategory() -> None: assert guess == "bar" -def _testGetMentionedPeople() -> None: +def _testGetMentionedPeople(baseDir: str) -> None: print('testGetMentionedPeople') - baseDir = os.getcwd() - content = "@dragon@cave.site @bat@cave.site This is a test." actors = getMentionedPeople(baseDir, 'https', content, @@ -3758,8 +4140,7 @@ def _testGetMentionedPeople() -> None: assert actors[1] == "https://cave.site/users/bat" -def _testReplyToPublicPost() -> None: - baseDir = os.getcwd() +def _testReplyToPublicPost(baseDir: str) -> None: systemLanguage = 'en' nickname = 'test7492362' domain = 'other.site' @@ -4288,15 +4669,14 @@ def _testFunctions(): modules, modGroups, maxModuleCalls) -def _testLinksWithinPost() -> None: - baseDir = os.getcwd() +def _testLinksWithinPost(baseDir: str) -> None: systemLanguage = 'en' nickname = 'test27636' domain = 'rando.site' port = 443 httpPrefix = 'https' content = 'This is a test post with links.\n\n' + \ - 'ftp://ftp.ncdc.noaa.gov/pub/data/ghcn/v4/\n\nhttps://freedombone.net' + 'ftp://ftp.ncdc.noaa.gov/pub/data/ghcn/v4/\n\nhttps://libreserver.org' followersOnly = False saveToFile = False clientToServer = False @@ -4335,10 +4715,10 @@ def _testLinksWithinPost() -> None: '' + \ '' + \ 'ftp.ncdc.noaa.gov/pub/data/ghcn/v4/' + \ - '



' + \ '' + \ - 'freedombone.net

' + 'libreserver.org

' assert postJsonObject['object']['content'] == \ postJsonObject['object']['contentMap'][systemLanguage] @@ -4577,7 +4957,7 @@ def _testExtractPGPPublicKey(): assert result == pubKey -def testUpdateActor(): +def testUpdateActor(baseDir: str): print('Testing update of actor properties') global testServerAliceRunning @@ -4587,7 +4967,6 @@ def testUpdateActor(): proxyType = None federationList = [] - baseDir = os.getcwd() if os.path.isdir(baseDir + '/.tests'): shutil.rmtree(baseDir + '/.tests') os.mkdir(baseDir + '/.tests') @@ -4658,13 +5037,14 @@ def testUpdateActor(): 'fnaZ2Wi050483Sj2RmQRpb99Dod7rVZTDtCqXk0J\n' + \ '=gv5G\n' + \ '-----END PGP PUBLIC KEY BLOCK-----' + signingPrivateKeyPem = None actorUpdate = \ pgpPublicKeyUpload(aliceDir, sessionAlice, 'alice', password, aliceDomain, alicePort, httpPrefix, cachedWebfingers, personCache, - True, pubKey) + True, pubKey, signingPrivateKeyPem) print('actor update result: ' + str(actorUpdate)) assert actorUpdate @@ -5029,13 +5409,12 @@ def _testUserAgentDomain() -> None: assert userAgentDomain(userAgent, False) is None -def _testSwitchWords() -> None: +def _testSwitchWords(baseDir: str) -> None: print('testSwitchWords') rules = [ "rock -> hamster", "orange -> lemon" ] - baseDir = os.getcwd() nickname = 'testuser' domain = 'testdomain.com' @@ -5218,13 +5597,12 @@ def _testGetPriceFromString() -> None: assert curr == "USD" -def _translateOntology() -> None: - baseDir = os.getcwd() +def _translateOntology(baseDir: str) -> None: ontologyTypes = getCategoryTypes(baseDir) url = 'https://translate.astian.org' apiKey = None ltLangList = libretranslateLanguages(url, apiKey) - baseDir = os.getcwd() + languagesStr = getSupportedLanguages(baseDir) assert languagesStr @@ -5274,12 +5652,77 @@ def _translateOntology() -> None: saveJson(ontologyJson, filename + '.new') +def _testCanReplyTo(baseDir: str) -> None: + print('testCanReplyTo') + systemLanguage = 'en' + nickname = 'test27637' + domain = 'rando.site' + port = 443 + httpPrefix = 'https' + content = 'This is a test post with links.\n\n' + \ + 'ftp://ftp.ncdc.noaa.gov/pub/data/ghcn/v4/\n\nhttps://libreserver.org' + followersOnly = False + saveToFile = False + clientToServer = False + commentsEnabled = True + attachImageFilename = None + mediaType = None + imageDescription = None + city = 'London, England' + testInReplyTo = None + testInReplyToAtomUri = None + testSubject = None + testSchedulePost = False + testEventDate = None + testEventTime = None + testLocation = None + testIsArticle = False + conversationId = None + lowBandwidth = True + + postJsonObject = \ + createPublicPost(baseDir, nickname, domain, port, httpPrefix, + content, followersOnly, saveToFile, + clientToServer, commentsEnabled, + attachImageFilename, mediaType, + imageDescription, city, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle, systemLanguage, conversationId, + lowBandwidth) + # set the date on the post + currDateStr = "2021-09-08T20:45:00Z" + postJsonObject['published'] = currDateStr + postJsonObject['object']['published'] = currDateStr + + # test a post within the reply interval + postUrl = postJsonObject['object']['id'] + replyIntervalHours = 2 + currDateStr = "2021-09-08T21:32:10Z" + assert canReplyTo(baseDir, nickname, domain, + postUrl, replyIntervalHours, + currDateStr, + postJsonObject) + + # test a post outside of the reply interval + currDateStr = "2021-09-09T09:24:47Z" + assert not canReplyTo(baseDir, nickname, domain, + postUrl, replyIntervalHours, + currDateStr, + postJsonObject) + + def runAllTests(): + baseDir = os.getcwd() print('Running tests...') updateDefaultThemesList(os.getcwd()) - _translateOntology() + _translateOntology(baseDir) _testGetPriceFromString() _testFunctions() + _testSignAndVerify() + _testDangerousSVG(baseDir) + _testCanReplyTo(baseDir) _testDateConversions() _testAuthorizeSharedItems() _testValidPassword() @@ -5287,7 +5730,7 @@ def runAllTests(): _testSetActorLanguages() _testLimitRepetedWords() _testLimitWordLengths() - _testSwitchWords() + _testSwitchWords(baseDir) _testUserAgentDomain() _testRoles() _testSkills() @@ -5303,9 +5746,9 @@ def runAllTests(): _testPrepareHtmlPostNickname() _testDomainHandling() _testMastoApi() - _testLinksWithinPost() - _testReplyToPublicPost() - _testGetMentionedPeople() + _testLinksWithinPost(baseDir) + _testReplyToPublicPost(baseDir) + _testGetMentionedPeople(baseDir) _testGuessHashtagCategory() _testValidNickname() _testParseFeedDate() @@ -5315,12 +5758,12 @@ def runAllTests(): _testRemoveHtmlTag() _testReplaceEmailQuote() _testConstantTimeStringCheck() - _testTranslations() + _testTranslations(baseDir) _testValidContentWarning() _testRemoveIdEnding() _testJsonPostAllowsComments() _runHtmlReplaceQuoteMarks() - _testDangerousCSS() + _testDangerousCSS(baseDir) _testDangerousMarkup() _testRemoveHtml() _testSiteIsActive() @@ -5332,16 +5775,17 @@ def runAllTests(): _testSaveLoadJson() _testJsonString() _testGetStatusNumber() - _testAddEmoji() + _testAddEmoji(baseDir) _testActorParsing() - _testHttpsig() + _testHttpsig(baseDir) + _testHttpSignedGET(baseDir) _testHttpSigNew() _testCache() _testThreads() - _testCreatePerson() - _testAuthentication() - _testFollowersOfPerson() - _testNoOfFollowersOnDomain() - _testFollows() - _testGroupFollowers() + _testCreatePerson(baseDir) + _testAuthentication(baseDir) + _testFollowersOfPerson(baseDir) + _testNoOfFollowersOnDomain(baseDir) + _testFollows(baseDir) + _testGroupFollowers(baseDir) print('Tests succeeded\n') diff --git a/theme.py b/theme.py index 7f8d96086..141b3d77e 100644 --- a/theme.py +++ b/theme.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -14,6 +14,7 @@ from utils import saveJson from utils import getImageExtensions from utils import copytree from utils import acctDir +from utils import dangerousSVG from shutil import copyfile from shutil import make_archive from shutil import unpack_archive @@ -70,6 +71,9 @@ def importTheme(baseDir: str, filename: str) -> bool: copytree(tempThemeDir, themeDir) if os.path.isdir(tempThemeDir): rmtree(tempThemeDir) + if scanThemesForScripts(themeDir): + rmtree(themeDir) + return False return os.path.isfile(themeDir + '/theme.json') @@ -83,7 +87,10 @@ def exportTheme(baseDir: str, theme: str) -> bool: os.mkdir(baseDir + '/exports') exportFilename = baseDir + '/exports/' + theme + '.zip' if os.path.isfile(exportFilename): - os.remove(exportFilename) + try: + os.remove(exportFilename) + except BaseException: + pass try: make_archive(baseDir + '/exports/' + theme, 'zip', themeDir) except BaseException: @@ -250,7 +257,10 @@ def _removeTheme(baseDir: str): themeFiles = _getThemeFiles() for filename in themeFiles: if os.path.isfile(baseDir + '/' + filename): - os.remove(baseDir + '/' + filename) + try: + os.remove(baseDir + '/' + filename) + except BaseException: + pass def setCSSparam(css: str, param: str, value: str) -> str: @@ -432,7 +442,10 @@ def disableGrayscale(baseDir: str) -> None: cssfile.write(css) grayscaleFilename = baseDir + '/accounts/.grayscale' if os.path.isfile(grayscaleFilename): - os.remove(grayscaleFilename) + try: + os.remove(grayscaleFilename) + except BaseException: + pass def _setCustomFont(baseDir: str): @@ -587,7 +600,10 @@ def _setTextModeTheme(baseDir: str, name: str) -> None: textModeBannerFilename = \ baseDir + '/theme/' + name + '/banner.txt' if os.path.isfile(baseDir + '/accounts/banner.txt'): - os.remove(baseDir + '/accounts/banner.txt') + try: + os.remove(baseDir + '/accounts/banner.txt') + except BaseException: + pass if os.path.isfile(textModeBannerFilename): try: copyfile(textModeBannerFilename, @@ -684,7 +700,10 @@ def _setThemeImages(baseDir: str, name: str) -> None: else: if os.path.isfile(accountDir + '/left_col_image.png'): - os.remove(accountDir + '/left_col_image.png') + try: + os.remove(accountDir + '/left_col_image.png') + except BaseException: + pass except BaseException: pass @@ -696,7 +715,10 @@ def _setThemeImages(baseDir: str, name: str) -> None: else: if os.path.isfile(accountDir + '/right_col_image.png'): - os.remove(accountDir + '/right_col_image.png') + try: + os.remove(accountDir + '/right_col_image.png') + except BaseException: + pass except BaseException: pass break @@ -719,7 +741,10 @@ def setNewsAvatar(baseDir: str, name: str, filename = baseDir + '/cache/avatars/' + avatarFilename if os.path.isfile(filename): - os.remove(filename) + try: + os.remove(filename) + except BaseException: + pass if os.path.isdir(baseDir + '/cache/avatars'): copyfile(newFilename, filename) accountDir = acctDir(baseDir, nickname, domain) @@ -805,3 +830,22 @@ def updateDefaultThemesList(baseDir: str) -> None: with open(defaultThemesFilename, 'w+') as defaultThemesFile: for name in themeNames: defaultThemesFile.write(name + '\n') + + +def scanThemesForScripts(baseDir: str) -> bool: + """Scans the theme directory for any svg files containing scripts + """ + for subdir, dirs, files in os.walk(baseDir + '/theme'): + for f in files: + if not f.endswith('.svg'): + continue + svgFilename = os.path.join(subdir, f) + content = '' + with open(svgFilename, 'r') as fp: + content = fp.read() + svgDangerous = dangerousSVG(content, False) + if svgDangerous: + print('svg file contains script: ' + svgFilename) + return True + # deliberately no break - should resursively scan + return False diff --git a/threads.py b/threads.py index d49981f90..f56f1f519 100644 --- a/threads.py +++ b/threads.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" @@ -46,8 +46,12 @@ class threadWithTrace(threading.Thread): def __run(self): sys.settrace(self.globaltrace) - self.__run_backup() - self.run = self.__run_backup + try: + self.__run_backup() + self.run = self.__run_backup + except Exception as e: + print('ERROR: threads.py/__run failed - ' + str(e)) + pass def globaltrace(self, frame, event, arg): if event == 'call': diff --git a/tox.py b/tox.py index ecb058673..4268991b4 100644 --- a/tox.py +++ b/tox.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata" diff --git a/translations/ar.json b/translations/ar.json index 9523f6978..027f0a02f 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -474,5 +474,18 @@ "Create a new wanted item": "قم بإنشاء عنصر مطلوب جديد", "Wanted Items Search": "البحث عن العناصر المطلوبة", "Website": "موقع إلكتروني", - "Low Bandwidth": "انخفاض النطاق الترددي" + "Low Bandwidth": "انخفاض النطاق الترددي", + "accommodation": "الإقامة", + "Forbidden": "محرم", + "You're not allowed": "كنت لا يسمح", + "Hours after posting during which replies are allowed": "ساعات بعد النشر المسموح بها خلال الردود", + "Twitter": "Twitter", + "Twitter Replacement Domain": "مجال استبدال تويتر", + "Buy": "يشتري", + "Request to stay": "طلب البقاء", + "Profile": "الملف الشخصي", + "Introduce yourself and specify the date and time when you wish to stay": "عرّف عن نفسك وحدد التاريخ والوقت اللذين ترغب في الإقامة فيهما", + "Members": "أعضاء", + "Join": "انضم", + "Leave": "يترك" } diff --git a/translations/ca.json b/translations/ca.json index f44884e41..7e3920654 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Creeu un element desitjat", "Wanted Items Search": "Cerca d'articles desitjats", "Website": "Lloc web", - "Low Bandwidth": "Ample de banda baixa" + "Low Bandwidth": "Ample de banda baixa", + "accommodation": "allotjament", + "Forbidden": "Prohibit", + "You're not allowed": "No està permès", + "Hours after posting during which replies are allowed": "Hores després de la publicació durant les respostes", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Domini de substitució de Twitter", + "Buy": "Comprar", + "Request to stay": "Sol·licitud de permanència", + "Profile": "Perfil", + "Introduce yourself and specify the date and time when you wish to stay": "Presenteu-vos i especifiqueu la data i l’hora en què voleu romandre", + "Members": "Membres", + "Join": "Uneix-te", + "Leave": "Marxa" } diff --git a/translations/cy.json b/translations/cy.json index 6f761d3e9..337eea82a 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Creu eitem newydd ei heisiau", "Wanted Items Search": "Chwilio Eitemau Eisiau", "Website": "Gwefan", - "Low Bandwidth": "Lled band isel" + "Low Bandwidth": "Lled band isel", + "accommodation": "llety", + "Forbidden": "Wedi'i wahardd", + "You're not allowed": "Ni chaniateir i chi", + "Hours after posting during which replies are allowed": "Oriau ar ôl postio pan ganiateir atebion", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Parth Amnewid Twitter", + "Buy": "Prynu", + "Request to stay": "Cais i aros", + "Profile": "Proffil", + "Introduce yourself and specify the date and time when you wish to stay": "Cyflwynwch eich hun a nodwch y dyddiad a'r amser pan fyddwch yn dymuno aros", + "Members": "Aelodau", + "Join": "Ymunwch", + "Leave": "Gadewch" } diff --git a/translations/de.json b/translations/de.json index d7d3dd56b..9abc39dc1 100644 --- a/translations/de.json +++ b/translations/de.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Erstelle einen neuen gesuchten Artikel", "Wanted Items Search": "Gesuchte Artikel suchen", "Website": "Webseite", - "Low Bandwidth": "Niedrige Bandbreite" + "Low Bandwidth": "Niedrige Bandbreite", + "accommodation": "unterkunft", + "Forbidden": "Verboten", + "You're not allowed": "Du darfst nicht", + "Hours after posting during which replies are allowed": "Stunden nach dem Posten, während denen Antworten erlaubt sind", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Twitter-Ersatzdomain", + "Buy": "Besorgen", + "Request to stay": "Bitte um bleiben", + "Profile": "Profil", + "Introduce yourself and specify the date and time when you wish to stay": "Stellen Sie sich vor und geben Sie Datum und Uhrzeit Ihres Aufenthalts an", + "Members": "Mitglieder", + "Join": "Verbinden", + "Leave": "Verlassen" } diff --git a/translations/en.json b/translations/en.json index d65ce0eed..eec7f8cc3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -181,7 +181,7 @@ "Instance Logo": "Instance Logo", "Bookmark this post": "Save this for later viewing", "Undo the bookmark": "Unbookmark", - "Bookmarks": "Saves", + "Bookmarks": "Saved", "Theme": "Theme", "Default": "Default", "Light": "Light", @@ -474,5 +474,18 @@ "Create a new wanted item": "Create a new wanted item", "Wanted Items Search": "Wanted Items Search", "Website": "Website", - "Low Bandwidth": "Low Bandwidth" + "Low Bandwidth": "Low Bandwidth", + "accommodation": "accommodation", + "Forbidden": "Forbidden", + "You're not allowed": "You're not allowed", + "Hours after posting during which replies are allowed": "Hours after posting during which replies are allowed", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Twitter Replacement Domain", + "Buy": "Buy", + "Request to stay": "Request to stay", + "Profile": "Profile", + "Introduce yourself and specify the date and time when you wish to stay": "Introduce yourself and specify the date and time when you wish to stay", + "Members": "Members", + "Join": "Join", + "Leave": "Leave" } diff --git a/translations/es.json b/translations/es.json index 53c6fab3d..5576bd971 100644 --- a/translations/es.json +++ b/translations/es.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Crea un nuevo artículo buscado", "Wanted Items Search": "Búsqueda de artículos deseados", "Website": "Sitio web", - "Low Bandwidth": "Ancho de banda bajo" + "Low Bandwidth": "Ancho de banda bajo", + "accommodation": "alojamiento", + "Forbidden": "Prohibida", + "You're not allowed": "No tienes permiso", + "Hours after posting during which replies are allowed": "Horas después de la publicación durante las cuales se permiten las respuestas", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Dominio de reemplazo de Twitter", + "Buy": "Comprar", + "Request to stay": "Solicitud para quedarse", + "Profile": "Perfil", + "Introduce yourself and specify the date and time when you wish to stay": "Preséntese y especifique la fecha y hora en que desea quedarse", + "Members": "Miembros", + "Join": "Entrar", + "Leave": "Dejar" } diff --git a/translations/fr.json b/translations/fr.json index 70788283f..a0b498f4f 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Créer un nouvel article recherché", "Wanted Items Search": "Recherche d'objets recherchés", "Website": "Site Internet", - "Low Bandwidth": "Bas débit" + "Low Bandwidth": "Bas débit", + "accommodation": "hébergement", + "Forbidden": "Interdite", + "You're not allowed": "Tu n'as pas le droit", + "Hours after posting during which replies are allowed": "Heures après la publication pendant laquelle les réponses sont autorisées", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Domaine de remplacement Twitter", + "Buy": "Acheter", + "Request to stay": "Demande de séjour", + "Profile": "Profil", + "Introduce yourself and specify the date and time when you wish to stay": "Présentez-vous et précisez la date et l'heure auxquelles vous souhaitez rester", + "Members": "Membres", + "Join": "Rejoindre", + "Leave": "Laisser" } diff --git a/translations/ga.json b/translations/ga.json index 97441e317..0ca375921 100644 --- a/translations/ga.json +++ b/translations/ga.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Cruthaigh mír nua a theastaigh", "Wanted Items Search": "Cuardaigh Míreanna Teastaíonn", "Website": "Suíomh gréasáin", - "Low Bandwidth": "Bandaleithead íseal" + "Low Bandwidth": "Bandaleithead íseal", + "accommodation": "lóistín", + "Forbidden": "Toirmiscthe", + "You're not allowed": "Níl cead agat", + "Hours after posting during which replies are allowed": "Uair an chloig tar éis an phostála ina gceadaítear freagraí", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Fearann Athsholáthair Twitter", + "Buy": "Ceannaigh", + "Request to stay": "Iarraidh fanacht", + "Profile": "Próifíl", + "Introduce yourself and specify the date and time when you wish to stay": "Cuir tú féin in aithne agus sonraigh an dáta agus an t-am ar mhaith leat fanacht", + "Members": "Baill", + "Join": "Bí páirteach", + "Leave": "Fág" } diff --git a/translations/hi.json b/translations/hi.json index 00e79e04b..6b9af71af 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -474,5 +474,18 @@ "Create a new wanted item": "एक नई वांछित वस्तु बनाएँ", "Wanted Items Search": "वांटेड आइटम सर्च", "Website": "वेबसाइट", - "Low Bandwidth": "कम बैंडविड्थ" + "Low Bandwidth": "कम बैंडविड्थ", + "accommodation": "निवास स्थान", + "Forbidden": "निषिद्ध", + "You're not allowed": "आपको अनुमति नहीं है", + "Hours after posting during which replies are allowed": "पोस्ट करने के कुछ घंटे जिसके बाद जवाब देने की अनुमति है", + "Twitter": "Twitter", + "Twitter Replacement Domain": "ट्विटर रिप्लेसमेंट डोमेन", + "Buy": "खरीदना", + "Request to stay": "रहने का अनुरोध", + "Profile": "प्रोफ़ाइल", + "Introduce yourself and specify the date and time when you wish to stay": "अपना परिचय दें और वह तारीख और समय निर्दिष्ट करें जब आप रुकना चाहते हैं", + "Members": "सदस्यों", + "Join": "शामिल हों", + "Leave": "छोड़ना" } diff --git a/translations/it.json b/translations/it.json index 64aa96724..19c143f5f 100644 --- a/translations/it.json +++ b/translations/it.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Crea un nuovo oggetto ricercato", "Wanted Items Search": "Ricerca articoli ricercati", "Website": "Sito web", - "Low Bandwidth": "Bassa larghezza di banda" + "Low Bandwidth": "Bassa larghezza di banda", + "accommodation": "struttura ricettiva", + "Forbidden": "Proibita", + "You're not allowed": "Non ti è permesso", + "Hours after posting during which replies are allowed": "Ore dopo la pubblicazione durante le quali le risposte sono consentite", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Dominio sostitutivo di Twitter", + "Buy": "Acquistare", + "Request to stay": "Richiesta di soggiorno", + "Profile": "Profilo", + "Introduce yourself and specify the date and time when you wish to stay": "Presentati e specifica la data e l'ora in cui desideri soggiornare", + "Members": "Membri", + "Join": "Aderire", + "Leave": "Lasciare" } diff --git a/translations/ja.json b/translations/ja.json index 2aef39f71..18a6d237c 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -474,5 +474,18 @@ "Create a new wanted item": "新しい欲しいアイテムを作成する", "Wanted Items Search": "欲しいアイテム検索", "Website": "Webサイト", - "Low Bandwidth": "低帯域幅" + "Low Bandwidth": "低帯域幅", + "accommodation": "宿泊施設", + "Forbidden": "禁断", + "You're not allowed": "あなたは許可されていません", + "Hours after posting during which replies are allowed": "転記後の時間返信が許可されています", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Twitterの代替ドメイン", + "Buy": "買う", + "Request to stay": "滞在リクエスト", + "Profile": "プロフィール", + "Introduce yourself and specify the date and time when you wish to stay": "自己紹介をし、滞在したい日時を指定してください", + "Members": "メンバー", + "Join": "加入", + "Leave": "離れる" } diff --git a/translations/ku.json b/translations/ku.json index 2d52651c1..e6f6acb02 100644 --- a/translations/ku.json +++ b/translations/ku.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Tiştek xwestî ya nû biafirînin", "Wanted Items Search": "Wanted Items Search", "Website": "Malper", - "Low Bandwidth": "Bandwidth kêm" + "Low Bandwidth": "Bandwidth kêm", + "accommodation": "cih", + "Forbidden": "Qedexekirî", + "You're not allowed": "Destûrê nadin te", + "Hours after posting during which replies are allowed": "Demjimêran piştî şandina di dema bersivê de destûr tê dayîn", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Domaina Veguheztina Twitter", + "Buy": "Kirrîn", + "Request to stay": "Daxwaza mayînê bikin", + "Profile": "Tengal", + "Introduce yourself and specify the date and time when you wish to stay": "Xwe bidin nasîn û roj û dema ku hûn dixwazin bimînin bimînin diyar bikin", + "Members": "Endam", + "Join": "Bihevgirêdan", + "Leave": "Terikandin" } diff --git a/translations/oc.json b/translations/oc.json index 191b28fb7..e895eba22 100644 --- a/translations/oc.json +++ b/translations/oc.json @@ -177,7 +177,7 @@ "Instance Logo": "Instance Logo", "Bookmark this post": "Save this for later viewing", "Undo the bookmark": "Undo the bookmark", - "Bookmarks": "Saves", + "Bookmarks": "Saved", "Theme": "Theme", "Default": "Default", "Light": "Light", @@ -470,5 +470,18 @@ "Create a new wanted item": "Create a new wanted item", "Wanted Items Search": "Wanted Items Search", "Website": "Website", - "Low Bandwidth": "Low Bandwidth" + "Low Bandwidth": "Low Bandwidth", + "accommodation": "accommodation", + "Forbidden": "Forbidden", + "You're not allowed": "You're not allowed", + "Hours after posting during which replies are allowed": "Hours after posting during which replies are allowed", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Twitter Replacement Domain", + "Buy": "Buy", + "Request to stay": "Request to stay", + "Profile": "Profile", + "Introduce yourself and specify the date and time when you wish to stay": "Introduce yourself and specify the date and time when you wish to stay", + "Members": "Members", + "Join": "Join", + "Leave": "Leave" } diff --git a/translations/pt.json b/translations/pt.json index 4565aa6d0..d59ecdcc3 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Crie um novo item desejado", "Wanted Items Search": "Pesquisa de Itens Desejados", "Website": "Local na rede Internet", - "Low Bandwidth": "Baixa largura de banda" + "Low Bandwidth": "Baixa largura de banda", + "accommodation": "alojamento", + "Forbidden": "Proibida", + "You're not allowed": "Você não tem permissão", + "Hours after posting during which replies are allowed": "Horas após a postagem durante as quais as respostas são permitidas", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Domínio de substituição do Twitter", + "Buy": "Comprar", + "Request to stay": "Pedido para ficar", + "Profile": "Perfil", + "Introduce yourself and specify the date and time when you wish to stay": "Apresente-se e especifique a data e hora em que deseja ficar", + "Members": "Membros", + "Join": "Juntar", + "Leave": "Sair" } diff --git a/translations/ru.json b/translations/ru.json index 7731076b2..c9af59f6a 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Создать новый требуемый предмет", "Wanted Items Search": "Поиск требуемых предметов", "Website": "Интернет сайт", - "Low Bandwidth": "Низкая пропускная способность" + "Low Bandwidth": "Низкая пропускная способность", + "accommodation": "размещение", + "Forbidden": "Запрещенный", + "You're not allowed": "Вам не разрешено", + "Hours after posting during which replies are allowed": "Часы после размещения, в течение которых разрешены ответы", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Замещающий домен Twitter", + "Buy": "Купить", + "Request to stay": "Просьба остаться", + "Profile": "Профиль", + "Introduce yourself and specify the date and time when you wish to stay": "Представьтесь и укажите дату и время, когда вы хотите остаться", + "Members": "Члены", + "Join": "Присоединиться", + "Leave": "Оставлять" } diff --git a/translations/sw.json b/translations/sw.json index f12cc4e80..326b271d1 100644 --- a/translations/sw.json +++ b/translations/sw.json @@ -474,5 +474,18 @@ "Create a new wanted item": "Unda kipengee kipya kinachotafutwa", "Wanted Items Search": "Utafutaji wa Vitu vinavyotafutwa", "Website": "Tovuti", - "Low Bandwidth": "Bandwidth ya chini" + "Low Bandwidth": "Bandwidth ya chini", + "accommodation": "malazi", + "Forbidden": "Imekatazwa", + "You're not allowed": "Hauruhusiwi", + "Hours after posting during which replies are allowed": "Masaa baada ya kuchapisha wakati majibu yanaruhusiwa.", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Kikoa cha Uingizwaji wa Twitter", + "Buy": "Nunua", + "Request to stay": "Omba kukaa", + "Profile": "Profaili", + "Introduce yourself and specify the date and time when you wish to stay": "Jitambulishe na taja tarehe na saa unapotaka kukaa", + "Members": "Wanachama", + "Join": "Jiunge", + "Leave": "Ondoka" } diff --git a/translations/zh.json b/translations/zh.json index 8d176593c..4a08be59f 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -474,5 +474,18 @@ "Create a new wanted item": "创建一个新的通缉物品", "Wanted Items Search": "通缉物品搜索", "Website": "网站", - "Low Bandwidth": "低带宽" + "Low Bandwidth": "低带宽", + "accommodation": "住所", + "Forbidden": "禁止的", + "You're not allowed": "你不被允许", + "Hours after posting during which replies are allowed": "发布后的时间允许答复", + "Twitter": "Twitter", + "Twitter Replacement Domain": "Twitter 替换域", + "Buy": "买", + "Request to stay": "要求留下", + "Profile": "轮廓", + "Introduce yourself and specify the date and time when you wish to stay": "自我介绍并指定您希望入住的日期和时间", + "Members": "会员", + "Join": "加入", + "Leave": "离开" } diff --git a/utils.py b/utils.py index 04ab3e8ba..6411f78e8 100644 --- a/utils.py +++ b/utils.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Core" @@ -24,7 +24,7 @@ from cryptography.hazmat.primitives import hashes # both incoming and outgoing. # Could include dubious clacks or admin dogwhistles invalidCharacters = ( - '卐', '卍', '࿕', '࿖', '࿗', '࿘' + '卐', '卍', '࿕', '࿖', '࿗', '࿘', 'ϟϟ', '🏳️‍🌈🚫', '⚡⚡' ) @@ -147,6 +147,14 @@ def getSHA256(msg: str): return digest.finalize() +def getSHA512(msg: str): + """Returns a SHA512 hash of the given string + """ + digest = hashes.Hash(hashes.SHA512(), backend=default_backend()) + digest.update(msg) + return digest.finalize() + + def _localNetworkHost(host: str) -> bool: """Returns true if the given host is on the local network """ @@ -601,7 +609,7 @@ def getLinkPrefixes() -> []: """Returns a list of valid web link prefixes """ return ('https://', 'http://', 'ftp://', - 'dat://', 'i2p://', 'gnunet://', + 'dat://', 'i2p://', 'gnunet://', 'payto://', 'hyper://', 'gemini://', 'gopher://', 'briar:') @@ -614,7 +622,10 @@ def removeAvatarFromCache(baseDir: str, actorStr: str) -> None: avatarFilename = \ baseDir + '/cache/avatars/' + actorStr + '.' + extension if os.path.isfile(avatarFilename): - os.remove(avatarFilename) + try: + os.remove(avatarFilename) + except BaseException: + pass def saveJson(jsonObject: {}, filename: str) -> bool: @@ -810,10 +821,10 @@ def isLocalNetworkAddress(ipAddress: str) -> bool: return False -def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool: - """Returns true if the given content contains dangerous html markup +def _isDangerousString(content: str, allowLocalNetworkAccess: bool, + separators: [], invalidStrings: []) -> bool: + """Returns true if the given string is dangerous """ - separators = (['<', '>'], ['<', '>']) for separatorStyle in separators: startChar = separatorStyle[0] endChar = separatorStyle[1] @@ -825,10 +836,6 @@ def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool: invalidPartials = () if not allowLocalNetworkAccess: invalidPartials = getLocalNetworkAddresses() - invalidStrings = ('script', 'noscript', - 'canvas', 'style', 'abbr', - 'frame', 'iframe', 'html', 'body', - 'hr', 'allow-popups', 'allow-scripts') for markup in contentSections: if endChar not in markup: continue @@ -847,6 +854,31 @@ def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool: return False +def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool: + """Returns true if the given content contains dangerous html markup + """ + separators = [['<', '>'], ['<', '>']] + invalidStrings = [ + 'script', 'noscript', 'code', 'pre', + 'canvas', 'style', 'abbr', + 'frame', 'iframe', 'html', 'body', + 'hr', 'allow-popups', 'allow-scripts' + ] + return _isDangerousString(content, allowLocalNetworkAccess, + separators, invalidStrings) + + +def dangerousSVG(content: str, allowLocalNetworkAccess: bool) -> bool: + """Returns true if the given svg file content contains dangerous scripts + """ + separators = [['<', '>'], ['<', '>']] + invalidStrings = [ + 'script' + ] + return _isDangerousString(content, allowLocalNetworkAccess, + separators, invalidStrings) + + def getDisplayName(baseDir: str, actor: str, personCache: {}) -> str: """Returns the display name for the given actor """ @@ -1001,14 +1033,15 @@ def getUserPaths() -> []: """Returns possible user paths e.g. /users/nickname, /channel/nickname """ - return ('/users/', '/profile/', '/accounts/', '/channel/', '/u/', '/c/') + return ('/users/', '/profile/', '/accounts/', '/channel/', '/u/', + '/c/', '/video-channels/') def getGroupPaths() -> []: """Returns possible group paths e.g. https://lemmy/c/groupname """ - return ['/c/'] + return ['/c/', '/video-channels/'] def getDomainFromActor(actor: str) -> (str, int): @@ -1306,6 +1339,95 @@ def locatePost(baseDir: str, nickname: str, domain: str, return None +def _getPublishedDate(postJsonObject: {}) -> str: + """Returns the published date on the given post + """ + published = None + if postJsonObject.get('published'): + published = postJsonObject['published'] + elif postJsonObject.get('object'): + if isinstance(postJsonObject['object'], dict): + if postJsonObject['object'].get('published'): + published = postJsonObject['object']['published'] + if not published: + return None + if not isinstance(published, str): + return None + return published + + +def getReplyIntervalHours(baseDir: str, nickname: str, domain: str, + defaultReplyIntervalHours: int) -> int: + """Returns the reply interval for the given account. + The reply interval is the number of hours after a post being made + during which replies are allowed + """ + replyIntervalFilename = \ + acctDir(baseDir, nickname, domain) + '/.replyIntervalHours' + if os.path.isfile(replyIntervalFilename): + with open(replyIntervalFilename, 'r') as fp: + hoursStr = fp.read() + if hoursStr.isdigit(): + return int(hoursStr) + return defaultReplyIntervalHours + + +def setReplyIntervalHours(baseDir: str, nickname: str, domain: str, + replyIntervalHours: int) -> bool: + """Sets the reply interval for the given account. + The reply interval is the number of hours after a post being made + during which replies are allowed + """ + replyIntervalFilename = \ + acctDir(baseDir, nickname, domain) + '/.replyIntervalHours' + with open(replyIntervalFilename, 'w+') as fp: + try: + fp.write(str(replyIntervalHours)) + return True + except BaseException: + pass + return False + + +def canReplyTo(baseDir: str, nickname: str, domain: str, + postUrl: str, replyIntervalHours: int, + currDateStr: str = None, + postJsonObject: {} = None) -> bool: + """Is replying to the given post permitted? + This is a spam mitigation feature, so that spammers can't + add a lot of replies to old post which you don't notice. + """ + if '/statuses/' not in postUrl: + return True + if not postJsonObject: + postFilename = locatePost(baseDir, nickname, domain, postUrl) + if not postFilename: + return False + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return False + published = _getPublishedDate(postJsonObject) + if not published: + return False + try: + pubDate = datetime.datetime.strptime(published, '%Y-%m-%dT%H:%M:%SZ') + except BaseException: + return False + if not currDateStr: + currDate = datetime.datetime.utcnow() + else: + try: + currDate = datetime.datetime.strptime(currDateStr, + '%Y-%m-%dT%H:%M:%SZ') + except BaseException: + return False + hoursSincePublication = int((currDate - pubDate).total_seconds() / 3600) + if hoursSincePublication < 0 or \ + hoursSincePublication >= replyIntervalHours: + return False + return True + + def _removeAttachment(baseDir: str, httpPrefix: str, domain: str, postJson: {}): if not postJson.get('attachment'): @@ -1318,10 +1440,16 @@ def _removeAttachment(baseDir: str, httpPrefix: str, domain: str, mediaFilename = baseDir + '/' + \ attachmentUrl.replace(httpPrefix + '://' + domain + '/', '') if os.path.isfile(mediaFilename): - os.remove(mediaFilename) + try: + os.remove(mediaFilename) + except BaseException: + pass etagFilename = mediaFilename + '.etag' if os.path.isfile(etagFilename): - os.remove(etagFilename) + try: + os.remove(etagFilename) + except BaseException: + pass postJson['attachment'] = [] @@ -1386,7 +1514,10 @@ def _deletePostRemoveReplies(baseDir: str, nickname: str, domain: str, nickname, domain, replyFile, debug, recentPostsCache) # remove the replies file - os.remove(repliesFilename) + try: + os.remove(repliesFilename) + except BaseException: + pass def _isBookmarked(baseDir: str, nickname: str, domain: str, @@ -1442,7 +1573,10 @@ def _deleteCachedHtml(baseDir: str, nickname: str, domain: str, getCachedPostFilename(baseDir, nickname, domain, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass def _deleteHashtagsOnPost(baseDir: str, postJsonObject: {}) -> None: @@ -1486,7 +1620,10 @@ def _deleteHashtagsOnPost(baseDir: str, postJsonObject: {}) -> None: newlines += fileLine if not newlines.strip(): # if there are no lines then remove the hashtag file - os.remove(tagIndexFilename) + try: + os.remove(tagIndexFilename) + except BaseException: + pass else: # write the new hashtag index without the given post in it with open(tagIndexFilename, 'w+') as f: @@ -1521,8 +1658,14 @@ def _deleteConversationPost(baseDir: str, nickname: str, domain: str, fp.write(conversationStr) else: if os.path.isfile(conversationFilename + '.muted'): - os.remove(conversationFilename + '.muted') - os.remove(conversationFilename) + try: + os.remove(conversationFilename + '.muted') + except BaseException: + pass + try: + os.remove(conversationFilename) + except BaseException: + pass def deletePost(baseDir: str, httpPrefix: str, @@ -1537,7 +1680,10 @@ def deletePost(baseDir: str, httpPrefix: str, httpPrefix, postFilename, recentPostsCache, debug) # finally, remove the post itself - os.remove(postFilename) + try: + os.remove(postFilename) + except BaseException: + pass return # don't allow deletion of bookmarked posts @@ -1562,7 +1708,10 @@ def deletePost(baseDir: str, httpPrefix: str, for ext in extensions: extFilename = postFilename + '.' + ext if os.path.isfile(extFilename): - os.remove(extFilename) + try: + os.remove(extFilename) + except BaseException: + pass # remove cached html version of the post _deleteCachedHtml(baseDir, nickname, domain, postJsonObject) @@ -1588,7 +1737,10 @@ def deletePost(baseDir: str, httpPrefix: str, httpPrefix, postFilename, recentPostsCache, debug) # finally, remove the post itself - os.remove(postFilename) + try: + os.remove(postFilename) + except BaseException: + pass def isValidLanguage(text: str) -> bool: @@ -1643,11 +1795,12 @@ def isValidLanguage(text: str) -> bool: def _getReservedWords() -> str: return ('inbox', 'dm', 'outbox', 'following', 'public', 'followers', 'category', - 'channel', 'calendar', + 'channel', 'calendar', 'video-channels', 'tlreplies', 'tlmedia', 'tlblogs', 'tlblogs', 'tlfeatures', 'moderation', 'moderationaction', 'activity', 'undo', 'pinned', + 'actor', 'Actor', 'reply', 'replies', 'question', 'like', 'likes', 'users', 'statuses', 'tags', 'accounts', 'headers', @@ -1659,7 +1812,8 @@ def _getReservedWords() -> str: 'ignores', 'linksmobile', 'newswiremobile', 'minimal', 'search', 'eventdelete', 'searchemoji', 'catalog', 'conversationId', - 'mention', 'http', 'https') + 'mention', 'http', 'https', + 'ontologies', 'data') def getNicknameValidationPattern() -> str: @@ -2021,7 +2175,10 @@ def undoLikesCollectionEntry(recentPostsCache: {}, domain, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass removePostFromCache(postJsonObject, recentPostsCache) if not postJsonObject.get('type'): @@ -2064,67 +2221,6 @@ def undoLikesCollectionEntry(recentPostsCache: {}, saveJson(postJsonObject, postFilename) -def updateLikesCollection(recentPostsCache: {}, - baseDir: str, postFilename: str, - objectUrl: str, actor: str, - nickname: str, domain: str, debug: bool) -> None: - """Updates the likes collection within a post - """ - postJsonObject = loadJson(postFilename) - if not postJsonObject: - return - # remove any cached version of this post so that the - # like icon is changed - removePostFromCache(postJsonObject, recentPostsCache) - cachedPostFilename = getCachedPostFilename(baseDir, nickname, - domain, postJsonObject) - if cachedPostFilename: - if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) - - if not hasObjectDict(postJsonObject): - if debug: - pprint(postJsonObject) - print('DEBUG: post ' + objectUrl + ' has no object') - return - if not objectUrl.endswith('/likes'): - objectUrl = objectUrl + '/likes' - if not postJsonObject['object'].get('likes'): - if debug: - print('DEBUG: Adding initial like to ' + objectUrl) - likesJson = { - "@context": "https://www.w3.org/ns/activitystreams", - 'id': objectUrl, - 'type': 'Collection', - "totalItems": 1, - 'items': [{ - 'type': 'Like', - 'actor': actor - }] - } - postJsonObject['object']['likes'] = likesJson - else: - if not postJsonObject['object']['likes'].get('items'): - postJsonObject['object']['likes']['items'] = [] - for likeItem in postJsonObject['object']['likes']['items']: - if likeItem.get('actor'): - if likeItem['actor'] == actor: - # already liked - return - newLike = { - 'type': 'Like', - 'actor': actor - } - postJsonObject['object']['likes']['items'].append(newLike) - itlen = len(postJsonObject['object']['likes']['items']) - postJsonObject['object']['likes']['totalItems'] = itlen - - if debug: - print('DEBUG: saving post with likes added') - pprint(postJsonObject) - saveJson(postJsonObject, postFilename) - - def undoAnnounceCollectionEntry(recentPostsCache: {}, baseDir: str, postFilename: str, actor: str, domain: str, debug: bool) -> None: @@ -2143,7 +2239,10 @@ def undoAnnounceCollectionEntry(recentPostsCache: {}, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass removePostFromCache(postJsonObject, recentPostsCache) if not postJsonObject.get('type'): @@ -2204,7 +2303,10 @@ def updateAnnounceCollection(recentPostsCache: {}, postJsonObject) if cachedPostFilename: if os.path.isfile(cachedPostFilename): - os.remove(cachedPostFilename) + try: + os.remove(cachedPostFilename) + except BaseException: + pass removePostFromCache(postJsonObject, recentPostsCache) if not hasObjectDict(postJsonObject): @@ -2907,3 +3009,14 @@ def getSharesFilesList() -> []: """Returns the possible shares files """ return ('shares', 'wanted') + + +def replaceUsersWithAt(actor: str) -> str: + """ https://domain/users/nick becomes https://domain/@nick + """ + uPaths = getUserPaths() + for path in uPaths: + if path in actor: + actor = actor.replace(path, '/@') + break + return actor diff --git a/video.py b/video.py new file mode 100644 index 000000000..ef42f8086 --- /dev/null +++ b/video.py @@ -0,0 +1,178 @@ +__filename__ = "video.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@libreserver.org" +__status__ = "Production" +__module_group__ = "Timeline" + +from utils import getFullDomain +from utils import getNicknameFromActor +from utils import getDomainFromActor +from utils import removeIdEnding +from blocking import isBlocked +from filters import isFiltered + + +def convertVideoToNote(baseDir: str, nickname: str, domain: str, + systemLanguage: str, + postJsonObject: {}, blockedCache: {}) -> {}: + """Converts a PeerTube Video ActivityPub(ish) object into + a Note, so that it can then be displayed in a timeline + """ + # check that the required fields are present + requiredFields = ( + 'type', '@context', 'id', 'published', 'to', 'cc', + 'attributedTo', 'commentsEnabled', 'content', 'sensitive', + 'name', 'url' + ) + for fieldName in requiredFields: + if not postJsonObject.get(fieldName): + return None + + if postJsonObject['type'] != 'Video': + return None + + # who is this attributed to ? + attributedTo = None + if isinstance(postJsonObject['attributedTo'], str): + attributedTo = postJsonObject['attributedTo'] + elif isinstance(postJsonObject['attributedTo'], list): + for entity in postJsonObject['attributedTo']: + if not isinstance(entity, dict): + continue + if not entity.get('type'): + continue + if entity['type'] != 'Person': + continue + if not entity.get('id'): + continue + attributedTo = entity['id'] + break + if not attributedTo: + return None + + # get the language of the video + postLanguage = systemLanguage + if postJsonObject.get('language'): + if isinstance(postJsonObject['language'], dict): + if postJsonObject['language'].get('identifier'): + postLanguage = postJsonObject['language']['identifier'] + + # check that the attributed actor is not blocked + postNickname = getNicknameFromActor(attributedTo) + if not postNickname: + return None + postDomain, postDomainPort = getDomainFromActor(attributedTo) + if not postDomain: + return None + postDomainFull = getFullDomain(postDomain, postDomainPort) + if isBlocked(baseDir, nickname, domain, + postNickname, postDomainFull, blockedCache): + return None + + # check that the content is valid + if isFiltered(baseDir, nickname, domain, postJsonObject['name']): + return None + if isFiltered(baseDir, nickname, domain, postJsonObject['content']): + return None + + # get the content + content = '

' + postJsonObject['name'] + '

' + if postJsonObject.get('license'): + if isinstance(postJsonObject['license'], dict): + if postJsonObject['license'].get('name'): + if isFiltered(baseDir, nickname, domain, + postJsonObject['license']['name']): + return None + content += '

' + postJsonObject['license']['name'] + '

' + content += postJsonObject['content'] + + conversationId = removeIdEnding(postJsonObject['id']) + + mediaType = None + mediaUrl = None + mediaTorrent = None + mediaMagnet = None + for mediaLink in postJsonObject['url']: + if not isinstance(mediaLink, dict): + continue + if not mediaLink.get('mediaType'): + continue + if not mediaLink.get('href'): + continue + if mediaLink['mediaType'] == 'application/x-bittorrent': + mediaTorrent = mediaLink['href'] + if mediaLink['href'].startswith('magnet:'): + mediaMagnet = mediaLink['href'] + if mediaLink['mediaType'] != 'video/mp4' and \ + mediaLink['mediaType'] != 'video/ogv': + continue + if not mediaUrl: + mediaType = mediaLink['mediaType'] + mediaUrl = mediaLink['href'] + + if not mediaUrl: + return None + + attachment = [{ + 'mediaType': mediaType, + 'name': postJsonObject['content'], + 'type': 'Document', + 'url': mediaUrl + }] + + if mediaTorrent or mediaMagnet: + content += '

' + if mediaTorrent: + content += ' ' + if mediaMagnet: + content += '🧲' + content += '

' + + newPostId = removeIdEnding(postJsonObject['id']) + newPost = { + '@context': postJsonObject['@context'], + 'id': newPostId + '/activity', + 'type': 'Create', + 'actor': attributedTo, + 'published': postJsonObject['published'], + 'to': postJsonObject['to'], + 'cc': postJsonObject['cc'], + 'object': { + 'id': newPostId, + 'conversation': conversationId, + 'type': 'Note', + 'summary': None, + 'inReplyTo': None, + 'published': postJsonObject['published'], + 'url': newPostId, + 'attributedTo': attributedTo, + 'to': postJsonObject['to'], + 'cc': postJsonObject['cc'], + 'sensitive': postJsonObject['sensitive'], + 'atomUri': newPostId, + 'inReplyToAtomUri': None, + 'commentsEnabled': postJsonObject['commentsEnabled'], + 'rejectReplies': not postJsonObject['commentsEnabled'], + 'mediaType': 'text/html', + 'content': content, + 'contentMap': { + postLanguage: content + }, + 'attachment': attachment, + 'tag': [], + 'replies': { + 'id': newPostId + '/replies', + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'partOf': newPostId + '/replies', + 'items': [] + } + } + } + } + + return newPost diff --git a/webapp_about.py b/webapp_about.py index 607e08a6d..ad794d631 100644 --- a/webapp_about.py +++ b/webapp_about.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" diff --git a/webapp_accesskeys.py b/webapp_accesskeys.py index a8eba7c16..ebf26dae8 100644 --- a/webapp_accesskeys.py +++ b/webapp_accesskeys.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Accessibility" diff --git a/webapp_calendar.py b/webapp_calendar.py index 81e4e65d8..38d0743e2 100644 --- a/webapp_calendar.py +++ b/webapp_calendar.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Calendar" @@ -22,6 +22,7 @@ from utils import getAltPath from utils import removeDomainPort from utils import acctDir from utils import localActorUrl +from utils import replaceUsersWithAt from happening import getTodaysEvents from happening import getCalendarEvents from webapp_utils import htmlHeaderWithExternalStyle @@ -109,7 +110,10 @@ def _htmlCalendarDay(personCache: {}, cssCache: {}, translate: {}, accountDir = acctDir(baseDir, nickname, domain) calendarFile = accountDir + '/.newCalendar' if os.path.isfile(calendarFile): - os.remove(calendarFile) + try: + os.remove(calendarFile) + except BaseException: + pass cssFilename = baseDir + '/epicyon-calendar.css' if os.path.isfile(baseDir + '/calendar.css'): @@ -175,7 +179,7 @@ def _htmlCalendarDay(personCache: {}, cssCache: {}, translate: {}, if senderName and eventDescription: # if the sender is also mentioned within the event # description then this is a reminder - senderActor2 = senderActor.replace('/users/', '/@') + senderActor2 = replaceUsersWithAt(senderActor) if senderActor not in eventDescription and \ senderActor2 not in eventDescription: eventDescription = senderName + eventDescription diff --git a/webapp_column_left.py b/webapp_column_left.py index 33cd04a51..7ffdd4636 100644 --- a/webapp_column_left.py +++ b/webapp_column_left.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface Columns" @@ -20,6 +20,7 @@ from webapp_utils import headerButtonsFrontScreen from webapp_utils import htmlHeaderWithExternalStyle from webapp_utils import htmlFooter from webapp_utils import getBannerFile +from shares import shareCategoryIcon def _linksExist(baseDir: str) -> bool: @@ -57,7 +58,11 @@ def _getLeftColumnShares(baseDir: str, shareId = item['shareId'] # selecting this link calls htmlShowShare shareLink = actor + '?showshare=' + shareId - linksList.append(sharedesc + ' ' + shareLink) + if item.get('category'): + shareLink += '?category=' + item['category'] + shareCategory = shareCategoryIcon(item['category']) + + linksList.append(shareCategory + sharedesc + ' ' + shareLink) ctr += 1 if ctr >= maxSharesInLeftColumn: break @@ -90,7 +95,7 @@ def _getLeftColumnWanted(baseDir: str, ctr = 0 for published, item in sharesJson.items(): sharedesc = item['displayName'] - if '<' in sharedesc or '?' in sharedesc: + if '<' in sharedesc or ';' in sharedesc: continue shareId = item['shareId'] # selecting this link calls htmlShowShare diff --git a/webapp_column_right.py b/webapp_column_right.py index b296027ee..74079928f 100644 --- a/webapp_column_right.py +++ b/webapp_column_right.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface Columns" diff --git a/webapp_confirm.py b/webapp_confirm.py index 982c8f921..b24ce5422 100644 --- a/webapp_confirm.py +++ b/webapp_confirm.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -30,11 +30,12 @@ def htmlConfirmDelete(cssCache: {}, cachedWebfingers: {}, personCache: {}, callingDomain: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, signingPrivateKeyPem: str) -> str: """Shows a screen asking to confirm the deletion of a post """ if '/statuses/' not in messageId: @@ -66,17 +67,19 @@ def htmlConfirmDelete(cssCache: {}, getConfigParam(baseDir, 'instanceTitle') deletePostStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) deletePostStr += \ - individualPostAsHtml(True, recentPostsCache, maxRecentPosts, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, pageNumber, baseDir, session, cachedWebfingers, personCache, nickname, domain, port, postJsonObject, None, True, False, httpPrefix, projectVersion, 'outbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, - False, False, False, False, False) + False, False, False, False, False, False) deletePostStr += '
' deletePostStr += \ '

' + \ diff --git a/webapp_create_post.py b/webapp_create_post.py index e7fa0e1cd..9128983ff 100644 --- a/webapp_create_post.py +++ b/webapp_create_post.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -188,6 +188,7 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, mentions: [], shareDescription: str, reportUrl: str, pageNumber: int, + category: str, nickname: str, domain: str, domainFull: str, defaultTimeline: str, newswire: {}, @@ -211,13 +212,15 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, newPostText = '

' + \ translate['Write your post text below.'] + '

\n' else: - newPostText = \ - '

' + \ - translate['Write your reply to'] + \ - ' ' + \ - translate['this post'] + '

\n' + newPostText = '' + if category != 'accommodation': + newPostText = \ + '

' + \ + translate['Write your reply to'] + \ + ' ' + \ + translate['this post'] + '

\n' replyStr = '\n' @@ -298,14 +301,20 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, newPostImageSection += \ ' \n' + ' accept="' + formatsString + '">\n' newPostImageSection += ' \n' scopeIcon = 'scope_public.png' scopeDescription = translate['Public'] if shareDescription: - placeholderSubject = translate['Ask about a shared item.'] + '..' + if category == 'accommodation': + placeholderSubject = translate['Request to stay'] + else: + placeholderSubject = translate['Ask about a shared item.'] + '..' else: placeholderSubject = \ translate['Subject or Content Warning (optional)'] + '...' @@ -313,7 +322,13 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, if inReplyTo: placeholderMentions = \ translate['Replying to'] + '...' - placeholderMessage = translate['Write something'] + '...' + placeholderMessage = '' + if category != 'accommodation': + placeholderMessage = translate['Write something'] + '...' + else: + idx = 'Introduce yourself and specify the date ' + \ + 'and time when you wish to stay' + placeholderMessage = translate[idx] extraFields = '' endpoint = 'newpost' if path.endswith('/newblog'): @@ -526,10 +541,15 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, endpoint != 'newreport' and \ endpoint != 'newquestion': dateAndLocation = \ - '
\n' + \ - '

\n' + '
\n' + if category != 'accommodation': + dateAndLocation += \ + '

\n' + else: + dateAndLocation += \ + '\n' if endpoint == 'newpost': dateAndLocation += \ diff --git a/webapp_frontscreen.py b/webapp_frontscreen.py index afcf3cb11..8462d80e2 100644 --- a/webapp_frontscreen.py +++ b/webapp_frontscreen.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -29,11 +29,13 @@ def _htmlFrontScreenPosts(recentPostsCache: {}, maxRecentPosts: int, session, cachedWebfingers: {}, personCache: {}, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, + signingPrivateKeyPem: str) -> str: """Shows posts on the front screen of a news instance These should only be public blog posts from the features timeline which is the blog timeline of the news actor @@ -61,7 +63,8 @@ def _htmlFrontScreenPosts(recentPostsCache: {}, maxRecentPosts: int, for item in outboxFeed['orderedItems']: if item['type'] == 'Create': postStr = \ - individualPostAsHtml(True, recentPostsCache, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, @@ -71,12 +74,14 @@ def _htmlFrontScreenPosts(recentPostsCache: {}, maxRecentPosts: int, None, True, False, httpPrefix, projectVersion, 'inbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, - False, False, False, True, False) + False, False, False, + True, False, False) if postStr: profileStr += postStr + separatorStr ctr += 1 @@ -86,7 +91,8 @@ def _htmlFrontScreenPosts(recentPostsCache: {}, maxRecentPosts: int, return profileStr -def htmlFrontScreen(rssIconAtTop: bool, +def htmlFrontScreen(signingPrivateKeyPem: str, + rssIconAtTop: bool, cssCache: {}, iconsAsButtons: bool, defaultTimeline: str, recentPostsCache: {}, maxRecentPosts: int, @@ -95,6 +101,7 @@ def htmlFrontScreen(rssIconAtTop: bool, profileJson: {}, selected: str, session, cachedWebfingers: {}, personCache: {}, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, theme: str, peertubeInstances: [], @@ -169,11 +176,13 @@ def htmlFrontScreen(rssIconAtTop: bool, session, cachedWebfingers, personCache, projectVersion, YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, theme, systemLanguage, - maxLikeCount) + licenseStr + maxLikeCount, + signingPrivateKeyPem) + licenseStr # Footer which is only used for system accounts profileFooterStr = ' \n' diff --git a/webapp_hashtagswarm.py b/webapp_hashtagswarm.py index 8cb784d8b..6eca2a7d7 100644 --- a/webapp_hashtagswarm.py +++ b/webapp_hashtagswarm.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" diff --git a/webapp_headerbuttons.py b/webapp_headerbuttons.py index 8b9213e50..507835bbf 100644 --- a/webapp_headerbuttons.py +++ b/webapp_headerbuttons.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" diff --git a/webapp_login.py b/webapp_login.py index c8d6e50a0..97e54811c 100644 --- a/webapp_login.py +++ b/webapp_login.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -57,7 +57,7 @@ def htmlLogin(cssCache: {}, translate: {}, baseDir: str, httpPrefix: str, domain: str, systemLanguage: str, - autocomplete: bool = True) -> str: + autocomplete: bool) -> str: """Shows the login screen """ accounts = noOfAccounts(baseDir) @@ -143,9 +143,11 @@ def htmlLogin(cssCache: {}, translate: {}, '' - autocompleteStr = '' + autocompleteNicknameStr = 'autocomplete="username"' + autocompletePasswordStr = 'autocomplete="current-password"' if not autocomplete: - autocompleteStr = 'autocomplete="off" value=""' + autocompleteNicknameStr = 'autocomplete="username" value=""' + autocompletePasswordStr = 'autocomplete="off" value=""' instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') @@ -168,14 +170,14 @@ def htmlLogin(cssCache: {}, translate: {}, '
\n' + \ ' \n' + \ - ' \n' + \ '\n' + \ ' \n' + \ - ' \n' + \ loginButtonStr + registerButtonStr + '\n' + \ diff --git a/webapp_media.py b/webapp_media.py index bf666063b..aa01b74b4 100644 --- a/webapp_media.py +++ b/webapp_media.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -115,36 +115,16 @@ def _addEmbeddedVideoFromSites(translate: {}, content: str, # peertube sites. peerTubeSites = peertubeInstances else: - # A default selection of the current larger peertube sites, - # mostly French and German language. - # These have only been semi-vetted, and so should be under - # continuous review. + # A default minimal set of peertube instances # Also see https://peertube_isolation.frama.io/list/ for # adversarial instances. Nothing in that list should be # in the defaults below. peerTubeSites = ('share.tube', - 'tube.22decembre.eu', - 'libre.video', - 'peertube.linuxrocks.online', - 'spacepub.space', - 'tube.tchncs.de', - 'video.irem.univ-paris-diderot.fr', - 'peertube.openstreetmap.fr', - 'video.antopie.org', - 'scitech.video', - 'video.ploud.fr', - 'diode.zone', 'visionon.tv', 'peertube.fr', - 'peertube.live', 'kolektiva.media', - 'betamax.video', 'peertube.social', - 'videos.lescommuns.org', - 'video.tedomum.net', - 'tilvids.com', - 'exode.me', - 'peertube.video') + 'videos.lescommuns.org') for site in peerTubeSites: site = site.strip() if not site: diff --git a/webapp_minimalbutton.py b/webapp_minimalbutton.py index b3e3ce40b..8b6e6f6e4 100644 --- a/webapp_minimalbutton.py +++ b/webapp_minimalbutton.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -34,7 +34,10 @@ def setMinimal(baseDir: str, domain: str, nickname: str, minimalFilename = accountDir + '/.notminimal' minimalFileExists = os.path.isfile(minimalFilename) if minimal and minimalFileExists: - os.remove(minimalFilename) + try: + os.remove(minimalFilename) + except BaseException: + pass elif not minimal and not minimalFileExists: with open(minimalFilename, 'w+') as fp: fp.write('\n') diff --git a/webapp_moderation.py b/webapp_moderation.py index 31823d8d7..ef455e412 100644 --- a/webapp_moderation.py +++ b/webapp_moderation.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Moderation" @@ -37,6 +37,7 @@ def htmlModeration(cssCache: {}, defaultTimeline: str, allowDeletion: bool, httpPrefix: str, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -50,7 +51,8 @@ def htmlModeration(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the moderation feed as html This is what you see when selecting the "mod" timeline """ @@ -60,25 +62,29 @@ def htmlModeration(cssCache: {}, defaultTimeline: str, itemsPerPage, session, baseDir, wfRequest, personCache, nickname, domain, port, inboxJson, 'moderation', allowDeletion, httpPrefix, projectVersion, True, False, - YTReplacementDomain, showPublishedDateOnly, + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, moderationActionStr, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, - maxLikeCount, sharedItemsFederatedDomains) + maxLikeCount, sharedItemsFederatedDomains, + signingPrivateKeyPem) def htmlAccountInfo(cssCache: {}, translate: {}, baseDir: str, httpPrefix: str, nickname: str, domain: str, port: int, searchHandle: str, debug: bool, - systemLanguage: str) -> str: + systemLanguage: str, signingPrivateKeyPem: str) -> str: """Shows which domains a search handle interacts with. This screen is shown if a moderator enters a handle and selects info on the moderation screen """ + signingPrivateKeyPem = None msgStr1 = 'This account interacts with the following instances' infoForm = '' @@ -112,15 +118,19 @@ def htmlAccountInfo(cssCache: {}, translate: {}, session = createSession(proxyType) wordFrequency = {} + originDomain = None domainDict = getPublicPostInfo(session, baseDir, searchNickname, searchDomain, + originDomain, proxyType, searchPort, httpPrefix, debug, - __version__, wordFrequency, systemLanguage) + __version__, wordFrequency, systemLanguage, + signingPrivateKeyPem) # get a list of any blocked followers followersList = \ - downloadFollowCollection('followers', session, + downloadFollowCollection(signingPrivateKeyPem, + 'followers', session, httpPrefix, searchActor, 1, 5) blockedFollowers = [] for followerActor in followersList: @@ -133,7 +143,8 @@ def htmlAccountInfo(cssCache: {}, translate: {}, # get a list of any blocked following followingList = \ - downloadFollowCollection('following', session, + downloadFollowCollection(signingPrivateKeyPem, + 'following', session, httpPrefix, searchActor, 1, 5) blockedFollowing = [] for followingActor in followingList: diff --git a/webapp_person_options.py b/webapp_person_options.py index ae5bdbc56..1ff3bcfc4 100644 --- a/webapp_person_options.py +++ b/webapp_person_options.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -60,7 +60,8 @@ def htmlPersonOptions(defaultTimeline: str, textModeBanner: str, newsInstance: bool, authorized: bool, - accessKeys: {}) -> str: + accessKeys: {}, + isGroup: bool) -> str: """Show options for a person: view/follow/block/report """ optionsDomain, optionsPort = getDomainFromActor(optionsActor) @@ -73,6 +74,8 @@ def htmlPersonOptions(defaultTimeline: str, dormant = False followStr = 'Follow' + if isGroup: + followStr = 'Join' blockStr = 'Block' nickname = None optionsNickname = None @@ -86,6 +89,8 @@ def htmlPersonOptions(defaultTimeline: str, followerDomain, followerPort = getDomainFromActor(optionsActor) if isFollowingActor(baseDir, nickname, domain, optionsActor): followStr = 'Unfollow' + if isGroup: + followStr = 'Leave' dormant = \ isDormant(baseDir, nickname, domain, optionsActor, dormantMonths) diff --git a/webapp_post.py b/webapp_post.py index 5becbd2ef..c27a94ce2 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -76,6 +76,7 @@ from devices import E2EEdecryptMessageFromDevice from webfinger import webfingerHandle from speaker import updateSpeaker from languages import autoTranslatePost +from blocking import isBlocked def _logPostTiming(enableTimingLog: bool, postStartTime, debugId: str) -> None: @@ -188,7 +189,8 @@ def _getPostFromRecentCache(session, postStartTime, pageNumber: int, recentPostsCache: {}, - maxRecentPosts: int) -> str: + maxRecentPosts: int, + signingPrivateKeyPem: str) -> str: """Attempts to get the html post from the recent posts cache in memory """ if boxName == 'tlmedia': @@ -213,7 +215,8 @@ def _getPostFromRecentCache(session, _logPostTiming(enableTimingLog, postStartTime, '2.1') - updateAvatarImageCache(session, baseDir, httpPrefix, + updateAvatarImageCache(signingPrivateKeyPem, + session, baseDir, httpPrefix, postActor, avatarUrl, personCache, allowDownloads) @@ -243,13 +246,20 @@ def _getAvatarImageHtml(showAvatarOptions: bool, avatarLink = '' if '/users/news/' not in avatarUrl: avatarLink = ' ' + showProfileStr = 'Show profile' + if translate.get(showProfileStr): + showProfileStr = translate[showProfileStr] avatarLink += \ ' \n' if showAvatarOptions and \ domainFull + '/users/' + nickname not in postActor: + showOptionsForThisPersonStr = 'Show options for this person' + if translate.get(showOptionsForThisPersonStr): + showOptionsForThisPersonStr = \ + translate[showOptionsForThisPersonStr] if '/users/news/' not in avatarUrl: avatarLink = \ ' \n' avatarLink += \ ' \n' else: # don't link to the person options for the news account avatarLink += \ ' \n' return avatarLink.strip() -def _getReplyIconHtml(nickname: str, isPublicRepeat: bool, +def _getReplyIconHtml(baseDir: str, nickname: str, domain: str, + isPublicRepeat: bool, showIcons: bool, commentsEnabled: bool, postJsonObject: {}, pageNumberParam: str, translate: {}, systemLanguage: str, @@ -286,7 +297,19 @@ def _getReplyIconHtml(nickname: str, isPublicRepeat: bool, return replyStr # reply is permitted - create reply icon - replyToLink = postJsonObject['object']['id'] + replyToLink = removeIdEnding(postJsonObject['object']['id']) + + # see Mike MacGirvin's replyTo suggestion + if postJsonObject['object'].get('replyTo'): + # check that the alternative replyTo url is not blocked + blockNickname = \ + getNicknameFromActor(postJsonObject['object']['replyTo']) + blockDomain, _ = \ + getDomainFromActor(postJsonObject['object']['replyTo']) + if not isBlocked(baseDir, nickname, domain, + blockNickname, blockDomain, {}): + replyToLink = postJsonObject['object']['replyTo'] + if postJsonObject['object'].get('attributedTo'): if isinstance(postJsonObject['object']['attributedTo'], str): replyToLink += \ @@ -303,7 +326,9 @@ def _getReplyIconHtml(nickname: str, isPublicRepeat: bool, replyToLink += pageNumberParam replyStr = '' - replyToThisPostStr = translate['Reply to this post'] + replyToThisPostStr = 'Reply to this post' + if translate.get(replyToThisPostStr): + replyToThisPostStr = translate[replyToThisPostStr] conversationStr = '' if conversationId: conversationStr = '?conversationId=' + conversationId @@ -355,13 +380,15 @@ def _getEditIconHtml(baseDir: str, nickname: str, domainFull: str, (isEditor(baseDir, nickname) and actor.endswith('/' + domainFull + '/users/news'))): - postId = postJsonObject['object']['id'] + postId = removeIdEnding(postJsonObject['object']['id']) if '/statuses/' not in postId: return editStr if isBlogPost(postJsonObject): - editBlogPostStr = translate['Edit blog post'] + editBlogPostStr = 'Edit blog post' + if translate.get(editBlogPostStr): + editBlogPostStr = translate[editBlogPostStr] if not isNewsPost(postJsonObject): editStr += \ ' ' + \ @@ -369,7 +396,7 @@ def _getEditIconHtml(baseDir: str, nickname: str, domainFull: str, nickname + \ '/tlblogs?editblogpost=' + \ postId.split('/statuses/')[1] + \ - '?actor=' + actorNickname + \ + ';actor=' + actorNickname + \ '" title="' + editBlogPostStr + '">' + \ '' + editBlogPostStr + \
@@ -386,7 +413,9 @@ def _getEditIconHtml(baseDir: str, nickname: str, domainFull: str,
                     editBlogPostStr + '\n' elif isEvent: - editEventStr = translate['Edit event'] + editEventStr = 'Edit event' + if translate.get(editEventStr): + editEventStr = translate[editEventStr] editStr += \ ' ' + \ '' likeStr += likeCountStr.replace('(', '').replace(')', '').strip() likeStr += '\n' + likePostId = removeIdEnding(postJsonObject['object']['id']) likeStr += \ ' \n' + '" title="' + muteThisPostStr + '">\n' muteStr += \ ' ' + \ '' + \
-            translate['Mute this post'] + \
-            ' |\n' else: + undoMuteStr = 'Undo mute' + if translate.get(undoMuteStr): + undoMuteStr = translate[undoMuteStr] muteStr = \ ' \n' + timelinePostBookmark + '" title="' + undoMuteStr + '">\n' muteStr += \ ' ' + \ - '🔇 ' + translate['Undo mute'] + \
-            ' |\n' return muteStr @@ -620,16 +671,19 @@ def _getDeleteIconHtml(nickname: str, domainFull: str, messageId.startswith(postActor))): if '/users/' + nickname + '/' in messageId: if not isNewsPost(postJsonObject): + deleteThisPostStr = 'Delete this post' + if translate.get(deleteThisPostStr): + deleteThisPostStr = translate[deleteThisPostStr] deleteStr = \ ' \n' + '" title="' + deleteThisPostStr + '">\n' deleteStr += \ ' ' + \ '' + \
-                    translate['Delete this post'] + \
-                    ' |\n' return deleteStr @@ -697,7 +751,10 @@ def _getBlogCitationsHtml(boxName: str, '' + tagJson['name'] + '\n' if citationsStr: - citationsStr = '

' + translate['Citations'] + ':

' + \ + translatedCitationsStr = 'Citations' + if translate.get(translatedCitationsStr): + translatedCitationsStr = translate[translatedCitationsStr] + citationsStr = '

' + translatedCitationsStr + ':

' + \ '
    \n' + citationsStr + '
\n' return citationsStr @@ -705,9 +762,12 @@ def _getBlogCitationsHtml(boxName: str, def _boostOwnPostHtml(translate: {}) -> str: """The html title for announcing your own post """ + announcesStr = 'announces' + if translate.get(announcesStr): + announcesStr = translate[announcesStr] return ' ' + translate['announces'] + \
+        announcesStr + \
+        '\n' @@ -717,13 +777,16 @@ def _announceUnattributedHtml(translate: {}, """Returns the html for an announce title where there is no attribution on the announced post """ + announcesStr = 'announces' + if translate.get(announcesStr): + announcesStr = translate[announcesStr] + postId = removeIdEnding(postJsonObject['object']['id']) return ' ' + \
-        translate['announces'] + '\n' + \ - ' @unattributed\n' @@ -732,13 +795,16 @@ def _announceWithDisplayNameHtml(translate: {}, announceDisplayName: str) -> str: """Returns html for an announce having a display name """ + announcesStr = 'announces' + if translate.get(announcesStr): + announcesStr = translate[announcesStr] + postId = removeIdEnding(postJsonObject['object']['id']) return ' ' + \
-        translate['announces'] + '\n' + \ - ' ' + announceDisplayName + '\n' @@ -823,6 +889,9 @@ def _getPostTitleAnnounceHtml(baseDir: str, idx = 'Show options for this person' if '/users/news/' not in announceAvatarUrl: + showOptionsForThisPersonStr = idx + if translate.get(idx): + showOptionsForThisPersonStr = translate[idx] replyAvatarImageInPost = \ '
\n' \ ' ' \ ' \n
\n' return (titleStr, replyAvatarImageInPost, @@ -840,9 +910,12 @@ def _getPostTitleAnnounceHtml(baseDir: str, def _replyToYourselfHtml(translate: {}) -> str: """Returns html for a title which is a reply to yourself """ + replyingToThemselvesStr = 'replying to themselves' + if translate.get(replyingToThemselvesStr): + replyingToThemselvesStr = translate[replyingToThemselvesStr] return ' ' + translate['replying to themselves'] + \
+        replyingToThemselvesStr + \
+        '\n' @@ -851,9 +924,12 @@ def _replyToUnknownHtml(translate: {}, postJsonObject: {}) -> str: """Returns the html title for a reply to an unknown handle """ + replyingToStr = 'replying to' + if translate.get(replyingToStr): + replyingToStr = translate[replyingToStr] return ' ' + \
-        translate['replying to'] + '\n' + \ ' \n' + \ ' \n' + \ ' \n' + \ ' \n' + \ '  \n
\n' @@ -1095,7 +1180,8 @@ def _getFooterWithIcons(showIcons: bool, return footerStr -def individualPostAsHtml(allowDownloads: bool, +def individualPostAsHtml(signingPrivateKeyPem: str, + allowDownloads: bool, recentPostsCache: {}, maxRecentPosts: int, translate: {}, pageNumber: int, baseDir: str, @@ -1105,17 +1191,20 @@ def individualPostAsHtml(allowDownloads: bool, avatarUrl: str, showAvatarOptions: bool, allowDeletion: bool, httpPrefix: str, projectVersion: str, - boxName: str, YTReplacementDomain: str, + boxName: str, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, maxLikeCount: int, - showRepeats: bool = True, - showIcons: bool = False, - manuallyApprovesFollowers: bool = False, - showPublicOnly: bool = False, - storeToCache: bool = True) -> str: + showRepeats: bool, + showIcons: bool, + manuallyApprovesFollowers: bool, + showPublicOnly: bool, + storeToCache: bool, + useCacheOnly: bool) -> str: """ Shows a single post as html """ if not postJsonObject: @@ -1169,9 +1258,12 @@ def individualPostAsHtml(allowDownloads: bool, postStartTime, pageNumber, recentPostsCache, - maxRecentPosts) + maxRecentPosts, + signingPrivateKeyPem) if postHtml: return postHtml + if useCacheOnly and postJsonObject['type'] != 'Announce': + return '' _logPostTiming(enableTimingLog, postStartTime, '4') @@ -1179,7 +1271,8 @@ def individualPostAsHtml(allowDownloads: bool, getAvatarImageUrl(session, baseDir, httpPrefix, postActor, personCache, - avatarUrl, allowDownloads) + avatarUrl, allowDownloads, + signingPrivateKeyPem) _logPostTiming(enableTimingLog, postStartTime, '5') @@ -1193,20 +1286,23 @@ def individualPostAsHtml(allowDownloads: bool, postActorWf = \ webfingerHandle(session, postActorHandle, httpPrefix, cachedWebfingers, - domain, __version__, False, False) + domain, __version__, False, False, + signingPrivateKeyPem) avatarUrl2 = None displayName = None if postActorWf: - (inboxUrl, pubKeyId, pubKey, - fromPersonId, sharedInbox, - avatarUrl2, displayName) = getPersonBox(baseDir, session, - postActorWf, - personCache, - projectVersion, - httpPrefix, - nickname, domain, - 'outbox', 72367) + originDomain = domain + (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl2, + displayName, _) = getPersonBox(signingPrivateKeyPem, + originDomain, + baseDir, session, + postActorWf, + personCache, + projectVersion, + httpPrefix, + nickname, domain, + 'outbox', 72367) _logPostTiming(enableTimingLog, postStartTime, '6') @@ -1253,22 +1349,48 @@ def individualPostAsHtml(allowDownloads: bool, announceJsonObject = None if postJsonObject['type'] == 'Announce': announceJsonObject = postJsonObject.copy() + blockedCache = {} postJsonAnnounce = \ downloadAnnounce(session, baseDir, httpPrefix, nickname, domain, postJsonObject, projectVersion, translate, YTReplacementDomain, + twitterReplacementDomain, allowLocalNetworkAccess, recentPostsCache, False, systemLanguage, - domainFull, personCache) + domainFull, personCache, + signingPrivateKeyPem, + blockedCache) if not postJsonAnnounce: # if the announce could not be downloaded then mark it as rejected - rejectPostId(baseDir, nickname, domain, postJsonObject['id'], + announcedPostId = removeIdEnding(postJsonObject['id']) + rejectPostId(baseDir, nickname, domain, announcedPostId, recentPostsCache) return '' postJsonObject = postJsonAnnounce + # is the announced post in the html cache? + postHtml = \ + _getPostFromRecentCache(session, baseDir, + httpPrefix, nickname, domain, + postJsonObject, + postActor, + personCache, + allowDownloads, + showPublicOnly, + storeToCache, + boxName, + avatarUrl, + enableTimingLog, + postStartTime, + pageNumber, + recentPostsCache, + maxRecentPosts, + signingPrivateKeyPem) + if postHtml: + return postHtml + announceFilename = \ locatePost(baseDir, nickname, domain, postJsonObject['id']) if announceFilename: @@ -1365,7 +1487,8 @@ def individualPostAsHtml(allowDownloads: bool, if postJsonObject['object']['conversation']: conversationId = postJsonObject['object']['conversation'] - replyStr = _getReplyIconHtml(nickname, isPublicRepeat, + replyStr = _getReplyIconHtml(baseDir, nickname, domain, + isPublicRepeat, showIcons, commentsEnabled, postJsonObject, pageNumberParam, translate, systemLanguage, @@ -1531,7 +1654,10 @@ def individualPostAsHtml(allowDownloads: bool, postIsSensitive = postJsonObject['object']['sensitive'] else: # add a generic summary if none is provided - postJsonObject['object']['summary'] = translate['Sensitive'] + sensitiveStr = 'Sensitive' + if translate.get(sensitiveStr): + sensitiveStr = translate[sensitiveStr] + postJsonObject['object']['summary'] = sensitiveStr # add an extra line if there is a content warning, # for better vertical spacing on mobile @@ -1583,7 +1709,10 @@ def individualPostAsHtml(allowDownloads: bool, else: objectContent = contentStr else: - objectContent = '🔒 ' + translate['Encrypted'] + encryptedStr = 'Encrypted' + if translate.get(encryptedStr): + encryptedStr = translate[encryptedStr] + objectContent = '🔒 ' + encryptedStr objectContent = '
' + objectContent + '
' @@ -1687,11 +1816,12 @@ def htmlIndividualPost(cssCache: {}, postJsonObject: {}, httpPrefix: str, projectVersion: str, likedBy: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, signingPrivateKeyPem: str) -> str: """Show an individual post as html """ postStr = '' @@ -1700,9 +1830,11 @@ def htmlIndividualPost(cssCache: {}, likedByDomain, likedByPort = getDomainFromActor(likedBy) likedByDomain = getFullDomain(likedByDomain, likedByPort) likedByHandle = likedByNickname + '@' + likedByDomain + likedByStr = 'Liked by' + if translate.get(likedByStr): + likedByStr = translate[likedByStr] postStr += \ - '

' + translate['Liked by'] + \ - ' @' + \ + '

' + likedByStr + ' @' + \ likedByHandle + '\n' domainFull = getFullDomain(domain, port) @@ -1715,26 +1847,34 @@ def htmlIndividualPost(cssCache: {}, ' \n' if not isFollowingActor(baseDir, nickname, domainFull, likedBy): + translateFollowStr = 'Follow' + if translate.get(translateFollowStr): + translateFollowStr = translate[translateFollowStr] followStr += ' \n' + 'name="submitSearch">' + translateFollowStr + '\n' + goBackStr = 'Go Back' + if translate.get(goBackStr): + goBackStr = translate[goBackStr] followStr += ' \n' + 'name="submitBack">' + goBackStr + '\n' followStr += ' \n' postStr += followStr + '

\n' postStr += \ - individualPostAsHtml(True, recentPostsCache, maxRecentPosts, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, personCache, nickname, domain, port, postJsonObject, None, True, False, httpPrefix, projectVersion, 'inbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, - False, authorized, False, False, False) + False, authorized, False, False, False, False) messageId = removeIdEnding(postJsonObject['id']) # show the previous posts @@ -1748,7 +1888,8 @@ def htmlIndividualPost(cssCache: {}, postJsonObject = loadJson(postFilename) if postJsonObject: postStr = \ - individualPostAsHtml(True, recentPostsCache, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, @@ -1758,13 +1899,14 @@ def htmlIndividualPost(cssCache: {}, None, True, False, httpPrefix, projectVersion, 'inbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, False, authorized, - False, False, False) + postStr + False, False, False, False) + postStr # show the following posts postFilename = locatePost(baseDir, nickname, domain, messageId) @@ -1781,7 +1923,8 @@ def htmlIndividualPost(cssCache: {}, # add items to the html output for item in repliesJson['orderedItems']: postStr += \ - individualPostAsHtml(True, recentPostsCache, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, @@ -1790,13 +1933,14 @@ def htmlIndividualPost(cssCache: {}, None, True, False, httpPrefix, projectVersion, 'inbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, False, authorized, - False, False, False) + False, False, False, False) cssFilename = baseDir + '/epicyon-profile.css' if os.path.isfile(baseDir + '/epicyon.css'): cssFilename = baseDir + '/epicyon.css' @@ -1814,18 +1958,21 @@ def htmlPostReplies(cssCache: {}, nickname: str, domain: str, port: int, repliesJson: {}, httpPrefix: str, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, + signingPrivateKeyPem: str) -> str: """Show the replies to an individual post as html """ repliesStr = '' if repliesJson.get('orderedItems'): for item in repliesJson['orderedItems']: repliesStr += \ - individualPostAsHtml(True, recentPostsCache, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, @@ -1834,12 +1981,13 @@ def htmlPostReplies(cssCache: {}, None, True, False, httpPrefix, projectVersion, 'inbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, - False, False, False, False, False) + False, False, False, False, False, False) cssFilename = baseDir + '/epicyon-profile.css' if os.path.isfile(baseDir + '/epicyon.css'): diff --git a/webapp_profile.py b/webapp_profile.py index ddb9be18e..1a1cf91ec 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -3,12 +3,13 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" import os from pprint import pprint +from utils import isGroupAccount from utils import hasObjectDict from utils import getOccupationName from utils import getLockedAccount @@ -25,6 +26,7 @@ from utils import getImageFormats from utils import acctDir from utils import getSupportedLanguages from utils import localActorUrl +from utils import getReplyIntervalHours from languages import getActorLanguages from skills import getSkills from theme import getThemesList @@ -34,6 +36,7 @@ from person import getPersonAvatarUrl from webfinger import webfingerHandle from posts import parseUserFeed from posts import getPersonBox +from posts import isCreateInsideAnnounce from donate import getDonationUrl from donate import getWebsite from xmpp import getXmppAddress @@ -77,6 +80,7 @@ def htmlProfileAfterSearch(cssCache: {}, session, cachedWebfingers: {}, personCache: {}, debug: bool, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, defaultTimeline: str, peertubeInstances: [], @@ -84,7 +88,8 @@ def htmlProfileAfterSearch(cssCache: {}, themeName: str, accessKeys: {}, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, + signingPrivateKeyPem: str) -> str: """Show a profile page after a search for a fediverse address """ http = False @@ -94,13 +99,18 @@ def htmlProfileAfterSearch(cssCache: {}, elif httpPrefix == 'gnunet': gnunet = True profileJson, asHeader = \ - getActorJson(domain, profileHandle, http, gnunet, debug, False) + getActorJson(domain, profileHandle, http, gnunet, debug, False, + signingPrivateKeyPem) if not profileJson: return None personUrl = profileJson['id'] searchDomain, searchPort = getDomainFromActor(personUrl) + if not searchDomain: + return None searchNickname = getNicknameFromActor(personUrl) + if not searchNickname: + return None searchDomainFull = getFullDomain(searchDomain, searchPort) profileStr = '' @@ -108,6 +118,11 @@ def htmlProfileAfterSearch(cssCache: {}, if os.path.isfile(baseDir + '/epicyon.css'): cssFilename = baseDir + '/epicyon.css' + isGroup = False + if profileJson.get('type'): + if profileJson['type'] == 'Group': + isGroup = True + avatarUrl = '' if profileJson.get('icon'): if profileJson['icon'].get('url'): @@ -125,6 +140,8 @@ def htmlProfileAfterSearch(cssCache: {}, movedTo = '' if profileJson.get('movedTo'): movedTo = profileJson['movedTo'] + if '"' in movedTo: + movedTo = movedTo.split('"')[1] displayName += ' ⌂' followsYou = \ @@ -217,6 +234,10 @@ def htmlProfileAfterSearch(cssCache: {}, followIsPermitted = False if followIsPermitted: + followStr = 'Follow' + if isGroup: + followStr = 'Join' + profileStr += \ '
\n' + \ '
\n' + \ ' \n' + \ + translate[followStr] + '\n' + \ ' \n' + \ @@ -249,37 +270,45 @@ def htmlProfileAfterSearch(cssCache: {}, '
\n' userFeed = \ - parseUserFeed(session, outboxUrl, asHeader, projectVersion, + parseUserFeed(signingPrivateKeyPem, + session, outboxUrl, asHeader, projectVersion, httpPrefix, domain, debug) if userFeed: i = 0 for item in userFeed: + isAnnouncedFeedItem = False + if isCreateInsideAnnounce(item): + isAnnouncedFeedItem = True + item = item['object'] if not item.get('actor'): continue - if item['actor'] != personUrl: + if not isAnnouncedFeedItem and item['actor'] != personUrl: continue if not item.get('type'): continue - if item['type'] != 'Create': - continue - if not hasObjectDict(item): + if item['type'] == 'Create': + if not hasObjectDict(item): + continue + if item['type'] != 'Create' and item['type'] != 'Announce': continue profileStr += \ - individualPostAsHtml(True, recentPostsCache, maxRecentPosts, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, personCache, nickname, domain, port, item, avatarUrl, False, False, httpPrefix, projectVersion, 'inbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, - False, False, False, False, False) + False, False, False, False, False, False) i += 1 - if i >= 20: + if i >= 8: break instanceTitle = \ @@ -398,6 +427,8 @@ def _getProfileHeaderAfterSearch(baseDir: str, """The header of a searched for handle, containing background image and avatar """ + if not imageUrl: + imageUrl = '/defaultprofilebackground' htmlStr = \ '\n\n
\n' + \ ' \n' + \ ' \n' + if not displayName: + displayName = searchNickname htmlStr += \ '

' + displayName + '

\n' + \ '

@' + searchNickname + '@' + searchDomainFull + '
\n' @@ -430,7 +463,7 @@ def _getProfileHeaderAfterSearch(baseDir: str, if newNickname and newDomain: newHandle = newNickname + '@' + newDomainFull htmlStr += '

' + translate['New account'] + \ - ': < a href="' + movedTo + '">@' + newHandle + '

\n' + ': @' + newHandle + '

\n' elif alsoKnownAs: otherAccountshtml = \ '

' + translate['Other accounts'] + ': ' @@ -464,7 +497,8 @@ def _getProfileHeaderAfterSearch(baseDir: str, return htmlStr -def htmlProfile(rssIconAtTop: bool, +def htmlProfile(signingPrivateKeyPem: str, + rssIconAtTop: bool, cssCache: {}, iconsAsButtons: bool, defaultTimeline: str, recentPostsCache: {}, maxRecentPosts: int, @@ -473,6 +507,7 @@ def htmlProfile(rssIconAtTop: bool, profileJson: {}, selected: str, session, cachedWebfingers: {}, personCache: {}, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, theme: str, dormantMonths: int, peertubeInstances: [], @@ -489,7 +524,8 @@ def htmlProfile(rssIconAtTop: bool, if not nickname: return "" if isSystemAccount(nickname): - return htmlFrontScreen(rssIconAtTop, + return htmlFrontScreen(signingPrivateKeyPem, + rssIconAtTop, cssCache, iconsAsButtons, defaultTimeline, recentPostsCache, maxRecentPosts, @@ -498,6 +534,7 @@ def htmlProfile(rssIconAtTop: bool, profileJson, selected, session, cachedWebfingers, personCache, YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, theme, extraJson, allowLocalNetworkAccess, accessKeys, @@ -550,6 +587,7 @@ def htmlProfile(rssIconAtTop: bool, donateSection = '' donateUrl = getDonationUrl(profileJson) websiteUrl = getWebsite(profileJson, translate) + blogAddress = getBlogAddress(profileJson) PGPpubKey = getPGPpubKey(profileJson) PGPfingerprint = getPGPfingerprint(profileJson) emailAddress = getEmailAddress(profileJson) @@ -579,6 +617,10 @@ def htmlProfile(rssIconAtTop: bool, donateSection += \ '

' + translate['Email'] + ': ' + emailAddress + '

\n' + if blogAddress: + donateSection += \ + '

Blog: ' + blogAddress + '

\n' if xmppAddress: donateSection += \ '

' + translate['XMPP'] + ': ' - profileStr += \ - ' ' + \ - '' + if not isGroup: + profileStr += \ + ' ' + \ + '' profileStr += \ ' ' + \ '' - profileStr += \ - ' ' + \ - '' - profileStr += \ - ' ' + \ - '' + '">' + followersStr + ' ' + if not isGroup: + profileStr += \ + ' ' + \ + '' + profileStr += \ + ' ' + \ + '' # profileStr += \ # ' ' + \ # '

' @@ -898,11 +957,13 @@ def _htmlProfilePosts(recentPostsCache: {}, maxRecentPosts: int, session, cachedWebfingers: {}, personCache: {}, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, + signingPrivateKeyPem: str) -> str: """Shows posts on the profile screen These should only be public posts """ @@ -930,7 +991,8 @@ def _htmlProfilePosts(recentPostsCache: {}, maxRecentPosts: int, for item in outboxFeed['orderedItems']: if item['type'] == 'Create': postStr = \ - individualPostAsHtml(True, recentPostsCache, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, @@ -939,12 +1001,14 @@ def _htmlProfilePosts(recentPostsCache: {}, maxRecentPosts: int, None, True, False, httpPrefix, projectVersion, 'inbox', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, - False, False, False, True, False) + False, False, False, + True, False, False) if postStr: profileStr += postStr + separatorStr ctr += 1 @@ -963,7 +1027,8 @@ def _htmlProfileFollowing(translate: {}, baseDir: str, httpPrefix: str, feedName: str, actor: str, pageNumber: int, maxItemsPerPage: int, - dormantMonths: int, debug: bool) -> str: + dormantMonths: int, debug: bool, + signingPrivateKeyPem: str) -> str: """Shows following on the profile screen """ profileStr = '' @@ -990,7 +1055,8 @@ def _htmlProfileFollowing(translate: {}, baseDir: str, httpPrefix: str, dormantMonths) profileStr += \ - _individualFollowAsHtml(translate, baseDir, session, + _individualFollowAsHtml(signingPrivateKeyPem, + translate, baseDir, session, cachedWebfingers, personCache, domain, followingActor, authorized, nickname, @@ -1136,6 +1202,25 @@ def _htmlEditProfileGraphicDesign(baseDir: str, translate: {}) -> str: return graphicsStr +def _htmlEditProfileTwitter(baseDir: str, translate: {}, + removeTwitter: str) -> str: + """Edit twitter settings within profile + """ + # Twitter section + twitterStr = beginEditSection(translate['Twitter']) + twitterStr += \ + editCheckBox(translate['Remove Twitter posts'], + 'removeTwitter', removeTwitter) + twitterReplacementDomain = getConfigParam(baseDir, "twitterdomain") + if not twitterReplacementDomain: + twitterReplacementDomain = '' + twitterStr += \ + editTextField(translate['Twitter Replacement Domain'], + 'twitterdomain', twitterReplacementDomain) + twitterStr += endEditSection() + return twitterStr + + def _htmlEditProfileInstance(baseDir: str, translate: {}, peertubeInstances: [], mediaInstanceStr: str, @@ -1414,7 +1499,8 @@ def _htmlEditProfileSharedItems(baseDir: str, nickname: str, domain: str, def _htmlEditProfileFiltering(baseDir: str, nickname: str, domain: str, - userAgentsBlocked: str, translate: {}) -> str: + userAgentsBlocked: str, translate: {}, + replyIntervalHours: int) -> str: """Filtering and blocking section of edit profile screen """ filterStr = '' @@ -1468,6 +1554,14 @@ def _htmlEditProfileFiltering(baseDir: str, nickname: str, domain: str, editProfileForm = beginEditSection(translate['Filtering and Blocking']) + idx = 'Hours after posting during which replies are allowed' + editProfileForm += \ + '
\n' + editProfileForm += \ '
\n' elif b == 'unfollow': + unfollowStr = 'Unfollow' + if isGroup: + unfollowStr = 'Leave' buttonsStr += \ '\n' + translate[unfollowStr] + '\n' resultStr = '
\n' resultStr += \ diff --git a/webapp_question.py b/webapp_question.py index bf198642e..51dd69af8 100644 --- a/webapp_question.py +++ b/webapp_question.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" diff --git a/webapp_search.py b/webapp_search.py index 2ed8d497f..b604d2352 100644 --- a/webapp_search.py +++ b/webapp_search.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -112,7 +112,7 @@ def _matchSharedItem(searchStrLowerList: [], if sharedItem.get('location'): if searchSubstr in sharedItem['location'].lower(): return True - elif searchSubstr in sharedItem['summary'].lower(): + if searchSubstr in sharedItem['summary'].lower(): return True elif searchSubstr in sharedItem['displayName'].lower(): return True @@ -178,7 +178,8 @@ def _htmlSharesResult(baseDir: str, htmlSearchResultShare(baseDir, sharedItem, translate, httpPrefix, domainFull, contactNickname, - name, actor, sharesFileType) + name, actor, sharesFileType, + sharedItem['category']) if not resultsExist and currPage > 1: # show the previous page button sharedItemsForm += \ @@ -570,12 +571,14 @@ def htmlHistorySearch(cssCache: {}, translate: {}, baseDir: str, personCache: {}, port: int, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, boxName: str, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, + signingPrivateKeyPem: str) -> str: """Show a page containing search results for your post history """ if historysearch.startswith("'"): @@ -641,7 +644,8 @@ def htmlHistorySearch(cssCache: {}, translate: {}, baseDir: str, showIndividualPostIcons = True allowDeletion = False postStr = \ - individualPostAsHtml(True, recentPostsCache, + individualPostAsHtml(signingPrivateKeyPem, + True, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, @@ -652,13 +656,14 @@ def htmlHistorySearch(cssCache: {}, translate: {}, baseDir: str, httpPrefix, projectVersion, 'search', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, themeName, systemLanguage, maxLikeCount, showIndividualPostIcons, showIndividualPostIcons, - False, False, False) + False, False, False, False) if postStr: historySearchForm += separatorStr + postStr index += 1 @@ -676,11 +681,13 @@ def htmlHashtagSearch(cssCache: {}, session, cachedWebfingers: {}, personCache: {}, httpPrefix: str, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, themeName: str, systemLanguage: str, - maxLikeCount: int) -> str: + maxLikeCount: int, + signingPrivateKeyPem: str) -> str: """Show a page containing search results for a hashtag or after selecting a hashtag from the swarm """ @@ -816,7 +823,8 @@ def htmlHashtagSearch(cssCache: {}, avatarUrl = None showAvatarOptions = True postStr = \ - individualPostAsHtml(allowDownloads, recentPostsCache, + individualPostAsHtml(signingPrivateKeyPem, + allowDownloads, recentPostsCache, maxRecentPosts, translate, None, baseDir, session, cachedWebfingers, @@ -828,6 +836,7 @@ def htmlHashtagSearch(cssCache: {}, httpPrefix, projectVersion, 'search', YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, @@ -835,7 +844,7 @@ def htmlHashtagSearch(cssCache: {}, showRepeats, showIcons, manuallyApprovesFollowers, showPublicOnly, - storeToCache) + storeToCache, False) if postStr: hashtagSearchForm += separatorStr + postStr index += 1 @@ -861,7 +870,9 @@ def rssHashtagSearch(nickname: str, domain: str, port: int, postsPerPage: int, session, cachedWebfingers: {}, personCache: {}, httpPrefix: str, projectVersion: str, - YTReplacementDomain: str, systemLanguage: str) -> str: + YTReplacementDomain: str, + twitterReplacementDomain: str, + systemLanguage: str) -> str: """Show an rss feed for a hashtag """ if hashtag.startswith('#'): diff --git a/webapp_suspended.py b/webapp_suspended.py index 40f8f4699..bcb69d277 100644 --- a/webapp_suspended.py +++ b/webapp_suspended.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" diff --git a/webapp_timeline.py b/webapp_timeline.py index cfd470dce..91afad775 100644 --- a/webapp_timeline.py +++ b/webapp_timeline.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Timeline" @@ -395,6 +395,27 @@ def _htmlTimelineEnd(baseDir: str, nickname: str, domainFull: str, return tlStr +def _pageNumberButtons(usersPath: str, boxName: str, pageNumber: int) -> str: + """Shows selactable page numbers at the bottom of the screen + """ + pagesWidth = 3 + minPageNumber = pageNumber - pagesWidth + if minPageNumber < 1: + minPageNumber = 1 + maxPageNumber = minPageNumber + 1 + (pagesWidth * 2) + numStr = '' + for page in range(minPageNumber, maxPageNumber): + if numStr: + numStr += ' ⸻ ' + pageStr = str(page) + if page == pageNumber: + pageStr = '' + str(page) + '' + numStr += \ + '' + pageStr + '' + return '
' + numStr + '
' + + def htmlTimeline(cssCache: {}, defaultTimeline: str, recentPostsCache: {}, maxRecentPosts: int, translate: {}, pageNumber: int, @@ -406,6 +427,7 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, manuallyApproveFollowers: bool, minimal: bool, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, moderator: bool, editor: bool, @@ -423,7 +445,8 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the timeline as html """ enableTimingLog = False @@ -450,7 +473,10 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, if os.path.isfile(dmFile): newDM = True if boxName == 'dm': - os.remove(dmFile) + try: + os.remove(dmFile) + except BaseException: + pass # should the Replies button be highlighted? newReply = False @@ -458,7 +484,10 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, if os.path.isfile(replyFile): newReply = True if boxName == 'tlreplies': - os.remove(replyFile) + try: + os.remove(replyFile) + except BaseException: + pass # should the Shares button be highlighted? newShare = False @@ -466,7 +495,10 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, if os.path.isfile(newShareFile): newShare = True if boxName == 'tlshares': - os.remove(newShareFile) + try: + os.remove(newShareFile) + except BaseException: + pass # should the Wanted button be highlighted? newWanted = False @@ -474,7 +506,10 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, if os.path.isfile(newWantedFile): newWanted = True if boxName == 'tlwanted': - os.remove(newWantedFile) + try: + os.remove(newWantedFile) + except BaseException: + pass # should the Moderation/reports button be highlighted? newReport = False @@ -482,7 +517,10 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, if os.path.isfile(newReportFile): newReport = True if boxName == 'moderation': - os.remove(newReportFile) + try: + os.remove(newReportFile) + except BaseException: + pass separatorStr = '' if boxName != 'tlmedia': @@ -790,6 +828,7 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, # page up arrow if pageNumber > 1: tlStr += textModeSeparator + tlStr += '
' + _pageNumberButtons(usersPath, boxName, pageNumber) tlStr += \ '
\n' + \ ' \n' + \ '
\n' + tlStr += _pageNumberButtons(usersPath, boxName, pageNumber) tlStr += textModeSeparator elif itemCtr == 0: tlStr += _getHelpForTimeline(baseDir, boxName) @@ -923,52 +964,62 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, def htmlIndividualShare(domain: str, shareId: str, - actor: str, item: {}, translate: {}, + actor: str, sharedItem: {}, translate: {}, showContact: bool, removeButton: bool, sharesFileType: str) -> str: """Returns an individual shared item as html """ profileStr = '
\n' - profileStr += '\n' - if item.get('imageUrl'): - profileStr += '\n' + profileStr += \ + '\n' + if sharedItem.get('imageUrl'): + profileStr += '\n' profileStr += \ - '' + translate['Item image'] + '\n\n' - profileStr += '

' + item['summary'] + '

\n

' - if item.get('itemQty'): - if item['itemQty'] > 1: + profileStr += '

' + sharedItem['summary'] + '

\n

' + if sharedItem.get('itemQty'): + if sharedItem['itemQty'] > 1: profileStr += \ '' + translate['Quantity'] + ': ' + \ - str(item['itemQty']) + '
' + str(sharedItem['itemQty']) + '
' profileStr += \ - '' + translate['Type'] + ': ' + item['itemType'] + '
' + '' + translate['Type'] + ': ' + sharedItem['itemType'] + '
' profileStr += \ - '' + translate['Category'] + ': ' + item['category'] + '
' - if item.get('location'): + '' + translate['Category'] + ': ' + \ + sharedItem['category'] + '
' + if sharedItem.get('location'): profileStr += \ '' + translate['Location'] + ': ' + \ - item['location'] + '
' - if item.get('itemPrice') and item.get('itemCurrency'): - if isfloat(item['itemPrice']): - if float(item['itemPrice']) > 0: + sharedItem['location'] + '
' + contactTitleStr = translate['Contact'] + if sharedItem.get('itemPrice') and sharedItem.get('itemCurrency'): + if isfloat(sharedItem['itemPrice']): + if float(sharedItem['itemPrice']) > 0: profileStr += ' ' + \ '' + translate['Price'] + ': ' + \ - item['itemPrice'] + ' ' + item['itemCurrency'] + sharedItem['itemPrice'] + ' ' + sharedItem['itemCurrency'] + contactTitleStr = translate['Buy'] profileStr += '

\n' - sharedesc = item['displayName'] - if '<' not in sharedesc and '?' not in sharedesc: + sharedesc = sharedItem['displayName'] + if '<' not in sharedesc and ';' not in sharedesc: if showContact: - contactActor = item['actor'] + buttonStyleStr = 'button' + if sharedItem['category'] == 'accommodation': + contactTitleStr = translate['Request to stay'] + buttonStyleStr = 'contactbutton' + + contactActor = sharedItem['actor'] profileStr += \ '

' + \ '\n' + '?mention=' + contactActor + '">' + \ + '\n' profileStr += \ '\n' + translate['Profile'] + '\n' if removeButton and domain in shareId: if sharesFileType == 'shares': profileStr += \ @@ -1006,6 +1057,8 @@ def _htmlSharesTimeline(translate: {}, pageNumber: int, itemsPerPage: int, timelineStr = '' if pageNumber > 1: + timelineStr += '
' + \ + _pageNumberButtons(actor, 'tl' + sharesFileType, pageNumber) timelineStr += \ '

\n' + \ ' \n' + \ '
\n' + timelineStr += \ + _pageNumberButtons(actor, 'tl' + sharesFileType, pageNumber) return timelineStr @@ -1066,6 +1122,7 @@ def htmlShares(cssCache: {}, defaultTimeline: str, allowDeletion: bool, httpPrefix: str, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1079,7 +1136,8 @@ def htmlShares(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the shares timeline as html """ manuallyApproveFollowers = \ @@ -1093,7 +1151,9 @@ def htmlShares(cssCache: {}, defaultTimeline: str, nickname, domain, port, None, 'tlshares', allowDeletion, httpPrefix, projectVersion, manuallyApproveFollowers, - False, YTReplacementDomain, + False, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1102,7 +1162,7 @@ def htmlShares(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlWanted(cssCache: {}, defaultTimeline: str, @@ -1114,6 +1174,7 @@ def htmlWanted(cssCache: {}, defaultTimeline: str, allowDeletion: bool, httpPrefix: str, projectVersion: str, YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1127,7 +1188,8 @@ def htmlWanted(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the wanted timeline as html """ manuallyApproveFollowers = \ @@ -1141,7 +1203,9 @@ def htmlWanted(cssCache: {}, defaultTimeline: str, nickname, domain, port, None, 'tlwanted', allowDeletion, httpPrefix, projectVersion, manuallyApproveFollowers, - False, YTReplacementDomain, + False, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1150,7 +1214,7 @@ def htmlWanted(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlInbox(cssCache: {}, defaultTimeline: str, @@ -1161,7 +1225,9 @@ def htmlInbox(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, inboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1175,7 +1241,8 @@ def htmlInbox(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the inbox as html """ manuallyApproveFollowers = \ @@ -1189,7 +1256,9 @@ def htmlInbox(cssCache: {}, defaultTimeline: str, nickname, domain, port, inboxJson, 'inbox', allowDeletion, httpPrefix, projectVersion, manuallyApproveFollowers, - minimal, YTReplacementDomain, + minimal, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1198,7 +1267,7 @@ def htmlInbox(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlBookmarks(cssCache: {}, defaultTimeline: str, @@ -1209,7 +1278,9 @@ def htmlBookmarks(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, bookmarksJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1223,7 +1294,8 @@ def htmlBookmarks(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the bookmarks as html """ manuallyApproveFollowers = \ @@ -1237,7 +1309,9 @@ def htmlBookmarks(cssCache: {}, defaultTimeline: str, nickname, domain, port, bookmarksJson, 'tlbookmarks', allowDeletion, httpPrefix, projectVersion, manuallyApproveFollowers, - minimal, YTReplacementDomain, + minimal, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1246,7 +1320,7 @@ def htmlBookmarks(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlInboxDMs(cssCache: {}, defaultTimeline: str, @@ -1257,7 +1331,9 @@ def htmlInboxDMs(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, inboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1271,7 +1347,8 @@ def htmlInboxDMs(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the DM timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1281,7 +1358,9 @@ def htmlInboxDMs(cssCache: {}, defaultTimeline: str, cachedWebfingers, personCache, nickname, domain, port, inboxJson, 'dm', allowDeletion, httpPrefix, projectVersion, False, minimal, - YTReplacementDomain, showPublishedDateOnly, + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, fullWidthTimelineButtonHeader, @@ -1289,7 +1368,7 @@ def htmlInboxDMs(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlInboxReplies(cssCache: {}, defaultTimeline: str, @@ -1300,7 +1379,9 @@ def htmlInboxReplies(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, inboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1314,7 +1395,8 @@ def htmlInboxReplies(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the replies timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1324,7 +1406,9 @@ def htmlInboxReplies(cssCache: {}, defaultTimeline: str, cachedWebfingers, personCache, nickname, domain, port, inboxJson, 'tlreplies', allowDeletion, httpPrefix, projectVersion, False, - minimal, YTReplacementDomain, + minimal, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1333,7 +1417,7 @@ def htmlInboxReplies(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlInboxMedia(cssCache: {}, defaultTimeline: str, @@ -1344,7 +1428,9 @@ def htmlInboxMedia(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, inboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1358,7 +1444,8 @@ def htmlInboxMedia(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the media timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1368,7 +1455,9 @@ def htmlInboxMedia(cssCache: {}, defaultTimeline: str, cachedWebfingers, personCache, nickname, domain, port, inboxJson, 'tlmedia', allowDeletion, httpPrefix, projectVersion, False, - minimal, YTReplacementDomain, + minimal, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1377,7 +1466,7 @@ def htmlInboxMedia(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, @@ -1388,7 +1477,9 @@ def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, inboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1402,7 +1493,8 @@ def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the blogs timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1412,7 +1504,9 @@ def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, cachedWebfingers, personCache, nickname, domain, port, inboxJson, 'tlblogs', allowDeletion, httpPrefix, projectVersion, False, - minimal, YTReplacementDomain, + minimal, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1421,7 +1515,7 @@ def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, @@ -1432,7 +1526,9 @@ def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, inboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1447,7 +1543,8 @@ def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the features timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1457,7 +1554,9 @@ def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, cachedWebfingers, personCache, nickname, domain, port, inboxJson, 'tlfeatures', allowDeletion, httpPrefix, projectVersion, False, - minimal, YTReplacementDomain, + minimal, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, @@ -1466,7 +1565,7 @@ def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlInboxNews(cssCache: {}, defaultTimeline: str, @@ -1477,7 +1576,9 @@ def htmlInboxNews(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, inboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, moderator: bool, editor: bool, positiveVoting: bool, showPublishAsIcon: bool, @@ -1491,7 +1592,8 @@ def htmlInboxNews(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the news timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1501,7 +1603,9 @@ def htmlInboxNews(cssCache: {}, defaultTimeline: str, cachedWebfingers, personCache, nickname, domain, port, inboxJson, 'tlnews', allowDeletion, httpPrefix, projectVersion, False, - minimal, YTReplacementDomain, + minimal, + YTReplacementDomain, + twitterReplacementDomain, showPublishedDateOnly, newswire, moderator, editor, positiveVoting, showPublishAsIcon, @@ -1510,7 +1614,7 @@ def htmlInboxNews(cssCache: {}, defaultTimeline: str, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) def htmlOutbox(cssCache: {}, defaultTimeline: str, @@ -1521,7 +1625,9 @@ def htmlOutbox(cssCache: {}, defaultTimeline: str, nickname: str, domain: str, port: int, outboxJson: {}, allowDeletion: bool, httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, + minimal: bool, + YTReplacementDomain: str, + twitterReplacementDomain: str, showPublishedDateOnly: bool, newswire: {}, positiveVoting: bool, showPublishAsIcon: bool, @@ -1535,7 +1641,8 @@ def htmlOutbox(cssCache: {}, defaultTimeline: str, textModeBanner: str, accessKeys: {}, systemLanguage: str, maxLikeCount: int, - sharedItemsFederatedDomains: []) -> str: + sharedItemsFederatedDomains: [], + signingPrivateKeyPem: str) -> str: """Show the Outbox as html """ manuallyApproveFollowers = \ @@ -1548,11 +1655,13 @@ def htmlOutbox(cssCache: {}, defaultTimeline: str, nickname, domain, port, outboxJson, 'outbox', allowDeletion, httpPrefix, projectVersion, manuallyApproveFollowers, minimal, - YTReplacementDomain, showPublishedDateOnly, + YTReplacementDomain, + twitterReplacementDomain, + showPublishedDateOnly, newswire, False, False, positiveVoting, showPublishAsIcon, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, allowLocalNetworkAccess, textModeBanner, accessKeys, systemLanguage, maxLikeCount, - sharedItemsFederatedDomains) + sharedItemsFederatedDomains, signingPrivateKeyPem) diff --git a/webapp_tos.py b/webapp_tos.py index 6f9a6ba14..e96575e9b 100644 --- a/webapp_tos.py +++ b/webapp_tos.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" diff --git a/webapp_utils.py b/webapp_utils.py index 584376529..521372a38 100644 --- a/webapp_utils.py +++ b/webapp_utils.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Web Interface" @@ -232,7 +232,8 @@ def setBlogAddress(actorJson: {}, blogAddress: str) -> None: _setActorPropertyUrl(actorJson, 'Blog', removeHtml(blogAddress)) -def updateAvatarImageCache(session, baseDir: str, httpPrefix: str, +def updateAvatarImageCache(signingPrivateKeyPem: str, + session, baseDir: str, httpPrefix: str, actor: str, avatarUrl: str, personCache: {}, allowDownloads: bool, force: bool = False, debug: bool = False) -> str: @@ -279,7 +280,10 @@ def updateAvatarImageCache(session, baseDir: str, httpPrefix: str, str(result.status_code)) # remove partial download if os.path.isfile(avatarImageFilename): - os.remove(avatarImageFilename) + try: + os.remove(avatarImageFilename) + except BaseException: + pass else: with open(avatarImageFilename, 'wb') as f: f.write(result.content) @@ -299,7 +303,7 @@ def updateAvatarImageCache(session, baseDir: str, httpPrefix: str, 'Accept': 'application/ld+json; profile="' + prof + '"' } personJson = \ - getJson(session, actor, sessionHeaders, None, + getJson(signingPrivateKeyPem, session, actor, sessionHeaders, None, debug, __version__, httpPrefix, None) if personJson: if not personJson.get('id'): @@ -468,6 +472,16 @@ def _getImageFile(baseDir: str, name: str, directory: str, bannerExtensions = getImageExtensions() bannerFile = '' bannerFilename = '' + for ext in bannerExtensions: + bannerFileTest = name + '.' + ext + bannerFilenameTest = directory + '/' + bannerFileTest + if os.path.isfile(bannerFilenameTest): + bannerFile = name + '_' + theme + '.' + ext + bannerFilename = bannerFilenameTest + return bannerFile, bannerFilename + # if not found then use the default image + theme = 'default' + directory = baseDir + '/theme/' + theme for ext in bannerExtensions: bannerFileTest = name + '.' + ext bannerFilenameTest = directory + '/' + bannerFileTest @@ -903,7 +917,8 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, if attach.get('name'): imageDescription = attach['name'].replace('"', "'") if _isImageMimeType(mediaType): - if _isAttachedImage(attach['url']): + imageUrl = attach['url'] + if _isAttachedImage(attach['url']) and 'svg' not in mediaType: if not attachmentStr: attachmentStr += '
\n' mediaStyleAdded = True @@ -913,10 +928,10 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, if boxName == 'tlmedia': galleryStr += '\n' galleryStr += '
\n' - attachmentStr += '' + attachmentStr += '' attachmentStr += \ - '' + imageDescription + '\n' attachmentCtr += 1 @@ -1103,7 +1118,8 @@ def htmlHighlightLabel(label: str, highlight: bool) -> str: def getAvatarImageUrl(session, baseDir: str, httpPrefix: str, postActor: str, personCache: {}, - avatarUrl: str, allowDownloads: bool) -> str: + avatarUrl: str, allowDownloads: bool, + signingPrivateKeyPem: str) -> str: """Returns the avatar image url """ # get the avatar image url for the post actor @@ -1112,11 +1128,13 @@ def getAvatarImageUrl(session, getPersonAvatarUrl(baseDir, postActor, personCache, allowDownloads) avatarUrl = \ - updateAvatarImageCache(session, baseDir, httpPrefix, + updateAvatarImageCache(signingPrivateKeyPem, + session, baseDir, httpPrefix, postActor, avatarUrl, personCache, allowDownloads) else: - updateAvatarImageCache(session, baseDir, httpPrefix, + updateAvatarImageCache(signingPrivateKeyPem, + session, baseDir, httpPrefix, postActor, avatarUrl, personCache, allowDownloads) @@ -1274,7 +1292,8 @@ def editTextArea(label: str, name: str, value: str = "", def htmlSearchResultShare(baseDir: str, sharedItem: {}, translate: {}, httpPrefix: str, domainFull: str, contactNickname: str, itemID: str, - actor: str, sharesFileType: str) -> str: + actor: str, sharesFileType: str, + category: str) -> str: """Returns the html for an individual shared item """ sharedItemsForm = '
\n' @@ -1301,6 +1320,7 @@ def htmlSearchResultShare(baseDir: str, sharedItem: {}, translate: {}, sharedItemsForm += \ '' + translate['Location'] + ': ' + \ sharedItem['location'] + '
' + contactTitleStr = translate['Contact'] if sharedItem.get('itemPrice') and \ sharedItem.get('itemCurrency'): if isfloat(sharedItem['itemPrice']): @@ -1309,17 +1329,24 @@ def htmlSearchResultShare(baseDir: str, sharedItem: {}, translate: {}, ' ' + translate['Price'] + \ ': ' + sharedItem['itemPrice'] + \ ' ' + sharedItem['itemCurrency'] + contactTitleStr = translate['Buy'] sharedItemsForm += '

\n' contactActor = \ localActorUrl(httpPrefix, contactNickname, domainFull) + buttonStyleStr = 'button' + if category == 'accommodation': + contactTitleStr = translate['Request to stay'] + buttonStyleStr = 'contactbutton' + sharedItemsForm += \ '

' + \ '\n' + \ '\n' + translate['Profile'] + '\n' # should the remove button be shown? showRemoveButton = False @@ -1354,7 +1381,7 @@ def htmlShowShare(baseDir: str, domain: str, nickname: str, itemID: str, translate: {}, sharedItemsFederatedDomains: [], defaultTimeline: str, theme: str, - sharesFileType: str) -> str: + sharesFileType: str, category: str) -> str: """Shows an individual shared item after selecting it from the left column """ sharesJson = None @@ -1419,7 +1446,7 @@ def htmlShowShare(baseDir: str, domain: str, nickname: str, shareStr += \ htmlSearchResultShare(baseDir, sharedItem, translate, httpPrefix, domainFull, contactNickname, itemID, - actor, sharesFileType) + actor, sharesFileType, category) cssFilename = baseDir + '/epicyon-profile.css' if os.path.isfile(baseDir + '/epicyon.css'): diff --git a/webapp_welcome.py b/webapp_welcome.py index 11bca121c..24f687094 100644 --- a/webapp_welcome.py +++ b/webapp_welcome.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Onboarding" diff --git a/webapp_welcome_final.py b/webapp_welcome_final.py index de5940199..088151840 100644 --- a/webapp_welcome_final.py +++ b/webapp_welcome_final.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Onboarding" diff --git a/webapp_welcome_profile.py b/webapp_welcome_profile.py index e6a4d7ce8..d708df833 100644 --- a/webapp_welcome_profile.py +++ b/webapp_welcome_profile.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Onboarding" diff --git a/webfinger.py b/webfinger.py index a562f24ad..3ca4f00e5 100644 --- a/webfinger.py +++ b/webfinger.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "ActivityPub" @@ -63,7 +63,8 @@ def _parseHandle(handle: str) -> (str, str, bool): def webfingerHandle(session, handle: str, httpPrefix: str, cachedWebfingers: {}, fromDomain: str, projectVersion: str, - debug: bool, groupAccount: bool) -> {}: + debug: bool, groupAccount: bool, + signingPrivateKeyPem: str) -> {}: """Gets webfinger result for the given ActivityPub handle """ if not session: @@ -98,9 +99,8 @@ def webfingerHandle(session, handle: str, httpPrefix: str, } try: result = \ - getJson(session, url, hdr, par, - debug, projectVersion, - httpPrefix, fromDomain) + getJson(signingPrivateKeyPem, session, url, hdr, par, + debug, projectVersion, httpPrefix, fromDomain) except Exception as e: print('ERROR: webfingerHandle ' + str(e)) return None @@ -159,7 +159,6 @@ def createWebfingerEndpoint(nickname: str, domain: str, port: int, profilePageHref = httpPrefix + '://' + domain + \ '/about/more?instance_actor=true' - actor = localActorUrl(httpPrefix, nickname, domain) account = { "aliases": [ httpPrefix + "://" + domain + "/@" + personName, @@ -171,11 +170,6 @@ def createWebfingerEndpoint(nickname: str, domain: str, port: int, "rel": "http://webfinger.net/rel/profile-page", "type": "text/html" }, - { - "href": actor + ".atom", - "rel": "http://schemas.google.com/g/2010#updates-from", - "type": "application/atom+xml" - }, { "href": personId, "rel": "self", @@ -267,6 +261,11 @@ def webfingerLookup(path: str, baseDir: str, if onionDomain in handle: handle = handle.replace(onionDomain, domain) onionify = True + # instance actor + if handle.startswith('actor@'): + handle = handle.replace('actor@', 'inbox@', 1) + elif handle.startswith('Actor@'): + handle = handle.replace('Actor@', 'inbox@', 1) filename = baseDir + '/wfendpoints/' + handle + '.json' if debug: print('DEBUG: WEBFINGER filename ' + filename) diff --git a/website/EN/index.html b/website/EN/index.html index 01292cbf5..a30abfcdd 100644 --- a/website/EN/index.html +++ b/website/EN/index.html @@ -11,8 +11,8 @@ "author" : { "@type" : "Person", "name" : "Bob Mottram", - "email": "bob@freedombone.net", - "url": "https://epicyon.freedombone.net/users/bob" + "email": "bob@libreserver.org", + "url": "https://epicyon.libreserver.org/users/bob" }, "applicationCategory" : ["server", "software", "bash", "debian", "linux", "self-hosting"], "downloadUrl" : "https://libreserver.org/epicyon/epicyon.tar.gz" @@ -1370,9 +1370,6 @@ And paste the following:

- proxy_cache_path /var/www/cache levels=1:2 keys_zone=my_cache:10m max_size=10g - inactive=60m use_temp_path=off; - server {
listen 80;
listen [::]:80;
@@ -1425,30 +1422,24 @@ }

location / {
- proxy_http_version 1.1;
- client_max_body_size 31M;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header Host $http_host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forward-Proto http;
- proxy_set_header X-Nginx-Proxy true;
- proxy_temp_file_write_size 64k;
- proxy_connect_timeout 10080s;
- proxy_send_timeout 10080;
- proxy_read_timeout 10080;
- proxy_buffer_size 64k;
- proxy_buffers 16 32k;
- proxy_busy_buffers_size 64k;
- proxy_redirect off;
- proxy_request_buffering off;
- proxy_buffering off;
- location ~ ^/accounts/(avatars|headers)/(.*).(png|jpg|gif|webp|svg) {
- expires 1d;
- proxy_pass http://localhost:7156;
- }
- proxy_pass http://localhost:7156;
+ proxy_http_version 1.1;
+ client_max_body_size 31M;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forward-Proto http;
+ proxy_set_header X-Nginx-Proxy true;
+ proxy_temp_file_write_size 64k;
+ proxy_connect_timeout 10080s;
+ proxy_send_timeout 10080;
+ proxy_read_timeout 10080;
+ proxy_buffer_size 64k;
+ proxy_buffers 16 32k;
+ proxy_busy_buffers_size 64k;
+ proxy_redirect off;
+ proxy_request_buffering off;
+ proxy_buffering off;
+ proxy_pass http://localhost:7156;
}
}
@@ -1458,7 +1449,6 @@

ln -s /etc/nginx/sites-available/YOUR_DOMAIN /etc/nginx/sites-enabled/
- mkdir /var/www/cache

@@ -1477,7 +1467,7 @@ If you need to use fail2ban then failed login attempts can be found in accounts/loginfailures.log.

- If you are using the Caddy web server then see caddy.example.conf + If you are using the Caddy web server then see caddy.example.conf

Now you can navigate to your domain and register an account. The first account becomes the administrator. @@ -1505,6 +1495,6 @@ Please be aware that such installations will not federate with ordinary fedivers

diff --git a/xmpp.py b/xmpp.py index 707767a79..9fc124c8d 100644 --- a/xmpp.py +++ b/xmpp.py @@ -3,7 +3,7 @@ __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.2.0" __maintainer__ = "Bob Mottram" -__email__ = "bob@freedombone.net" +__email__ = "bob@libreserver.org" __status__ = "Production" __module_group__ = "Profile Metadata"