├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── app ├── __init__.py ├── filters.py ├── forms.py ├── hooks.py ├── jinja.py ├── management │ └── commands │ │ ├── email.py │ │ ├── import.py │ │ ├── locations.py │ │ ├── openmoji.py │ │ └── stats.py ├── markdown.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_reset.py │ ├── 0003_auto_20200707_0900.py │ ├── 0004_user_location.py │ ├── 0005_auto_20201202_1927.py │ ├── 0006_auto_20201202_1927.py │ ├── 0007_user_readonly.py │ ├── 0008_auto_20210130_1759.py │ ├── 0009_auto_20210309_1942.py │ ├── 0010_remove_comment_ancestors.py │ ├── 0011_auto_20210310_1928.py │ ├── 0012_remove_user_readonly.py │ ├── 0013_comment_to_user.py │ ├── 0014_auto_20210313_1033.py │ ├── 0015_auto_20210321_0028.py │ ├── 0016_auto_20210806_0901.py │ ├── 0017_auto_20210807_2241.py │ ├── 0018_auto_20210809_2008.py │ ├── 0019_auto_20210911_0832.py │ ├── 0020_auto_20210919_1919.py │ ├── 0021_alter_user_unique_together.py │ ├── 0022_rename_bio_user_description.py │ ├── 0023_alter_comment_content.py │ ├── 0024_alter_comment_content.py │ ├── 0025_alter_comment_content.py │ ├── 0026_article.py │ ├── 0027_message.py │ ├── 0028_rename_ips_article_ids_rename_score_article_readers_and_more.py │ ├── 0029_user_is_notified.py │ ├── 0030_delete_message.py │ ├── 0031_alter_user_emoji.py │ ├── 0032_delete_article.py │ ├── 0033_article.py │ ├── 0034_delete_article.py │ ├── 0035_remove_user_is_notified_remove_user_seen_at.py │ ├── 0036_rename_joined_at_user_created_at.py │ ├── 0037_alter_comment_link.py │ ├── 0038_alter_comment_hashtag_alter_comment_link.py │ ├── 0039_rename_links_user_social_user_phone_user_wallet.py │ ├── 0040_room_comment_at_room_comment_in_room.py │ ├── 0041_remove_room_created_at.py │ ├── 0042_remove_comment_hashtag.py │ ├── 0043_rename_relation_bond_post_alter_save_to_comment_and_more.py │ ├── 0044_post_hashtag.py │ ├── 0045_remove_post_at_room_remove_post_in_room_delete_room.py │ ├── 0046_remove_user_wallet.py │ ├── 0047_text.py │ └── __init__.py ├── models.py ├── resources.py ├── utils.py ├── validation.py └── xhr.py ├── crontab ├── deploy.sh ├── gunicorn.conf.py ├── lm.conf ├── manage.py ├── net.conf ├── project ├── __init__.py ├── settings.py └── vars.py ├── requirements.txt ├── router.py ├── static ├── 192.png ├── 256.png ├── cities.json ├── countries.json ├── manifest.json ├── route159 │ ├── bold.woff2 │ └── regular.woff2 ├── script.js ├── style.css └── style.sass ├── sub.conf ├── sub.service └── templates ├── base.html ├── common ├── entry.html ├── inbox.html ├── inline.html ├── message.html ├── nothing.html ├── replies.html ├── status.html ├── stream.html ├── threads.html └── user.html ├── input ├── edit.html ├── message.html ├── query.html ├── reply.html └── thread.html ├── meta.html ├── pages ├── about.html ├── account.html ├── details.html ├── edit.html ├── emoji.html ├── loader.html ├── login.html ├── privacy.html ├── profile.html ├── recover.html ├── register.html ├── regular.html ├── reply.html └── terms.html └── sidebar.html /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .pytest_cache 3 | .ruff_cache 4 | 5 | __pycache__ 6 | 7 | local.py 8 | sub.pid 9 | 10 | *.ipynb 11 | 12 | openmoji 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Gunicorn", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "module": "gunicorn", 12 | "args": [ 13 | "router:app" 14 | ], 15 | "jinja": true, 16 | "autoStartBrowser": false 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "sass", 8 | "type": "shell", 9 | "command": "sassc -t compact static/style.sass static/style.css", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | }, 14 | "presentation": { 15 | "echo": true, 16 | "reveal": "silent", 17 | "focus": false, 18 | "panel": "dedicated", 19 | "showReuseMessage": false, 20 | "clear": true, 21 | "close": true 22 | }, 23 | "problemMatcher": [] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All rights reserved. 2 | 3 | Copyright (c) 2014 Lucian Marin 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subreply 2 | 3 | Tiny, but mighty social network. Create an account at [subreply.com](https://subreply.com/). 4 | 5 | ## Install 6 | 7 | ```shell 8 | pip3 install -r requirements.txt 9 | python3 manage.py migrate 10 | ``` 11 | 12 | Create `project/local.py` file and generate `SIGNATURE` for it: 13 | 14 | ```python 15 | from cryptography.fernet import Fernet 16 | SIGNATURE = Fernet.generate_key() 17 | ``` 18 | 19 | ## Coding 20 | 21 | - speed of 50ms or lower for each page request 22 | - clean code: easy to read, easy to modify 23 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucianmarin/subreply/b0085dedb4c1537d8f64590f9a401fbb2df72ce7/app/__init__.py -------------------------------------------------------------------------------- /app/filters.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timezone 2 | from string import ascii_letters, digits 3 | 4 | from tldextract import extract 5 | 6 | from project.vars import LINKS 7 | 8 | 9 | def hexcode(emoji): 10 | """Get hexcode for an emoji.""" 11 | # rjust(4, '0') is necessary to convert "2A" to "002A" 12 | codes = [hex(ord(e))[2:].upper().rjust(4, '0') for e in emoji] 13 | return "-".join(codes) 14 | 15 | 16 | def enumerize(links): 17 | """Enumerate social links.""" 18 | keys = sorted(links) # eg. github 19 | holder = "" 20 | for key in keys[:-2]: 21 | holder += LINKS[key].format(links[key]) + ", " 22 | for index, key in enumerate(keys[-2:]): 23 | holder += LINKS[key].format(links[key]) 24 | if not index and len(keys) > 1: 25 | holder += " and " 26 | return holder 27 | 28 | 29 | def hostname(value): 30 | """Get hostname from an url.""" 31 | subdomain, domain, suffix = extract(value) 32 | if subdomain in ['', 'www']: 33 | return f'{domain}.{suffix}' 34 | return f'{subdomain}.{domain}.{suffix}' 35 | 36 | 37 | def age(birthday, delimiter="-"): 38 | """Age based on yyyy-mm-dd format.""" 39 | delimiters = birthday.count(delimiter) 40 | if delimiters: 41 | integers = [int(v) for v in birthday.split(delimiter)] 42 | if len(integers) == 2: 43 | integers += [15] 44 | year, month, day = integers 45 | delta = datetime.now(timezone.utc).date() - date(year, month, day) 46 | return int(round(delta.days / 365.25)) 47 | return datetime.now(timezone.utc).year - int(birthday) 48 | 49 | 50 | def timeago(seconds): 51 | """Convert seconds to m, h, d, w, y.""" 52 | milliseconds = round(seconds * 1000) 53 | seconds = round(seconds) 54 | days = seconds // (3600 * 24) 55 | years = days // 365.25 56 | weeks = (days - 365.25 * years) // 7 57 | days = days - 365.25 * years 58 | if not years and not days: 59 | if not seconds: 60 | return "%dms" % milliseconds 61 | if seconds < 60: 62 | return "%ds" % seconds 63 | if seconds < 3600: 64 | return "%dm" % (seconds // 60) 65 | return "%dh" % (seconds // 3600) 66 | if not years: 67 | if not weeks: 68 | return "%dd" % days 69 | return "%dw" % weeks 70 | if not weeks and not days: 71 | return "%dy" % years 72 | if not weeks: 73 | return "%dy, %dd" % (years, days) 74 | return "%dy, %dw" % (years, weeks) 75 | 76 | 77 | def superscript(number): 78 | """Convert 1 to sup(1).""" 79 | text = str(number) 80 | ints = [8304, 185, 178, 179, 8308, 8309, 8310, 8311, 8312, 8313] 81 | for i, o in enumerate(ints): 82 | text = text.replace(str(i), chr(o)) 83 | return text 84 | 85 | 86 | def parser(text): 87 | """Convert plain text to HTML.""" 88 | limits = digits + ascii_letters + "_" 89 | # unicode xml safe 90 | text = text.replace('&', '&').replace('<', '<').replace('>', '>') 91 | # replace   (160) with space (32) 92 | text = text.replace(chr(160), chr(32)) 93 | # split text in words and parse each 94 | words = [] 95 | for word in text.split(): 96 | # unwrap word 97 | endswith = "" 98 | startswith = "" 99 | if word.endswith(('.', ',', '!', '?', ':', ';')): 100 | endswith = word[-1:] 101 | word = word[:-1] 102 | if word.endswith((')', ']', '}', "'", '"')): 103 | endswith = word[-1:] + endswith 104 | word = word[:-1] 105 | if word.startswith(('(', '[', '{', "'", '"')): 106 | startswith = word[:1] 107 | word = word[1:] 108 | if word.endswith("'s"): 109 | endswith = word[-2:] + endswith 110 | word = word[:-2] 111 | # replace word 112 | if word.startswith(('http://', 'https://')): 113 | protocol, separator, address = word.partition('://') 114 | if address.startswith('www.'): 115 | address = address[4:] 116 | if address.endswith('/'): 117 | address = address[:-1] 118 | if len(address) > 21: 119 | address = address[:18] + '...' 120 | if address: 121 | word = f'{address}' 122 | elif word.startswith('@'): 123 | handle = word[1:] 124 | if handle and all(c in limits for c in handle): 125 | word = f'' 126 | elif word.startswith('#'): 127 | handle = word[1:] 128 | if handle and all(c in digits for c in handle): 129 | word = f'#{handle}' 130 | elif handle and all(c in digits + ascii_letters for c in handle): 131 | word = f'' 132 | # wrap word 133 | word = startswith + word + endswith 134 | words.append(word) 135 | return " ".join(words) 136 | -------------------------------------------------------------------------------- /app/forms.py: -------------------------------------------------------------------------------- 1 | from string import ascii_letters, digits 2 | 3 | from emoji import demojize 4 | from unidecode import unidecode 5 | 6 | from project.vars import COUNTRIES 7 | 8 | 9 | def get_content(form, field="content"): 10 | value = form.getvalue(field, '') 11 | demojized = demojize(value) 12 | decoded = unidecode(demojized) 13 | words = [word.strip() for word in decoded.split()] 14 | return " ".join(words) 15 | 16 | 17 | def get_emoji(form): 18 | value = form.getvalue('emoji', '').strip() 19 | return demojize(value) 20 | 21 | 22 | def get_location(form, delimiter=", "): 23 | location = form.getvalue('location', '').strip() 24 | if delimiter in location: 25 | city, country = location.split(delimiter) 26 | country = COUNTRIES.get(country, country) 27 | return delimiter.join([city, country]) 28 | return location 29 | 30 | 31 | def get_name(form, field): 32 | value = form.getvalue(f'{field}_name', '') 33 | return "-".join(w.strip().capitalize() for w in value.split()) 34 | 35 | 36 | def get_metadata(text): 37 | limits = digits + ascii_letters + "_" 38 | hashtags, links, mentions = [], [], [] 39 | for word in text.split(): 40 | if word.endswith(('.', ',', '!', '?', ':', ';')): 41 | word = word[:-1] 42 | if word.endswith((')', ']', '}', "'", '"')): 43 | word = word[:-1] 44 | if word.startswith(('(', '[', '{', "'", '"')): 45 | word = word[1:] 46 | if word.endswith("'s"): 47 | word = word[:-2] 48 | if word.startswith(('http://', 'https://')): 49 | protocol, separator, address = word.partition('://') 50 | if "." in address: 51 | links.append(word.lower()) 52 | if word.startswith('@'): 53 | handle = word[1:] 54 | if handle and all(c in limits for c in handle): 55 | mentions.append(handle.lower()) 56 | if word.startswith('#'): 57 | handle = word[1:] 58 | if handle and all(c in digits for c in handle): 59 | continue 60 | elif handle and all(c in digits + ascii_letters for c in handle): 61 | hashtags.append(handle.lower()) 62 | return hashtags, links, mentions 63 | -------------------------------------------------------------------------------- /app/hooks.py: -------------------------------------------------------------------------------- 1 | from falcon import HTTPFound 2 | 3 | from app.models import User 4 | from project.settings import FERNET 5 | 6 | 7 | def auth_user(req, resp, resource, params): 8 | token = req.cookies.get('identity', '') 9 | try: 10 | identity = FERNET.decrypt(token.encode()).decode() if token else 0 11 | except Exception as e: 12 | print(e) 13 | identity = 0 14 | req.user = User.objects.filter(id=identity).first() 15 | 16 | 17 | def login_required(req, resp, resource, params): 18 | if not req.user: 19 | raise HTTPFound('/login') 20 | -------------------------------------------------------------------------------- /app/jinja.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from textwrap import shorten 3 | from urllib.parse import quote_plus 4 | 5 | from emoji import emojize 6 | from jinja2 import Environment, FileSystemBytecodeCache, FileSystemLoader 7 | 8 | from app.filters import age, enumerize, hexcode, parser, timeago 9 | from app.utils import utc_timestamp 10 | from project.settings import DEBUG 11 | 12 | env = Environment(autoescape=True) 13 | 14 | env.auto_reload = DEBUG 15 | env.bytecode_cache = FileSystemBytecodeCache() 16 | env.loader = FileSystemLoader('templates') 17 | 18 | env.filters['age'] = age 19 | env.filters['cap'] = lambda notif: "*" if notif > 9 else str(notif) 20 | env.filters['city'] = lambda loc: loc.split(",")[0] if "," in loc else loc 21 | env.filters['emojize'] = emojize 22 | env.filters['enumerize'] = enumerize 23 | env.filters['hexcode'] = hexcode 24 | env.filters['isoformat'] = lambda ts: datetime.fromtimestamp(ts).isoformat() 25 | env.filters['keywords'] = lambda emo: ", ".join(emo[1:-1].split("_")) 26 | env.filters['parser'] = parser 27 | env.filters['quote'] = quote_plus 28 | env.filters['shortdate'] = lambda ts: timeago(utc_timestamp() - ts) 29 | env.filters['shorten'] = lambda txt, w: shorten(txt, w, placeholder="...") 30 | 31 | env.globals['brand'] = "Subreply" 32 | env.globals['v'] = 250 33 | 34 | 35 | def render(page, **kwargs): 36 | print('\n---', page, kwargs.get('view', '')) 37 | template = env.get_template(f'pages/{page}.html') 38 | return template.render(**kwargs) 39 | -------------------------------------------------------------------------------- /app/management/commands/email.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from emails import Message 3 | from emails.template import JinjaTemplate 4 | 5 | from app.models import User 6 | from project.settings import SMTP 7 | from project.vars import ACTIVITY_HTML, ACTIVITY_TEXT 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Email inactive users." 12 | 13 | def get_user(self, mock=True): 14 | users = User.objects.filter(is_notified=False).order_by('seen_at') 15 | if mock: 16 | users = users.filter(id__in=[1]) 17 | for user in users: 18 | notifs = [] 19 | if user.notif_followers: 20 | notifs.append('followers') 21 | if user.notif_mentions: 22 | notifs.append('mentions') 23 | if user.notif_replies: 24 | notifs.append('replies') 25 | user.notifs = ", ".join(notifs) 26 | if user.notifs: 27 | return user 28 | 29 | def send_mail(self, user): 30 | # compose email 31 | m = Message( 32 | html=JinjaTemplate(ACTIVITY_HTML), 33 | text=JinjaTemplate(ACTIVITY_TEXT), 34 | subject="Activity left unseen on Subreply", 35 | mail_from=("Subreply", "subreply@outlook.com") 36 | ) 37 | # send email 38 | response = m.send( 39 | render={"username": user, "notifs": user.notifs}, 40 | to=user.email, 41 | smtp=SMTP 42 | ) 43 | # fallback 44 | if response.status_code == 250: 45 | print(user, "sent") 46 | user.is_notified = True 47 | user.save(update_fields=['is_notified']) 48 | else: 49 | print(user, "failed") 50 | 51 | def handle(self, *args, **options): 52 | to_user = self.get_user(mock=False) 53 | if to_user: 54 | self.send_mail(to_user) 55 | -------------------------------------------------------------------------------- /app/management/commands/import.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from dateutil.parser import parse 4 | from django.core.management.base import BaseCommand 5 | from unidecode import unidecode 6 | 7 | from app.forms import get_metadata 8 | from app.models import Bond, Post, Save, User 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Fetch users, posts from db.json." 13 | users = {} 14 | posts = {} 15 | 16 | def fetch_users(self, fields, pk): 17 | user, is_new = User.objects.get_or_create( 18 | username=fields['username'][:15], 19 | first_name=unidecode(fields['first_name'])[:15], 20 | last_name=unidecode(fields['last_name'])[:15], 21 | email=fields['email'], 22 | password=fields['password'], 23 | joined_at=parse(fields['date_joined']).timestamp(), 24 | seen_at=parse(fields['last_login']).timestamp(), 25 | country=fields['location'], 26 | bio=unidecode(fields['bio'])[:120], 27 | website=unidecode(fields['website'])[:120], 28 | remote_addr="0.0.0.0" 29 | ) 30 | self.users[pk] = user 31 | 32 | def fetch_posts(self, fields, pk): 33 | hashtags, links, mentions = get_metadata(fields['content']) 34 | at_user = None 35 | if mentions: 36 | at_user = User.objects.filter(username=mentions[0].lower()).first() 37 | post, is_new = Post.objects.get_or_create( 38 | ancestors=None, 39 | parent=self.posts.get(fields['parent']), 40 | created_at=parse(fields['created_at']).timestamp(), 41 | created_by=self.users.get(fields['created_by']), 42 | at_user=at_user, 43 | content=fields['content'] 44 | ) 45 | print(post.id, pk) 46 | self.posts[pk] = post 47 | post.set_ancestors() 48 | 49 | def fetch_likes(self, fields): 50 | Save.objects.get_or_create( 51 | created_at=parse(fields['created_at']).timestamp(), 52 | created_by=self.users.get(fields['created_by']), 53 | post=self.posts.get(fields['post']) 54 | ) 55 | 56 | def fetch_bonds(self, fields): 57 | Bond.objects.get_or_create( 58 | created_at=parse(fields['created_at']).timestamp(), 59 | created_by=self.users.get(fields['created_by']), 60 | to_user=self.users.get(fields['to_user']) 61 | ) 62 | 63 | def handle(self, *args, **options): 64 | with open('db_clean.json') as db: 65 | rows = json.load(db) 66 | for row in rows: 67 | if row['model'] == "app.user": 68 | self.fetch_users(row['fields'], row['pk']) 69 | elif row['model'] == "app.post": 70 | self.fetch_posts(row['fields'], row['pk']) 71 | elif row['model'] == "app.postlike": 72 | self.fetch_likes(row['fields']) 73 | elif row['model'] == "app.relationship": 74 | self.fetch_bonds(row['fields']) 75 | -------------------------------------------------------------------------------- /app/management/commands/locations.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import io 3 | import json 4 | import sys 5 | import zipfile 6 | from collections import defaultdict 7 | 8 | import requests 9 | from django.core.management.base import BaseCommand 10 | 11 | NAME = "simplemaps_worldcities_basicv1.77.zip" 12 | 13 | 14 | class Command(BaseCommand): 15 | help = "Fetch cities, countries from download." 16 | url = f"https://simplemaps.com/static/data/world-cities/basic/{NAME}" 17 | 18 | def get_csv(self): 19 | print(f"Downloading {NAME}") 20 | r = requests.get(self.url, stream=True) 21 | c = io.BytesIO(r.content) 22 | with zipfile.ZipFile(c) as zip_file, zip_file.open('worldcities.csv') as file: 23 | self.csv_file = io.StringIO(file.read().decode()) 24 | 25 | def fix_country(self, name): 26 | replaces = [ 27 | (", The", ""), 28 | ("Korea, South", "South Korea"), 29 | ("Korea, North", "North Korea"), 30 | ("Curaçao", "Curacao"), 31 | ("Czechia", "Czech Republic"), 32 | ("Côte D’Ivoire", "Cote d'Ivoire"), 33 | ("Congo (Brazzaville)", "Congo-Brazzaville"), 34 | ("Congo (Kinshasa)", "Congo-Kinshasa"), 35 | ("Micronesia, Federated States Of", "Micronesia"), 36 | ("Falkland Islands (Islas Malvinas)", "Falkland Islands"), 37 | (", And Tristan Da Cunha", " and Tristan da Cunha"), 38 | (" And ", " and "), 39 | (" The ", " the "), 40 | (" Of ", " of ") 41 | ] 42 | for before, after in replaces: 43 | name = name.replace(before, after) 44 | return name 45 | 46 | def fix_city(self, name): 47 | replaces = [ 48 | ("Beaubassin East / Beaubassin-est", "Beaubassin East"), 49 | ("Islamorada, Village of Islands", "Islamorada"), 50 | ("Dolores Hidalgo Cuna de la Independencia Nacional", "Dolores Hidalgo"), 51 | ("`", "'") 52 | ] 53 | for before, after in replaces: 54 | name = name.replace(before, after) 55 | return name.split(" / ")[0] 56 | 57 | def convert(self): 58 | print("Converting CSV file to JSON files") 59 | cities = defaultdict(set) 60 | countries = {} 61 | maxim, location = 0, "" 62 | reader = csv.DictReader(self.csv_file) 63 | for row in reader: 64 | country = self.fix_country(row['country']) 65 | city = self.fix_city(row['city_ascii']) 66 | name = f"{city}, {country}" 67 | if name.count(", ") == 1: 68 | cities[country].add(city) 69 | countries[row['iso2']] = country 70 | if len(name) > maxim: 71 | maxim, location = len(name), name 72 | 73 | print('Maxim', maxim, "characters") 74 | print('Location', location) 75 | print('Size', sys.getsizeof(self.csv_file)) 76 | print('Countries', len(cities.keys())) 77 | print('Cities', sum(len(v) for k, v in cities.items())) 78 | 79 | for country, city_set in cities.items(): 80 | cities[country] = sorted(city_set) 81 | 82 | with open('static/cities.json', 'w') as file: 83 | json.dump(cities, file, sort_keys=True, indent=4) 84 | 85 | with open('static/countries.json', 'w') as file: 86 | json.dump(countries, file, sort_keys=True, indent=4) 87 | 88 | def handle(self, *args, **options): 89 | self.get_csv() 90 | self.convert() 91 | -------------------------------------------------------------------------------- /app/management/commands/openmoji.py: -------------------------------------------------------------------------------- 1 | import io 2 | import zipfile 3 | 4 | import requests 5 | from django.core.management.base import BaseCommand 6 | 7 | NAME = "openmoji-72x72-color.zip" 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Fetch OpenMoji PNGs from download." 12 | url = f"https://github.com/hfg-gmuend/openmoji/releases/latest/download/{NAME}" 13 | 14 | def get(self): 15 | print(f"Downloading {NAME}") 16 | r = requests.get(self.url, stream=True) 17 | c = io.BytesIO(r.content) 18 | with zipfile.ZipFile(c) as zip_file: 19 | zip_file.extractall("static/openmoji") 20 | 21 | 22 | def handle(self, *args, **options): 23 | self.get() 24 | -------------------------------------------------------------------------------- /app/management/commands/stats.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.db.models import F 5 | 6 | from app.models import Bond, Post, Save, User 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Count users, posts, etc." 11 | years = range(2014, datetime.now().year + 1) 12 | 13 | def total(self): 14 | users = User.objects.count() 15 | bonds = Bond.objects.exclude(created_by_id=F('to_user_id')).count() 16 | saves = Save.objects.count() 17 | threads = Post.objects.filter(parent=None).count() 18 | replies = Post.objects.exclude(parent=None).count() 19 | print('Total:') 20 | print(' users:', users) 21 | print(' bonds:', bonds) 22 | print(' saves:', saves) 23 | print(' threads:', threads) 24 | print(' replies:', replies) 25 | 26 | def yearly(self): 27 | for year in self.years: 28 | first_day = datetime(year, 1, 1).timestamp() 29 | last_day = datetime(year, 12, 31).timestamp() 30 | users = User.objects.filter( 31 | created_at__gt=first_day, created_at__lt=last_day 32 | ).count() 33 | bonds = Bond.objects.filter( 34 | created_at__gt=first_day, created_at__lt=last_day 35 | ).exclude(created_by_id=F('to_user_id')).count() 36 | saves = Save.objects.filter( 37 | created_at__gt=first_day, created_at__lt=last_day 38 | ).count() 39 | threads = Post.objects.filter( 40 | created_at__gt=first_day, created_at__lt=last_day, parent=None 41 | ).count() 42 | replies = Post.objects.filter( 43 | created_at__gt=first_day, created_at__lt=last_day 44 | ).exclude(parent=None).count() 45 | print(f'{year}:') 46 | print(' users:', users) 47 | print(' bonds:', bonds) 48 | print(' saves:', saves) 49 | print(' threads:', threads) 50 | print(' replies:', replies) 51 | 52 | def handle(self, *args, **options): 53 | self.yearly() 54 | self.total() 55 | -------------------------------------------------------------------------------- /app/markdown.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def markdown(input): 5 | lines = input.splitlines() 6 | output = [] 7 | md = Markdown(output) 8 | md.render(lines) 9 | return output 10 | 11 | 12 | class Markdown: 13 | def __init__(self, out=[]): 14 | self.out = out 15 | self.block = "" 16 | self.typ = None 17 | 18 | def render_block(self, typ, block): 19 | if not block: 20 | return 21 | 22 | def tt(m): 23 | return "" + m.group(1).replace("<", "<") + "" 24 | 25 | block = re.sub("`(.+?)`", tt, block) 26 | block = re.sub("\*\*(.+?)\*\*", "\\1", block) 27 | block = re.sub("\*(.+?)\*", "\\1", block) 28 | block = re.sub("~~(.+)~~", "\\1", block) 29 | # block = re.sub("!\[(.+?)\]\((.+?)\)", '\\1', block) 30 | block = re.sub("\[(.+?)\]\((.+?)\)", '\\1', block) 31 | 32 | if typ == "list": 33 | tag = "li" 34 | elif typ == "bquote": 35 | tag = "blockquote" 36 | else: 37 | tag = "p" 38 | 39 | self.out.append(f"<{tag}>") 40 | self.out.append(block) 41 | self.out.append(f"") 42 | 43 | def flush_block(self): 44 | self.render_block(self.typ, self.block) 45 | self.block = "" 46 | self.typ = None 47 | 48 | def render_line(self, line): 49 | l_strip = line.rstrip() 50 | # print(l_strip) 51 | 52 | # Handle pre block content/end 53 | if self.typ == "```" or self.typ == "~~~": 54 | if l_strip == self.typ: 55 | self.typ = None 56 | self.out.append("") 57 | else: 58 | self.out.append(line) 59 | return 60 | 61 | # Handle pre block start 62 | if line.startswith("```") or line.startswith("~~~"): 63 | self.flush_block() 64 | self.typ = line[0:3] 65 | self.out.append("
")
 66 |             return
 67 | 
 68 |         # Empty line ends current block
 69 |         if not l_strip and self.block:
 70 |             self.flush_block()
 71 |             return
 72 | 
 73 |         # Repeating empty lines are ignored - TODO
 74 |         if not l_strip:
 75 |             return
 76 | 
 77 |         # Handle heading
 78 |         if line.startswith("#"):
 79 |             self.flush_block()
 80 |             level = 0
 81 |             while line.startswith("#"):
 82 |                 line = line[1:]
 83 |                 level += 1
 84 |             line = line.strip()
 85 |             level = 4  # overwrite heading
 86 |             self.out.append("%s" % (level, line, level))
 87 |             return
 88 | 
 89 |         if line.startswith("> "):
 90 |             if self.typ != "bquote":
 91 |                 self.flush_block()
 92 |             self.typ = "bquote"
 93 |             line = line[2:]
 94 |         elif line.startswith("* "):
 95 |             self.flush_block()
 96 |             self.typ = "list"
 97 |             line = line[2:]
 98 | 
 99 |         if not self.typ:
100 |             self.typ = "para"
101 | 
102 |         self.block += line
103 | 
104 |     def render(self, lines):
105 |         for line in lines:
106 |             self.render_line(line)
107 | 
108 |         # Render trailing block
109 |         self.flush_block()
110 | 


--------------------------------------------------------------------------------
/app/migrations/0001_initial.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.0.6 on 2020-06-14 13:16
 2 | 
 3 | import django.contrib.postgres.fields
 4 | import django.db.models.deletion
 5 | from django.db import migrations, models
 6 | 
 7 | 
 8 | class Migration(migrations.Migration):
 9 | 
10 |     initial = True
11 | 
12 |     dependencies = [
13 |     ]
14 | 
15 |     operations = [
16 |         migrations.CreateModel(
17 |             name='Comment',
18 |             fields=[
19 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20 |                 ('ancestors', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(), default=list, size=None)),
21 |                 ('created_at', models.FloatField(default=0.0)),
22 |                 ('content', models.CharField(db_index=True, max_length=480, unique=True)),
23 |                 ('hashtag', models.CharField(default='', max_length=15)),
24 |                 ('link', models.CharField(default='', max_length=120)),
25 |                 ('mention', models.CharField(default='', max_length=15)),
26 |                 ('edited_at', models.FloatField(default=0.0)),
27 |                 ('mention_seen_at', models.FloatField(default=0.0)),
28 |                 ('reply_seen_at', models.FloatField(default=0.0)),
29 |                 ('replies', models.IntegerField(db_index=True, default=0)),
30 |                 ('saves', models.IntegerField(default=0)),
31 |             ],
32 |         ),
33 |         migrations.CreateModel(
34 |             name='User',
35 |             fields=[
36 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
37 |                 ('username', models.CharField(max_length=15, unique=True)),
38 |                 ('first_name', models.CharField(max_length=15)),
39 |                 ('last_name', models.CharField(default='', max_length=15)),
40 |                 ('email', models.CharField(max_length=120, unique=True)),
41 |                 ('password', models.CharField(max_length=80)),
42 |                 ('remote_addr', models.GenericIPAddressField()),
43 |                 ('joined_at', models.FloatField(default=0.0)),
44 |                 ('seen_at', models.FloatField(db_index=True, default=0.0)),
45 |                 ('emoji', models.CharField(default='', max_length=15)),
46 |                 ('country', models.CharField(default='', max_length=2)),
47 |                 ('birthyear', models.CharField(default='', max_length=4)),
48 |                 ('bio', models.CharField(default='', max_length=120)),
49 |                 ('website', models.CharField(default='', max_length=120)),
50 |                 ('notif_followers', models.PositiveIntegerField(default=0)),
51 |                 ('notif_mentions', models.PositiveIntegerField(default=0)),
52 |                 ('notif_replies', models.PositiveIntegerField(default=0)),
53 |                 ('saves', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(), default=list, size=None)),
54 |             ],
55 |         ),
56 |         migrations.CreateModel(
57 |             name='Save',
58 |             fields=[
59 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
60 |                 ('created_at', models.FloatField(default=0.0)),
61 |                 ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved', to='app.User')),
62 |                 ('to_comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_by', to='app.Comment')),
63 |             ],
64 |         ),
65 |         migrations.CreateModel(
66 |             name='Relation',
67 |             fields=[
68 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
69 |                 ('created_at', models.FloatField(default=0.0)),
70 |                 ('seen_at', models.FloatField(default=0.0)),
71 |                 ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to='app.User')),
72 |                 ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to='app.User')),
73 |             ],
74 |         ),
75 |         migrations.AddField(
76 |             model_name='comment',
77 |             name='created_by',
78 |             field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='app.User'),
79 |         ),
80 |         migrations.AddField(
81 |             model_name='comment',
82 |             name='mentioned',
83 |             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mentions', to='app.User'),
84 |         ),
85 |         migrations.AddField(
86 |             model_name='comment',
87 |             name='parent',
88 |             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kids', to='app.Comment'),
89 |         ),
90 |         migrations.AlterUniqueTogether(
91 |             name='comment',
92 |             unique_together={('parent', 'created_by')},
93 |         ),
94 |     ]
95 | 


--------------------------------------------------------------------------------
/app/migrations/0002_reset.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.0.6 on 2020-06-16 17:51
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0001_initial'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.CreateModel(
14 |             name='Reset',
15 |             fields=[
16 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17 |                 ('created_at', models.FloatField(default=0.0)),
18 |                 ('email', models.CharField(max_length=120, unique=True)),
19 |                 ('code', models.CharField(default='', max_length=32)),
20 |             ],
21 |         ),
22 |     ]
23 | 


--------------------------------------------------------------------------------
/app/migrations/0003_auto_20200707_0900.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.0.6 on 2020-07-07 09:00
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0002_reset'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='content',
16 |             field=models.CharField(db_index=True, max_length=480),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0004_user_location.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.3 on 2020-12-02 18:29
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0003_auto_20200707_0900'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AddField(
14 |             model_name='user',
15 |             name='location',
16 |             field=models.CharField(default='', max_length=60),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0005_auto_20201202_1927.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.3 on 2020-12-02 19:27
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0004_user_location'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='user',
15 |             name='country',
16 |         ),
17 |         migrations.AlterField(
18 |             model_name='user',
19 |             name='birthyear',
20 |             field=models.CharField(default='', max_length=10),
21 |         ),
22 |     ]
23 | 


--------------------------------------------------------------------------------
/app/migrations/0006_auto_20201202_1927.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.3 on 2020-12-02 19:27
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0005_auto_20201202_1927'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RenameField(
14 |             model_name='user',
15 |             old_name='birthyear',
16 |             new_name='birthday',
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0007_user_readonly.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.3 on 2021-01-24 17:27
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0006_auto_20201202_1927'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AddField(
14 |             model_name='user',
15 |             name='readonly',
16 |             field=models.BooleanField(default=False),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0008_auto_20210130_1759.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.3 on 2021-01-30 17:59
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0007_user_readonly'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='user',
15 |             name='readonly',
16 |             field=models.BooleanField(db_index=True, default=False),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0009_auto_20210309_1942.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.6 on 2021-03-09 19:42
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0008_auto_20210130_1759'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='comment',
15 |             name='replies',
16 |         ),
17 |         migrations.RemoveField(
18 |             model_name='comment',
19 |             name='saves',
20 |         ),
21 |         migrations.RemoveField(
22 |             model_name='user',
23 |             name='notif_followers',
24 |         ),
25 |         migrations.RemoveField(
26 |             model_name='user',
27 |             name='notif_mentions',
28 |         ),
29 |         migrations.RemoveField(
30 |             model_name='user',
31 |             name='notif_replies',
32 |         ),
33 |         migrations.RemoveField(
34 |             model_name='user',
35 |             name='saves',
36 |         ),
37 |     ]
38 | 


--------------------------------------------------------------------------------
/app/migrations/0010_remove_comment_ancestors.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.6 on 2021-03-10 12:56
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0009_auto_20210309_1942'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='comment',
15 |             name='ancestors',
16 |         ),
17 |     ]
18 | 


--------------------------------------------------------------------------------
/app/migrations/0011_auto_20210310_1928.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.6 on 2021-03-10 19:28
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0010_remove_comment_ancestors'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AddField(
14 |             model_name='comment',
15 |             name='ancestors',
16 |             field=models.ManyToManyField(related_name='descendants', to='app.Comment'),
17 |         ),
18 |         migrations.AddField(
19 |             model_name='user',
20 |             name='links',
21 |             field=models.JSONField(default=dict),
22 |         ),
23 |     ]
24 | 


--------------------------------------------------------------------------------
/app/migrations/0012_remove_user_readonly.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.6 on 2021-03-11 01:13
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0011_auto_20210310_1928'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='user',
15 |             name='readonly',
16 |         ),
17 |     ]
18 | 


--------------------------------------------------------------------------------
/app/migrations/0013_comment_to_user.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.6 on 2021-03-13 10:09
 2 | 
 3 | import django.db.models.deletion
 4 | from django.db import migrations, models
 5 | 
 6 | 
 7 | class Migration(migrations.Migration):
 8 | 
 9 |     dependencies = [
10 |         ('app', '0012_remove_user_readonly'),
11 |     ]
12 | 
13 |     operations = [
14 |         migrations.AddField(
15 |             model_name='comment',
16 |             name='to_user',
17 |             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='app.user'),
18 |         ),
19 |     ]
20 | 


--------------------------------------------------------------------------------
/app/migrations/0014_auto_20210313_1033.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.6 on 2021-03-13 10:33
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0013_comment_to_user'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RenameField(
14 |             model_name='comment',
15 |             old_name='mentioned',
16 |             new_name='at_user',
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0015_auto_20210321_0028.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.1.7 on 2021-03-21 00:28
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0014_auto_20210313_1033'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='mention_seen_at',
16 |             field=models.FloatField(db_index=True, default=0.0),
17 |         ),
18 |         migrations.AlterField(
19 |             model_name='comment',
20 |             name='reply_seen_at',
21 |             field=models.FloatField(db_index=True, default=0.0),
22 |         ),
23 |         migrations.AlterField(
24 |             model_name='relation',
25 |             name='seen_at',
26 |             field=models.FloatField(db_index=True, default=0.0),
27 |         ),
28 |     ]
29 | 


--------------------------------------------------------------------------------
/app/migrations/0016_auto_20210806_0901.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.4 on 2021-08-06 09:01
 2 | 
 3 | from django.db import migrations, models
 4 | import django.db.models.deletion
 5 | 
 6 | 
 7 | class Migration(migrations.Migration):
 8 | 
 9 |     dependencies = [
10 |         ('app', '0015_auto_20210321_0028'),
11 |     ]
12 | 
13 |     operations = [
14 |         migrations.CreateModel(
15 |             name='Invite',
16 |             fields=[
17 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 |                 ('created_at', models.FloatField(default=0.0)),
19 |                 ('email', models.CharField(max_length=120, unique=True)),
20 |                 ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='app.user')),
21 |                 ('to_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invited', to='app.user')),
22 |             ],
23 |         ),
24 |         migrations.DeleteModel(
25 |             name='Reset',
26 |         ),
27 |     ]
28 | 


--------------------------------------------------------------------------------
/app/migrations/0017_auto_20210807_2241.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.6 on 2021-08-07 22:41
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0016_auto_20210806_0901'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='user',
15 |             name='remote_addr',
16 |         ),
17 |         migrations.AddField(
18 |             model_name='comment',
19 |             name='agent',
20 |             field=models.CharField(default='', max_length=480),
21 |         ),
22 |     ]
23 | 


--------------------------------------------------------------------------------
/app/migrations/0018_auto_20210809_2008.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.6 on 2021-08-09 20:08
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0017_auto_20210807_2241'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AddField(
14 |             model_name='user',
15 |             name='is_approved',
16 |             field=models.BooleanField(default=False),
17 |         ),
18 |         migrations.DeleteModel(
19 |             name='Invite',
20 |         ),
21 |     ]
22 | 


--------------------------------------------------------------------------------
/app/migrations/0019_auto_20210911_0832.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.6 on 2021-09-11 08:32
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0018_auto_20210809_2008'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='agent',
16 |             field=models.CharField(default='', max_length=320),
17 |         ),
18 |         migrations.AlterField(
19 |             model_name='comment',
20 |             name='content',
21 |             field=models.CharField(db_index=True, max_length=640),
22 |         ),
23 |     ]
24 | 


--------------------------------------------------------------------------------
/app/migrations/0020_auto_20210919_1919.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.6 on 2021-09-19 19:19
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0019_auto_20210911_0832'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='comment',
15 |             name='agent',
16 |         ),
17 |         migrations.RemoveField(
18 |             model_name='comment',
19 |             name='mention',
20 |         ),
21 |     ]
22 | 


--------------------------------------------------------------------------------
/app/migrations/0021_alter_user_unique_together.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.6 on 2021-10-06 18:50
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0020_auto_20210919_1919'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterUniqueTogether(
14 |             name='user',
15 |             unique_together={('emoji', 'first_name', 'last_name')},
16 |         ),
17 |     ]
18 | 


--------------------------------------------------------------------------------
/app/migrations/0022_rename_bio_user_description.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.8 on 2021-10-16 17:35
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0021_alter_user_unique_together'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RenameField(
14 |             model_name='user',
15 |             old_name='bio',
16 |             new_name='description',
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0023_alter_comment_content.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.8 on 2021-11-17 19:14
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0022_rename_bio_user_description'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='content',
16 |             field=models.CharField(db_index=True, max_length=800),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0024_alter_comment_content.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.8 on 2021-11-21 11:30
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0023_alter_comment_content'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='content',
16 |             field=models.CharField(db_index=True, max_length=720),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0025_alter_comment_content.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 3.2.8 on 2021-11-22 08:34
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0024_alter_comment_content'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='content',
16 |             field=models.CharField(db_index=True, max_length=640),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0026_article.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.0 on 2022-04-17 08:47
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0025_alter_comment_content'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.CreateModel(
14 |             name='Article',
15 |             fields=[
16 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17 |                 ('pub_at', models.FloatField(db_index=True, default=0.0)),
18 |                 ('url', models.URLField(max_length=240, unique=True)),
19 |                 ('title', models.CharField(max_length=240, unique=True)),
20 |                 ('domain', models.CharField(db_index=True, max_length=120)),
21 |                 ('author', models.CharField(default='', max_length=120)),
22 |                 ('description', models.CharField(default='', max_length=640)),
23 |                 ('score', models.IntegerField(db_index=True, default=0)),
24 |                 ('paragraphs', models.JSONField(default=list)),
25 |                 ('ips', models.JSONField(default=list)),
26 |             ],
27 |         ),
28 |     ]
29 | 


--------------------------------------------------------------------------------
/app/migrations/0027_message.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.0.4 on 2022-04-27 06:51
 2 | 
 3 | from django.db import migrations, models
 4 | import django.db.models.deletion
 5 | 
 6 | 
 7 | class Migration(migrations.Migration):
 8 | 
 9 |     dependencies = [
10 |         ('app', '0026_article'),
11 |     ]
12 | 
13 |     operations = [
14 |         migrations.CreateModel(
15 |             name='Message',
16 |             fields=[
17 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 |                 ('content', models.CharField(max_length=640)),
19 |                 ('created_at', models.FloatField(default=0.0)),
20 |                 ('seen_at', models.FloatField(default=0.0)),
21 |                 ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent', to='app.user')),
22 |                 ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received', to='app.user')),
23 |             ],
24 |         ),
25 |     ]
26 | 


--------------------------------------------------------------------------------
/app/migrations/0028_rename_ips_article_ids_rename_score_article_readers_and_more.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.0.4 on 2022-04-29 07:21
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0027_message'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RenameField(
14 |             model_name='article',
15 |             old_name='ips',
16 |             new_name='ids',
17 |         ),
18 |         migrations.RenameField(
19 |             model_name='article',
20 |             old_name='score',
21 |             new_name='readers',
22 |         ),
23 |         migrations.AlterField(
24 |             model_name='message',
25 |             name='content',
26 |             field=models.TextField(),
27 |         ),
28 |     ]
29 | 


--------------------------------------------------------------------------------
/app/migrations/0029_user_is_notified.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.0.4 on 2022-05-01 18:21
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0028_rename_ips_article_ids_rename_score_article_readers_and_more'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AddField(
14 |             model_name='user',
15 |             name='is_notified',
16 |             field=models.BooleanField(default=False),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0030_delete_message.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.0.4 on 2022-05-02 15:56
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0029_user_is_notified'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.DeleteModel(
14 |             name='Message',
15 |         ),
16 |     ]
17 | 


--------------------------------------------------------------------------------
/app/migrations/0031_alter_user_emoji.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.0.4 on 2022-05-04 16:18
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0030_delete_message'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='user',
15 |             name='emoji',
16 |             field=models.CharField(default='', max_length=80),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0032_delete_article.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1 on 2022-08-17 07:32
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0031_alter_user_emoji'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.DeleteModel(
14 |             name='Article',
15 |         ),
16 |     ]
17 | 


--------------------------------------------------------------------------------
/app/migrations/0033_article.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1 on 2022-10-22 17:56
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0032_delete_article'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.CreateModel(
14 |             name='Article',
15 |             fields=[
16 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17 |                 ('pub_at', models.FloatField(db_index=True, default=0.0)),
18 |                 ('url', models.URLField(max_length=240, unique=True)),
19 |                 ('title', models.CharField(max_length=240, unique=True)),
20 |                 ('domain', models.CharField(db_index=True, max_length=120)),
21 |                 ('author', models.CharField(default='', max_length=120)),
22 |                 ('description', models.TextField(default='')),
23 |                 ('paragraphs', models.JSONField(default=list)),
24 |                 ('ids', models.JSONField(default=list)),
25 |                 ('readers', models.IntegerField(db_index=True, default=0)),
26 |             ],
27 |         ),
28 |     ]
29 | 


--------------------------------------------------------------------------------
/app/migrations/0034_delete_article.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1.3 on 2022-11-17 17:49
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0033_article'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.DeleteModel(
14 |             name='Article',
15 |         ),
16 |     ]
17 | 


--------------------------------------------------------------------------------
/app/migrations/0035_remove_user_is_notified_remove_user_seen_at.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1.3 on 2022-11-24 22:09
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0034_delete_article'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='user',
15 |             name='is_notified',
16 |         ),
17 |         migrations.RemoveField(
18 |             model_name='user',
19 |             name='seen_at',
20 |         ),
21 |     ]
22 | 


--------------------------------------------------------------------------------
/app/migrations/0036_rename_joined_at_user_created_at.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1.3 on 2022-11-25 10:40
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0035_remove_user_is_notified_remove_user_seen_at'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RenameField(
14 |             model_name='user',
15 |             old_name='joined_at',
16 |             new_name='created_at',
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0037_alter_comment_link.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1.3 on 2022-11-25 21:15
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0036_rename_joined_at_user_created_at'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='link',
16 |             field=models.CharField(default='', max_length=240),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0038_alter_comment_hashtag_alter_comment_link.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1.3 on 2022-11-26 22:51
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0037_alter_comment_link'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AlterField(
14 |             model_name='comment',
15 |             name='hashtag',
16 |             field=models.CharField(db_index=True, default='', max_length=15),
17 |         ),
18 |         migrations.AlterField(
19 |             model_name='comment',
20 |             name='link',
21 |             field=models.CharField(db_index=True, default='', max_length=240),
22 |         ),
23 |     ]
24 | 


--------------------------------------------------------------------------------
/app/migrations/0039_rename_links_user_social_user_phone_user_wallet.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.1.3 on 2022-12-01 16:38
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0038_alter_comment_hashtag_alter_comment_link'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RenameField(
14 |             model_name='user',
15 |             old_name='links',
16 |             new_name='social',
17 |         ),
18 |         migrations.AddField(
19 |             model_name='user',
20 |             name='phone',
21 |             field=models.JSONField(default=dict),
22 |         ),
23 |         migrations.AddField(
24 |             model_name='user',
25 |             name='wallet',
26 |             field=models.JSONField(default=dict),
27 |         ),
28 |     ]
29 | 


--------------------------------------------------------------------------------
/app/migrations/0040_room_comment_at_room_comment_in_room.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.2.5 on 2024-01-09 09:05
 2 | 
 3 | from django.db import migrations, models
 4 | import django.db.models.deletion
 5 | 
 6 | 
 7 | class Migration(migrations.Migration):
 8 | 
 9 |     dependencies = [
10 |         ('app', '0039_rename_links_user_social_user_phone_user_wallet'),
11 |     ]
12 | 
13 |     operations = [
14 |         migrations.CreateModel(
15 |             name='Room',
16 |             fields=[
17 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 |                 ('name', models.CharField(max_length=15, unique=True)),
19 |                 ('created_at', models.FloatField(default=0.0)),
20 |             ],
21 |         ),
22 |         migrations.AddField(
23 |             model_name='comment',
24 |             name='at_room',
25 |             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hashtags', to='app.room'),
26 |         ),
27 |         migrations.AddField(
28 |             model_name='comment',
29 |             name='in_room',
30 |             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='app.room'),
31 |         ),
32 |     ]
33 | 


--------------------------------------------------------------------------------
/app/migrations/0041_remove_room_created_at.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.2.5 on 2024-01-09 11:19
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0040_room_comment_at_room_comment_in_room'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='room',
15 |             name='created_at',
16 |         ),
17 |     ]
18 | 


--------------------------------------------------------------------------------
/app/migrations/0042_remove_comment_hashtag.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 4.2.5 on 2024-02-25 16:47
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0041_remove_room_created_at'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='comment',
15 |             name='hashtag',
16 |         ),
17 |     ]
18 | 


--------------------------------------------------------------------------------
/app/migrations/0043_rename_relation_bond_post_alter_save_to_comment_and_more.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 5.0.6 on 2024-06-19 14:33
 2 | 
 3 | import django.db.models.deletion
 4 | from django.db import migrations, models
 5 | 
 6 | 
 7 | class Migration(migrations.Migration):
 8 | 
 9 |     dependencies = [
10 |         ('app', '0042_remove_comment_hashtag'),
11 |     ]
12 | 
13 |     operations = [
14 |         migrations.RenameModel(
15 |             old_name='Relation',
16 |             new_name='Bond',
17 |         ),
18 |         migrations.RenameModel(
19 |             old_name='Comment',
20 |             new_name='Post',
21 |         ),
22 |         migrations.AlterField(
23 |             model_name='post',
24 |             name='created_by',
25 |             field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='app.user'),
26 |         ),
27 |         migrations.AlterField(
28 |             model_name='save',
29 |             name='to_comment',
30 |             field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_by', to='app.post'),
31 |         ),
32 |         migrations.RenameField(
33 |             model_name='save',
34 |             old_name='to_comment',
35 |             new_name='post',
36 |         ),
37 |     ]
38 | 


--------------------------------------------------------------------------------
/app/migrations/0044_post_hashtag.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 5.0.6 on 2024-08-20 20:03
 2 | 
 3 | from django.db import migrations, models
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0043_rename_relation_bond_post_alter_save_to_comment_and_more'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.AddField(
14 |             model_name='post',
15 |             name='hashtag',
16 |             field=models.CharField(db_index=True, default='', max_length=15),
17 |         ),
18 |     ]
19 | 


--------------------------------------------------------------------------------
/app/migrations/0045_remove_post_at_room_remove_post_in_room_delete_room.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 5.0.6 on 2024-08-20 20:33
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0044_post_hashtag'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='post',
15 |             name='at_room',
16 |         ),
17 |         migrations.RemoveField(
18 |             model_name='post',
19 |             name='in_room',
20 |         ),
21 |         migrations.DeleteModel(
22 |             name='Room',
23 |         ),
24 |     ]
25 | 


--------------------------------------------------------------------------------
/app/migrations/0046_remove_user_wallet.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 5.0.6 on 2024-10-01 10:22
 2 | 
 3 | from django.db import migrations
 4 | 
 5 | 
 6 | class Migration(migrations.Migration):
 7 | 
 8 |     dependencies = [
 9 |         ('app', '0045_remove_post_at_room_remove_post_in_room_delete_room'),
10 |     ]
11 | 
12 |     operations = [
13 |         migrations.RemoveField(
14 |             model_name='user',
15 |             name='wallet',
16 |         ),
17 |     ]
18 | 


--------------------------------------------------------------------------------
/app/migrations/0047_text.py:
--------------------------------------------------------------------------------
 1 | # Generated by Django 5.0.6 on 2024-11-18 19:22
 2 | 
 3 | import django.db.models.deletion
 4 | from django.db import migrations, models
 5 | 
 6 | 
 7 | class Migration(migrations.Migration):
 8 | 
 9 |     dependencies = [
10 |         ('app', '0046_remove_user_wallet'),
11 |     ]
12 | 
13 |     operations = [
14 |         migrations.CreateModel(
15 |             name='Text',
16 |             fields=[
17 |                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 |                 ('content', models.CharField(max_length=640)),
19 |                 ('created_at', models.FloatField(default=0.0)),
20 |                 ('seen_at', models.FloatField(default=0.0)),
21 |                 ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent', to='app.user')),
22 |                 ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received', to='app.user')),
23 |             ],
24 |         ),
25 |     ]
26 | 


--------------------------------------------------------------------------------
/app/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lucianmarin/subreply/b0085dedb4c1537d8f64590f9a401fbb2df72ce7/app/migrations/__init__.py


--------------------------------------------------------------------------------
/app/models.py:
--------------------------------------------------------------------------------
  1 | from os import environ
  2 | 
  3 | from django import setup
  4 | from django.conf import settings
  5 | from django.db import models
  6 | from django.utils.functional import cached_property
  7 | 
  8 | if not settings.configured:
  9 |     environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
 10 |     setup()
 11 | 
 12 | if settings.DEBUG:
 13 |     import logging
 14 |     logger = logging.getLogger('django.db.backends')
 15 |     logger.setLevel(logging.DEBUG)
 16 |     logger.addHandler(logging.StreamHandler())
 17 | 
 18 | 
 19 | class User(models.Model):
 20 |     username = models.CharField(max_length=15, unique=True)
 21 |     first_name = models.CharField(max_length=15)
 22 |     last_name = models.CharField(max_length=15, default='')
 23 |     email = models.CharField(max_length=120, unique=True)
 24 |     password = models.CharField(max_length=80)
 25 | 
 26 |     created_at = models.FloatField(default=.0)
 27 |     is_approved = models.BooleanField(default=False)
 28 | 
 29 |     emoji = models.CharField(max_length=80, default='')
 30 |     birthday = models.CharField(max_length=10, default='')
 31 |     location = models.CharField(max_length=60, default='')
 32 |     description = models.CharField(max_length=120, default='')
 33 |     website = models.CharField(max_length=120, default='')
 34 | 
 35 |     phone = models.JSONField(default=dict)
 36 |     social = models.JSONField(default=dict)
 37 | 
 38 |     class Meta:
 39 |         unique_together = ['emoji', 'first_name', 'last_name']
 40 | 
 41 |     def __str__(self):
 42 |         return self.username
 43 | 
 44 |     @cached_property
 45 |     def full_name(self):
 46 |         if len(self.last_name) == 1:
 47 |             self.last_name += "."
 48 |         return f"{self.emoji} {self.first_name} {self.last_name}".strip()
 49 | 
 50 |     @cached_property
 51 |     def short_name(self):
 52 |         if len(self.last_name) == 1:
 53 |             self.last_name += "."
 54 |         return f"{self.first_name} {self.last_name}".strip()
 55 | 
 56 |     @cached_property
 57 |     def abbr_name(self):
 58 |         if self.last_name:
 59 |             return self.first_name[:1] + self.last_name[:1]
 60 |         return self.first_name[:3]
 61 | 
 62 |     @cached_property
 63 |     def notif_followers(self):
 64 |         return self.followers.filter(seen_at=.0).count()
 65 | 
 66 |     @cached_property
 67 |     def notif_mentions(self):
 68 |         return self.mentions.filter(mention_seen_at=.0).count()
 69 | 
 70 |     @cached_property
 71 |     def notif_replies(self):
 72 |         return self.replies.filter(reply_seen_at=.0).count()
 73 | 
 74 |     @cached_property
 75 |     def notif_arrivals(self):
 76 |         if self.id == 1:
 77 |             return User.objects.filter(is_approved=False).count()
 78 |         return 0
 79 | 
 80 |     @cached_property
 81 |     def notif_messages(self):
 82 |         return self.received.filter(seen_at=.0).count()
 83 | 
 84 |     @cached_property
 85 |     def follows(self):
 86 |         return self.following.values_list('to_user_id', flat=True)
 87 | 
 88 |     @cached_property
 89 |     def saves(self):
 90 |         return self.saved.values_list('post_id', flat=True)
 91 | 
 92 |     @cached_property
 93 |     def links(self):
 94 |         if self.phone:
 95 |             self.social['phone'] = self.phone['code'] + self.phone['number']
 96 |         return self.social
 97 | 
 98 | 
 99 | class Post(models.Model):
100 |     ancestors = models.ManyToManyField('self', related_name='descendants',
101 |                                        symmetrical=False)
102 |     parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True,
103 |                                related_name='kids')
104 |     created_at = models.FloatField(default=.0)
105 |     edited_at = models.FloatField(default=.0)
106 |     created_by = models.ForeignKey('User', on_delete=models.CASCADE,
107 |                                    related_name='posts')
108 |     to_user = models.ForeignKey('User', on_delete=models.CASCADE, null=True,
109 |                                 related_name='replies')
110 |     at_user = models.ForeignKey('User', on_delete=models.SET_NULL, null=True,
111 |                                 related_name='mentions')
112 |     content = models.CharField(max_length=640, db_index=True)
113 |     link = models.CharField(max_length=240, default='', db_index=True)
114 |     hashtag = models.CharField(max_length=15, default='', db_index=True)
115 |     mention_seen_at = models.FloatField(default=.0, db_index=True)
116 |     reply_seen_at = models.FloatField(default=.0, db_index=True)
117 | 
118 |     class Meta:
119 |         unique_together = ['parent', 'created_by']
120 | 
121 |     def __str__(self):
122 |         return self.content
123 | 
124 |     def get_ancestors(self):
125 |         if not self.parent:
126 |             return []
127 |         return [self.parent] + self.parent.get_ancestors()
128 | 
129 |     def set_ancestors(self):
130 |         self.ancestors.set(self.get_ancestors())
131 | 
132 | 
133 | class Save(models.Model):
134 |     created_at = models.FloatField(default=.0)
135 |     created_by = models.ForeignKey('User', on_delete=models.CASCADE,
136 |                                    related_name='saved')
137 |     post = models.ForeignKey('Post', on_delete=models.CASCADE,
138 |                              related_name='saved_by')
139 | 
140 | 
141 | class Bond(models.Model):
142 |     created_at = models.FloatField(default=.0)
143 |     created_by = models.ForeignKey('User', on_delete=models.CASCADE,
144 |                                    related_name='following')
145 |     to_user = models.ForeignKey('User', on_delete=models.CASCADE,
146 |                                 related_name='followers')
147 |     seen_at = models.FloatField(default=.0, db_index=True)
148 | 
149 | 
150 | class Text(models.Model):
151 |     content = models.CharField(max_length=640)
152 |     created_by = models.ForeignKey('User', on_delete=models.CASCADE, related_name='sent')
153 |     to_user = models.ForeignKey('User', on_delete=models.CASCADE, related_name='received')
154 |     created_at = models.FloatField(default=.0)
155 |     seen_at = models.FloatField(default=.0)
156 | 


--------------------------------------------------------------------------------
/app/utils.py:
--------------------------------------------------------------------------------
 1 | from base64 import b64encode
 2 | from datetime import datetime, timezone
 3 | from hashlib import pbkdf2_hmac
 4 | from random import choice
 5 | from string import ascii_letters, ascii_lowercase, digits
 6 | 
 7 | 
 8 | def has_repetions(word, n=3):
 9 |     return any(char * n in word for char in word)
10 | 
11 | 
12 | def utc_timestamp():
13 |     return datetime.now(timezone.utc).timestamp()
14 | 
15 | 
16 | def generate_salt(length=12):
17 |     chars = digits + ascii_letters
18 |     return "".join(choice(chars) for i in range(length))
19 | 
20 | 
21 | def build_hash(password):
22 |     salt = generate_salt()
23 |     iterations = 55555
24 |     dk = pbkdf2_hmac('sha256', password.encode(), salt.encode(), iterations)
25 |     h = b64encode(dk).decode('ascii').strip()
26 |     return "%s$%d$%s$%s" % ("pbkdf2_sha256", iterations, salt, h)
27 | 
28 | 
29 | def verify_hash(password, hashed):
30 |     algorithm, iters, salt, old_h = hashed.split('$')
31 |     dk = pbkdf2_hmac('sha256', password.encode(), salt.encode(), int(iters))
32 |     h = b64encode(dk).decode('ascii').strip()
33 |     return h == old_h
34 | 
35 | 
36 | def base36encode(number):
37 |     alphabet, base36 = digits + ascii_lowercase, ""
38 |     while number:
39 |         number, i = divmod(number, 36)
40 |         base36 = alphabet[i] + base36
41 |     return base36 or alphabet[0]
42 | 
43 | 
44 | def base36decode(number):
45 |     return int(number, 36)
46 | 


--------------------------------------------------------------------------------
/app/validation.py:
--------------------------------------------------------------------------------
  1 | from datetime import date
  2 | from string import ascii_letters, ascii_uppercase, digits
  3 | 
  4 | from django.db.models import Q
  5 | from dns.resolver import query as dns_query
  6 | from emoji import EMOJI_DATA
  7 | from phonenumbers import is_possible_number, is_valid_number, parse
  8 | from requests import head
  9 | 
 10 | from app.forms import get_metadata
 11 | from app.models import Post, User
 12 | from app.utils import has_repetions, verify_hash
 13 | from project.vars import INVALID, LATIN, MAX_YEAR, MIN_YEAR, CITIES
 14 | 
 15 | 
 16 | def valid_hashtag(value):
 17 |     limits = digits + ascii_letters
 18 |     if not value:
 19 |         return "Value cannot be empty"
 20 |     elif len(value) > 15:
 21 |         return "Hashtag can't be longer than 15 characters"
 22 |     elif all(c in digits for c in value):
 23 |         return "Hashtag contains only digits"
 24 |     elif not all(c in limits for c in value):
 25 |         return "Hashtag can be only alphanumeric"
 26 |     elif has_repetions(value):
 27 |         return "Hashtag contains repeating characters"
 28 |     elif value in INVALID:
 29 |         return "Hashtag is not valid"
 30 | 
 31 | 
 32 | def valid_content(value, user, limit=640):
 33 |     hashtags, links, mentions = get_metadata(value)
 34 |     if not value:
 35 |         return "Value cannot be emtpy"
 36 |     elif len(value) > limit:
 37 |         return f"Share fewer than {limit} characters"
 38 |     elif len(value) != len(value.encode()):
 39 |         return "Only ASCII characters are allowed"
 40 |     elif len(mentions) > 1:
 41 |         return "Mention a single member"
 42 |     elif len(links) > 1:
 43 |         return "Link a single address"
 44 |     elif len(hashtags) > 1:
 45 |         return "Use a single hashtag"
 46 |     elif hashtags:
 47 |         hashtag = hashtags[0]
 48 |         if hashtag == value.lower()[1:]:
 49 |             return "Share more than a hashtag"
 50 |         return valid_hashtag(hashtag)
 51 |     elif links:
 52 |         link = links[0]
 53 |         if len(link) > 240:
 54 |             return "Link can't be longer than 240 characters"
 55 |         elif link == value.lower():
 56 |             return "Share more than a link"
 57 |         elif link.startswith(('http://subreply.com', 'https://subreply.com')):
 58 |             return "Share a hashtag, a mention or a reply id"
 59 |     elif mentions:
 60 |         mention = mentions[0]
 61 |         if user and mention == user.username:
 62 |             return "Don't mention yourself"
 63 |         elif mention == value.lower()[1:]:
 64 |             return "Share more than a mention"
 65 |         elif not User.objects.filter(username=mention).exists():
 66 |             return "@{0} account doesn't exists".format(mention)
 67 | 
 68 | 
 69 | def valid_thread(value):
 70 |     """Duplicate topic against old topics."""
 71 |     threads = Post.objects.filter(parent=None).order_by('-id')[:32]
 72 |     duplicates = [t for t in threads if t.content.lower() == value.lower()]
 73 |     if duplicates:
 74 |         duplicate = duplicates[0]
 75 |         err = 'Thread #{0} started by @{1}'
 76 |         return err.format(duplicate.id, duplicate.created_by)
 77 | 
 78 | 
 79 | def valid_reply(parent, user, value, mentions):
 80 |     """Duplicate reply against replies for topic including topic."""
 81 |     ancestors = parent.ancestors.values_list('id', flat=True)
 82 |     top_id = min(ancestors) if ancestors else parent.id
 83 |     duplicate = Post.objects.filter(
 84 |         (Q(ancestors=top_id) | Q(id=top_id)) & Q(content__iexact=value)
 85 |     ).first()
 86 |     if duplicate:
 87 |         err = 'Duplicate of #{0} by @{1}'
 88 |         return err.format(duplicate.id, duplicate.created_by)
 89 |     elif parent.created_by_id == user.id:
 90 |         return "Don't reply to yourself"
 91 |     elif mentions and mentions[0] == parent.created_by.username:
 92 |         return "Don't mention the author"
 93 | 
 94 | 
 95 | def authentication(username, password):
 96 |     errors = {}
 97 |     title = "Email" if "@" in username else "Username"
 98 |     if "@" in username:
 99 |         user = User.objects.filter(email=username).first()
100 |     else:
101 |         user = User.objects.filter(username=username).first()
102 |     if not user:
103 |         errors['username'] = "{0} doesn't exist".format(title)
104 |     elif not verify_hash(password, user.password):
105 |         errors['password'] = "Password doesn't match"
106 |     return errors, user
107 | 
108 | 
109 | def valid_username(value, user_id=0):
110 |     limits = digits + ascii_letters + "_"
111 |     if not value:
112 |         return "Username can't be blank"
113 |     elif len(value) > 15:
114 |         return "Username can't be longer than 15 characters"
115 |     elif not all(c in limits for c in value):
116 |         return "Username can be only alphanumeric"
117 |     elif has_repetions(value):
118 |         return "Username contains repeating characters"
119 |     elif "__" in value:
120 |         return "Username contains consecutive underscores"
121 |     elif value in INVALID:
122 |         return "Username is invalid"
123 |     elif User.objects.filter(username=value).exclude(id=user_id).exists():
124 |         return "Username is already taken"
125 | 
126 | 
127 | def valid_handle(value):
128 |     limits = digits + ascii_letters + "_-"
129 |     if len(value) > 15:
130 |         return "Handle can't be longer than 15 characters"
131 |     elif not all(c in limits for c in value):
132 |         return "Handle can be only alphanumeric"
133 | 
134 | 
135 | def valid_id(value):
136 |     if len(value) > 15:
137 |         return "Handle can't be longer than 15 characters"
138 |     elif not all(c in digits for c in value):
139 |         return "ID can be only numeric"
140 | 
141 | 
142 | def valid_first_name(value):
143 |     if not value:
144 |         return "First name can't be blank"
145 |     elif len(value) > 15:
146 |         return "First name can't be longer than 15 characters"
147 |     elif len(value) == 1:
148 |         return "First name is just too short"
149 |     elif not all(c in LATIN for c in value):
150 |         return "First name should use Latin characters"
151 |     elif has_repetions(value):
152 |         return "First name contains repeating characters"
153 |     elif value.count('-') > 1 or value.startswith('-') or value.endswith('-'):
154 |         return "Only one double-name is allowed"
155 | 
156 | 
157 | def valid_last_name(value):
158 |     if len(value) > 15:
159 |         return "Last name can't be longer than 15 characters"
160 |     elif not all(c in LATIN for c in value):
161 |         return "Last name should use Latin characters"
162 |     elif value and has_repetions(value):
163 |         return "Last name contains repeating characters"
164 |     elif value.count('-') > 1 or value.startswith('-') or value.endswith('-'):
165 |         return "Only one double-name is allowed"
166 | 
167 | 
168 | def valid_full_name(emoji, first_name, last_name, user_id=0):
169 |     if User.objects.filter(
170 |         emoji=emoji, first_name=first_name, last_name=last_name
171 |     ).exclude(id=user_id).exists():
172 |         return "Emoji and names combination is taken"
173 |     elif first_name == last_name:
174 |         return "First and last names should be different"
175 | 
176 | 
177 | def valid_email(value, user_id=0):
178 |     if not value:
179 |         return "Email can't be blank"
180 |     elif len(value) > 120:
181 |         return "Email can't be longer than 120 characters"
182 |     elif len(value) != len(value.encode()):
183 |         return "Email should use ASCII characters"
184 |     elif "@" not in value:
185 |         return "Email isn't a valid address"
186 |     elif User.objects.filter(email=value).exclude(id=user_id).exists():
187 |         return "Email is used by someone else"
188 |     else:
189 |         handle, domain = value.split('@', 1)
190 |         try:
191 |             has_mx = bool(dns_query(domain, 'MX'))
192 |         except Exception as e:
193 |             has_mx = False
194 |             print(e)
195 |         if not has_mx:
196 |             return "Email can't be sent to this address"
197 | 
198 | 
199 | def valid_description(value, user_id=0):
200 |     if value:
201 |         user = User.objects.filter(id=user_id).first()
202 |         return valid_content(value, user, limit=120)
203 | 
204 | 
205 | def valid_website(value, user_id=0):
206 |     if value:
207 |         duplicate = User.objects.filter(website=value).exclude(id=user_id).first()
208 |         if len(value) > 120:
209 |             return "Website can't be longer than 120 characters"
210 |         elif len(value) != len(value.encode()):
211 |             return "Website should use ASCII characters"
212 |         elif not value.startswith(('http://', 'https://')):
213 |             return "Website hasn't a valid http(s) address"
214 |         elif duplicate:
215 |             return f'Website is used by @{duplicate}'
216 |         else:
217 |             try:
218 |                 headers = head(value, allow_redirects=True, timeout=5).headers
219 |             except Exception as e:
220 |                 headers = {}
221 |                 print(e)
222 |             if 'text/html' not in headers.get('Content-Type', '').lower():
223 |                 return "Website isn't a valid HTML page"
224 | 
225 | 
226 | def valid_password(value1, value2):
227 |     if not value1 or not value2:
228 |         return "Password can't be blank"
229 |     elif value1 != value2:
230 |         return "Password doesn't match"
231 |     elif len(value1) < 8:
232 |         return "Password is just too short"
233 |     elif len(value1) != sum(len(p) for p in value1.split()):
234 |         return "Password contains spaces"
235 |     elif value1 == value1.lower():
236 |         return "Password needs an uppercase letter"
237 |     elif value1 == value1.upper():
238 |         return "Password needs a lowercase letter"
239 | 
240 | 
241 | def valid_birthday(value, delimiter="-"):
242 |     if value:
243 |         years = [str(y) for y in range(MIN_YEAR, MAX_YEAR + 1)]
244 |         zeroes = [str(z).zfill(2) for z in range(1, 10)]
245 |         months = [str(m) for m in range(1, 13)] + zeroes
246 |         days = [str(d) for d in range(1, 32)] + zeroes
247 |         if value.count(delimiter) > 2:
248 |             return "Birthday has an invalid format"
249 |         elif len(value) > 10:
250 |             return "Birthday is too long"
251 |         elif value.count(delimiter) == 2:
252 |             year, month, day = value.split(delimiter)
253 |             if year not in years:
254 |                 return "Year is not between {0}-{1}".format(MIN_YEAR, MAX_YEAR)
255 |             elif month not in months:
256 |                 return "Month is not between 1-12"
257 |             elif day not in days:
258 |                 return "Day is not between 1-31"
259 |             else:
260 |                 try:
261 |                     _ = date(int(year), int(month), int(day))
262 |                 except Exception as e:
263 |                     print(e)
264 |                     return "Birthday is invalid"
265 |         elif value.count(delimiter):
266 |             year, month = value.split(delimiter)
267 |             if year not in years:
268 |                 return "Year is not between {0}-{1}".format(MIN_YEAR, MAX_YEAR)
269 |             elif month not in months:
270 |                 return "Month is not between 1-12"
271 |         elif value not in years:
272 |             return "Year is not between {0}-{1}".format(MIN_YEAR, MAX_YEAR)
273 | 
274 | 
275 | def valid_location(value, delimiter=", "):
276 |     if value:
277 |         if value.count(delimiter) > 1:
278 |             return "City, Country or just Country"
279 |         elif value.count(delimiter):
280 |             city, country = value.split(delimiter)
281 |             if country not in CITIES:
282 |                 return "Country is invalid"
283 |             elif city not in CITIES[country]:
284 |                 return "City is invalid"
285 |         elif value not in CITIES:
286 |             return "Country is invalid"
287 | 
288 | 
289 | def valid_emoji(value):
290 |     codes = [v['en'] for v in EMOJI_DATA.values()]
291 |     if value and value not in codes:
292 |         return "Emoji is invalid"
293 | 
294 | 
295 | def valid_phone(code, number):
296 |     if not code and not number:
297 |         return
298 |     elif not code:
299 |         return "Code is needed"
300 |     elif not number:
301 |         return "Number is needed"
302 |     elif not code.startswith('+'):
303 |         return "Code starts with +"
304 |     elif len(code) < 2 and len(code) > 4:
305 |         return "Code between +1 and +999"
306 |     elif not code[1:].isdecimal():
307 |         return "Code must be numeric"
308 |     elif not number.isdecimal():
309 |         return "Number must be numeric"
310 |     else:
311 |         phone = parse(code + number, None)
312 |         if not is_possible_number(phone):
313 |             return "Number is impossible"
314 |         elif not is_valid_number(phone):
315 |             return "Number is invalid"
316 | 
317 | 
318 | def valid_wallet(coin, id):
319 |     if not coin and not id:
320 |         return
321 |     elif not coin:
322 |         return "Coin or currency is needed"
323 |     elif not id:
324 |         return "ID or IBAN is needed"
325 |     elif len(coin) > 5:
326 |         return "Coin or currency is too long"
327 |     elif len(id) < 15 or len(id) > 95:
328 |         return "ID or IBAN between 15 and 95"
329 |     elif not all(c in ascii_uppercase for c in coin):
330 |         return "Coin or currency must be in uppercase letters"
331 |     elif not all(c in digits + ascii_letters for c in id):
332 |         return "ID or IBAN must be only digits and letters"
333 | 
334 | 
335 | def changing(user, current, password1, password2):
336 |     errors = {}
337 |     if not verify_hash(current, user.password):
338 |         errors['current'] = "Password doesn't match"
339 |     errors['password'] = valid_password(password1, password2)
340 |     return {k: v for k, v in errors.items() if v}
341 | 
342 | 
343 | def profiling(f, user_id):
344 |     errors = {}
345 |     errors['username'] = valid_username(f['username'], user_id=user_id)
346 |     errors['email'] = valid_email(f['email'], user_id=user_id)
347 |     errors['first_name'] = valid_first_name(f['first_name'])
348 |     errors['last_name'] = valid_last_name(f['last_name'])
349 |     errors['full_name'] = valid_full_name(
350 |         f['emoji'], f['first_name'], f['last_name'], user_id=user_id
351 |     )
352 |     errors['emoji'] = valid_emoji(f['emoji'])
353 |     errors['birthday'] = valid_birthday(f['birthday'])
354 |     errors['location'] = valid_location(f['location'])
355 |     errors['description'] = valid_description(
356 |         f['description'], user_id=user_id
357 |     )
358 |     errors['website'] = valid_website(f['website'], user_id=user_id)
359 |     return {k: v for k, v in errors.items() if v}
360 | 
361 | 
362 | def registration(f):
363 |     errors = {}
364 |     errors['username'] = valid_username(f['username'])
365 |     errors['email'] = valid_email(f['email'])
366 |     errors['password'] = valid_password(f['password1'], f['password2'])
367 |     errors['first_name'] = valid_first_name(f['first_name'])
368 |     errors['last_name'] = valid_last_name(f['last_name'])
369 |     errors['full_name'] = valid_full_name(
370 |         f['emoji'], f['first_name'], f['last_name']
371 |     )
372 |     errors['emoji'] = valid_emoji(f['emoji'])
373 |     errors['birthday'] = valid_birthday(f['birthday'])
374 |     errors['location'] = valid_location(f['location'])
375 |     return {k: v for k, v in errors.items() if v}
376 | 


--------------------------------------------------------------------------------
/app/xhr.py:
--------------------------------------------------------------------------------
  1 | from falcon.constants import MEDIA_JSON
  2 | from falcon.hooks import before
  3 | 
  4 | from app.hooks import auth_user
  5 | from app.models import Bond, Post, Save, User, Text
  6 | from app.utils import utc_timestamp
  7 | 
  8 | 
  9 | class PostCallback:
 10 |     @before(auth_user)
 11 |     def on_post_delete(self, req, resp, id):
 12 |         resp.content_type = MEDIA_JSON
 13 |         if not req.user:
 14 |             resp.media = {'status': 'not auth'}
 15 |             return
 16 |         entry = Post.objects.filter(id=id).first()
 17 |         if not entry:
 18 |             resp.media = {'status': 'not found'}
 19 |             return
 20 |         valid_ids = [
 21 |             entry.created_by_id, entry.parent.created_by_id
 22 |         ] if entry.parent_id else [entry.created_by_id]
 23 |         if req.user.id not in valid_ids:
 24 |             resp.media = {'status': 'not valid'}
 25 |             return
 26 |         entry.delete()
 27 |         resp.media = {'status': 'deleted'}
 28 | 
 29 |     @before(auth_user)
 30 |     def on_post_save(self, req, resp, id):
 31 |         resp.content_type = MEDIA_JSON
 32 |         if not req.user:
 33 |             resp.media = {'status': 'not auth'}
 34 |             return
 35 |         entry = Post.objects.filter(id=id).exclude(created_by=req.user).first()
 36 |         if not entry:
 37 |             resp.media = {'status': 'not found'}
 38 |             return
 39 |         Save.objects.get_or_create(
 40 |             created_at=utc_timestamp(),
 41 |             created_by=req.user,
 42 |             post=entry
 43 |         )
 44 |         resp.media = {'status': 'unsave'}
 45 | 
 46 |     @before(auth_user)
 47 |     def on_post_unsave(self, req, resp, id):
 48 |         resp.content_type = MEDIA_JSON
 49 |         if not req.user:
 50 |             resp.media = {'status': 'not auth'}
 51 |             return
 52 |         entry = Post.objects.filter(id=id).first()
 53 |         if not entry:
 54 |             resp.media = {'status': 'not found'}
 55 |             return
 56 |         Save.objects.filter(created_by=req.user, post=entry).delete()
 57 |         resp.media = {'status': 'save'}
 58 | 
 59 | 
 60 | class BondCallback:
 61 |     @before(auth_user)
 62 |     def on_post_follow(self, req, resp, username):
 63 |         resp.content_type = MEDIA_JSON
 64 |         if not req.user:
 65 |             resp.media = {'status': 'not auth'}
 66 |             return
 67 |         member = User.objects.filter(username=username.lower()).first()
 68 |         if not member:
 69 |             resp.media = {'status': 'not found'}
 70 |             return
 71 |         Bond.objects.get_or_create(
 72 |             created_at=utc_timestamp(), created_by=req.user, to_user=member
 73 |         )
 74 |         resp.media = {'status': 'unfollow'}
 75 | 
 76 | 
 77 |     @before(auth_user)
 78 |     def on_post_unfollow(self, req, resp, username):
 79 |         resp.content_type = MEDIA_JSON
 80 |         if not req.user:
 81 |             resp.media = {'status': 'not auth'}
 82 |             return
 83 |         member = User.objects.filter(username=username.lower()).first()
 84 |         if not member:
 85 |             resp.media = {'status': 'not found'}
 86 |             return
 87 |         Bond.objects.filter(created_by=req.user, to_user=member).delete()
 88 |         resp.media = {'status': 'follow'}
 89 | 
 90 | 
 91 | class TextCallback:
 92 |     @before(auth_user)
 93 |     def on_post_unsend(self, req, resp, id):
 94 |         resp.content_type = MEDIA_JSON
 95 |         if not req.user:
 96 |             resp.media = {'status': 'not auth'}
 97 |             return
 98 |         entry = Text.objects.filter(id=id).first()
 99 |         if not entry:
100 |             resp.media = {'status': 'not found'}
101 |             return
102 |         if req.user.id != entry.created_by_id:
103 |             resp.media = {'status': 'not valid'}
104 |             return
105 |         entry.delete()
106 |         resp.media = {'status': 'unsent'}
107 | 


--------------------------------------------------------------------------------
/crontab:
--------------------------------------------------------------------------------
1 | # sudo
2 | 0 0 1 */1 * certbot renew --renew-hook "systemctl reload nginx"
3 | 
4 | # user
5 | 15 0 * * * bash sqldata/backup.sh
6 | 
7 | 30 * * * * logparser/venv/bin/python3 logparser/parse.py logs/sub.log.gz --html subreply/static/logs.html --skip subreply.com,199.247.2.88 --lowest 1
8 | 


--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | PREVIOUS=$PWD
2 | cd ~/subreply/
3 | git pull
4 | venv/bin/pip install -U -r requirements.txt
5 | venv/bin/python3 manage.py migrate
6 | pkill -HUP -F sub.pid
7 | cd $PREVIOUS
8 | 


--------------------------------------------------------------------------------
/gunicorn.conf.py:
--------------------------------------------------------------------------------
 1 | from multiprocessing import cpu_count
 2 | 
 3 | from project.settings import DEBUG
 4 | 
 5 | bind = "127.0.0.1:8000" if DEBUG else "unix:sub.socket"
 6 | pidfile = "sub.pid"
 7 | threads = 1 if DEBUG else cpu_count() * 2 + 1
 8 | worker_class = "gthread"
 9 | reload = DEBUG
10 | 


--------------------------------------------------------------------------------
/lm.conf:
--------------------------------------------------------------------------------
 1 | server {
 2 |     listen 443 ssl http2;
 3 |     server_name lucianmarin.com;
 4 |     error_page 404 /;
 5 |     ssl_certificate /etc/letsencrypt/live/lucianmarin.com/fullchain.pem;
 6 |     ssl_certificate_key /etc/letsencrypt/live/lucianmarin.com/privkey.pem;
 7 |     location /.well-known/acme-challenge {
 8 |         root /home/lucian;
 9 |     }
10 |     location / {
11 |         return 301 https://subreply.com/lucian;
12 |     }
13 | }
14 | 
15 | server {
16 |     listen 80;
17 |     server_name lucianmarin.com;
18 |     return 301 https://lucianmarin.com$request_uri;
19 | }
20 | 
21 | # sudo cp lm.conf /etc/nginx/conf.d/lm.conf
22 | # sudo systemctl restart nginx
23 | 


--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env python
 2 | """Django's command-line utility for administrative tasks."""
 3 | import os
 4 | import sys
 5 | 
 6 | 
 7 | def main():
 8 |     os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
 9 |     try:
10 |         from django.core.management import execute_from_command_line
11 |     except ImportError as exc:
12 |         raise ImportError(
13 |             "Couldn't import Django. Are you sure it's installed and "
14 |             "available on your PYTHONPATH environment variable? Did you "
15 |             "forget to activate a virtual environment?"
16 |         ) from exc
17 |     execute_from_command_line(sys.argv)
18 | 
19 | 
20 | if __name__ == '__main__':
21 |     main()
22 | 


--------------------------------------------------------------------------------
/net.conf:
--------------------------------------------------------------------------------
 1 | server {
 2 |     listen 443 ssl http2;
 3 |     server_name networkxp.com;
 4 |     error_page 404 /;
 5 |     ssl_certificate /etc/letsencrypt/live/networkxp.com/fullchain.pem;
 6 |     ssl_certificate_key /etc/letsencrypt/live/networkxp.com/privkey.pem;
 7 |     location /.well-known/acme-challenge {
 8 |         root /home/lucian;
 9 |     }
10 |     location / {
11 |         return 301 https://subreply.com$request_uri;
12 |     }
13 | }
14 | 
15 | server {
16 |     listen 80;
17 |     server_name networkxp.com;
18 |     return 301 https://networkxp.com$request_uri;
19 | }
20 | 
21 | # sudo cp net.conf /etc/nginx/conf.d/net.conf
22 | # sudo systemctl restart nginx
23 | 


--------------------------------------------------------------------------------
/project/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lucianmarin/subreply/b0085dedb4c1537d8f64590f9a401fbb2df72ce7/project/__init__.py


--------------------------------------------------------------------------------
/project/settings.py:
--------------------------------------------------------------------------------
 1 | from cryptography.fernet import Fernet
 2 | 
 3 | from project.local import DEBUG, SIGNATURE, SMTP
 4 | 
 5 | ALLOWED_HOSTS = []
 6 | AUTH_PASSWORD_VALIDATORS = []
 7 | INSTALLED_APPS = ["app", "django_extensions"]
 8 | MIDDLEWARE = []
 9 | PASSWORD_HASHERS = []
10 | STORAGES = {}
11 | TEMPLATES = []
12 | STATICFILES_FINDERS = []
13 | FILE_UPLOAD_HANDLERS = []
14 | 
15 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
16 | 
17 | DATABASES = {
18 |     "default": {
19 |         "ENGINE": "django.db.backends.postgresql_psycopg2",
20 |         "NAME": "subreply",
21 |         "USER": "postgres",
22 |         "PASSWORD": "postgres",
23 |         "HOST": "",
24 |         "PORT": "6432",
25 |     }
26 | }
27 | 
28 | LANGUAGE_CODE = "en-us"
29 | TIME_ZONE = "UTC"
30 | USE_I18N = False
31 | USE_L10N = False
32 | USE_TZ = False
33 | 
34 | FERNET = Fernet(SIGNATURE.encode())
35 | MAX_AGE = 3600 * 24 * 365
36 | 
37 | DEBUG = DEBUG
38 | SMTP = SMTP
39 | 


--------------------------------------------------------------------------------
/project/vars.py:
--------------------------------------------------------------------------------
  1 | from json import load
  2 | from pathlib import Path
  3 | 
  4 | ROOT = Path(__file__).parent.parent
  5 | 
  6 | with open(ROOT / 'static/cities.json') as file:
  7 |     CITIES = load(file)
  8 | 
  9 | with open(ROOT / 'static/countries.json') as file:
 10 |     COUNTRIES = load(file)
 11 | 
 12 | MIN_YEAR, MAX_YEAR = 1918, 2018
 13 | 
 14 | LINKS = {
 15 |     'dribbble': 'Dribbble',
 16 |     'github': 'GitHub',
 17 |     'instagram': 'Instagram',
 18 |     'linkedin': 'LinkedIn',
 19 |     'patreon': 'Patreon',
 20 |     'paypal': 'PayPal',
 21 |     'phone': 'Phone',
 22 |     'pinboard': 'Pinboard',
 23 |     'reddit': 'Reddit',
 24 |     'soundcloud': 'SoundCloud',
 25 |     'spotify': 'Spotify',
 26 |     'telegram': 'Telegram',
 27 |     'twitter': 'Twitter',
 28 |     'x': 'X',
 29 |     'youtube': 'YouTube'
 30 | }
 31 | 
 32 | LATIN = "-"
 33 | LATIN += "abcdefghijklmnopqrstuvwxyz"
 34 | LATIN += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
 35 | # lat-1
 36 | LATIN += "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ"
 37 | LATIN += "ÐÑÒÓÔÕÖØÙÚÛÜÝÞß"
 38 | LATIN += "àáâãäåæçèéêëìíîï"
 39 | LATIN += "ðñòóôõöøùúûüýþÿ"
 40 | # ext-a
 41 | LATIN += "ĀāĂ㥹ĆćĈĉĊċČčĎď"
 42 | LATIN += "ĐđĒēĔĕĖėĘęĚěĜĝĞğ"
 43 | LATIN += "ĠġĢģĤĥĦħĨĩĪīĬĭĮį"
 44 | LATIN += "İıIJijĴĵĶķĸĹĺĻļĽľĿ"
 45 | LATIN += "ŀŁłŃńŅņŇňʼnŊŋŌōŎŏ"
 46 | LATIN += "ŐőŒœŔŕŖŗŘřŚśŜŝŞş"
 47 | LATIN += "ŠšŢţŤťŦŧŨũŪūŬŭŮů"
 48 | LATIN += "ŰűŲųŴŵŶŷŸŹźŻżŽžſ"
 49 | # ext-b
 50 | LATIN += "ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ"
 51 | LATIN += "ƐƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟ"
 52 | LATIN += "ƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯ"
 53 | LATIN += "ưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿ"
 54 | LATIN += "ǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏ"
 55 | LATIN += "ǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟ"
 56 | LATIN += "ǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯ"
 57 | LATIN += "ǰDZDzdzǴǵǶǷǸǹǺǻǼǽǾǿ"
 58 | LATIN += "ȀȁȂȃȄȅȆȇȈȉȊȋȌȍȎȏ"
 59 | LATIN += "ȐȑȒȓȔȕȖȗȘșȚțȜȝȞȟ"
 60 | LATIN += "ȠȡȢȣȤȥȦȧȨȩȪȫȬȭȮȯ"
 61 | LATIN += "ȰȱȲȳȴȵȶȷȸȹȺȻȼȽȾȿ"
 62 | LATIN += "ɀɁɂɃɄɅɆɇɈɉɊɋɌɍɎɏ"
 63 | 
 64 | ACTIVITY_HTML = (
 65 |     ""
 66 |     "

Hello,

" 67 | "

You got a couple of {{ notifs }} left unseen on your Subreply account.

" 68 | "

Login from https://subreply.com/login to check them out. Your username is @{{ username }}.

" 69 | "

You won't be emailed again in couple of months if there's no activity on your account.

" 70 | "

Have a sunny day!

" 71 | "" 72 | ) 73 | 74 | ACTIVITY_TEXT = ( 75 | "Hello,\n" 76 | "You got a couple of {{ notifs }} left unseen on your Subreply account.\n" 77 | "Login from https://subreply.com/login to check them out. Your username is @{{ username }}.\n" 78 | "You won't be emailed again in couple of months if there's no activity on your account.\n" 79 | "Have a sunny day!\n" 80 | ) 81 | 82 | RECOVER_HTML = ( 83 | "" 84 | "

Hello,

" 85 | "

Recover your account on Subreply by going to https://subreply.com/recover/{{ token }}

" 86 | "

Your username is @{{ username }}.

" 87 | "

Delete this email if you didn't make such request.

" 88 | "" 89 | ) 90 | 91 | RECOVER_TEXT = ( 92 | "Hello,\n" 93 | "Recover your account on Subreply by going to https://subreply.com/recover/{{ token }}\n" 94 | "Your username is @{{ username }}.\n" 95 | "Delete this email if you didn't make such request.\n" 96 | ) 97 | 98 | HEADERS = { 99 | 'User-Agent': "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Safari/605.1.15", 100 | 'Accept': "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" 101 | } 102 | 103 | RESERVED = [ 104 | "lm", 105 | "lucianmarin", 106 | "sublevel" 107 | ] 108 | 109 | INVALID = RESERVED + [ 110 | "about", 111 | "account", 112 | "api", 113 | "arrivals", 114 | "at", 115 | "chat", 116 | "delete", 117 | "details", 118 | "discover", 119 | "edit", 120 | "emoji", 121 | "feed", 122 | "followers", 123 | "following", 124 | "group", 125 | "groups", 126 | "invite", 127 | "invites", 128 | "login", 129 | "logout", 130 | "media", 131 | "member", 132 | "members", 133 | "mention", 134 | "mentions", 135 | "message", 136 | "messages", 137 | "news", 138 | "options", 139 | "password", 140 | "people", 141 | "policy", 142 | "privacy", 143 | "profile", 144 | "read", 145 | "recover", 146 | "replies", 147 | "reply", 148 | "request", 149 | "reset", 150 | "save", 151 | "saved", 152 | "saves", 153 | "search", 154 | "settings", 155 | "social", 156 | "static", 157 | "terms", 158 | "threads", 159 | "trending", 160 | "xhr" 161 | ] 162 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography 2 | django 3 | django-extensions 4 | dnspython 5 | emails 6 | emoji 7 | falcon 8 | gunicorn 9 | jinja2 10 | legacy-cgi 11 | phonenumbers 12 | pip 13 | psycopg2-binary 14 | requests 15 | strictyaml 16 | tldextract 17 | unidecode 18 | -------------------------------------------------------------------------------- /router.py: -------------------------------------------------------------------------------- 1 | from falcon import App 2 | from falcon.constants import MEDIA_HTML 3 | 4 | from app import resources, xhr 5 | from project.settings import DEBUG 6 | 7 | app = App(media_type=MEDIA_HTML) 8 | 9 | app.req_options.auto_parse_form_urlencoded = True 10 | app.req_options.strip_url_path_trailing_slash = True 11 | 12 | app.resp_options.secure_cookies_by_default = not DEBUG 13 | 14 | app.add_route('/', resources.MainResource()) 15 | 16 | app.add_route('/xhr/unsend/{id:int}', xhr.TextCallback(), suffix="unsend") 17 | app.add_route('/xhr/delete/{id:int}', xhr.PostCallback(), suffix="delete") 18 | app.add_route('/xhr/save/{id:int}', xhr.PostCallback(), suffix="save") 19 | app.add_route('/xhr/unsave/{id:int}', xhr.PostCallback(), suffix="unsave") 20 | app.add_route('/xhr/follow/{username}', xhr.BondCallback(), suffix="follow") 21 | app.add_route('/xhr/unfollow/{username}', xhr.BondCallback(), suffix="unfollow") 22 | 23 | app.add_route('/feed', resources.FeedResource()) 24 | app.add_route('/following', resources.FollowingResource()) 25 | app.add_route('/followers', resources.FollowersResource()) 26 | app.add_route('/mentions', resources.MentionsResource()) 27 | app.add_route('/messages', resources.InboxResource()) 28 | app.add_route('/replies', resources.RepliesResource()) 29 | app.add_route('/saved', resources.SavedResource()) 30 | 31 | # app.add_route('/links', resources.LinksResource()) 32 | app.add_route('/people', resources.PeopleResource()) 33 | app.add_route('/trending', resources.TrendingResource()) 34 | app.add_route('/discover', resources.DiscoverResource()) 35 | 36 | app.add_route('/about', resources.AboutResource()) 37 | app.add_route('/terms', resources.AboutResource(), suffix="terms") 38 | app.add_route('/privacy', resources.AboutResource(), suffix="privacy") 39 | app.add_route('/emoji', resources.EmojiResource()) 40 | 41 | app.add_route('/robots.txt', resources.TxtResource(), suffix="bots") 42 | app.add_route('/sitemap.txt', resources.TxtResource(), suffix="map") 43 | 44 | app.add_route('/login', resources.LoginResource()) 45 | app.add_route('/logout', resources.LogoutResource()) 46 | app.add_route('/register', resources.RegisterResource()) 47 | app.add_route('/recover', resources.RecoverResource()) 48 | app.add_route('/recover/{token}', resources.RecoverResource(), suffix="link") 49 | 50 | app.add_route('/account', resources.AccountResource()) 51 | app.add_route('/account/change', resources.AccountResource(), suffix="change") 52 | app.add_route('/account/delete', resources.AccountResource(), suffix="delete") 53 | app.add_route('/account/export', resources.AccountResource(), suffix="export") 54 | 55 | app.add_route('/profile', resources.ProfileResource()) 56 | app.add_route('/details', resources.DetailsResource()) 57 | 58 | app.add_route('/arrivals', resources.ArrivalsResource()) 59 | app.add_route('/arrivals/destroy', resources.ArrivalsResource(), suffix="destroy") 60 | 61 | app.add_route('/reply/{id:int}', resources.ReplyResource()) 62 | app.add_route('/edit/{id:int}', resources.EditResource()) 63 | app.add_route('/message/{username}', resources.MessageResource()) 64 | app.add_route('/{username}/approve', resources.ArrivalsResource(), suffix="approve") 65 | app.add_route('/{username}', resources.MemberResource()) 66 | 67 | if DEBUG: 68 | app.add_route('/static/{filename}', resources.StaticResource()) 69 | app.add_route('/static/route159/{filename}', resources.StaticResource("route159")) 70 | 71 | application = app 72 | -------------------------------------------------------------------------------- /static/192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucianmarin/subreply/b0085dedb4c1537d8f64590f9a401fbb2df72ce7/static/192.png -------------------------------------------------------------------------------- /static/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucianmarin/subreply/b0085dedb4c1537d8f64590f9a401fbb2df72ce7/static/256.png -------------------------------------------------------------------------------- /static/countries.json: -------------------------------------------------------------------------------- 1 | { 2 | "AD": "Andorra", 3 | "AE": "United Arab Emirates", 4 | "AF": "Afghanistan", 5 | "AG": "Antigua and Barbuda", 6 | "AI": "Anguilla", 7 | "AL": "Albania", 8 | "AM": "Armenia", 9 | "AO": "Angola", 10 | "AR": "Argentina", 11 | "AS": "American Samoa", 12 | "AT": "Austria", 13 | "AU": "Australia", 14 | "AW": "Aruba", 15 | "AZ": "Azerbaijan", 16 | "BA": "Bosnia and Herzegovina", 17 | "BB": "Barbados", 18 | "BD": "Bangladesh", 19 | "BE": "Belgium", 20 | "BF": "Burkina Faso", 21 | "BG": "Bulgaria", 22 | "BH": "Bahrain", 23 | "BI": "Burundi", 24 | "BJ": "Benin", 25 | "BL": "Saint Barthelemy", 26 | "BM": "Bermuda", 27 | "BN": "Brunei", 28 | "BO": "Bolivia", 29 | "BR": "Brazil", 30 | "BS": "Bahamas", 31 | "BT": "Bhutan", 32 | "BW": "Botswana", 33 | "BY": "Belarus", 34 | "BZ": "Belize", 35 | "CA": "Canada", 36 | "CD": "Congo-Kinshasa", 37 | "CF": "Central African Republic", 38 | "CG": "Congo-Brazzaville", 39 | "CH": "Switzerland", 40 | "CI": "C\u00f4te d\u2019Ivoire", 41 | "CK": "Cook Islands", 42 | "CL": "Chile", 43 | "CM": "Cameroon", 44 | "CN": "China", 45 | "CO": "Colombia", 46 | "CR": "Costa Rica", 47 | "CU": "Cuba", 48 | "CV": "Cabo Verde", 49 | "CW": "Curacao", 50 | "CX": "Christmas Island", 51 | "CY": "Cyprus", 52 | "CZ": "Czech Republic", 53 | "DE": "Germany", 54 | "DJ": "Djibouti", 55 | "DK": "Denmark", 56 | "DM": "Dominica", 57 | "DO": "Dominican Republic", 58 | "DZ": "Algeria", 59 | "EC": "Ecuador", 60 | "EE": "Estonia", 61 | "EG": "Egypt", 62 | "ER": "Eritrea", 63 | "ES": "Spain", 64 | "ET": "Ethiopia", 65 | "FI": "Finland", 66 | "FJ": "Fiji", 67 | "FK": "Falkland Islands", 68 | "FO": "Faroe Islands", 69 | "FR": "France", 70 | "GA": "Gabon", 71 | "GB": "United Kingdom", 72 | "GD": "Grenada", 73 | "GE": "Georgia", 74 | "GF": "French Guiana", 75 | "GG": "Guernsey", 76 | "GH": "Ghana", 77 | "GI": "Gibraltar", 78 | "GL": "Greenland", 79 | "GM": "Gambia", 80 | "GN": "Guinea", 81 | "GP": "Guadeloupe", 82 | "GQ": "Equatorial Guinea", 83 | "GR": "Greece", 84 | "GS": "South Georgia and South Sandwich Islands", 85 | "GT": "Guatemala", 86 | "GU": "Guam", 87 | "GW": "Guinea-Bissau", 88 | "GY": "Guyana", 89 | "HK": "Hong Kong", 90 | "HN": "Honduras", 91 | "HR": "Croatia", 92 | "HT": "Haiti", 93 | "HU": "Hungary", 94 | "ID": "Indonesia", 95 | "IE": "Ireland", 96 | "IL": "Israel", 97 | "IM": "Isle of Man", 98 | "IN": "India", 99 | "IQ": "Iraq", 100 | "IR": "Iran", 101 | "IS": "Iceland", 102 | "IT": "Italy", 103 | "JE": "Jersey", 104 | "JM": "Jamaica", 105 | "JO": "Jordan", 106 | "JP": "Japan", 107 | "KE": "Kenya", 108 | "KG": "Kyrgyzstan", 109 | "KH": "Cambodia", 110 | "KI": "Kiribati", 111 | "KM": "Comoros", 112 | "KN": "Saint Kitts and Nevis", 113 | "KP": "North Korea", 114 | "KR": "South Korea", 115 | "KW": "Kuwait", 116 | "KY": "Cayman Islands", 117 | "KZ": "Kazakhstan", 118 | "LA": "Laos", 119 | "LB": "Lebanon", 120 | "LC": "Saint Lucia", 121 | "LI": "Liechtenstein", 122 | "LK": "Sri Lanka", 123 | "LR": "Liberia", 124 | "LS": "Lesotho", 125 | "LT": "Lithuania", 126 | "LU": "Luxembourg", 127 | "LV": "Latvia", 128 | "LY": "Libya", 129 | "MA": "Morocco", 130 | "MC": "Monaco", 131 | "MD": "Moldova", 132 | "ME": "Montenegro", 133 | "MF": "Saint Martin", 134 | "MG": "Madagascar", 135 | "MH": "Marshall Islands", 136 | "MK": "North Macedonia", 137 | "ML": "Mali", 138 | "MM": "Burma", 139 | "MN": "Mongolia", 140 | "MO": "Macau", 141 | "MP": "Northern Mariana Islands", 142 | "MQ": "Martinique", 143 | "MR": "Mauritania", 144 | "MS": "Montserrat", 145 | "MT": "Malta", 146 | "MU": "Mauritius", 147 | "MV": "Maldives", 148 | "MW": "Malawi", 149 | "MX": "Mexico", 150 | "MY": "Malaysia", 151 | "MZ": "Mozambique", 152 | "NA": "Namibia", 153 | "NC": "New Caledonia", 154 | "NE": "Niger", 155 | "NF": "Norfolk Island", 156 | "NG": "Nigeria", 157 | "NI": "Nicaragua", 158 | "NL": "Netherlands", 159 | "NO": "Norway", 160 | "NP": "Nepal", 161 | "NR": "Nauru", 162 | "NU": "Niue", 163 | "NZ": "New Zealand", 164 | "OM": "Oman", 165 | "PA": "Panama", 166 | "PE": "Peru", 167 | "PF": "French Polynesia", 168 | "PG": "Papua New Guinea", 169 | "PH": "Philippines", 170 | "PK": "Pakistan", 171 | "PL": "Poland", 172 | "PM": "Saint Pierre and Miquelon", 173 | "PN": "Pitcairn Islands", 174 | "PR": "Puerto Rico", 175 | "PT": "Portugal", 176 | "PW": "Palau", 177 | "PY": "Paraguay", 178 | "QA": "Qatar", 179 | "RE": "Reunion", 180 | "RO": "Romania", 181 | "RS": "Serbia", 182 | "RU": "Russia", 183 | "RW": "Rwanda", 184 | "SA": "Saudi Arabia", 185 | "SB": "Solomon Islands", 186 | "SC": "Seychelles", 187 | "SD": "Sudan", 188 | "SE": "Sweden", 189 | "SG": "Singapore", 190 | "SI": "Slovenia", 191 | "SK": "Slovakia", 192 | "SL": "Sierra Leone", 193 | "SM": "San Marino", 194 | "SN": "Senegal", 195 | "SO": "Somalia", 196 | "SR": "Suriname", 197 | "SS": "South Sudan", 198 | "ST": "Sao Tome and Principe", 199 | "SV": "El Salvador", 200 | "SX": "Sint Maarten", 201 | "SY": "Syria", 202 | "SZ": "Eswatini", 203 | "TC": "Turks and Caicos Islands", 204 | "TD": "Chad", 205 | "TG": "Togo", 206 | "TH": "Thailand", 207 | "TJ": "Tajikistan", 208 | "TL": "Timor-Leste", 209 | "TM": "Turkmenistan", 210 | "TN": "Tunisia", 211 | "TO": "Tonga", 212 | "TR": "Turkey", 213 | "TT": "Trinidad and Tobago", 214 | "TV": "Tuvalu", 215 | "TW": "Taiwan", 216 | "TZ": "Tanzania", 217 | "UA": "Ukraine", 218 | "UG": "Uganda", 219 | "US": "United States", 220 | "UY": "Uruguay", 221 | "UZ": "Uzbekistan", 222 | "VA": "Vatican City", 223 | "VC": "Saint Vincent and the Grenadines", 224 | "VE": "Venezuela", 225 | "VI": "U.S. Virgin Islands", 226 | "VN": "Vietnam", 227 | "VU": "Vanuatu", 228 | "WF": "Wallis and Futuna", 229 | "WS": "Samoa", 230 | "XG": "Gaza Strip", 231 | "XK": "Kosovo", 232 | "XR": "Svalbard", 233 | "XW": "West Bank", 234 | "YE": "Yemen", 235 | "YT": "Mayotte", 236 | "ZA": "South Africa", 237 | "ZM": "Zambia", 238 | "ZW": "Zimbabwe" 239 | } -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en", 3 | "name": "Subreply", 4 | "short_name": "Subreply", 5 | "description": "Tiny, but mighty social network.", 6 | "icons": [ 7 | { 8 | "src": "/static/192.png", 9 | "sizes": "192x192", 10 | "type": "image/png", 11 | "purpose": "any" 12 | }, 13 | { 14 | "src": "/static/256.png", 15 | "sizes": "256x256", 16 | "type": "image/png", 17 | "purpose": "maskable" 18 | } 19 | ], 20 | "id": "/feed", 21 | "start_url": "/feed", 22 | "display": "standalone", 23 | "orientation": "portrait", 24 | "status": "ok" 25 | } 26 | -------------------------------------------------------------------------------- /static/route159/bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucianmarin/subreply/b0085dedb4c1537d8f64590f9a401fbb2df72ce7/static/route159/bold.woff2 -------------------------------------------------------------------------------- /static/route159/regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucianmarin/subreply/b0085dedb4c1537d8f64590f9a401fbb2df72ce7/static/route159/regular.woff2 -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | function ajax(path, method = "post", type = "json", callback) { 2 | var xhr = new XMLHttpRequest(); 3 | xhr.open(method, path, true); 4 | xhr.responseType = type; 5 | xhr.onreadystatechange = function () { 6 | if (this.readyState == 4 && this.status == 200) { 7 | callback(xhr.response); 8 | } 9 | }; 10 | xhr.send(); 11 | } 12 | 13 | function getPage(event) { 14 | event.preventDefault(); 15 | var element = event.currentTarget; 16 | var page = element.dataset.page; 17 | var loader = element.parentElement.parentElement; 18 | var items = loader.parentElement; 19 | var url = window.location.pathname + "?p=" + page; 20 | element.innerText = "Loading..."; 21 | ajax(url, "get", "text", function (data) { 22 | loader.remove(); 23 | items.innerHTML = items.innerHTML + data; 24 | }); 25 | } 26 | 27 | function postDelete(event, call, status) { 28 | event.preventDefault(); 29 | var element = event.currentTarget; 30 | var small = element.parentElement; 31 | var id = element.dataset.id; 32 | if (id != "0") { 33 | var confirm = document.createElement("a"); 34 | element.innerText = "undo"; 35 | confirm.innerText = "yes"; 36 | confirm.onclick = function (event) { 37 | event.preventDefault(); 38 | ajax("/xhr/" + call + "/" + id, "post", "json", function (data) { 39 | if (data.status == status) { 40 | var state = document.createElement("b"); 41 | state.innerText = data.status; 42 | small.appendChild(state); 43 | confirm.remove(); 44 | element.remove(); 45 | } else { 46 | confirm.innerText = "error"; 47 | confirm.style.cursor = "default"; 48 | } 49 | }); 50 | }; 51 | small.appendChild(confirm); 52 | element.dataset.id = "0"; 53 | element.dataset.oldId = id; 54 | } else { 55 | element.innerText = call; 56 | var confirm = element.nextElementSibling; 57 | confirm.remove(); 58 | element.dataset.id = element.dataset.oldId; 59 | element.dataset.oldId = "0"; 60 | } 61 | } 62 | 63 | function postSave(event, call) { 64 | event.preventDefault(); 65 | var reverse = call == "save" ? "unsave" : "save"; 66 | var element = event.currentTarget; 67 | var id = element.dataset.id; 68 | ajax("/xhr/" + call + "/" + id, "post", "json", function (data) { 69 | if (data.status == reverse) { 70 | element.innerText = data.status; 71 | element.onclick = function (ev) { 72 | ev.preventDefault(); 73 | postSave(ev, reverse); 74 | }; 75 | } 76 | }); 77 | } 78 | 79 | function postFollow(event, call) { 80 | event.preventDefault(); 81 | var reverse = call == "follow" ? "unfollow" : "follow"; 82 | var element = event.currentTarget; 83 | var username = element.dataset.username; 84 | ajax("/xhr/" + call + "/" + username, "post", "json", function (data) { 85 | if (data.status == reverse) { 86 | element.innerText = data.status; 87 | element.className = data.status == "unfollow" ? "action" : "accent"; 88 | element.onclick = function (ev) { 89 | ev.preventDefault(); 90 | postFollow(ev, reverse); 91 | }; 92 | } 93 | }); 94 | } 95 | 96 | function expand(element, limit = 640, padding = 10) { 97 | element.style.height = "auto"; 98 | element.style.height = element.scrollHeight - padding + "px"; 99 | element.style.backgroundColor = 100 | element.value.length > limit ? "var(--redsmoke)" : "var(--whitesmoke)"; 101 | } 102 | 103 | function send(event) { 104 | if (event.keyCode == 13) { 105 | event.preventDefault(); 106 | event.currentTarget.parentElement.submit(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | html { --sup: 8px; --tiny: 10px; --small: 13px; --regular: 14px; --radius: 6px; --d: 0.2s; --white: #fff; --whitesmoke: #f0f0f0; --gainsboro: #e0e0e0; --silver: #c0c0c0; --gray: #808080; --black: #000; --accent: #4060e0; --alert: #ff4000; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; } 2 | 3 | ::-moz-placeholder { color: #1f1f1f; } 4 | 5 | ::-webkit-input-placeholder { color: var(--gray); } 6 | 7 | @media (prefers-color-scheme: dark) { html { --white: #000; --whitesmoke: #181818; --gainsboro: #282828; --silver: #404040; --gray: #808080; --black: #fff; --accent: #6080e0; } ::-moz-placeholder { color: #eeeeee; } } 8 | 9 | * { margin: 0; padding: 0; } 10 | 11 | body { background-color: var(--white); color: var(--black); cursor: default; } 12 | 13 | a { color: var(--black); cursor: pointer; text-decoration: none; transition: color var(--d); } 14 | 15 | b, i, u { font-style: normal; font-weight: normal; text-decoration: none; } 16 | 17 | form, input, img, label { display: block; } 18 | 19 | body, label, input, select, textarea { font-family: "Route 159", sans-serif; font-size: var(--regular); line-height: 20px; } 20 | 21 | label { color: var(--black); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 22 | 23 | label i { color: var(--gray); } 24 | 25 | sup { line-height: 0; } 26 | 27 | input, select, textarea { -moz-appearance: none; -webkit-appearance: none; appearance: none; background-color: var(--whitesmoke); border-radius: var(--radius); border: 0 none; color: var(--black); display: block; font-weight: normal; } 28 | 29 | input:focus, select:focus, textarea:focus { outline: 0 none; } 30 | 31 | input[type=text], input[type=password], input[type=email], input[type=url], select, textarea { overflow: hidden; padding: 5px 10px; padding-right: 5px; resize: none; width: calc(100% - 15px); } 32 | 33 | input[type=submit] { cursor: pointer; float: right; padding: 5px 15px; transition: background-color var(--d); } 34 | 35 | input[type=submit]:hover { background-color: var(--gainsboro); } 36 | 37 | svg { stroke-linecap: round; stroke-linejoin: round; stroke-width: 2; } 38 | 39 | svg path { fill: none; stroke: var(--black); } 40 | 41 | table { border-spacing: 0px; font-size: var(--small); line-height: 15px; margin: 10px 0; width: 100%; } 42 | 43 | table a { color: var(--accent); } 44 | 45 | table a:hover { color: var(--gray); } 46 | 47 | table th { color: var(--silver); font-weight: normal; text-align: left; padding-bottom: 6px; } 48 | 49 | table td { border-top: var(--whitesmoke) 1px solid; padding: 6px 0; } 50 | 51 | table td:nth-child(odd), table th:nth-child(odd) { text-align: center; width: 30px; } 52 | 53 | table td:nth-child(even), table th:nth-child(even) { width: calc(50% - 30px); overflow-wrap: break-word; word-break: break-all; } 54 | 55 | table td:nth-child(even):last-child, table th:nth-child(even):last-child { border-right: 0 none; } 56 | 57 | .container { display: grid; grid-column-gap: 10px; grid-template-columns: auto 150px 525px auto; } 58 | 59 | .container > div { min-height: 100vh; } 60 | 61 | .logo { font-size: var(--tiny); font-weight: bold; line-height: 30px; text-align: center; overflow: hidden; position: sticky; top: 10px; padding: 0 10px; } 62 | 63 | .logo a { background-color: var(--white); border-radius: 15px; color: var(--black); display: block; float: right; height: 30px; width: 30px; } 64 | 65 | .logo.on a { color: var(--accent); } 66 | 67 | .links { position: sticky; text-align: right; top: 10px; } 68 | 69 | .links a { background-color: var(--white); border-radius: var(--radius); color: var(--black); display: block; height: 20px; padding: 5px 0; position: relative; transition: background-color var(--d); } 70 | 71 | .links a:hover { background-color: var(--whitesmoke); } 72 | 73 | .links a.on { background-color: var(--whitesmoke); color: var(--accent); } 74 | 75 | .links a.on svg path { stroke: var(--accent); } 76 | 77 | .links a.on span { color: var(--accent); } 78 | 79 | .links a.red svg path { stroke: var(--alert); } 80 | 81 | .links span { color: var(--alert); display: block; font-size: var(--sup); font-weight: bold; position: absolute; right: 0; text-align: center; top: 0; width: 17px; } 82 | 83 | .links svg { float: right; margin: 0 10px; padding: 2px; } 84 | 85 | .title { color: var(--silver); font-weight: bold; margin: 15px 0; overflow: hidden; } 86 | 87 | .title a { color: var(--silver); float: left; margin-right: 5px; } 88 | 89 | .title a:last-child { margin-right: 0; } 90 | 91 | .title a:hover { color: var(--gray); } 92 | 93 | .title a.on { color: var(--black); } 94 | 95 | .title a.on::first-letter { border-bottom: 2px solid var(--black); } 96 | 97 | .reply { margin: 10px 0; overflow: hidden; } 98 | 99 | .reply .placeholder { background: var(--whitesmoke); background: linear-gradient(90deg, var(--whitesmoke) 0%, var(--white) 100%); border-radius: var(--radius); color: var(--gray); padding: 5px 10px; } 100 | 101 | .reply label { background-color: var(--whitesmoke); border-bottom-right-radius: var(--radius); border-top-right-radius: var(--radius); float: right; padding: 7px 10px; padding-left: 5px; height: 16px; width: 16px; } 102 | 103 | .reply input[type="text"] { border-bottom-right-radius: 0; border-top-right-radius: 0; float: left; width: calc(100% - 46px); } 104 | 105 | .list { margin: 10px 0; } 106 | 107 | .list > .entry:first-child { border-top: 0 none; padding-top: 0; } 108 | 109 | .list > .entry:last-child { padding-bottom: 0; } 110 | 111 | .entry { border-top: var(--whitesmoke) 1px solid; padding: 5px 0; } 112 | 113 | .entry.indent { margin-left: 30px; } 114 | 115 | .entry .text { margin-left: 30px; } 116 | 117 | .entry .text p { margin: 7.5px 0; } 118 | 119 | .entry .text p:first-child { margin-top: 0; } 120 | 121 | .entry .text p strong { margin-left: -30px; } 122 | 123 | .sublist { margin-left: 30px; } 124 | 125 | .load { text-align: center; } 126 | 127 | .load a { color: var(--gray); } 128 | 129 | .load a:hover { color: var(--silver); } 130 | 131 | .load b { color: var(--gray); } 132 | 133 | .inline { opacity: 0.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 134 | 135 | .inline .name { font-weight: bold; } 136 | 137 | .inline .name:hover { color: var(--gray); } 138 | 139 | .content { cursor: text; word-break: break-word; } 140 | 141 | .content a { color: var(--accent); } 142 | 143 | .content a.name { color: var(--black); font-weight: bold; } 144 | 145 | .content a.story { display: block; } 146 | 147 | .content a:hover { color: var(--gray); } 148 | 149 | .content b { color: var(--silver); } 150 | 151 | .small { font-size: var(--small); overflow: hidden; } 152 | 153 | .small a, .small b { float: left; margin-right: 10px; } 154 | 155 | .small a:first-child, .small b:first-child { float: right; margin-right: 0; } 156 | 157 | .small b { color: var(--silver); } 158 | 159 | .small a { color: var(--gray); } 160 | 161 | .small a:hover { color: var(--silver); } 162 | 163 | .author { color: var(--gray); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 164 | 165 | .author .done, .author .handle, .author i { font-weight: bold; } 166 | 167 | .author .action, .author .ago, .author .done { color: var(--gray); } 168 | 169 | .author .action:hover, .author .ago:hover, .author .done:hover { color: var(--silver); } 170 | 171 | .author .accent { color: var(--accent); } 172 | 173 | .author .accent:hover, .author .handle:hover { color: var(--gray); } 174 | 175 | .author b { color: var(--silver); float: right; } 176 | 177 | .status { color: var(--gray); font-size: var(--small); } 178 | 179 | .status a { color: var(--gray); } 180 | 181 | .status a:hover { color: var(--silver); } 182 | 183 | .request.upper { margin-top: -10px; } 184 | 185 | .line { margin-bottom: 10px; overflow: hidden; } 186 | 187 | .line span { color: var(--gray); font-size: var(--small); line-height: 30px; } 188 | 189 | .line label { font-size: var(--small); } 190 | 191 | .line .one, .line .two, .line .left, .line .right, .line .three { float: left; } 192 | 193 | .line .one { width: calc(100% / 3 - 1px); } 194 | 195 | .line .two { width: calc(100% / 3 * 2 - 1px); } 196 | 197 | .line .left, .line .right { width: calc(50% - 1px); } 198 | 199 | .line .left, .line .one { margin-right: 1px; } 200 | 201 | .line .left input, .line .one input { border-bottom-right-radius: 0; border-top-right-radius: 0; } 202 | 203 | .line .right, .line .two { margin-left: 1px; } 204 | 205 | .line .right input, .line .two input { border-bottom-left-radius: 0; border-top-left-radius: 0; } 206 | 207 | .line .three { width: calc(100% / 3 - 2px); } 208 | 209 | .line .three input { border-radius: 0; } 210 | 211 | .line .three.first { margin-right: 2px; width: calc(100% / 3 - 1px); } 212 | 213 | .line .three.first input { border-bottom-left-radius: var(--radius); border-top-left-radius: var(--radius); } 214 | 215 | .line .three.last { margin-left: 2px; width: calc(100% / 3 - 1px); } 216 | 217 | .line .three.last input { border-bottom-right-radius: var(--radius); border-top-right-radius: var(--radius); } 218 | 219 | .error { clear: both; color: var(--alert); font-size: var(--small); } 220 | 221 | .error a { color: var(--alert); font-weight: bold; } 222 | 223 | .about a { font-weight: bold; } 224 | 225 | .about a:hover { color: var(--gray); } 226 | 227 | .about h4 { margin-top: 10px; margin-bottom: 3px; } 228 | 229 | .about h4 + p { text-indent: 0; } 230 | 231 | .about p { padding-bottom: 3px; text-indent: 40px; } 232 | 233 | .about p:first-child { text-indent: 0; } 234 | 235 | .about p:last-child { padding-bottom: 0; } 236 | 237 | .about .block { margin-bottom: 10px; } 238 | 239 | .about ol, .about ul { margin-bottom: 10px; } 240 | 241 | .about ol li, .about ul li { border-bottom: var(--whitesmoke) 1px solid; padding: 3px 0; } 242 | 243 | .about ol li:last-child, .about ul li:last-child { border-bottom: 0 none; } 244 | 245 | .about ul { list-style: none; } 246 | 247 | .about ul li { padding-left: 40px; } 248 | 249 | .about ol { margin-left: 40px; } 250 | 251 | .about svg { float: left; margin-left: -40px; padding: 2px 12px; } 252 | 253 | .about .emoji { float: left; margin-left: -40px; text-align: center; width: 40px; } 254 | 255 | @media (max-width: 704px) { .container { grid-template-columns: auto 40px 525px 0; } .links i { display: none; } } 256 | 257 | @media (max-width: 594px) { .container { grid-template-columns: 0 40px calc(100% - 70px) 0; } } 258 | 259 | @font-face { font-family: "Route 159"; font-style: normal; font-weight: normal; src: url(/static/route159/regular.woff2) format("woff2"); } 260 | 261 | @font-face { font-family: "Route 159"; font-style: normal; font-weight: bold; src: url(/static/route159/bold.woff2) format("woff2"); } 262 | -------------------------------------------------------------------------------- /static/style.sass: -------------------------------------------------------------------------------- 1 | html 2 | --sup: 8px 3 | --tiny: 10px 4 | --small: 13px 5 | --regular: 14px 6 | --radius: 6px 7 | --d: 0.2s 8 | --white: #fff 9 | --whitesmoke: #f0f0f0 10 | --gainsboro: #e0e0e0 11 | --silver: #c0c0c0 12 | --gray: #808080 13 | --black: #000 14 | --accent: #4060e0 15 | --alert: #ff4000 16 | -ms-text-size-adjust: 100% 17 | -webkit-text-size-adjust: 100% 18 | 19 | ::-moz-placeholder 20 | color: darken(#808080, 38%) 21 | 22 | ::-webkit-input-placeholder 23 | color: var(--gray) 24 | 25 | @mixin dots 26 | overflow: hidden 27 | text-overflow: ellipsis 28 | white-space: nowrap 29 | 30 | @media (prefers-color-scheme: dark) 31 | html 32 | --white: #000 33 | --whitesmoke: #181818 34 | --gainsboro: #282828 35 | --silver: #404040 36 | --gray: #808080 37 | --black: #fff 38 | --accent: #6080e0 39 | ::-moz-placeholder 40 | color: lighten(#808080, 43%) 41 | 42 | * 43 | margin: 0 44 | padding: 0 45 | 46 | body 47 | background-color: var(--white) 48 | color: var(--black) 49 | cursor: default 50 | 51 | a 52 | color: var(--black) 53 | cursor: pointer 54 | text-decoration: none 55 | transition: color var(--d) 56 | 57 | b, i, u 58 | font-style: normal 59 | font-weight: normal 60 | text-decoration: none 61 | 62 | form, input, img, label 63 | display: block 64 | 65 | body, label, input, select, textarea 66 | font-family: "Route 159", sans-serif 67 | font-size: var(--regular) 68 | line-height: 20px 69 | 70 | label 71 | color: var(--black) 72 | @include dots 73 | i 74 | color: var(--gray) 75 | 76 | sup 77 | line-height: 0 78 | 79 | input, select, textarea 80 | -moz-appearance: none 81 | -webkit-appearance: none 82 | appearance: none 83 | background-color: var(--whitesmoke) 84 | border-radius: var(--radius) 85 | border: 0 none 86 | color: var(--black) 87 | display: block 88 | font-weight: normal 89 | &:focus 90 | outline: 0 none 91 | 92 | input[type=text], 93 | input[type=password], 94 | input[type=email], 95 | input[type=url], 96 | select, 97 | textarea 98 | overflow: hidden 99 | padding: 5px 10px 100 | padding-right: 5px 101 | resize: none 102 | width: calc(100% - 15px) 103 | 104 | input[type=submit] 105 | cursor: pointer 106 | float: right 107 | padding: 5px 15px 108 | transition: background-color var(--d) 109 | &:hover 110 | background-color: var(--gainsboro) 111 | 112 | svg 113 | stroke-linecap: round 114 | stroke-linejoin: round 115 | stroke-width: 2 116 | path 117 | fill: none 118 | stroke: var(--black) 119 | 120 | table 121 | border-spacing: 0px 122 | font-size: var(--small) 123 | line-height: 15px 124 | margin: 10px 0 125 | width: 100% 126 | a 127 | color: var(--accent) 128 | &:hover 129 | color: var(--gray) 130 | th 131 | color: var(--silver) 132 | font-weight: normal 133 | text-align: left 134 | padding-bottom: 6px 135 | td 136 | border-top: var(--whitesmoke) 1px solid 137 | padding: 6px 0 138 | td, th 139 | &:nth-child(odd) 140 | text-align: center 141 | width: 30px 142 | &:nth-child(even) 143 | width: calc(50% - 30px) 144 | overflow-wrap: break-word 145 | word-break: break-all 146 | &:last-child 147 | border-right: 0 none 148 | 149 | .container 150 | display: grid 151 | grid-column-gap: 10px 152 | grid-template-columns: auto 150px 525px auto 153 | &> div 154 | min-height: 100vh 155 | 156 | .logo 157 | font-size: var(--tiny) 158 | font-weight: bold 159 | line-height: 30px 160 | text-align: center 161 | overflow: hidden 162 | position: sticky 163 | top: 10px 164 | padding: 0 10px 165 | a 166 | background-color: var(--white) 167 | border-radius: 15px 168 | color: var(--black) 169 | display: block 170 | float: right 171 | height: 30px 172 | width: 30px 173 | &.on a 174 | color: var(--accent) 175 | 176 | .links 177 | position: sticky 178 | text-align: right 179 | top: 10px 180 | a 181 | background-color: var(--white) 182 | border-radius: var(--radius) 183 | color: var(--black) 184 | display: block 185 | height: 20px 186 | padding: 5px 0 187 | position: relative 188 | transition: background-color var(--d) 189 | &:hover 190 | background-color: var(--whitesmoke) 191 | &.on 192 | background-color: var(--whitesmoke) 193 | color: var(--accent) 194 | svg path 195 | stroke: var(--accent) 196 | span 197 | color: var(--accent) 198 | &.red 199 | svg path 200 | stroke: var(--alert) 201 | span 202 | color: var(--alert) 203 | display: block 204 | font-size: var(--sup) 205 | font-weight: bold 206 | position: absolute 207 | right: 0 208 | text-align: center 209 | top: 0 210 | width: 17px 211 | svg 212 | float: right 213 | margin: 0 10px 214 | padding: 2px 215 | 216 | .title 217 | color: var(--silver) 218 | font-weight: bold 219 | margin: 15px 0 220 | overflow: hidden 221 | a 222 | color: var(--silver) 223 | float: left 224 | margin-right: 5px 225 | &:last-child 226 | margin-right: 0 227 | &:hover 228 | color: var(--gray) 229 | &.on 230 | color: var(--black) 231 | &::first-letter 232 | border-bottom: 2px solid var(--black) 233 | 234 | .reply 235 | margin: 10px 0 236 | overflow: hidden 237 | .placeholder 238 | background: var(--whitesmoke) 239 | background: linear-gradient(90deg, var(--whitesmoke) 0%, var(--white) 100%) 240 | border-radius: var(--radius) 241 | color: var(--gray) 242 | padding: 5px 10px 243 | label 244 | background-color: var(--whitesmoke) 245 | border-bottom-right-radius: var(--radius) 246 | border-top-right-radius: var(--radius) 247 | float: right 248 | padding: 7px 10px 249 | padding-left: 5px 250 | height: 16px 251 | width: 16px 252 | input[type="text"] 253 | border-bottom-right-radius: 0 254 | border-top-right-radius: 0 255 | float: left 256 | width: calc(100% - 46px) 257 | 258 | .list 259 | margin: 10px 0 260 | &> .entry 261 | &:first-child 262 | border-top: 0 none 263 | padding-top: 0 264 | &:last-child 265 | padding-bottom: 0 266 | 267 | .entry 268 | border-top: var(--whitesmoke) 1px solid 269 | padding: 5px 0 270 | &.indent 271 | margin-left: 30px 272 | .text 273 | margin-left: 30px 274 | p 275 | margin: 7.5px 0 276 | &:first-child 277 | margin-top: 0 278 | strong 279 | margin-left: -30px 280 | 281 | .sublist 282 | margin-left: 30px 283 | 284 | .load 285 | text-align: center 286 | a 287 | color: var(--gray) 288 | &:hover 289 | color: var(--silver) 290 | b 291 | color: var(--gray) 292 | 293 | .inline 294 | opacity: 0.5 295 | @include dots 296 | .name 297 | font-weight: bold 298 | &:hover 299 | color: var(--gray) 300 | 301 | .content 302 | cursor: text 303 | word-break: break-word 304 | a 305 | color: var(--accent) 306 | &.name 307 | color: var(--black) 308 | font-weight: bold 309 | &.story 310 | display: block 311 | &:hover 312 | color: var(--gray) 313 | b 314 | color: var(--silver) 315 | 316 | .small 317 | font-size: var(--small) 318 | overflow: hidden 319 | a, b 320 | float: left 321 | margin-right: 10px 322 | &:first-child 323 | float: right 324 | margin-right: 0 325 | b 326 | color: var(--silver) 327 | a 328 | color: var(--gray) 329 | &:hover 330 | color: var(--silver) 331 | 332 | .author 333 | color: var(--gray) 334 | @include dots 335 | .done, .handle, i 336 | font-weight: bold 337 | .action, .ago, .done 338 | color: var(--gray) 339 | &:hover 340 | color: var(--silver) 341 | .accent 342 | color: var(--accent) 343 | .accent, .handle 344 | &:hover 345 | color: var(--gray) 346 | b 347 | color: var(--silver) 348 | float: right 349 | 350 | .status 351 | color: var(--gray) 352 | font-size: var(--small) 353 | a 354 | color: var(--gray) 355 | &:hover 356 | color: var(--silver) 357 | 358 | .request 359 | &.upper 360 | margin-top: -10px 361 | 362 | .line 363 | margin-bottom: 10px 364 | overflow: hidden 365 | span 366 | color: var(--gray) 367 | font-size: var(--small) 368 | line-height: 30px 369 | label 370 | font-size: var(--small) 371 | .one, .two, .left, .right, .three 372 | float: left 373 | .one 374 | width: calc(100% / 3 - 1px) 375 | .two 376 | width: calc(100% / 3 * 2 - 1px) 377 | .left, .right 378 | width: calc(50% - 1px) 379 | .left, .one 380 | margin-right: 1px 381 | input 382 | border-bottom-right-radius: 0 383 | border-top-right-radius: 0 384 | .right, .two 385 | margin-left: 1px 386 | input 387 | border-bottom-left-radius: 0 388 | border-top-left-radius: 0 389 | .three 390 | width: calc(100% / 3 - 2px) 391 | input 392 | border-radius: 0 393 | &.first 394 | margin-right: 2px 395 | width: calc(100% / 3 - 1px) 396 | input 397 | border-bottom-left-radius: var(--radius) 398 | border-top-left-radius: var(--radius) 399 | &.last 400 | margin-left: 2px 401 | width: calc(100% / 3 - 1px) 402 | input 403 | border-bottom-right-radius: var(--radius) 404 | border-top-right-radius: var(--radius) 405 | 406 | .error 407 | clear: both 408 | color: var(--alert) 409 | font-size: var(--small) 410 | a 411 | color: var(--alert) 412 | font-weight: bold 413 | 414 | .about 415 | a 416 | font-weight: bold 417 | &:hover 418 | color: var(--gray) 419 | h4 420 | margin-top: 10px 421 | margin-bottom: 3px 422 | &+ p 423 | text-indent: 0 424 | p 425 | padding-bottom: 3px 426 | text-indent: 40px 427 | &:first-child 428 | text-indent: 0 429 | &:last-child 430 | padding-bottom: 0 431 | .block 432 | margin-bottom: 10px 433 | ol, ul 434 | margin-bottom: 10px 435 | li 436 | border-bottom: var(--whitesmoke) 1px solid 437 | padding: 3px 0 438 | &:last-child 439 | border-bottom: 0 none 440 | ul 441 | list-style: none 442 | li 443 | padding-left: 40px 444 | ol 445 | margin-left: 40px 446 | svg 447 | float: left 448 | margin-left: -40px 449 | padding: 2px 12px 450 | .emoji 451 | float: left 452 | margin-left: -40px 453 | text-align: center 454 | width: 40px 455 | 456 | @media (max-width: 704px) 457 | .container 458 | grid-template-columns: auto 40px 525px 0 459 | .links i 460 | display: none 461 | 462 | @media (max-width: 594px) 463 | .container 464 | grid-template-columns: 0 40px calc(100% - 70px) 0 465 | 466 | @font-face 467 | font-family: "Route 159" 468 | font-style: normal 469 | font-weight: normal 470 | src: url(/static/route159/regular.woff2) format("woff2") 471 | 472 | @font-face 473 | font-family: "Route 159" 474 | font-style: normal 475 | font-weight: bold 476 | src: url(/static/route159/bold.woff2) format("woff2") 477 | -------------------------------------------------------------------------------- /sub.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2; 3 | server_name subreply.com; 4 | access_log /home/lucian/logs/sub.log.gz combined gzip; 5 | ssl_certificate /etc/letsencrypt/live/subreply.com/fullchain.pem; 6 | ssl_certificate_key /etc/letsencrypt/live/subreply.com/privkey.pem; 7 | location /static { 8 | root /home/lucian/subreply; 9 | expires 365d; 10 | autoindex off; 11 | } 12 | location /.well-known/acme-challenge { 13 | root /home/lucian; 14 | } 15 | location / { 16 | proxy_pass http://unix:/home/lucian/subreply/sub.socket; 17 | proxy_redirect off; 18 | proxy_set_header Host $host; 19 | proxy_set_header X-Real-IP $remote_addr; 20 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 21 | proxy_set_header X-Forwarded-Proto $scheme; 22 | } 23 | } 24 | 25 | server { 26 | listen 80; 27 | server_name subreply.com; 28 | return 301 https://subreply.com$request_uri; 29 | } 30 | 31 | # sudo cp sub.conf /etc/nginx/conf.d/sub.conf 32 | # sudo systemctl restart nginx 33 | -------------------------------------------------------------------------------- /sub.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Subreply service 3 | After=network.target nginx.service postgresql.service pgbouncer.service 4 | 5 | [Service] 6 | User=lucian 7 | Group=lucian 8 | PIDFile=/home/lucian/subreply/sub.pid 9 | RuntimeDirectory=gunicorn 10 | WorkingDirectory=/home/lucian/subreply 11 | ExecStart=/home/lucian/subreply/venv/bin/gunicorn router:app -c gunicorn.conf.py 12 | ExecReload=/bin/kill -s HUP $MAINPID 13 | ExecStop=/bin/kill -s TERM $MAINPID 14 | PrivateTmp=true 15 | 16 | [Install] 17 | WantedBy=default.target 18 | 19 | # sudo cp sub.service /etc/systemd/system/sub.service 20 | # sudo systemctl daemon-reload 21 | # sudo systemctl restart sub.service 22 | # start on boot 23 | # sudo systemctl enable sub.service 24 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include "meta.html" %} 5 | {% block meta %}{% endblock %} 6 | 7 | 8 |
9 |
10 | 13 |
14 | {% block main %}{% endblock %} 15 |
16 |
17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /templates/common/entry.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | {{ entry.created_by.full_name | emojize }} 5 | 6 | {{ entry.content | parser | emojize | safe }} 7 |
8 |
9 | 10 | {% if entry.edited_at %}···{% endif %} 11 | {{ entry.created_at | shortdate }} 12 | 13 | 14 | {% if not entry.replies %} 15 | reply{% elif entry.replies == 1 %}1 reply{% else %}{{ entry.replies }} replies 16 | {% endif %} 17 | {% if not entry.parent %}¬{% endif %} 18 | 19 | {% if user %} 20 | {% if entry.created_by_id == user.id %} 21 | {% if not entry.replies %} 22 | edit 23 | {% endif %} 24 | {% else %} 25 | {% if entry.id in user.saves %} 26 | unsave 27 | {% else %} 28 | save 29 | {% endif %} 30 | {% endif %} 31 | {% if user.id in [entry.created_by_id, entry.to_user_id] and not entry.replies %} 32 | 33 | delete 34 | 35 | {% endif %} 36 | {% endif %} 37 |
38 |
39 | -------------------------------------------------------------------------------- /templates/common/inbox.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | {{ entry.created_by.full_name | emojize }} 5 | 6 | {{ entry.content | parser | emojize | safe }} 7 |
8 |
9 | {{ entry.created_at | shortdate }} 10 | message 11 | {% if not entry.seen_at %}un{% endif %}read 12 |
13 |
14 | -------------------------------------------------------------------------------- /templates/common/inline.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | {{ entry.created_by.full_name | emojize }} 5 | 6 | {{ entry.content | parser | emojize | safe }} 7 |
8 |
9 | -------------------------------------------------------------------------------- /templates/common/message.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | {{ entry.created_by.full_name | emojize }} 5 | 6 | {{ entry.content | parser | emojize | safe }} 7 |
8 | {% if entry.created_by == user %} 9 |
10 | {{ entry.created_at | shortdate }} 11 | {% if not entry.seen_at %}un{% endif %}read 12 | 13 | unsend 14 | 15 |
16 | {% endif %} 17 |
18 | -------------------------------------------------------------------------------- /templates/common/nothing.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if view == 'member' and member.is_approved %} 4 | Start a thread or leave a reply to keep your account. 5 | {% else %} 6 | Uh, nothing here yet. 7 | {% endif %} 8 |
9 |
10 | -------------------------------------------------------------------------------- /templates/common/replies.html: -------------------------------------------------------------------------------- 1 | {% for entry in [entry.parent] %} 2 | {% include "common/entry.html" %} 3 | {% endfor %} 4 | 5 |
6 | {% include "common/entry.html" %} 7 |
8 | -------------------------------------------------------------------------------- /templates/common/status.html: -------------------------------------------------------------------------------- 1 | {% if member.links %} 2 |
3 |
4 | Also on {{ member.links | enumerize | safe }}. 5 |
6 |
7 | {% endif %} 8 | 9 | {% if user.id == 1 and not member.is_approved %} 10 |
11 |
12 | {{ member.email }} 13 |
14 |
15 | {% endif %} 16 | -------------------------------------------------------------------------------- /templates/common/stream.html: -------------------------------------------------------------------------------- 1 | {% if entry.parent %} 2 | {% set hidden = prev and prev.parent == entry.parent %} 3 | 4 | {% if not hidden %} 5 | {% for entry in [entry.parent] %} 6 | {% include "common/inline.html" %} 7 | {% endfor %} 8 | {% endif %} 9 | 10 |
11 | {% include "common/entry.html" %} 12 |
13 | {% else %} 14 | {% include "common/entry.html" %} 15 | 16 | {% set kids = entry.kids.all()[:8] | reverse | list %} 17 | 18 | {% if kids %} 19 |
20 | {% for entry in kids %} 21 | {% if loop.revindex == 1 %} 22 | {% include "common/entry.html" %} 23 | {% else %} 24 | {% include "common/inline.html" %} 25 | {% endif %} 26 | {% endfor %} 27 |
28 | {% endif %} 29 | {% endif %} 30 | -------------------------------------------------------------------------------- /templates/common/threads.html: -------------------------------------------------------------------------------- 1 | {% include "common/entry.html" %} 2 | 3 | {% set kids = entry.kids.all() %} 4 | 5 | {% if kids %} 6 |
7 | {% for entry in kids %} 8 | {% include "common/entry.html" %} 9 | {% endfor %} 10 |
11 | {% endif %} 12 | -------------------------------------------------------------------------------- /templates/common/user.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if view in ['following', 'followers'] %} 4 | {{ entry.created_at | shortdate }} 5 | {% else %} 6 | {{ member.created_at | shortdate }} 7 | {% endif %} 8 | 9 | {{ member.full_name | emojize }} 10 | 11 | @{{ member }} 12 | {% if user %} 13 | 14 | {% if not member.is_approved and user.id == 1 %} 15 | approve 16 | {% elif member.id == user.id %} 17 | logout 18 | {% elif member.id in user.follows %} 19 | unfollow 20 | {% else %} 21 | follow 22 | {% endif %} 23 | {% endif %} 24 |
25 | {% if member.description %} 26 |
27 | {{ member.description | parser | emojize | safe }} 28 |
29 | {% endif %} 30 | {% if member.website or member.birthday or member.location %} 31 |
32 | {% if member.website %} 33 | {{ member.website | parser | safe }} 34 | {% endif %} 35 | {% if member.birthday %} 36 | ~ {{ member.birthday | age }}y old 37 | {% endif %} 38 | {% if member.location %} 39 | from 40 | 41 | {{ member.location | city }} 42 | 43 | {% endif %} 44 |
45 | {% endif %} 46 |
47 | -------------------------------------------------------------------------------- /templates/input/edit.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 | {% for name, error in errors.items() %} 9 |
{{ error | safe }}
10 | {% endfor %} 11 |
12 |
13 | -------------------------------------------------------------------------------- /templates/input/message.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if blocked %} 4 |
5 | Please wait for {{ user.first_name }} to respond 6 |
7 | {% elif not user.is_approved %} 8 |
9 | Improve your profile to get approved 10 |
11 | {% else %} 12 | 17 | {% endif %} 18 | {% for name, error in errors.items() %} 19 |
{{ error | safe }}
20 | {% endfor %} 21 |
22 |
23 | -------------------------------------------------------------------------------- /templates/input/query.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 | 10 | {% for name, error in errors.items() %} 11 |
{{ error | safe }}
12 | {% endfor %} 13 |
14 |
15 | -------------------------------------------------------------------------------- /templates/input/reply.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if entry.created_by == user %} 4 |
5 | {% if entry.replies %} 6 | Reply back to them 7 | {% else %} 8 | No reply yet 9 | {% endif %} 10 |
11 | {% elif duplicate %} 12 |
13 | {% if user %} 14 | Share reply #{{ entry.id }} anywhere 15 | {% else %} 16 | Login or register your account to reply 17 | {% endif %} 18 |
19 | {% elif not user.is_approved %} 20 |
21 | Improve your profile to get approved 22 |
23 | {% else %} 24 | 29 | {% endif %} 30 | {% for name, error in errors.items() %} 31 |
{{ error | safe }}
32 | {% endfor %} 33 |
34 |
35 | -------------------------------------------------------------------------------- /templates/input/thread.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if not user %} 4 |
5 | Login or register your account to share 6 |
7 | {% elif not user.is_approved %} 8 |
9 | Improve your profile to get approved 10 |
11 | {% else %} 12 | 17 | {% endif %} 18 | {% for name, error in errors.items() %} 19 |
{{ error | safe }}
20 | {% endfor %} 21 |
22 |
23 | -------------------------------------------------------------------------------- /templates/meta.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% if view == 'member' %} 6 | {{ member.full_name | emojize }} @{{ member }} 7 | 8 | {% if member.emoji %} 9 | 10 | {% endif %} 11 | 12 | 13 | 14 | 15 | {% if member.last_name %} 16 | 17 | {% endif %} 18 | {% elif view == 'reply' %} 19 | {{ entry.content | emojize | shorten(60) }} 20 | 21 | {% if entry.created_by.emoji %} 22 | 23 | {% endif %} 24 | 25 | 26 | 27 | 28 | {% if entry.edited_at %} 29 | 30 | {% endif %} 31 | {% if entry.hashtag %} 32 | 33 | {% endif %} 34 | {% else %} 35 | {{ brand }} / {{ view | capitalize }} 36 | 37 | 38 | 39 | 40 | {% endif %} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% if content and view in ['feed', 'reply', 'edit'] %} 53 | 59 | {% endif %} 60 | -------------------------------------------------------------------------------- /templates/pages/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | About 6 | Emoji 7 | Privacy 8 | Terms 9 |
10 | 11 |
12 |
13 |

14 | Welcome to an online platform designed to encourage meaningful connection and engagement. It eliminates distractions and clutter, allowing users to focus on meaningful conversations and content sharing. 15 |

16 |

17 | {{ sub.full_name | emojize }} aims to reduce the impact that social apps have on your time, relationships and self-esteem, while enhancing the positive aspects of connecting with others online. The overall goal is to foster a more meaningful and intentional digital experience that encourages communication and connection. 18 |

19 |

20 | {{ luc.full_name | emojize }} created Subreply for people who enjoy reading and writing, who value creativity and originality over visual, who want to express themselves freely without censorship or filters, and who want to connect with like-mined people from around the world. 21 |

22 |
23 | 24 |

Web app install

25 | 45 | 46 |

Tech stack and donations

47 |
    48 |
  1. High frequency Vultr VPS running on Ubuntu 24.04 LTS
  2. 49 |
  3. Coded in Python 3.12 using Falcon and Django ORM
  4. 50 |
  5. Speed up by Gunicorn in front of Nginx, PgBouncer in front of PostgreSQL
  6. 51 |
  7. Icons provided by Heroicons, Route 159 font by Sora Sagano
  8. 52 |
  9. Donate with PayPal to keep it ad-free and running indefinitely
  10. 53 |
54 | 55 |

Features and limitations

56 |
    57 |
  1. Reverse-chronological order for all feeds
  2. 58 |
  3. Unique name handles like {{ emo.full_name | emojize }}
  4. 59 |
  5. Unique conversation view similar to a chat interface
  6. 60 |
  7. Light and dark themes based on OS preference
  8. 61 |
  9. Supports links, mentions, hashrefs and hashtags
  10. 62 |
  11. Limited to ASCII characters and emojis
  12. 63 |
  13. Limited to 640 characters per reply
  14. 64 |
  15. Limited to one link, mention, hashref or hashtag per reply
  16. 65 |
  17. No support for paragraphs, keeps conversation tidy
  18. 66 |
  19. No popularity counters or instant gratification
  20. 67 |
68 | 69 |

Keeping things private

70 |
    71 |
  1. Edit or delete your replies before they are replied to
  2. 72 |
  3. Delete received replies before they are replied to
  4. 73 |
  5. No redistribution of any data, private or public based on GDPR
  6. 74 |
  7. Straightforward and complete account deletion
  8. 75 |
  9. Data export in a human readable format
  10. 76 |
77 |
78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /templates/pages/account.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | Profile 6 | Details 7 | Account 8 |
9 | 10 |
11 |
12 |
13 |
14 | 15 | 17 |
18 |
19 | 20 | 22 |
23 | {% if 'password' in change_errors %} 24 |
{{ change_errors['password'] }}
25 | {% endif %} 26 |
27 |
28 | 29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 | 39 | {% if 'username' in delete_errors %} 40 |
{{ delete_errors['username'] }}
41 | {% endif %} 42 |
43 |
44 | 45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 | 53 | 55 | {% if 'confirm' in delete_errors %} 56 |
{{ delete_errors['confirm'] }}
57 | {% endif %} 58 |
59 |
60 | 61 |
62 |
63 |
64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /templates/pages/details.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | Profile 6 | Details 7 | Account 8 |
9 | 10 |
11 |
12 |
13 |
14 | 15 | 17 |
18 |
19 | 20 | 22 |
23 | {% if 'github' in errors %} 24 |
{{ errors['github'] }}
25 | {% endif %} 26 | {% if 'instagram' in errors %} 27 |
{{ errors['instagram'] }}
28 | {% endif %} 29 |
30 |
31 |
32 | 33 | 35 |
36 |
37 | 38 | 40 |
41 | {% if 'linkedin' in errors %} 42 |
{{ errors['linkedin'] }}
43 | {% endif %} 44 | {% if 'reddit' in errors %} 45 |
{{ errors['reddit'] }}
46 | {% endif %} 47 |
48 |
49 |
50 |
51 | 52 | 54 |
55 |
56 | 57 | 59 |
60 |
61 |
62 | 63 | 65 |
66 | {% if 'phone' in errors %} 67 |
{{ errors['phone'] }}
68 | {% endif %} 69 | {% if 'paypal' in errors %} 70 |
{{ errors['paypal'] }}
71 | {% endif %} 72 |
73 |
74 |
75 | 76 | 78 |
79 |
80 | 81 | 83 |
84 | {% if 'spotify' in errors %} 85 |
{{ errors['spotify'] }}
86 | {% endif %} 87 | {% if 'x' in errors %} 88 |
{{ errors['x'] }}
89 | {% endif %} 90 |
91 |
92 | 93 |
94 |
95 |
96 | {% endblock %} 97 | -------------------------------------------------------------------------------- /templates/pages/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | {% for entry in ancestors %} 6 | {% include "common/entry.html" %} 7 | {% endfor %} 8 | {% include "common/entry.html" %} 9 |
10 | 11 | {% include "input/edit.html" %} 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /templates/pages/emoji.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | About 6 | Emoji 7 | Privacy 8 | Terms 9 |
10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% for row in rows %} 20 | 21 | {% for shortcode in row %} 22 | 23 | 24 | {% endfor %} 25 | 26 | {% endfor %} 27 |
:)Shortcode;)Shortcode
{{ shortcode | emojize }}{{ shortcode }}
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /templates/pages/loader.html: -------------------------------------------------------------------------------- 1 | {% for entry in entries %} 2 | {% if view in ['arrivals', 'people'] %} 3 | {% set member = entry %} 4 | {% include "common/user.html" %} 5 | {% elif view == 'following' %} 6 | {% set member = entry.to_user %} 7 | {% include "common/user.html" %} 8 | {% elif view == 'followers' %} 9 | {% set member = entry.created_by %} 10 | {% include "common/user.html" %} 11 | {% else %} 12 | {% with prev=loop.previtem %} 13 | {% include "common/stream.html" %} 14 | {% endwith %} 15 | {% endif %} 16 | {% else %} 17 | {% include "common/nothing.html" %} 18 | {% endfor %} 19 | 20 | {% if not limit %} {% set limit = 16 %} {% endif %} 21 | 22 | {% if entries | length == limit %} 23 |
24 |
25 | Load more 26 | 27 | About 28 |
29 |
30 | {% endif %} 31 | -------------------------------------------------------------------------------- /templates/pages/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | Login 6 | Recover 7 |
8 | 9 |
10 |
11 |
12 |
13 | 14 | 16 |
17 |
18 | 19 | 21 |
22 | {% if 'username' in errors %} 23 |
{{ errors['username'] }}
24 | {% endif %} 25 | {% if 'password' in errors %} 26 |
{{ errors['password'] }}
27 | {% endif %} 28 |
29 |
30 | 31 |
32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /templates/pages/privacy.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | About 6 | Emoji 7 | Privacy 8 | Terms 9 |
10 | 11 |
12 |

Read this page to learn about personal information that Subreply collects and how it may be used.

13 | 14 |

Links to third party web sites

15 | 16 |

Our service may contain links to other websites and software. We are not responsible for the privacy practices or the content of these websites or software. Please visit the privacy policies of these third party sites in order to understand their privacy and information collection practices.

17 | 18 |

Disclosures required by law

19 | 20 |

We reserve the right to disclose your personally identifiable information when we believe in good faith that an applicable law, regulation, or legal process requires it, or when we believe disclosure is necessary to protect or enforce our rights or the rights of another member.

21 | 22 |

Cookies

23 | 24 |

Cookies are small text files stored by your browser on your computer when you visit a website. We use cookies to improve our website and make it easier to use. Cookies permit us to recognize you and avoid repetitive requests for the same information. Most browsers will accept cookies until you change your browser settings to refuse them. You may change your browser's settings to refuse our cookies.

25 | 26 |

Your privacy

27 | 28 |

Respecting members' privacy is important to Subreply. Here's a small list of few things you should know before signing up:

29 | 46 |
47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /templates/pages/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | Profile 6 | Details 7 | Account 8 |
9 | 10 |
11 |
12 |
13 |
14 | 15 | 17 |
18 |
19 | 20 | 22 |
23 | {% if 'username' in errors %} 24 |
{{ errors['username'] }}
25 | {% endif %} 26 | {% if 'email' in errors %} 27 |
{{ errors['email'] }}
28 | {% endif %} 29 |
30 |
31 |
32 | 33 | 35 |
36 |
37 | 38 | 40 |
41 | {% if 'first_name' in errors %} 42 |
{{ errors['first_name'] }}
43 | {% endif %} 44 | {% if 'last_name' in errors %} 45 |
{{ errors['last_name'] }}
46 | {% endif %} 47 | {% if 'full_name' in errors %} 48 |
{{ errors['full_name'] }}
49 | {% endif %} 50 |
51 |
52 |
53 |
54 | 55 | 58 |
59 |
60 | 61 | 64 |
65 |
66 |
67 | 68 | 71 |
72 | {% if 'emoji' in errors %} 73 |
{{ errors['emoji'] }}
74 | {% endif %} 75 | {% if 'birthday' in errors %} 76 |
{{ errors['birthday'] }}
77 | {% endif %} 78 | {% if 'location' in errors %} 79 |
{{ errors['location'] }}
80 | {% endif %} 81 |
82 |
83 | 84 | 86 | {% if 'description' in errors %} 87 |
{{ errors['description'] }}
88 | {% endif %} 89 |
90 |
91 | 92 | 95 | {% if 'website' in errors %} 96 |
{{ errors['website'] }}
97 | {% endif %} 98 |
99 |
100 | 101 |
102 |
103 |
104 | {% endblock %} 105 | -------------------------------------------------------------------------------- /templates/pages/recover.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | Login 6 | Recover 7 |
8 | 9 |
10 |
11 |
12 | 13 | 15 | {% if 'email' in errors %} 16 |
{{ errors['email'] }}
17 | {% endif %} 18 |
19 |
20 | 21 |
22 |
23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /templates/pages/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | Register 6 |
7 | 8 |
9 |
10 |
11 |
12 | 13 | 15 |
16 |
17 | 18 | 20 |
21 | {% if 'username' in errors %} 22 |
{{ errors['username'] }}
23 | {% endif %} 24 | {% if 'email' in errors %} 25 |
{{ errors['email'] }}
26 | {% endif %} 27 |
28 |
29 |
30 | 31 | 33 |
34 |
35 | 36 | 38 |
39 | {% if 'password' in errors %} 40 |
{{ errors['password'] }}
41 | {% endif %} 42 |
43 |
44 |
45 | 46 | 48 |
49 |
50 | 51 | 53 |
54 | {% if 'first_name' in errors %} 55 |
{{ errors['first_name'] }}
56 | {% endif %} 57 | {% if 'last_name' in errors %} 58 |
{{ errors['last_name'] }}
59 | {% endif %} 60 | {% if 'full_name' in errors %} 61 |
{{ errors['full_name'] }}
62 | {% endif %} 63 |
64 |
65 |
66 |
67 | 68 | 71 |
72 |
73 | 74 | 77 |
78 |
79 |
80 | 81 | 84 |
85 | {% if 'emoji' in errors %} 86 |
{{ errors['emoji'] }}
87 | {% endif %} 88 | {% if 'birthday' in errors %} 89 |
{{ errors['birthday'] }}
90 | {% endif %} 91 | {% if 'location' in errors %} 92 |
{{ errors['location'] }}
93 | {% endif %} 94 |
95 |
96 | You agree with Privacy and Terms. 97 | 98 |
99 |
100 |
101 | {% endblock %} 102 | -------------------------------------------------------------------------------- /templates/pages/regular.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 | {% if view in ['feed'] %} 5 | {% include "input/thread.html" %} 6 | {% elif view == 'message' %} 7 | {% include "input/message.html" %} 8 | {% elif view in ['discover', 'people'] %} 9 | {% include "input/query.html" %} 10 | {% endif %} 11 | 12 |
13 | {% if view == 'member' %} 14 | {% include "common/user.html" %} 15 | {% include "common/status.html" %} 16 | {% elif view == 'arrivals' %} 17 |
18 |
19 | Destroy all 20 |
21 |
22 | {% endif %} 23 | 24 | {% for entry in entries %} 25 | {% if view in ['arrivals', 'people'] %} 26 | {% set member = entry %} 27 | {% include "common/user.html" %} 28 | {% elif view == 'following' %} 29 | {% set member = entry.to_user %} 30 | {% include "common/user.html" %} 31 | {% elif view == 'followers' %} 32 | {% set member = entry.created_by %} 33 | {% include "common/user.html" %} 34 | {% elif view == 'inbox' %} 35 | {% include "common/inbox.html" %} 36 | {% elif view == 'message' %} 37 | {% include "common/message.html" %} 38 | {% else %} 39 | {% with prev=loop.previtem %} 40 | {% include "common/stream.html" %} 41 | {% endwith %} 42 | {% endif %} 43 | {% else %} 44 | {% include "common/nothing.html" %} 45 | {% endfor %} 46 | 47 | {% if not limit %} {% set limit = 16 %} {% endif %} 48 | 49 | {% if not q and entries | length == limit %} 50 |
51 |
52 | Load more 53 | 54 | About 55 |
56 |
57 | {% endif %} 58 |
59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /templates/pages/reply.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | {% for entry in ancestors %} 6 | {% include "common/entry.html" %} 7 | {% endfor %} 8 | 9 | {% include "common/entry.html" %} 10 |
11 | 12 | {% include "input/reply.html" %} 13 | 14 |
15 | {% for entry in entries %} 16 | {% include "common/threads.html" %} 17 | {% endfor %} 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /templates/pages/terms.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main %} 4 |
5 | About 6 | Emoji 7 | Privacy 8 | Terms 9 |
10 | 11 |
12 |

13 | By accessing the Subreply website ("Site") or using the services offered by Subreply ("Services") you agree and acknowledge to be bound by these Terms of Service ("Terms"). If you do not agree to these Terms or to our Privacy Policy, please do not access the Site or use the Services. Subreply reserves the right to change these Terms at any time. We recommend that you periodically check this Site for changes. 14 |

15 |

Prohibited Uses

16 |

17 | You may not use the Subreply site and or its services to transmit any content which: 18 |

19 | 51 |

No Warranty and Limitation of Liability

52 |

53 | Subreply PROVIDES THE SITE AND SERVICES "AS IS" AND WITHOUT ANY WARRANTY OR CONDITION, EXPRESS, IMPLIED OR STATUTORY. Subreply SPECIFICALLY DISCLAIMS ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, INFORMATION ACCURACY, INTEGRATION, INTEROPERABILITY OR QUIET ENJOYMENT. Some states do not allow the disclaimer of implied warranties, so the foregoing disclaimer may not apply to you. 54 |

55 |

56 | You understand and agree that you use the Site and Services at your own discretion and risk and that you will be solely responsible for any damages that arise from such use. UNDER NO CIRCUMSTANCES SHALL Subreply BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY KIND, OR ANY OTHER DAMAGES WHATSOEVER (HOWEVER ARISING, INCLUDING BY NEGLIGENCE), INCLUDING WITHOUT LIMITATION, DAMAGES RELATED TO USE, MISUSE, RELIANCE ON, INABILITY TO USE AND INTERRUPTION, SUSPENSION, OR TERMINATION OF THE SITE OR SERVICES, DAMAGES INCURRED THROUGH ANY LINKS PROVIDED ON THE SITE AND THE NONPERFORMANCE THEREOF AND DAMAGES RESULTING FROM LOSS OF USE, SALES, DATA, GOODWILL OR PROFITS, WHETHER OR NOT Subreply HAS BEEN ADVISED OF SUCH POSSIBILITY. YOUR ONLY RIGHT WITH RESPECT TO ANY DISSATISFACTION WITH THIS SITE OR SERVICES OR WITH Subreply SHALL BE TO TERMINATE USE OF THIS SITE AND SERVICES. Some states do not allow the exclusion of liability for incidental or consequential damages, so the above exclusions may not apply to you. 57 |

58 |

Other

59 |

60 | Subreply, in its sole discretion, may terminate your membership and remove and discard any information associated with the membership without notice if Subreply believes that you have violated or acted inconsistently with the Terms. Subreply will not be liable to you for termination of your membership to the Service. 61 |

62 |

63 | Subreply, in its sole discretion, may delete any of the images posted to the Site and remove and discard any information associated with the image without notice if Subreply believes that you have violated or acted inconsistently with the Terms. Subreply will not be liable to you for deletion of the images. 64 |

65 |

66 | Subreply reserves the right at any time and from time to time to modify or discontinue, temporarily or permanently, the Service (or any part thereof) with or without notice. You agree that Subreply shall not be liable to you or to any third party for any modification, suspension or discontinuance of the Service. 67 |

68 |

69 | Subreply Terms of Service as noted above may be updated by us from time to time without notice to you. In addition, when using particular Subreply owned or operated services, you and Subreply shall be subject to any posted guidelines or rules applicable to such services, which may be posted from time to time. 70 |

71 |
72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /templates/sidebar.html: -------------------------------------------------------------------------------- 1 | 122 | --------------------------------------------------------------------------------