Files
azul/front/ymsg/http.py
T
Athena Funderburg 4b463a3432 init
2026-05-25 07:05:17 +00:00

490 lines
17 KiB
Python

from typing import Any, Dict, Optional, Tuple
from aiohttp import web
import asyncio, shutil, time, uuid, datetime, util.misc, settings
from markupsafe import Markup
from urllib.parse import unquote, unquote_plus, quote
from pathlib import Path
from core.backend import Backend, BackendSession
from core.models import Contact, Substatus, ContactList
from util.hash import gen_salt
from util.misc import Logger
from .ymsg_ctrl import YMSGCtrlBase, _try_decode_ymsg
from .misc import YMSGService
from .pager import _encode_yahoo_id, Y_COOKIE_TEMPLATE, T_COOKIE_TEMPLATE
YAHOO_TMPL_DIR = 'front/ymsg/tmpl'
# https://github.com/ifwe/digsby/blob/f5fe00244744aa131e07f09348d10563f3d8fa99/digsby/src/yahoo/yahooutil.py#L33
FILE_STORE_PATH = '/storage/file/{filename}'
_tasks_by_token = {} # type: Dict[str, asyncio.Task[None]]
def register(app: web.Application, *, devmode: bool = False) -> None:
util.misc.add_to_jinja_env(app, 'ymsg', YAHOO_TMPL_DIR)
# HTTP auth
app.router.add_get('/config/ncclogin', handle_ncclogin)
app.router.add_get('/config/pwtoken_get', handle_gettoken)
app.router.add_get('/config/pwtoken_login', handle_login)
# Yahoo! Insider
app.router.add_get('/ycontent/', handle_insider_ycontent)
# Yahoo! Chat/Ads
app.router.add_get('/us.yimg.com/i/msgr/chat/conf-banner.html', handle_chat_banad)
app.router.add_get('/c/msg/tabs.html', handle_chat_tabad)
app.router.add_get('/etc/yahoo-tab-ad', handle_chat_tabad)
app.router.add_get('/c/msg/chat.html', handle_chat_notice)
app.router.add_get('/c/msg/alerts.html', handle_chat_alertad)
app.router.add_get('/etc/yahoo-placeholder', handle_placeholder)
app.router.add_get('/external/client_ad.php', handle_banneradredir)
app.router.add_get('/client_ad.php', handle_banneradredir)
# Yahoo!'s redirector to cookie-based services
#app.router.add_get('/config/reset_cookies', handle_cookies_redirect)
# Yahoo!'s redirect service (rd.yahoo.com)
app.router.add_get('/messenger/search/', handle_rd_yahoo)
app.router.add_get('/messenger/client/', handle_rd_yahoo)
# Yahoo HTTP file transfer fallback
app.router.add_post('/notifyft', handle_ft_http)
app.router.add_get(FILE_STORE_PATH, handle_yahoo_filedl)
# Misc stuff
app.router.add_get('/capacity', handle_capacity)
app.router.add_get('/nofriends/', handle_nofriends)
async def handle_insider_ycontent(req: web.Request) -> web.Response:
backend = req.app['backend']
yab_received = False
yab_set = False
config_xml = []
for query_xml in req.query.keys():
# Ignore any `chatroom_##########` requests for now
if query_xml in IGNORED_QUERIES or query_xml.startswith('chatroom_'): continue
if query_xml in ('ab2','addab2'):
(_, bs) = _parse_cookies(req, backend)
if bs is not None:
user = bs.user
detail = user.detail
if detail is not None:
ab2_tmpl = req.app['jinja_env'].get_template('ymsg:Yinsider/Yinsider.ab2.xml')
if query_xml == 'ab2':
if yab_received or yab_set: continue
ctcs = detail.contacts.values()
records = []
for ctc in ctcs:
records.append(_gen_yab_record(ctc))
config_xml.append(ab2_tmpl.render(epoch = round(time.time()), records = Markup('\n'.join(records))))
if query_xml == 'addab2':
edit_mode = False
if yab_set or yab_received: continue
if req.query.get('ee') == '1' and req.query.get('ow') == '1':
edit_mode = True
if edit_mode:
if req.query.get('id') is None:
continue
target_ctc = None
entry_id = str(req.query['id'])
for ctc in detail.contacts.values():
if ctc.detail.index_id == entry_id:
target_ctc = ctc
if not target_ctc:
continue
if req.query.get('pp') is not None:
if str(req.query['pp']) not in ('0','1','2'):
continue
new_first_name = req.query.get('fn')
new_last_name = req.query.get('ln')
# Yahoo! will set the email/YID as the first name when editing contact details;
# if new_first_name == email and last name isn't set, don't set first name
if new_first_name != target_ctc.head.email and new_last_name:
target_ctc.detail.first_name = new_first_name
target_ctc.detail.last_name = new_last_name
target_ctc.detail.nickname = req.query.get('nn')
target_ctc.detail.personal_email = req.query.get('e')
target_ctc.detail.home_phone = req.query.get('hp')
target_ctc.detail.work_phone = req.query.get('wp')
target_ctc.detail.mobile_phone = req.query.get('mb')
backend._mark_modified(user)
else:
continue
config_xml.append(ab2_tmpl.render(epoch = round(time.time()), records = Markup(_gen_yab_record(target_ctc))))
continue
tmpl = req.app['jinja_env'].get_template('ymsg:Yinsider/Yinsider.' + query_xml + '.xml')
config_xml.append(tmpl.render())
return render(req, 'ymsg:Yinsider/Yinsider.xml', {
'epoch': round(time.time()),
'configxml': Markup('\n'.join(config_xml)),
})
# 'intl', 'os', 'ver', 'fn', 'ln', 'yid', 'nn', 'e', 'hp', 'wp', 'mp', 'pp', 'ee',
# 'ow', and 'id' are NOT queries to retrieve config XML files;
# 'getwc' and 'getgp' are undocumented as of now
# Other queries most likely are just not implemented
IGNORED_QUERIES = {
'intl', 'os', 'ver',
'imv', 'sms', 'getimv', 'getwc', 'getgp',
'fn', 'ln', 'yid',
'nn', 'e', 'hp',
'wp', 'mb', 'pp',
'ee', 'ow', 'id',
}
def _gen_yab_record(ctc: Contact) -> str:
fname = None
lname = None
nname = None
email = None
hphone = None
wphone = None
mphone = None
if ctc.detail.first_name is not None:
fname = ' fname="{}"'.format(ctc.detail.first_name)
if ctc.detail.last_name is not None:
lname = ' lname="{}"'.format(ctc.detail.last_name)
if ctc.detail.nickname is not None:
nname = ' nname="{}"'.format(ctc.detail.nickname)
if ctc.detail.personal_email is not None:
email = ' email="{}"'.format(ctc.detail.personal_email)
if ctc.detail.home_phone is not None:
hphone = ' hphone="{}"'.format(ctc.detail.home_phone)
if ctc.detail.work_phone is not None:
wphone = ' wphone="{}"'.format(ctc.detail.work_phone)
if ctc.detail.mobile_phone is not None:
mphone = ' mphone="{}"'.format(ctc.detail.mobile_phone)
return '<record userid="{yid}"{fname}{lname}{nname}{email}{hphone}{wphone}{mphone} dbid="{contact_id}"/>'.format(
yid = ctc.head.username,
fname = fname or '', lname = lname or '', nname = nname or '',
email = email or '', hphone = hphone or '', wphone = wphone or '', mphone = mphone or '',
contact_id = ctc.detail.index_id,
)
async def handle_chat_banad(req: web.Request) -> web.Response:
return render(req, 'ymsg:placeholders/banad.html')
async def handle_chat_tabad(req: web.Request) -> web.Response:
query = req.query
return render(req, 'ymsg:placeholders/adsmall.html', {
'adtitle': 'banner ad',
'spaceid': (query.get('spaceid') or 0),
})
async def handle_chat_alertad(req: web.Request) -> web.Response:
query = req.query
return render(req, 'ymsg:placeholders/adsmall.html', {
'adtitle': 'alert ad usmsgr',
'spaceid': (query.get('spaceid') or 0),
})
async def handle_placeholder(req: web.Request) -> web.Response:
return render(req, 'ymsg:placeholders/generic.html')
async def handle_chat_notice(req: web.Request) -> web.Response:
return render(req, 'ymsg:placeholders/generic.html')
async def handle_rd_yahoo(req: web.Request) -> web.Response:
return web.HTTPFound(req.query_string.replace(' ', '+'))
async def handle_ft_http(req: web.Request) -> web.Response:
body = await req.read()
# Look for incomplete key-value field `29`
stream_loc = body.find(b'29\xC0\x80')
stream = body[(stream_loc + 4):]
# Parse the rest of the YMSG packet
raw_ymsg_data = body[:stream_loc]
# Now change the length field as fit to get the YMSG parser to gobble it up
import struct
raw_ymsg_part_pre = raw_ymsg_data[0:8]
raw_ymsg_part_post = raw_ymsg_data[10:]
raw_ymsg_data = raw_ymsg_part_pre + struct.pack('!H', len(raw_ymsg_part_post[10:])) + raw_ymsg_part_post
backend = req.app['backend']
try:
y_ft_pkt = _try_decode_ymsg(raw_ymsg_data, 0)[0]
except Exception:
return web.HTTPInternalServerError(text = '')
try:
# check version and vendorId
if y_ft_pkt[1] > 16 or y_ft_pkt[2] not in (0, 100):
return web.HTTPInternalServerError(text = '')
except Exception:
return web.HTTPInternalServerError(text = '')
if y_ft_pkt[0] is not YMSGService.FileTransfer:
return web.HTTPInternalServerError(text = '')
ymsg_data = y_ft_pkt[5]
yahoo_id_sender = util.misc.arbitrary_decode(ymsg_data.get(b'0') or b'')
(yahoo_id, bs) = _parse_cookies(req, backend)
if None in (bs,yahoo_id):
return web.HTTPInternalServerError(text = '')
assert bs is not None
yahoo_id_recipient = util.misc.arbitrary_decode(ymsg_data.get(b'5') or b'')
recipient_uuid = backend.util_get_uuid_from_username(yahoo_id_recipient)
if recipient_uuid is None:
return web.HTTPInternalServerError(text = '')
recipient_head = backend._load_user_record(recipient_uuid)
if recipient_head is None or recipient_head.status.substatus is Substatus.Offline:
return web.HTTPInternalServerError(text = '')
message = util.misc.arbitrary_decode(ymsg_data.get(b'14') or b'')
file_path_raw = ymsg_data.get(b'27') # type: Optional[bytes]
file_len = util.misc.arbitrary_decode(ymsg_data.get(b'28') or b'0')
# https://github.com/ifwe/digsby/blob/master/digsby/src/yahoo/yfiletransfer.py#L7
# Looks like the HTTP file transfer server had its own size limits (10 MB)
if file_path_raw is None or str(len(stream)) != file_len or len(stream) > (2 ** 20):
return web.HTTPInternalServerError(text = '')
file_path = util.misc.arbitrary_decode(file_path_raw)
try:
filename = Path(file_path).name
except:
return web.HTTPInternalServerError(text = '')
token = gen_salt(length = 30)
path = _get_tmp_file_storage_path(token)
path.mkdir(exist_ok = True, parents = True)
file_tmp_path = path / unquote_plus(filename)
file_tmp_path.write_bytes(stream)
upload_time = time.time()
expiry_task = req.app.loop.create_task(_store_tmp_file_until_expiry(file_tmp_path))
_tasks_by_token[token] = expiry_task
for bs_other in bs.backend._sc.iter_sessions():
if bs_other.user.uuid != recipient_uuid:
continue
bs_other.evt.ymsg_on_sent_ft_http(
yahoo_id_sender, '{}?{}'.format(FILE_STORE_PATH.format(filename = quote(file_tmp_path.name)), token),
upload_time, message,
)
bs.evt.ymsg_on_upload_file_ft(yahoo_id_recipient, message)
return web.HTTPOk(text = '')
async def _store_tmp_file_until_expiry(path: Path) -> None:
await asyncio.sleep(86400)
# When a day passes, delete the file (unless it has already been deleted by
# the downloader handler; it will cancel the according task then)
shutil.rmtree(str(path), ignore_errors = True)
async def handle_yahoo_filedl(req: web.Request) -> web.Response:
filename = req.match_info['filename']
token = None
query_keys = list(req.query.keys())
if query_keys:
token = list(req.query.keys())[0]
if token is None:
return web.HTTPNotFound(text = '')
file_storage_path = _get_tmp_file_storage_path(token)
file_path = file_storage_path / unquote(filename)
try:
file_stream = file_path.read_bytes()
except FileNotFoundError:
return web.HTTPNotFound(text = '')
# Only delete temporary file if request is specifically `GET`
if req.method == 'GET':
_tasks_by_token[token].cancel()
del _tasks_by_token[token]
shutil.rmtree(file_storage_path, ignore_errors = True)
return web.Response(status = 200, headers = {
'Content-Disposition': 'attachment; filename="{}"'.format(filename),
}, body = file_stream)
async def handle_capacity(req: web.Request) -> web.Response:
return web.Response(text="COLO_CAPACITY=1\nCS_IP_ADDRESS={}".format(settings.TARGET_IP))
async def handle_nofriends(req: web.Request) -> web.Response:
return render(req, 'ymsg:layout/nofriends.html')
async def handle_gettoken(req: web.Request) -> web.Response:
backend = req.app['backend']
params = req.rel_url.query
username = params.get('login')
encoded_pwd = params.get('passwd')
pwd = unquote(encoded_pwd) if encoded_pwd is not None else None
error_int = None
uuid_val = backend.util_get_uuid_from_username(username)
user = backend.user_service.get(uuid_val) if uuid_val else None
if not username or not encoded_pwd:
error_int = "100"
elif not user:
error_int = "1235"
elif user.suspended:
error_int = "1218"
else:
token_tpl = _login(req, username, pwd, lifetime=86400)
if token_tpl is None:
error_int = "1212"
else:
token, _ = token_tpl
if error_int:
return web.Response(text=error_int)
else:
return web.Response(text="0\r\nymsgr={}\r\npartnerid=0".format(token))
async def handle_login(req: web.Request) -> web.Response:
backend = req.app['backend']
params = req.rel_url.query
token = params.get('token')
tpl = backend.login_auth_service.get_token('ymsg/cookie', token)
crumb = util.misc.generate_random_string(chars=32)
crumbstr = crumb.decode('utf-8')
y_cookie = Y_COOKIE_TEMPLATE.format(encodedname=_encode_yahoo_id(token))
t_cookie = T_COOKIE_TEMPLATE.format(token=token)
import uuid
ssl_cookie = str(uuid.uuid4())
b_cookie = str(uuid.uuid4())
if tpl is not None:
resp = render(req, 'ymsg:auth/ok.tmpl', {
'crumb': crumbstr,
't_cookie': t_cookie,
'y_cookie': y_cookie,
'ssl_cookie': ssl_cookie,
'b_cookie': b_cookie
})
resp.set_cookie('T', t_cookie, path='/')
resp.set_cookie('Y', y_cookie, path='/',)
resp.set_cookie('SSL', ssl_cookie, path='/')
resp.set_cookie('B', b_cookie, path='/')
return resp
else:
return web.Response(text="100")
async def handle_ncclogin(req: web.Request) -> web.Response:
backend = req.app['backend']
params = req.rel_url.query
username = params.get('login')
encoded_pwd = params.get('passwd')
pwd = unquote(encoded_pwd)
uuid = backend.util_get_uuid_from_username(username)
user = backend.user_service.get(uuid) if uuid else None
detail = backend._load_detail(user)
if settings.DEBUG:
print(f"Username: {username}")
print(f"Encoded Password: {encoded_pwd}")
print(f"Decoded Password: {pwd}")
print(f"UUID: {uuid}")
print(f"User: {user}")
contacts = detail.contacts
cs = list(contacts.values())
cs_fl = [c for c in cs if c.lists & ContactList.FL and not c.lists & ContactList.BL]
contact_group_list = []
for grp in detail._groups_by_id.values():
contact_list = []
for c in cs_fl:
for group in c._groups.copy():
if group.id == grp.id:
contact_list.append(c.head.username)
if contact_list:
contact_group_list.append(f"{grp.name}:{','.join(contact_list)}\n")
# Handle contacts that aren't part of any groups
no_group_contacts = [c.head.username for c in cs_fl if not c._groups]
if no_group_contacts:
contact_group_list.append(f"(No Group):{','.join(no_group_contacts)}\n")
contact_list_format = ''.join(contact_group_list)
ignore_list = [c.head.username for c in cs if c.lists & ContactList.BL]
ignore_list_format = ','.join(ignore_list)
if user is None:
return web.Response(status=500, text="Internal Server Error")
else:
return web.Response(text=(
f"OK\r\n"
f"BEGIN BUDDYLIST\r\n{contact_list_format}END BUDDYLIST\r\n"
f"BEGIN IGNORELIST\r\n{ignore_list_format}\r\nEND IGNORELIST\r\n"
f"BEGIN IDENTITIES\r\n{username}\r\nEND IDENTITIES\r\n"
f"Mail=1\r\nLogin={username}"
))
async def handle_banneradredir(req: web.Request) -> web.Response:
return web.HTTPFound(f'http://{settings.TARGET_HOST}/ads/banner')
def _get_tmp_file_storage_path(token: str) -> Path:
return Path('storage/file') / token
def _parse_cookies(
req: web.Request, backend: Backend, y: Optional[str] = None, t: Optional[str] = None,
) -> Tuple[Optional[str], Optional[BackendSession]]:
cookies = req.cookies
if None in (y,t):
y_cookie = cookies.get('Y')
t_cookie = cookies.get('T')
else:
y_cookie = y
t_cookie = t
return (backend.auth_service.get_token('ymsg/cookie', y_cookie or ''), backend.auth_service.get_token('ymsg/cookie', t_cookie or ''))
def _login(req: web.Request, username: str, pwd: str, lifetime: int = 86400) -> Optional[Tuple[str, datetime]]:
backend: Backend = req.app['backend']
uuid_val = backend.user_service.login_with_username(username, pwd)
if uuid_val is None:
return None
token_tuple = backend.login_auth_service.create_token('ymsg/cookie', [uuid_val, None], lifetime=lifetime)
if token_tuple is None:
return None
token, expiry = token_tuple
return (token, expiry)
def render(req: web.Request, tmpl_name: str, ctxt: Optional[Dict[str, Any]] = None, status: int = 200) -> web.Response:
if tmpl_name.endswith('.xml'):
content_type = 'text/xml'
else:
content_type = 'text/html'
tmpl = req.app['jinja_env'].get_template(tmpl_name)
content = tmpl.render(**(ctxt or {})).replace('\n', '\r\n')
return web.Response(status = status, content_type = content_type, text = content)