mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-24 11:51:27 -08:00
1688 lines
48 KiB
Python
1688 lines
48 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from typing import Union, Tuple
|
||
from flask import current_app, request
|
||
from sqlalchemy import text
|
||
from app import db, cache, constants
|
||
from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \
|
||
Site, PostVote, PostReplyVote
|
||
import time
|
||
import base64
|
||
import requests
|
||
from cryptography.hazmat.primitives import serialization, hashes
|
||
from cryptography.hazmat.primitives.asymmetric import padding
|
||
from app.constants import *
|
||
from urllib.parse import urlparse
|
||
|
||
from app.utils import get_request, allowlist_html, html_to_markdown, get_setting, ap_datetime, markdown_to_html, \
|
||
is_image_url, domain_from_url
|
||
|
||
|
||
def public_key():
|
||
if not os.path.exists('./public.pem'):
|
||
os.system('openssl genrsa -out private.pem 2048')
|
||
os.system('openssl rsa -in private.pem -outform PEM -pubout -out public.pem')
|
||
else:
|
||
publicKey = open('./public.pem', 'r').read()
|
||
PUBLICKEY = publicKey.replace('\n', '\\n') # JSON-LD doesn't want to work with linebreaks,
|
||
# but needs the \n character to know where to break the line ;)
|
||
return PUBLICKEY
|
||
|
||
|
||
def users_total():
|
||
return db.session.execute(text(
|
||
'SELECT COUNT(id) as c FROM "user" WHERE ap_id is null AND verified is true AND banned is false AND deleted is false')).scalar()
|
||
|
||
|
||
def active_half_year():
|
||
return db.session.execute(text(
|
||
"SELECT COUNT(id) as c FROM \"user\" WHERE last_seen >= CURRENT_DATE - INTERVAL '6 months' AND ap_id is null AND verified is true AND banned is false AND deleted is false")).scalar()
|
||
|
||
|
||
def active_month():
|
||
return db.session.execute(text(
|
||
"SELECT COUNT(id) as c FROM \"user\" WHERE last_seen >= CURRENT_DATE - INTERVAL '1 month' AND ap_id is null AND verified is true AND banned is false AND deleted is false")).scalar()
|
||
|
||
|
||
def local_posts():
|
||
return db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE ap_id is null')).scalar()
|
||
|
||
|
||
def local_comments():
|
||
return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE ap_id is null')).scalar()
|
||
|
||
|
||
def send_activity(sender: User, host: str, content: str):
|
||
date = time.strftime('%a, %d %b %Y %H:%M:%S UTC', time.gmtime())
|
||
|
||
private_key = serialization.load_pem_private_key(sender.private_key, password=None)
|
||
|
||
# todo: look up instance details to set host_inbox
|
||
host_inbox = '/inbox'
|
||
|
||
signed_string = f"(request-target): post {host_inbox}\nhost: {host}\ndate: " + date
|
||
signature = private_key.sign(signed_string.encode('utf-8'), padding.PKCS1v15(), hashes.SHA256())
|
||
encoded_signature = base64.b64encode(signature).decode('utf-8')
|
||
|
||
# Construct the Signature header
|
||
header = f'keyId="https://{current_app.config["SERVER_NAME"]}/u/{sender.user_name}",headers="(request-target) host date",signature="{encoded_signature}"'
|
||
|
||
# Create headers for the request
|
||
headers = {
|
||
'Host': host,
|
||
'Date': date,
|
||
'Signature': header
|
||
}
|
||
|
||
# Make the HTTP request
|
||
try:
|
||
response = requests.post(f'https://{host}{host_inbox}', headers=headers, data=content,
|
||
timeout=REQUEST_TIMEOUT)
|
||
except requests.exceptions.RequestException:
|
||
time.sleep(1)
|
||
response = requests.post(f'https://{host}{host_inbox}', headers=headers, data=content,
|
||
timeout=REQUEST_TIMEOUT / 2)
|
||
return response.status_code
|
||
|
||
|
||
def post_to_activity(post: Post, community: Community):
|
||
activity_data = {
|
||
"actor": f"https://{current_app.config['SERVER_NAME']}/c/{community.name}",
|
||
"to": [
|
||
"https://www.w3.org/ns/activitystreams#Public"
|
||
],
|
||
"object": {
|
||
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{post.ap_create_id}",
|
||
"actor": f"https://{current_app.config['SERVER_NAME']}/u/{post.author.user_name}",
|
||
"to": [
|
||
"https://www.w3.org/ns/activitystreams#Public"
|
||
],
|
||
"object": {
|
||
"type": "Page",
|
||
"id": f"https://{current_app.config['SERVER_NAME']}/post/{post.id}",
|
||
"attributedTo": f"https://{current_app.config['SERVER_NAME']}/u/{post.author.user_name}",
|
||
"to": [
|
||
f"https://{current_app.config['SERVER_NAME']}/c/{community.name}",
|
||
"https://www.w3.org/ns/activitystreams#Public"
|
||
],
|
||
"name": post.title,
|
||
"cc": [],
|
||
"content": post.body_html,
|
||
"mediaType": "text/html",
|
||
"source": {
|
||
"content": post.body,
|
||
"mediaType": "text/markdown"
|
||
},
|
||
"attachment": [],
|
||
"commentsEnabled": True,
|
||
"sensitive": post.nsfw or post.nsfl,
|
||
"published": ap_datetime(post.created_at),
|
||
"audience": f"https://{current_app.config['SERVER_NAME']}/c/{community.name}"
|
||
},
|
||
"cc": [
|
||
f"https://{current_app.config['SERVER_NAME']}/c/{community.name}"
|
||
],
|
||
"type": "Create",
|
||
"audience": f"https://{current_app.config['SERVER_NAME']}/c/{community.name}"
|
||
},
|
||
"cc": [
|
||
f"https://{current_app.config['SERVER_NAME']}/c/{community.name}/followers"
|
||
],
|
||
"type": "Announce",
|
||
"id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{post.ap_announce_id}"
|
||
}
|
||
if post.edited_at is not None:
|
||
activity_data["object"]["object"]["updated"] = post.edited_at
|
||
if post.language is not None:
|
||
activity_data["object"]["object"]["language"] = {"identifier": post.language}
|
||
if post.type == POST_TYPE_LINK and post.url is not None:
|
||
activity_data["object"]["object"]["attachment"] = {"href": post.url, "type": "Link"}
|
||
if post.image_id is not None:
|
||
activity_data["object"]["object"]["image"] = {"href": post.image.source_url, "type": "Image"}
|
||
return activity_data
|
||
|
||
|
||
def banned_user_agents():
|
||
return [] # todo: finish this function
|
||
|
||
|
||
@cache.memoize(150)
|
||
def instance_blocked(host: str) -> bool:
|
||
host = host.lower()
|
||
if 'https://' in host or 'http://' in host:
|
||
host = urlparse(host).hostname
|
||
instance = BannedInstances.query.filter_by(domain=host.strip()).first()
|
||
return instance is not None
|
||
|
||
|
||
@cache.memoize(150)
|
||
def instance_allowed(host: str) -> bool:
|
||
host = host.lower()
|
||
if 'https://' in host or 'http://' in host:
|
||
host = urlparse(host).hostname
|
||
instance = AllowedInstances.query.filter_by(domain=host.strip()).first()
|
||
return instance is not None
|
||
|
||
|
||
def find_actor_or_create(actor: str) -> Union[User, Community, None]:
|
||
user = None
|
||
# actor parameter must be formatted as https://server/u/actor or https://server/c/actor
|
||
|
||
# Initially, check if the user exists in the local DB already
|
||
if current_app.config['SERVER_NAME'] + '/c/' in actor:
|
||
return Community.query.filter_by(
|
||
ap_profile_id=actor).first() # finds communities formatted like https://localhost/c/*
|
||
|
||
if current_app.config['SERVER_NAME'] + '/u/' in actor:
|
||
user = User.query.filter_by(user_name=actor.split('/')[-1], ap_id=None, banned=False).first() # finds local users
|
||
if user is None:
|
||
return None
|
||
elif actor.startswith('https://'):
|
||
server, address = extract_domain_and_actor(actor)
|
||
if get_setting('use_allowlist', False):
|
||
if not instance_allowed(server):
|
||
return None
|
||
else:
|
||
if instance_blocked(server):
|
||
return None
|
||
user = User.query.filter_by(
|
||
ap_profile_id=actor).first() # finds users formatted like https://kbin.social/u/tables
|
||
if user and user.banned:
|
||
return None
|
||
if user is None:
|
||
user = Community.query.filter_by(ap_profile_id=actor).first()
|
||
|
||
if user is not None:
|
||
return user
|
||
else: # User does not exist in the DB, it's going to need to be created from it's remote home instance
|
||
if actor.startswith('https://'):
|
||
actor_data = get_request(actor, headers={'Accept': 'application/activity+json'})
|
||
if actor_data.status_code == 200:
|
||
actor_json = actor_data.json()
|
||
actor_data.close()
|
||
return actor_json_to_model(actor_json, address, server)
|
||
else:
|
||
# retrieve user details via webfinger, etc
|
||
# todo: try, except block around every get_request
|
||
webfinger_data = get_request(f"https://{server}/.well-known/webfinger",
|
||
params={'resource': f"acct:{address}@{server}"})
|
||
if webfinger_data.status_code == 200:
|
||
webfinger_json = webfinger_data.json()
|
||
webfinger_data.close()
|
||
for links in webfinger_json['links']:
|
||
if 'rel' in links and links['rel'] == 'self': # this contains the URL of the activitypub profile
|
||
type = links['type'] if 'type' in links else 'application/activity+json'
|
||
# retrieve the activitypub profile
|
||
print('****', links['href'])
|
||
actor_data = get_request(links['href'], headers={'Accept': type})
|
||
# to see the structure of the json contained in actor_data, do a GET to https://lemmy.world/c/technology with header Accept: application/activity+json
|
||
if actor_data.status_code == 200:
|
||
actor_json = actor_data.json()
|
||
actor_data.close()
|
||
return actor_json_to_model(actor_json, address, server)
|
||
return None
|
||
|
||
|
||
def extract_domain_and_actor(url_string: str):
|
||
# Parse the URL
|
||
parsed_url = urlparse(url_string)
|
||
|
||
# Extract the server domain name
|
||
server_domain = parsed_url.netloc
|
||
|
||
# Extract the part of the string after the last '/' character
|
||
actor = parsed_url.path.split('/')[-1]
|
||
|
||
return server_domain, actor
|
||
|
||
|
||
def actor_json_to_model(activity_json, address, server):
|
||
if activity_json['type'] == 'Person':
|
||
user = User(user_name=activity_json['preferredUsername'],
|
||
email=f"{address}@{server}",
|
||
about=parse_summary(activity_json),
|
||
created=activity_json['published'] if 'published' in activity_json else utcnow(),
|
||
ap_id=f"{address}@{server}",
|
||
ap_public_url=activity_json['id'],
|
||
ap_profile_id=activity_json['id'],
|
||
ap_inbox_url=activity_json['endpoints']['sharedInbox'],
|
||
ap_followers_url=activity_json['followers'] if 'followers' in activity_json else None,
|
||
ap_preferred_username=activity_json['preferredUsername'],
|
||
ap_fetched_at=utcnow(),
|
||
ap_domain=server,
|
||
public_key=activity_json['publicKey']['publicKeyPem'],
|
||
instance_id=find_instance_id(server)
|
||
# language=community_json['language'][0]['identifier'] # todo: language
|
||
)
|
||
if 'icon' in activity_json:
|
||
# todo: retrieve icon, save to disk, save more complete File record
|
||
avatar = File(source_url=activity_json['icon']['url'])
|
||
user.avatar = avatar
|
||
db.session.add(avatar)
|
||
if 'image' in activity_json:
|
||
# todo: retrieve image, save to disk, save more complete File record
|
||
cover = File(source_url=activity_json['image']['url'])
|
||
user.cover = cover
|
||
db.session.add(cover)
|
||
db.session.add(user)
|
||
db.session.commit()
|
||
return user
|
||
elif activity_json['type'] == 'Group':
|
||
if 'attributedTo' in activity_json: # lemmy and mbin
|
||
mods_url = activity_json['attributedTo']
|
||
elif 'moderators' in activity_json: # kbin
|
||
mods_url = activity_json['moderators']
|
||
else:
|
||
mods_url = None
|
||
community = Community(name=activity_json['preferredUsername'],
|
||
title=activity_json['name'],
|
||
description=activity_json['summary'] if 'summary' in activity_json else '',
|
||
rules=activity_json['rules'] if 'rules' in activity_json else '',
|
||
rules_html=markdown_to_html(activity_json['rules'] if 'rules' in activity_json else ''),
|
||
nsfw=activity_json['sensitive'],
|
||
restricted_to_mods=activity_json['postingRestrictedToMods'],
|
||
created_at=activity_json['published'] if 'published' in activity_json else utcnow(),
|
||
last_active=activity_json['updated'] if 'updated' in activity_json else utcnow(),
|
||
ap_id=f"{address[1:]}",
|
||
ap_public_url=activity_json['id'],
|
||
ap_profile_id=activity_json['id'],
|
||
ap_followers_url=activity_json['followers'],
|
||
ap_inbox_url=activity_json['endpoints']['sharedInbox'],
|
||
ap_moderators_url=mods_url,
|
||
ap_fetched_at=utcnow(),
|
||
ap_domain=server,
|
||
public_key=activity_json['publicKey']['publicKeyPem'],
|
||
# language=community_json['language'][0]['identifier'] # todo: language
|
||
instance_id=find_instance_id(server),
|
||
low_quality='memes' in activity_json['preferredUsername']
|
||
)
|
||
# parse markdown and overwrite html field with result
|
||
if 'source' in activity_json and \
|
||
activity_json['source']['mediaType'] == 'text/markdown':
|
||
community.description = activity_json['source']['content']
|
||
community.description_html = markdown_to_html(community.description)
|
||
elif 'content' in activity_json:
|
||
community.description_html = allowlist_html(activity_json['content'])
|
||
community.description = html_to_markdown(community.description_html)
|
||
if 'icon' in activity_json:
|
||
# todo: retrieve icon, save to disk, save more complete File record
|
||
icon = File(source_url=activity_json['icon']['url'])
|
||
community.icon = icon
|
||
db.session.add(icon)
|
||
if 'image' in activity_json:
|
||
# todo: retrieve image, save to disk, save more complete File record
|
||
image = File(source_url=activity_json['image']['url'])
|
||
community.image = image
|
||
db.session.add(image)
|
||
db.session.add(community)
|
||
db.session.commit()
|
||
return community
|
||
|
||
|
||
def post_json_to_model(post_json, user, community) -> Post:
|
||
post = Post(user_id=user.id, community_id=community.id,
|
||
title=post_json['name'],
|
||
comments_enabled=post_json['commentsEnabled'],
|
||
sticky=post_json['stickied'] if 'stickied' in post_json else False,
|
||
nsfw=post_json['sensitive'],
|
||
nsfl=post_json['nsfl'] if 'nsfl' in post_json else False,
|
||
ap_id=post_json['id'],
|
||
type=constants.POST_TYPE_ARTICLE,
|
||
posted_at=post_json['published'],
|
||
last_active=post_json['published'],
|
||
)
|
||
if 'source' in post_json and \
|
||
post_json['source']['mediaType'] == 'text/markdown':
|
||
post.body = post_json['source']['content']
|
||
post.body_html = markdown_to_html(post.body)
|
||
elif 'content' in post_json:
|
||
post.body_html = allowlist_html(post_json['content'])
|
||
post.body = html_to_markdown(post.body_html)
|
||
if 'attachment' in post_json and \
|
||
len(post_json['attachment']) > 0 and \
|
||
'type' in post_json['attachment'][0]:
|
||
if post_json['attachment'][0]['type'] == 'Link':
|
||
post.url = post_json['attachment'][0]['href']
|
||
if is_image_url(post.url):
|
||
post.type = POST_TYPE_IMAGE
|
||
else:
|
||
post.type = POST_TYPE_LINK
|
||
domain = domain_from_url(post.url)
|
||
if not domain.banned:
|
||
post.domain_id = domain.id
|
||
else:
|
||
post = None
|
||
if 'image' in post_json:
|
||
image = File(source_url=post_json['image']['url'])
|
||
db.session.add(image)
|
||
post.image = image
|
||
|
||
if post is not None:
|
||
db.session.add(post)
|
||
community.post_count += 1
|
||
db.session.commit()
|
||
return post
|
||
|
||
# create a summary from markdown if present, otherwise use html if available
|
||
def parse_summary(user_json) -> str:
|
||
if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown':
|
||
# Convert Markdown to HTML
|
||
markdown_text = user_json['source']['content']
|
||
html_content = html_to_markdown(markdown_text)
|
||
return html_content
|
||
elif 'summary' in user_json:
|
||
return allowlist_html(user_json['summary'])
|
||
else:
|
||
return ''
|
||
|
||
|
||
def default_context():
|
||
context = [
|
||
"https://www.w3.org/ns/activitystreams",
|
||
"https://w3id.org/security/v1",
|
||
]
|
||
if current_app.config['FULL_AP_CONTEXT']:
|
||
context.append({
|
||
"lemmy": "https://join-lemmy.org/ns#",
|
||
"litepub": "http://litepub.social/ns#",
|
||
"pt": "https://joinpeertube.org/ns#",
|
||
"sc": "http://schema.org/",
|
||
"ChatMessage": "litepub:ChatMessage",
|
||
"commentsEnabled": "pt:commentsEnabled",
|
||
"sensitive": "as:sensitive",
|
||
"matrixUserId": "lemmy:matrixUserId",
|
||
"postingRestrictedToMods": "lemmy:postingRestrictedToMods",
|
||
"removeData": "lemmy:removeData",
|
||
"stickied": "lemmy:stickied",
|
||
"moderators": {
|
||
"@type": "@id",
|
||
"@id": "lemmy:moderators"
|
||
},
|
||
"expires": "as:endTime",
|
||
"distinguished": "lemmy:distinguished",
|
||
"language": "sc:inLanguage",
|
||
"identifier": "sc:identifier"
|
||
})
|
||
return context
|
||
|
||
|
||
def find_reply_parent(in_reply_to: str) -> Tuple[int, int, int]:
|
||
if 'comment' in in_reply_to:
|
||
parent_comment = PostReply.get_by_ap_id(in_reply_to)
|
||
if not parent_comment:
|
||
return (None, None, None)
|
||
parent_comment_id = parent_comment.id
|
||
post_id = parent_comment.post_id
|
||
root_id = parent_comment.root_id
|
||
elif 'post' in in_reply_to:
|
||
parent_comment_id = None
|
||
post = Post.get_by_ap_id(in_reply_to)
|
||
if not post:
|
||
return (None, None, None)
|
||
post_id = post.id
|
||
root_id = None
|
||
else:
|
||
parent_comment_id = None
|
||
root_id = None
|
||
post_id = None
|
||
post = Post.get_by_ap_id(in_reply_to)
|
||
if post:
|
||
post_id = post.id
|
||
else:
|
||
parent_comment = PostReply.get_by_ap_id(in_reply_to)
|
||
if parent_comment:
|
||
parent_comment_id = parent_comment.id
|
||
post_id = parent_comment.post_id
|
||
root_id = parent_comment.root_id
|
||
else:
|
||
return (None, None, None)
|
||
|
||
return post_id, parent_comment_id, root_id
|
||
|
||
|
||
def find_liked_object(ap_id) -> Union[Post, PostReply, None]:
|
||
post = Post.get_by_ap_id(ap_id)
|
||
if post:
|
||
return post
|
||
else:
|
||
post_reply = PostReply.get_by_ap_id(ap_id)
|
||
if post_reply:
|
||
return post_reply
|
||
return None
|
||
|
||
|
||
def find_instance_id(server):
|
||
server = server.strip()
|
||
instance = Instance.query.filter_by(domain=server).first()
|
||
if instance:
|
||
return instance.id
|
||
else:
|
||
try:
|
||
instance_data = get_request(f"https://{server}", headers={'Accept': 'application/activity+json'})
|
||
except:
|
||
return None
|
||
if instance_data.status_code == 200:
|
||
try:
|
||
instance_json = instance_data.json()
|
||
instance_data.close()
|
||
except requests.exceptions.JSONDecodeError as ex:
|
||
instance_json = {}
|
||
if 'type' in instance_json and instance_json['type'] == 'Application':
|
||
if instance_json['name'].lower() == 'kbin':
|
||
software = 'Kbin'
|
||
elif instance_json['name'].lower() == 'mbin':
|
||
software = 'Mbin'
|
||
else:
|
||
software = 'Lemmy'
|
||
new_instance = Instance(domain=server,
|
||
inbox=instance_json['inbox'],
|
||
outbox=instance_json['outbox'],
|
||
software=software,
|
||
created_at=instance_json['published'] if 'published' in instance_json else utcnow()
|
||
)
|
||
else:
|
||
new_instance = Instance(domain=server, software='unknown', created_at=utcnow())
|
||
db.session.add(new_instance)
|
||
db.session.commit()
|
||
return new_instance.id
|
||
return None
|
||
|
||
|
||
# alter the effect of upvotes based on their instance. Default to 1.0
|
||
@cache.memoize(timeout=50)
|
||
def instance_weight(domain):
|
||
if domain:
|
||
instance = Instance.query.filter_by(domain=domain).first()
|
||
if instance:
|
||
return instance.vote_weight
|
||
return 1.0
|
||
|
||
|
||
def is_activitypub_request():
|
||
return 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', '')
|
||
|
||
|
||
def downvote_post(post, user):
|
||
user.last_seen = utcnow()
|
||
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first()
|
||
if not existing_vote:
|
||
effect = -1.0
|
||
post.down_votes += 1
|
||
post.score -= 1.0
|
||
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
|
||
effect=effect)
|
||
post.author.reputation += effect
|
||
db.session.add(vote)
|
||
else:
|
||
# remove previously cast upvote
|
||
if existing_vote.effect > 0:
|
||
post.author.reputation -= existing_vote.effect
|
||
post.up_votes -= 1
|
||
post.score -= existing_vote.effect
|
||
db.session.delete(existing_vote)
|
||
|
||
# apply down vote
|
||
effect = -1.0
|
||
post.down_votes += 1
|
||
post.score -= 1.0
|
||
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
|
||
effect=effect)
|
||
post.author.reputation += effect
|
||
db.session.add(vote)
|
||
else:
|
||
pass # they have already downvoted this post
|
||
|
||
|
||
def downvote_post_reply(comment, user):
|
||
user.last_seen = utcnow()
|
||
existing_vote = PostReplyVote.query.filter_by(user_id=user.id,
|
||
post_reply_id=comment.id).first()
|
||
if not existing_vote:
|
||
effect = -1.0
|
||
comment.down_votes += 1
|
||
comment.score -= 1.0
|
||
vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
|
||
author_id=comment.author.id, effect=effect)
|
||
comment.author.reputation += effect
|
||
db.session.add(vote)
|
||
else:
|
||
# remove previously cast upvote
|
||
if existing_vote.effect > 0:
|
||
comment.author.reputation -= existing_vote.effect
|
||
comment.up_votes -= 1
|
||
comment.score -= existing_vote.effect
|
||
db.session.delete(existing_vote)
|
||
|
||
# apply down vote
|
||
effect = -1.0
|
||
comment.down_votes += 1
|
||
comment.score -= 1.0
|
||
vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
|
||
author_id=comment.author.id, effect=effect)
|
||
comment.author.reputation += effect
|
||
db.session.add(vote)
|
||
else:
|
||
pass # they have already downvoted this reply
|
||
|
||
|
||
def upvote_post_reply(comment, user):
|
||
user.last_seen = utcnow()
|
||
effect = instance_weight(user.ap_domain)
|
||
existing_vote = PostReplyVote.query.filter_by(user_id=user.id,
|
||
post_reply_id=comment.id).first()
|
||
if not existing_vote:
|
||
comment.up_votes += 1
|
||
comment.score += effect
|
||
vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
|
||
author_id=comment.author.id, effect=effect)
|
||
comment.author.reputation += effect
|
||
db.session.add(vote)
|
||
else:
|
||
# remove previously cast downvote
|
||
if existing_vote.effect < 0:
|
||
comment.author.reputation -= existing_vote.effect
|
||
comment.down_votes -= 1
|
||
comment.score -= existing_vote.effect
|
||
db.session.delete(existing_vote)
|
||
|
||
# apply up vote
|
||
comment.up_votes += 1
|
||
comment.score += effect
|
||
vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
|
||
author_id=comment.author.id, effect=effect)
|
||
comment.author.reputation += effect
|
||
db.session.add(vote)
|
||
else:
|
||
pass # they have already upvoted this reply
|
||
|
||
|
||
def upvote_post(post, user):
|
||
user.last_seen = utcnow()
|
||
effect = instance_weight(user.ap_domain)
|
||
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first()
|
||
if not existing_vote:
|
||
post.up_votes += 1
|
||
post.score += effect
|
||
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
|
||
effect=effect)
|
||
post.author.reputation += effect
|
||
db.session.add(vote)
|
||
else:
|
||
# remove previous cast downvote
|
||
if existing_vote.effect < 0:
|
||
post.author.reputation -= existing_vote.effect
|
||
post.down_votes -= 1
|
||
post.score -= existing_vote.effect
|
||
db.session.delete(existing_vote)
|
||
|
||
# apply up vote
|
||
post.up_votes += 1
|
||
post.score += effect
|
||
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
|
||
effect=effect)
|
||
post.author.reputation += effect
|
||
db.session.add(vote)
|
||
|
||
|
||
def lemmy_site_data():
|
||
site = Site.query.get(1)
|
||
data = {
|
||
"site_view": {
|
||
"site": {
|
||
"id": 1,
|
||
"name": site.name,
|
||
"sidebar": site.sidebar,
|
||
"published": site.created_at.isoformat(),
|
||
"updated": site.updated.isoformat(),
|
||
"icon": "https://lemmy.nz/pictrs/image/d308ef8d-4381-4a7a-b047-569ed5b8dd88.png",
|
||
"banner": "https://lemmy.nz/pictrs/image/68beebd5-4e01-44b6-bd4e-008b0d443ac1.png",
|
||
"description": site.description,
|
||
"actor_id": f"https://{current_app.config['SERVER_NAME']}/",
|
||
"last_refreshed_at": site.updated.isoformat(),
|
||
"inbox_url": f"https://{current_app.config['SERVER_NAME']}/inbox",
|
||
"public_key": site.public_key,
|
||
"instance_id": 1
|
||
},
|
||
"local_site": {
|
||
"id": 1,
|
||
"site_id": 1,
|
||
"site_setup": True,
|
||
"enable_downvotes": site.enable_downvotes,
|
||
"enable_nsfw": site.enable_nsfw,
|
||
"enable_nsfl": site.enable_nsfl,
|
||
"community_creation_admin_only": site.community_creation_admin_only,
|
||
"require_email_verification": True,
|
||
"application_question": site.application_question,
|
||
"private_instance": False,
|
||
"default_theme": "browser",
|
||
"default_post_listing_type": "All",
|
||
"hide_modlog_mod_names": True,
|
||
"application_email_admins": True,
|
||
"actor_name_max_length": 20,
|
||
"federation_enabled": True,
|
||
"captcha_enabled": True,
|
||
"captcha_difficulty": "medium",
|
||
"published": site.created_at.isoformat(),
|
||
"updated": site.updated.isoformat(),
|
||
"registration_mode": site.registration_mode,
|
||
"reports_email_admins": site.reports_email_admins
|
||
},
|
||
"local_site_rate_limit": {
|
||
"id": 1,
|
||
"local_site_id": 1,
|
||
"message": 999,
|
||
"message_per_second": 60,
|
||
"post": 50,
|
||
"post_per_second": 600,
|
||
"register": 20,
|
||
"register_per_second": 3600,
|
||
"image": 100,
|
||
"image_per_second": 3600,
|
||
"comment": 100,
|
||
"comment_per_second": 600,
|
||
"search": 999,
|
||
"search_per_second": 600,
|
||
"published": site.created_at.isoformat(),
|
||
},
|
||
"counts": {
|
||
"id": 1,
|
||
"site_id": 1,
|
||
"users": 816,
|
||
"posts": 3017,
|
||
"comments": 19693,
|
||
"communities": 7,
|
||
"users_active_day": 21,
|
||
"users_active_week": 49,
|
||
"users_active_month": 85,
|
||
"users_active_half_year": 312
|
||
}
|
||
},
|
||
"admins": [
|
||
{
|
||
"person": {
|
||
"id": 2,
|
||
"name": "Dave",
|
||
"avatar": "https://lemmy.nz/pictrs/image/5eb39c6b-a1f0-4cba-9832-40a5d8ffb76a.png",
|
||
"banned": False,
|
||
"published": "2023-06-02T09:46:20.302035",
|
||
"actor_id": "https://lemmy.nz/u/Dave",
|
||
"local": True,
|
||
"deleted": False,
|
||
"matrix_user_id": "@bechorin:matrix.org",
|
||
"admin": True,
|
||
"bot_account": False,
|
||
"instance_id": 1
|
||
},
|
||
"counts": {
|
||
"id": 1,
|
||
"person_id": 2,
|
||
"post_count": 165,
|
||
"post_score": 1442,
|
||
"comment_count": 2624,
|
||
"comment_score": 10207
|
||
}
|
||
},
|
||
{
|
||
"person": {
|
||
"id": 15059,
|
||
"name": "idanoo",
|
||
"banned": False,
|
||
"published": "2023-06-08T22:13:43.366681",
|
||
"actor_id": "https://lemmy.nz/u/idanoo",
|
||
"local": True,
|
||
"deleted": False,
|
||
"matrix_user_id": "@idanoo:mtrx.nz",
|
||
"admin": True,
|
||
"bot_account": False,
|
||
"instance_id": 1
|
||
},
|
||
"counts": {
|
||
"id": 6544,
|
||
"person_id": 15059,
|
||
"post_count": 0,
|
||
"post_score": 0,
|
||
"comment_count": 5,
|
||
"comment_score": 10
|
||
}
|
||
}
|
||
],
|
||
"version": "1.0.0",
|
||
"all_languages": [
|
||
{
|
||
"id": 0,
|
||
"code": "und",
|
||
"name": "Undetermined"
|
||
},
|
||
{
|
||
"id": 1,
|
||
"code": "aa",
|
||
"name": "Afaraf"
|
||
},
|
||
{
|
||
"id": 2,
|
||
"code": "ab",
|
||
"name": "аҧсуа бызшәа"
|
||
},
|
||
{
|
||
"id": 3,
|
||
"code": "ae",
|
||
"name": "avesta"
|
||
},
|
||
{
|
||
"id": 4,
|
||
"code": "af",
|
||
"name": "Afrikaans"
|
||
},
|
||
{
|
||
"id": 5,
|
||
"code": "ak",
|
||
"name": "Akan"
|
||
},
|
||
{
|
||
"id": 6,
|
||
"code": "am",
|
||
"name": "አማርኛ"
|
||
},
|
||
{
|
||
"id": 7,
|
||
"code": "an",
|
||
"name": "aragonés"
|
||
},
|
||
{
|
||
"id": 8,
|
||
"code": "ar",
|
||
"name": "اَلْعَرَبِيَّةُ"
|
||
},
|
||
{
|
||
"id": 9,
|
||
"code": "as",
|
||
"name": "অসমীয়া"
|
||
},
|
||
{
|
||
"id": 10,
|
||
"code": "av",
|
||
"name": "авар мацӀ"
|
||
},
|
||
{
|
||
"id": 11,
|
||
"code": "ay",
|
||
"name": "aymar aru"
|
||
},
|
||
{
|
||
"id": 12,
|
||
"code": "az",
|
||
"name": "azərbaycan dili"
|
||
},
|
||
{
|
||
"id": 13,
|
||
"code": "ba",
|
||
"name": "башҡорт теле"
|
||
},
|
||
{
|
||
"id": 14,
|
||
"code": "be",
|
||
"name": "беларуская мова"
|
||
},
|
||
{
|
||
"id": 15,
|
||
"code": "bg",
|
||
"name": "български език"
|
||
},
|
||
{
|
||
"id": 16,
|
||
"code": "bi",
|
||
"name": "Bislama"
|
||
},
|
||
{
|
||
"id": 17,
|
||
"code": "bm",
|
||
"name": "bamanankan"
|
||
},
|
||
{
|
||
"id": 18,
|
||
"code": "bn",
|
||
"name": "বাংলা"
|
||
},
|
||
{
|
||
"id": 19,
|
||
"code": "bo",
|
||
"name": "བོད་ཡིག"
|
||
},
|
||
{
|
||
"id": 20,
|
||
"code": "br",
|
||
"name": "brezhoneg"
|
||
},
|
||
{
|
||
"id": 21,
|
||
"code": "bs",
|
||
"name": "bosanski jezik"
|
||
},
|
||
{
|
||
"id": 22,
|
||
"code": "ca",
|
||
"name": "Català"
|
||
},
|
||
{
|
||
"id": 23,
|
||
"code": "ce",
|
||
"name": "нохчийн мотт"
|
||
},
|
||
{
|
||
"id": 24,
|
||
"code": "ch",
|
||
"name": "Chamoru"
|
||
},
|
||
{
|
||
"id": 25,
|
||
"code": "co",
|
||
"name": "corsu"
|
||
},
|
||
{
|
||
"id": 26,
|
||
"code": "cr",
|
||
"name": "ᓀᐦᐃᔭᐍᐏᐣ"
|
||
},
|
||
{
|
||
"id": 27,
|
||
"code": "cs",
|
||
"name": "čeština"
|
||
},
|
||
{
|
||
"id": 28,
|
||
"code": "cu",
|
||
"name": "ѩзыкъ словѣньскъ"
|
||
},
|
||
{
|
||
"id": 29,
|
||
"code": "cv",
|
||
"name": "чӑваш чӗлхи"
|
||
},
|
||
{
|
||
"id": 30,
|
||
"code": "cy",
|
||
"name": "Cymraeg"
|
||
},
|
||
{
|
||
"id": 31,
|
||
"code": "da",
|
||
"name": "dansk"
|
||
},
|
||
{
|
||
"id": 32,
|
||
"code": "de",
|
||
"name": "Deutsch"
|
||
},
|
||
{
|
||
"id": 33,
|
||
"code": "dv",
|
||
"name": "ދިވެހި"
|
||
},
|
||
{
|
||
"id": 34,
|
||
"code": "dz",
|
||
"name": "རྫོང་ཁ"
|
||
},
|
||
{
|
||
"id": 35,
|
||
"code": "ee",
|
||
"name": "Eʋegbe"
|
||
},
|
||
{
|
||
"id": 36,
|
||
"code": "el",
|
||
"name": "Ελληνικά"
|
||
},
|
||
{
|
||
"id": 37,
|
||
"code": "en",
|
||
"name": "English"
|
||
},
|
||
{
|
||
"id": 38,
|
||
"code": "eo",
|
||
"name": "Esperanto"
|
||
},
|
||
{
|
||
"id": 39,
|
||
"code": "es",
|
||
"name": "Español"
|
||
},
|
||
{
|
||
"id": 40,
|
||
"code": "et",
|
||
"name": "eesti"
|
||
},
|
||
{
|
||
"id": 41,
|
||
"code": "eu",
|
||
"name": "euskara"
|
||
},
|
||
{
|
||
"id": 42,
|
||
"code": "fa",
|
||
"name": "فارسی"
|
||
},
|
||
{
|
||
"id": 43,
|
||
"code": "ff",
|
||
"name": "Fulfulde"
|
||
},
|
||
{
|
||
"id": 44,
|
||
"code": "fi",
|
||
"name": "suomi"
|
||
},
|
||
{
|
||
"id": 45,
|
||
"code": "fj",
|
||
"name": "vosa Vakaviti"
|
||
},
|
||
{
|
||
"id": 46,
|
||
"code": "fo",
|
||
"name": "føroyskt"
|
||
},
|
||
{
|
||
"id": 47,
|
||
"code": "fr",
|
||
"name": "Français"
|
||
},
|
||
{
|
||
"id": 48,
|
||
"code": "fy",
|
||
"name": "Frysk"
|
||
},
|
||
{
|
||
"id": 49,
|
||
"code": "ga",
|
||
"name": "Gaeilge"
|
||
},
|
||
{
|
||
"id": 50,
|
||
"code": "gd",
|
||
"name": "Gàidhlig"
|
||
},
|
||
{
|
||
"id": 51,
|
||
"code": "gl",
|
||
"name": "galego"
|
||
},
|
||
{
|
||
"id": 52,
|
||
"code": "gn",
|
||
"name": "Avañe'ẽ"
|
||
},
|
||
{
|
||
"id": 53,
|
||
"code": "gu",
|
||
"name": "ગુજરાતી"
|
||
},
|
||
{
|
||
"id": 54,
|
||
"code": "gv",
|
||
"name": "Gaelg"
|
||
},
|
||
{
|
||
"id": 55,
|
||
"code": "ha",
|
||
"name": "هَوُسَ"
|
||
},
|
||
{
|
||
"id": 56,
|
||
"code": "he",
|
||
"name": "עברית"
|
||
},
|
||
{
|
||
"id": 57,
|
||
"code": "hi",
|
||
"name": "हिन्दी"
|
||
},
|
||
{
|
||
"id": 58,
|
||
"code": "ho",
|
||
"name": "Hiri Motu"
|
||
},
|
||
{
|
||
"id": 59,
|
||
"code": "hr",
|
||
"name": "Hrvatski"
|
||
},
|
||
{
|
||
"id": 60,
|
||
"code": "ht",
|
||
"name": "Kreyòl ayisyen"
|
||
},
|
||
{
|
||
"id": 61,
|
||
"code": "hu",
|
||
"name": "magyar"
|
||
},
|
||
{
|
||
"id": 62,
|
||
"code": "hy",
|
||
"name": "Հայերեն"
|
||
},
|
||
{
|
||
"id": 63,
|
||
"code": "hz",
|
||
"name": "Otjiherero"
|
||
},
|
||
{
|
||
"id": 64,
|
||
"code": "ia",
|
||
"name": "Interlingua"
|
||
},
|
||
{
|
||
"id": 65,
|
||
"code": "id",
|
||
"name": "Bahasa Indonesia"
|
||
},
|
||
{
|
||
"id": 66,
|
||
"code": "ie",
|
||
"name": "Interlingue"
|
||
},
|
||
{
|
||
"id": 67,
|
||
"code": "ig",
|
||
"name": "Asụsụ Igbo"
|
||
},
|
||
{
|
||
"id": 68,
|
||
"code": "ii",
|
||
"name": "ꆈꌠ꒿ Nuosuhxop"
|
||
},
|
||
{
|
||
"id": 69,
|
||
"code": "ik",
|
||
"name": "Iñupiaq"
|
||
},
|
||
{
|
||
"id": 70,
|
||
"code": "io",
|
||
"name": "Ido"
|
||
},
|
||
{
|
||
"id": 71,
|
||
"code": "is",
|
||
"name": "Íslenska"
|
||
},
|
||
{
|
||
"id": 72,
|
||
"code": "it",
|
||
"name": "Italiano"
|
||
},
|
||
{
|
||
"id": 73,
|
||
"code": "iu",
|
||
"name": "ᐃᓄᒃᑎᑐᑦ"
|
||
},
|
||
{
|
||
"id": 74,
|
||
"code": "ja",
|
||
"name": "日本語"
|
||
},
|
||
{
|
||
"id": 75,
|
||
"code": "jv",
|
||
"name": "basa Jawa"
|
||
},
|
||
{
|
||
"id": 76,
|
||
"code": "ka",
|
||
"name": "ქართული"
|
||
},
|
||
{
|
||
"id": 77,
|
||
"code": "kg",
|
||
"name": "Kikongo"
|
||
},
|
||
{
|
||
"id": 78,
|
||
"code": "ki",
|
||
"name": "Gĩkũyũ"
|
||
},
|
||
{
|
||
"id": 79,
|
||
"code": "kj",
|
||
"name": "Kuanyama"
|
||
},
|
||
{
|
||
"id": 80,
|
||
"code": "kk",
|
||
"name": "қазақ тілі"
|
||
},
|
||
{
|
||
"id": 81,
|
||
"code": "kl",
|
||
"name": "kalaallisut"
|
||
},
|
||
{
|
||
"id": 82,
|
||
"code": "km",
|
||
"name": "ខេមរភាសា"
|
||
},
|
||
{
|
||
"id": 83,
|
||
"code": "kn",
|
||
"name": "ಕನ್ನಡ"
|
||
},
|
||
{
|
||
"id": 84,
|
||
"code": "ko",
|
||
"name": "한국어"
|
||
},
|
||
{
|
||
"id": 85,
|
||
"code": "kr",
|
||
"name": "Kanuri"
|
||
},
|
||
{
|
||
"id": 86,
|
||
"code": "ks",
|
||
"name": "कश्मीरी"
|
||
},
|
||
{
|
||
"id": 87,
|
||
"code": "ku",
|
||
"name": "Kurdî"
|
||
},
|
||
{
|
||
"id": 88,
|
||
"code": "kv",
|
||
"name": "коми кыв"
|
||
},
|
||
{
|
||
"id": 89,
|
||
"code": "kw",
|
||
"name": "Kernewek"
|
||
},
|
||
{
|
||
"id": 90,
|
||
"code": "ky",
|
||
"name": "Кыргызча"
|
||
},
|
||
{
|
||
"id": 91,
|
||
"code": "la",
|
||
"name": "latine"
|
||
},
|
||
{
|
||
"id": 92,
|
||
"code": "lb",
|
||
"name": "Lëtzebuergesch"
|
||
},
|
||
{
|
||
"id": 93,
|
||
"code": "lg",
|
||
"name": "Luganda"
|
||
},
|
||
{
|
||
"id": 94,
|
||
"code": "li",
|
||
"name": "Limburgs"
|
||
},
|
||
{
|
||
"id": 95,
|
||
"code": "ln",
|
||
"name": "Lingála"
|
||
},
|
||
{
|
||
"id": 96,
|
||
"code": "lo",
|
||
"name": "ພາສາລາວ"
|
||
},
|
||
{
|
||
"id": 97,
|
||
"code": "lt",
|
||
"name": "lietuvių kalba"
|
||
},
|
||
{
|
||
"id": 98,
|
||
"code": "lu",
|
||
"name": "Kiluba"
|
||
},
|
||
{
|
||
"id": 99,
|
||
"code": "lv",
|
||
"name": "latviešu valoda"
|
||
},
|
||
{
|
||
"id": 100,
|
||
"code": "mg",
|
||
"name": "fiteny malagasy"
|
||
},
|
||
{
|
||
"id": 101,
|
||
"code": "mh",
|
||
"name": "Kajin M̧ajeļ"
|
||
},
|
||
{
|
||
"id": 102,
|
||
"code": "mi",
|
||
"name": "te reo Māori"
|
||
},
|
||
{
|
||
"id": 103,
|
||
"code": "mk",
|
||
"name": "македонски јазик"
|
||
},
|
||
{
|
||
"id": 104,
|
||
"code": "ml",
|
||
"name": "മലയാളം"
|
||
},
|
||
{
|
||
"id": 105,
|
||
"code": "mn",
|
||
"name": "Монгол хэл"
|
||
},
|
||
{
|
||
"id": 106,
|
||
"code": "mr",
|
||
"name": "मराठी"
|
||
},
|
||
{
|
||
"id": 107,
|
||
"code": "ms",
|
||
"name": "Bahasa Melayu"
|
||
},
|
||
{
|
||
"id": 108,
|
||
"code": "mt",
|
||
"name": "Malti"
|
||
},
|
||
{
|
||
"id": 109,
|
||
"code": "my",
|
||
"name": "ဗမာစာ"
|
||
},
|
||
{
|
||
"id": 110,
|
||
"code": "na",
|
||
"name": "Dorerin Naoero"
|
||
},
|
||
{
|
||
"id": 111,
|
||
"code": "nb",
|
||
"name": "Norsk bokmål"
|
||
},
|
||
{
|
||
"id": 112,
|
||
"code": "nd",
|
||
"name": "isiNdebele"
|
||
},
|
||
{
|
||
"id": 113,
|
||
"code": "ne",
|
||
"name": "नेपाली"
|
||
},
|
||
{
|
||
"id": 114,
|
||
"code": "ng",
|
||
"name": "Owambo"
|
||
},
|
||
{
|
||
"id": 115,
|
||
"code": "nl",
|
||
"name": "Nederlands"
|
||
},
|
||
{
|
||
"id": 116,
|
||
"code": "nn",
|
||
"name": "Norsk nynorsk"
|
||
},
|
||
{
|
||
"id": 117,
|
||
"code": "no",
|
||
"name": "Norsk"
|
||
},
|
||
{
|
||
"id": 118,
|
||
"code": "nr",
|
||
"name": "isiNdebele"
|
||
},
|
||
{
|
||
"id": 119,
|
||
"code": "nv",
|
||
"name": "Diné bizaad"
|
||
},
|
||
{
|
||
"id": 120,
|
||
"code": "ny",
|
||
"name": "chiCheŵa"
|
||
},
|
||
{
|
||
"id": 121,
|
||
"code": "oc",
|
||
"name": "occitan"
|
||
},
|
||
{
|
||
"id": 122,
|
||
"code": "oj",
|
||
"name": "ᐊᓂᔑᓈᐯᒧᐎᓐ"
|
||
},
|
||
{
|
||
"id": 123,
|
||
"code": "om",
|
||
"name": "Afaan Oromoo"
|
||
},
|
||
{
|
||
"id": 124,
|
||
"code": "or",
|
||
"name": "ଓଡ଼ିଆ"
|
||
},
|
||
{
|
||
"id": 125,
|
||
"code": "os",
|
||
"name": "ирон æвзаг"
|
||
},
|
||
{
|
||
"id": 126,
|
||
"code": "pa",
|
||
"name": "ਪੰਜਾਬੀ"
|
||
},
|
||
{
|
||
"id": 127,
|
||
"code": "pi",
|
||
"name": "पाऴि"
|
||
},
|
||
{
|
||
"id": 128,
|
||
"code": "pl",
|
||
"name": "Polski"
|
||
},
|
||
{
|
||
"id": 129,
|
||
"code": "ps",
|
||
"name": "پښتو"
|
||
},
|
||
{
|
||
"id": 130,
|
||
"code": "pt",
|
||
"name": "Português"
|
||
},
|
||
{
|
||
"id": 131,
|
||
"code": "qu",
|
||
"name": "Runa Simi"
|
||
},
|
||
{
|
||
"id": 132,
|
||
"code": "rm",
|
||
"name": "rumantsch grischun"
|
||
},
|
||
{
|
||
"id": 133,
|
||
"code": "rn",
|
||
"name": "Ikirundi"
|
||
},
|
||
{
|
||
"id": 134,
|
||
"code": "ro",
|
||
"name": "Română"
|
||
},
|
||
{
|
||
"id": 135,
|
||
"code": "ru",
|
||
"name": "Русский"
|
||
},
|
||
{
|
||
"id": 136,
|
||
"code": "rw",
|
||
"name": "Ikinyarwanda"
|
||
},
|
||
{
|
||
"id": 137,
|
||
"code": "sa",
|
||
"name": "संस्कृतम्"
|
||
},
|
||
{
|
||
"id": 138,
|
||
"code": "sc",
|
||
"name": "sardu"
|
||
},
|
||
{
|
||
"id": 139,
|
||
"code": "sd",
|
||
"name": "सिन्धी"
|
||
},
|
||
{
|
||
"id": 140,
|
||
"code": "se",
|
||
"name": "Davvisámegiella"
|
||
},
|
||
{
|
||
"id": 141,
|
||
"code": "sg",
|
||
"name": "yângâ tî sängö"
|
||
},
|
||
{
|
||
"id": 142,
|
||
"code": "si",
|
||
"name": "සිංහල"
|
||
},
|
||
{
|
||
"id": 143,
|
||
"code": "sk",
|
||
"name": "slovenčina"
|
||
},
|
||
{
|
||
"id": 144,
|
||
"code": "sl",
|
||
"name": "slovenščina"
|
||
},
|
||
{
|
||
"id": 145,
|
||
"code": "sm",
|
||
"name": "gagana fa'a Samoa"
|
||
},
|
||
{
|
||
"id": 146,
|
||
"code": "sn",
|
||
"name": "chiShona"
|
||
},
|
||
{
|
||
"id": 147,
|
||
"code": "so",
|
||
"name": "Soomaaliga"
|
||
},
|
||
{
|
||
"id": 148,
|
||
"code": "sq",
|
||
"name": "Shqip"
|
||
},
|
||
{
|
||
"id": 149,
|
||
"code": "sr",
|
||
"name": "српски језик"
|
||
},
|
||
{
|
||
"id": 150,
|
||
"code": "ss",
|
||
"name": "SiSwati"
|
||
},
|
||
{
|
||
"id": 151,
|
||
"code": "st",
|
||
"name": "Sesotho"
|
||
},
|
||
{
|
||
"id": 152,
|
||
"code": "su",
|
||
"name": "Basa Sunda"
|
||
},
|
||
{
|
||
"id": 153,
|
||
"code": "sv",
|
||
"name": "Svenska"
|
||
},
|
||
{
|
||
"id": 154,
|
||
"code": "sw",
|
||
"name": "Kiswahili"
|
||
},
|
||
{
|
||
"id": 155,
|
||
"code": "ta",
|
||
"name": "தமிழ்"
|
||
},
|
||
{
|
||
"id": 156,
|
||
"code": "te",
|
||
"name": "తెలుగు"
|
||
},
|
||
{
|
||
"id": 157,
|
||
"code": "tg",
|
||
"name": "тоҷикӣ"
|
||
},
|
||
{
|
||
"id": 158,
|
||
"code": "th",
|
||
"name": "ไทย"
|
||
},
|
||
{
|
||
"id": 159,
|
||
"code": "ti",
|
||
"name": "ትግርኛ"
|
||
},
|
||
{
|
||
"id": 160,
|
||
"code": "tk",
|
||
"name": "Türkmençe"
|
||
},
|
||
{
|
||
"id": 161,
|
||
"code": "tl",
|
||
"name": "Wikang Tagalog"
|
||
},
|
||
{
|
||
"id": 162,
|
||
"code": "tn",
|
||
"name": "Setswana"
|
||
},
|
||
{
|
||
"id": 163,
|
||
"code": "to",
|
||
"name": "faka Tonga"
|
||
},
|
||
{
|
||
"id": 164,
|
||
"code": "tr",
|
||
"name": "Türkçe"
|
||
},
|
||
{
|
||
"id": 165,
|
||
"code": "ts",
|
||
"name": "Xitsonga"
|
||
},
|
||
{
|
||
"id": 166,
|
||
"code": "tt",
|
||
"name": "татар теле"
|
||
},
|
||
{
|
||
"id": 167,
|
||
"code": "tw",
|
||
"name": "Twi"
|
||
},
|
||
{
|
||
"id": 168,
|
||
"code": "ty",
|
||
"name": "Reo Tahiti"
|
||
},
|
||
{
|
||
"id": 169,
|
||
"code": "ug",
|
||
"name": "ئۇيغۇرچە"
|
||
},
|
||
{
|
||
"id": 170,
|
||
"code": "uk",
|
||
"name": "Українська"
|
||
},
|
||
{
|
||
"id": 171,
|
||
"code": "ur",
|
||
"name": "اردو"
|
||
},
|
||
{
|
||
"id": 172,
|
||
"code": "uz",
|
||
"name": "Ўзбек"
|
||
},
|
||
{
|
||
"id": 173,
|
||
"code": "ve",
|
||
"name": "Tshivenḓa"
|
||
},
|
||
{
|
||
"id": 174,
|
||
"code": "vi",
|
||
"name": "Tiếng Việt"
|
||
},
|
||
{
|
||
"id": 175,
|
||
"code": "vo",
|
||
"name": "Volapük"
|
||
},
|
||
{
|
||
"id": 176,
|
||
"code": "wa",
|
||
"name": "walon"
|
||
},
|
||
{
|
||
"id": 177,
|
||
"code": "wo",
|
||
"name": "Wollof"
|
||
},
|
||
{
|
||
"id": 178,
|
||
"code": "xh",
|
||
"name": "isiXhosa"
|
||
},
|
||
{
|
||
"id": 179,
|
||
"code": "yi",
|
||
"name": "ייִדיש"
|
||
},
|
||
{
|
||
"id": 180,
|
||
"code": "yo",
|
||
"name": "Yorùbá"
|
||
},
|
||
{
|
||
"id": 181,
|
||
"code": "za",
|
||
"name": "Saɯ cueŋƅ"
|
||
},
|
||
{
|
||
"id": 182,
|
||
"code": "zh",
|
||
"name": "中文"
|
||
},
|
||
{
|
||
"id": 183,
|
||
"code": "zu",
|
||
"name": "isiZulu"
|
||
}
|
||
],
|
||
"discussion_languages": [
|
||
0,
|
||
37
|
||
],
|
||
"taglines": [
|
||
{
|
||
"id": 19,
|
||
"local_site_id": 1,
|
||
"content": "Welcome to Lemmy NZ! [Don't be a dick](https://lemmy.nz/post/63098) ~ [FAQ](https://lemmy.nz/post/31318) ~ [NZ Community List ](https://lemmy.nz/post/63156) ~ [Join Matrix chatroom](https://lemmy.nz/post/169187)\n\n",
|
||
"published": "2023-06-28T09:53:58.605042"
|
||
}
|
||
],
|
||
"custom_emojis": []
|
||
}
|
||
return data
|