This commit is contained in:
Athena Funderburg
2026-05-25 07:05:17 +00:00
commit 4b463a3432
682 changed files with 47796 additions and 0 deletions
View File
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import hashlib
from io import BytesIO
from pathlib import Path
from typing import Optional, Tuple
STORAGE_BASE = Path('storage/dp')
THUMB_SIZE = (21, 21)
MSN_SIZE = (96, 96)
1
def compute_md5(data: bytes) -> str:
return hashlib.md5(data).hexdigest()[:16]
def get_img_type(data: bytes) -> Optional[str]:
if data[:6] in (b'GIF87a', b'GIF89a'):
return 'gif'
if data[:2] == b'\xff\xd8':
return 'jpeg'
if data[:8] == b'\x89PNG\r\n\x1a\n':
return 'png'
return None
def get_avatar_dir(uuid: str) -> Path:
return STORAGE_BASE / uuid
def store_avatar_file(uuid: str, data: bytes, ext: str) -> str:
from PIL import Image
md5 = compute_md5(data)
avatar_dir = get_avatar_dir(uuid)
avatar_dir.mkdir(parents=True, exist_ok=True)
full_path = avatar_dir / f'{md5}.{ext}'
thumb_path = avatar_dir / f'{md5}_thumb.png'
msn_path = avatar_dir / f'{md5}_msn.png'
if not full_path.exists():
full_path.write_bytes(data)
if not thumb_path.exists() or not msn_path.exists():
try:
img = Image.open(BytesIO(data))
if not thumb_path.exists():
img.resize(THUMB_SIZE).save(str(thumb_path))
if not msn_path.exists():
img.resize(MSN_SIZE).save(str(msn_path))
except Exception:
pass
return md5
def find_avatar_file(
uuid: str,
md5: str,
*,
small: bool = False,
msn: bool = False,
) -> Optional[Path]:
avatar_dir = get_avatar_dir(uuid)
if not avatar_dir.is_dir():
return None
if small:
thumb = avatar_dir / f'{md5}_thumb.png'
if thumb.is_file():
return thumb
elif msn:
msn_file = avatar_dir / f'{md5}_msn.png'
if msn_file.is_file():
return msn_file
for p in avatar_dir.iterdir():
if p.is_file() and p.stem == md5:
return p
return None
def get_avatar_data(
uuid: str,
md5: Optional[str],
*,
small: bool = False,
msn: bool = False,
) -> Optional[Tuple[bytes, str]]:
if not md5:
return None
path = find_avatar_file(uuid, md5, small=small, msn=msn)
if path is None:
return None
try:
data = path.read_bytes()
except OSError:
return None
if not data:
return None
ext = path.suffix.lstrip('.').lower() or 'png'
mime_map = {'jpg': 'jpeg', 'jpeg': 'jpeg', 'png': 'png', 'gif': 'gif'}
content_type = 'image/' + mime_map.get(ext, ext)
return data, content_type
+219
View File
@@ -0,0 +1,219 @@
import hashlib, secrets, base64, binascii
from typing import Dict, Optional, Tuple, Any, List, Type
from argon2 import PasswordHasher as Argon2Impl
from argon2.exceptions import VerifyMismatchError
HASHERS: Dict[str, Type['Hasher']] = {}
class Hasher:
algorithm = 'unknown'
separator = '$'
@classmethod
def encode(cls, password: str, *stuff: Any, salt: Optional[str] = None, identifier: Optional[str] = None) -> str:
assert password is not None
if salt is None:
salt = gen_salt()
assert cls.separator not in salt
if identifier is None:
(hash_bytes, other_stuff) = cls._encode_impl(password, *stuff, salt=salt)
else:
(hash_bytes, other_stuff) = cls._encode_impl(password, *stuff, salt=salt, identifier=identifier)
hash = base64.b64encode(hash_bytes).decode('ascii').strip()
return cls.separator.join([cls.algorithm] + other_stuff + [salt, hash])
@classmethod
def _encode_impl(cls, password: str, *stuff: Any, salt: str, identifier: Optional[str] = None) -> Tuple[bytes, List[str]]:
raise NotImplementedError('Hasher._encode_impl')
@classmethod
def extract_salt(cls, encoded: str) -> Optional[str]:
return encoded.split(cls.separator)[-2]
@classmethod
def extract_hash(cls, encoded: str) -> bytes:
hash = encoded.split(cls.separator)[-1]
return base64.b64decode(hash)
@classmethod
def verify(cls, password: str, encoded: str) -> bool:
try:
(algorithm, *stuff, salt, _) = encoded.split(cls.separator)
except ValueError:
return False
try:
hasher = HASHERS[algorithm]
except KeyError:
return False
assert algorithm == hasher.algorithm
encoded_2 = hasher.encode(password, *stuff, salt=salt)
return secrets.compare_digest(encoded, encoded_2)
class Argon2PasswordHasher(Hasher):
algorithm = 'argon2id'
impl = Argon2Impl()
@classmethod
def _encode_impl(cls, password: str, *stuff: Any, salt: str) -> Tuple[bytes, List[str]]:
assert not stuff
hash_str = cls.impl.hash(password + salt)
hash_bytes = hash_str.encode('utf-8')
return hash_bytes, []
@classmethod
def verify(cls, password: str, encoded: str) -> bool | str:
try:
parts = encoded.split(cls.separator)
if len(parts) != 3 or parts[0] != cls.algorithm:
raise ValueError("Not an argon2 hash")
_, salt, b64hash = parts
hash_str = base64.b64decode(b64hash).decode('utf-8')
cls.impl.verify(hash_str, password + salt)
return True
except (ValueError, VerifyMismatchError, binascii.Error):
# PBKDF2 fallback, migrate hash to Argon2
try:
parts = encoded.split(cls.separator)
alg = parts[0]
if alg != PBKDF2PasswordHasher.algorithm:
return False
_, *stuff, salt, _ = parts
if PBKDF2PasswordHasher.verify(password, encoded):
return 'MIGRATEMEPLSTHX'
except Exception:
return False
return False
class PBKDF2PasswordHasher(Hasher):
algorithm = 'pbkdf2_sha256'
iterations = 24000
@classmethod
def _encode_impl(cls, password: str, *stuff: Any, salt: str) -> Tuple[bytes, List[str]]:
assert len(stuff) <= 1
iterations: Optional[int] = (stuff[0] if stuff else None)
if iterations is None:
iterations = cls.iterations
iterations = int(iterations)
hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), iterations, None)
return hash, [str(iterations)]
class MD5PasswordHasher(Hasher):
algorithm = 'md5'
digest = hashlib.md5
@classmethod
def _encode_impl(cls, password: str, *stuff: Any, salt: str, identifier: Optional[str] = None) -> Tuple[bytes, List[str]]:
assert not stuff
assert salt is not None
md5 = hashlib.md5()
if identifier is None:
md5.update((salt + password).encode('utf-8'))
else:
md5.update((salt + password + identifier).encode('utf-8'))
return md5.digest(), []
# maybe we could find a better way to do this? it works for now though
@classmethod
def encode_aim5(cls, password: str, *, salt: str) -> str:
md5_pw_raw = hashlib.md5(password.encode('utf-8')).digest()
md5 = hashlib.md5()
md5.update(salt.encode('utf-8'))
md5.update(md5_pw_raw)
md5.update(b'AOL Instant Messenger (SM)')
hash_b64 = base64.b64encode(md5.digest()).decode('ascii').strip()
return cls.separator.join([cls.algorithm, salt, hash_b64])
@classmethod
def verify_hash(cls, hash_1: str, encoded: str) -> bool:
try:
(_, _, hash) = encoded.split(cls.separator)
except ValueError:
return False
hash = binascii.hexlify(base64.b64decode(hash)).decode('ascii')
return secrets.compare_digest(hash_1, hash)
class MD5CryptPasswordHasher(Hasher):
algorithm = 'md5crypt'
@classmethod
def _encode_impl(cls, password: str, *stuff: Any, salt: str) -> Tuple[bytes, List[str]]:
from util.unixmd5crypt import unix_md5_crypt
assert not stuff
return unix_md5_crypt(password, salt), []
@classmethod
def encode(cls, password: str, *stuff: Any, salt: Optional[str] = None) -> str:
assert not stuff
assert salt is not None
if salt[:3] == '$1$':
salt = salt[3:]
salt = salt[:8]
return super().encode(password, salt=salt)
class SHA1PasswordHasher(Hasher):
algorithm = 'sha1'
encoding = 'utf-16le'
@classmethod
def encode(cls, password: str, *stuff: Any) -> str:
assert password is not None
(hash_bytes, other_stuff) = cls._encode_impl(password, *stuff)
hash = (base64.b64encode(hash_bytes)
.decode('ascii')
.strip())
return cls.separator.join([cls.algorithm] + other_stuff + [hash])
@classmethod
def _encode_impl(cls, password: str, *stuff: Any) -> Tuple[bytes, List[str]]:
assert not stuff
sha1 = hashlib.sha1()
sha1.update(password.encode(cls.encoding))
return sha1.digest(), []
@classmethod
def extract_salt(cls, encoded: str) -> Optional[str]:
raise NotImplementedError('SHA1PasswordHasher.extract_salt')
@classmethod
def verify(cls, password: str, encoded: str) -> bool:
try:
(algorithm, *stuff, _) = encoded.split(cls.separator)
except ValueError:
return False
try:
hasher = HASHERS[algorithm]
except KeyError:
return False
assert algorithm == hasher.algorithm
encoded_2 = hasher.encode(password, *stuff)
return secrets.compare_digest(encoded, encoded_2)
HASHERS[Argon2PasswordHasher.algorithm] = Argon2PasswordHasher
HASHERS[PBKDF2PasswordHasher.algorithm] = PBKDF2PasswordHasher
HASHERS[MD5PasswordHasher.algorithm] = MD5PasswordHasher
HASHERS[MD5CryptPasswordHasher.algorithm] = MD5CryptPasswordHasher
HASHERS[SHA1PasswordHasher.algorithm] = SHA1PasswordHasher
def gen_salt(length: int = 15) -> str:
return secrets.token_hex(length)[:length]
hasher = Argon2PasswordHasher
hasher_pbkdf2 = PBKDF2PasswordHasher
hasher_md5 = MD5PasswordHasher
hasher_md5crypt = MD5CryptPasswordHasher
hasher_sha1 = SHA1PasswordHasher
+24
View File
@@ -0,0 +1,24 @@
from typing import Any
import json
from sqlalchemy import types
from sqlalchemy.dialects import postgresql
class JSONType(types.TypeDecorator): # type: ignore
impl = types.TEXT
def load_dialect_impl(self, dialect: Any) -> Any:
if dialect.name == 'postgresql':
t = postgresql.JSON()
else:
t = types.TEXT()
return dialect.type_descriptor(t)
def process_bind_param(self, value: Any, dialect: Any) -> Any:
if value is None or dialect.name == 'postgresql':
return value
return json.dumps(value)
def process_result_value(self, value: Any, dialect: Any) -> Any:
if value is None or dialect.name == 'postgresql':
return value
return json.loads(value)
+240
View File
@@ -0,0 +1,240 @@
from typing import FrozenSet, Any, Iterable, Optional, TypeVar, List, Dict, Tuple, Generic, TYPE_CHECKING
from abc import ABCMeta, abstractmethod
import asyncio, functools, itertools, traceback, ssl, jinja2, settings, sys, platform
from datetime import datetime
from uuid import uuid4
from aiohttp import web
from pathlib import Path
EMPTY_SET: FrozenSet[Any] = frozenset()
if TYPE_CHECKING:
VoidTaskType = asyncio.Task[None]
else:
VoidTaskType = Any
def gen_uuid() -> str:
return str(uuid4())
T = TypeVar('T')
def first_in_iterable(iterable: Iterable[T]) -> Optional[T]:
for x in iterable: return x
return None
def last_in_iterable(iterable: Iterable[T]) -> Optional[T]:
last = None
for x in iterable:
last = x
return last
def generate_random_string(chars: int) -> bytes:
import random, string
result = ''.join(random.choice(string.ascii_letters) for i in range(chars))
return result.encode()
class Runner(metaclass = ABCMeta):
__slots__ = ('host', 'port', 'ssl_context', 'ssl_only', 'service')
host: str
port: int
ssl_context: Optional[ssl.SSLContext]
ssl_only: bool
service: str
def __init__(self, host: str, port: int, *, ssl_context: Optional[ssl.SSLContext] = None, ssl_only: bool = False, service: str) -> None:
self.host = host
self.port = port
self.ssl_context = ssl_context
self.ssl_only = ssl_only
self.service = service
@abstractmethod
def create_servers(self, loop: asyncio.AbstractEventLoop) -> List[Any]: pass
def teardown(self, loop: asyncio.AbstractEventLoop) -> Any:
pass
class ProtocolRunner(Runner):
__slots__ = ('_protocol')
_protocol: Any
def __init__(
self, host: str, port: int, protocol: Any, *, args: Optional[List[Any]] = None,
ssl_context: Optional[ssl.SSLContext] = None, ssl_only: bool = False, service: str
) -> None:
super().__init__(host, port, ssl_context = ssl_context, ssl_only = ssl_only, service = service)
if args:
protocol = functools.partial(protocol, *args)
self._protocol = protocol
def create_servers(self, loop: asyncio.AbstractEventLoop) -> List[Any]:
return [loop.create_server(self._protocol, self.host, self.port, ssl = self.ssl_context)]
class AIOHTTPRunner(Runner):
__slots__ = ('app', '_handler')
app: Any
_handler: Optional[Any]
def __init__(self, host: str, port: int, app: Any, *, ssl_context: Optional[ssl.SSLContext] = None, ssl_only: bool = False, service: str) -> None:
super().__init__(host, port, ssl_context = ssl_context, ssl_only = ssl_only, service = service)
self.app = app
self._handler = None
def create_servers(self, loop: asyncio.AbstractEventLoop) -> List[Any]:
assert self._handler is None
self._handler = self.app.make_handler(loop = loop)
loop.run_until_complete(self.app.startup())
ret = []
if not self.ssl_only:
ret.append(loop.create_server(self._handler, self.host, self.port, ssl = None))
if self.ssl_context is not None:
ret.append(loop.create_server(self._handler, self.host, (self.port if self.ssl_only else 443), ssl = self.ssl_context))
return ret
def teardown(self, loop: asyncio.AbstractEventLoop) -> None:
handler = self._handler
assert handler is not None
self._handler = None
loop.run_until_complete(self.app.shutdown())
loop.run_until_complete(handler.shutdown(60))
loop.run_until_complete(self.app.cleanup())
class Logger:
__slots__ = ('prefix', '_log')
prefix: str
_log: bool
def __init__(self, prefix: str, obj: object) -> None:
import settings
self.prefix = '[{}] ({:06x})'.format(prefix, hash(obj) % 0xFFFFFF)
def debug(self, *args: Any) -> None:
if settings.DEBUG:
print(self.prefix, '<Debug>', *args)
def info(self, *args: Any) -> None:
print(self.prefix, '<Info>', *args)
def error(self, exc: Exception) -> None:
trace = traceback.print_exception(type(exc), exc, exc.__traceback__)
print (self.prefix, '<Error>', trace)
def log_connect(self) -> None:
self.debug("Connected!")
def log_disconnect(self) -> None:
self.debug("Disconnected!")
def run_loop(loop: asyncio.AbstractEventLoop, runners: List[Runner]) -> None:
for runner in runners:
print("[{}] Started service on {}:{}".format(runner.service, runner.host, runner.port))
foos = itertools.chain(*(
runner.create_servers(loop) for runner in runners
))
servers = loop.run_until_complete(asyncio.gather(*foos))
try:
loop.run_forever()
except KeyboardInterrupt:
raise
finally:
for server in servers:
server.close()
loop.run_until_complete(asyncio.gather(*(
server.wait_closed() for server in servers
)))
for runner in runners:
runner.teardown(loop)
server_temp_cleanup()
loop.close()
def add_to_jinja_env(app: web.Application, prefix: str, tmpl_dir: str, *, globals: Optional[Dict[str, Any]] = None) -> None:
jinja_env = app['jinja_env']
jinja_env.loader.mapping[prefix] = jinja2.FileSystemLoader(tmpl_dir)
if globals:
jinja_env.globals.update(globals)
def arbitrary_decode(d: bytes) -> str:
if not d: return ''
return ''.join(map(chr, [b for b in d]))
def arbitrary_encode(s: str) -> bytes:
return bytes([ord(c) for c in s])
def date_format(d: Optional[datetime]) -> Optional[str]:
if d is None:
return None
d_iso = '{}{}'.format(
d.isoformat()[0:19], 'Z',
)
return d_iso
def server_temp_cleanup() -> None:
# For now, just clean up stuff in the Yahoo! HTTP file transfer storage folder
import shutil
from pathlib import Path
path = Path('storage/file')
if not path.exists():
return
for file_dir in path.iterdir():
shutil.rmtree(str(file_dir), ignore_errors = True)
def _get_avatar_path(uuid: str) -> Path:
return Path('storage/dp') / uuid[0:1] / uuid[0:2]
K = TypeVar('K')
V = TypeVar('V')
class DefaultDict(Dict[K, V]):
_default: V
def __init__(self, default: V, mapping: Dict[K, V]) -> None:
super().__init__(mapping)
self._default = default
def __getitem__(self, key: K) -> V:
v = super().__getitem__(key)
if v is None:
v = self._default
return v
class MultiDict(Generic[K, V]):
_impl: List[Tuple[K, V]]
def __init__(self, data: Optional[Iterable[Tuple[K, V]]] = None) -> None:
super().__init__()
self._impl = ([] if data is None else list(data))
def __contains__(self, key: K) -> bool:
for d in self._impl:
if d[0] == key: return True
return False
def add(self, key: K, value: V) -> None:
self._impl.append((key, value))
def get(self, key: K) -> Optional[V]:
for d in self._impl:
if d[0] == key: return d[1]
return None
def getall(self, key: K) -> Optional[Iterable[V]]:
values = [] # type: List[V]
for d in self._impl:
if d[0] == key:
values.append(d[1])
return values if values else None
def items(self) -> Iterable[Tuple[K, V]]:
return self._impl
+159
View File
@@ -0,0 +1,159 @@
# NOTICE:
#
# This script has been modified to work on Python 3 (e.g. hashlib, workarounds
# for ord() and byte strings). All credit is given where credit is due. :)
#########################################################
# md5crypt.py
#
# 0423.2000 by michal wallace http://www.sabren.com/
# based on perl's Crypt::PasswdMD5 by Luis Munoz (lem@cantv.net)
# based on /usr/src/libcrypt/crypt.c from FreeBSD 2.2.5-RELEASE
#
# MANY THANKS TO
#
# Carey Evans - http://home.clear.net.nz/pages/c.evans/
# Dennis Marti - http://users.starpower.net/marti1/
#
# For the patches that got this thing working!
#
#########################################################
"""md5crypt.py - Provides interoperable MD5-based crypt() function
SYNOPSIS
import md5crypt.py
cryptedpassword = md5crypt.md5crypt(password, salt);
DESCRIPTION
unix_md5_crypt() provides a crypt()-compatible interface to the
rather new MD5-based crypt() function found in modern operating systems.
It's based on the implementation found on FreeBSD 2.2.[56]-RELEASE and
contains the following license in it:
"THE BEER-WARE LICENSE" (Revision 42):
<phk@login.dknet.dk> wrote this file. As long as you retain this notice you
can do whatever you want with this stuff. If we meet some day, and you think
this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp
apache_md5_crypt() provides a function compatible with Apache's
.htpasswd files. This was contributed by Bryan Hart <bryan@eai.com>.
"""
from hashlib import md5
MAGIC = '$1$' # Magic string
ITOA64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
def unix_md5_crypt(pw: str, salt: str) -> bytes:
# Take care of the magic string if present
if salt[:len(MAGIC)] == MAGIC:
salt = salt[len(MAGIC):]
# salt can have up to 8 characters:
salt = salt.split('$', 1)[0]
salt = salt[:8]
ctx = md5()
ctx.update(pw.encode())
ctx.update(MAGIC.encode())
ctx.update(salt.encode())
tmp = md5()
tmp.update(pw.encode())
tmp.update(salt.encode())
tmp.update(pw.encode())
final = tmp.digest()
for pl in range(len(pw),0,-16):
if pl > 16:
ctx.update(final[:16])
else:
ctx.update(final[:pl])
# Now the 'weird' xform (??)
i = len(pw)
while i:
if i & 1:
#if ($i & 1) { $ctx->add(pack("C", 0)); }
ctx.update(b'\x00')
else:
ctx.update(pw[0].encode())
i = i >> 1
final = ctx.digest()
# The following is supposed to make
# things run slower.
# my question: WTF???
for i in range(1000):
ctx1 = md5()
if i & 1:
ctx1.update(pw.encode())
else:
ctx1.update(final[:16])
if i % 3:
ctx1.update(salt.encode())
if i % 7:
ctx1.update(pw.encode())
if i & 1:
ctx1.update(final[:16])
else:
ctx1.update(pw.encode())
final = ctx1.digest()
# Final xform
passwd = ''
passwd = passwd + to64(
(final[0] << 16)
| (final[6] << 8)
| (final[12]), 4
)
passwd = passwd + to64(
(final[1] << 16)
| (final[7] << 8)
| (final[13]), 4
)
passwd = passwd + to64(
(final[2] << 16)
| (final[8] << 8)
| (final[14]), 4
)
passwd = passwd + to64(
(final[3] << 16)
| (final[9] << 8)
| (final[15]), 4
)
passwd = passwd + to64(
(final[4] << 16)
| (final[10] << 8)
| (final[5]), 4
)
passwd = passwd + to64(final[11], 2)
return (MAGIC + salt + '$' + passwd).encode('utf-8')
def to64(v: int, n: int) -> str:
ret = ''
while (n - 1 >= 0):
n = n - 1
ret = ret + ITOA64[v & 0x3f]
v = v >> 6
return ret