mirror of
https://git.ugnet.gay/CrossTalk/azul.git
synced 2026-05-27 22:59:49 +00:00
490 lines
17 KiB
Python
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)
|