Compare commits
No commits in common. "d44c84379973808e07fcdd163731114b5e086a66" and "370865c2c1a6b9d5dc687b3cdff4af84a80fbfd0" have entirely different histories.
d44c843799
...
370865c2c1
23
SETUP.md
23
SETUP.md
@ -8,7 +8,7 @@
|
|||||||
These instructions describe how to install and run the bridge manually from a clone of this repository.
|
These instructions describe how to install and run the bridge manually from a clone of this repository.
|
||||||
|
|
||||||
## Minimum requirements
|
## Minimum requirements
|
||||||
* Python 3.8
|
* Python 3.7
|
||||||
* Node 16.13 (not yet tested with earlier versions)
|
* Node 16.13 (not yet tested with earlier versions)
|
||||||
* postgresql 11 or sqlite3
|
* postgresql 11 or sqlite3
|
||||||
* A KakaoTalk account on a smartphone (Android or iOS)
|
* A KakaoTalk account on a smartphone (Android or iOS)
|
||||||
@ -41,26 +41,7 @@ These instructions describe how to install and run the bridge manually from a cl
|
|||||||
* Note that on first use, you must enter a verification code on a smartphone version of KakaoTalk in order for the login to complete
|
* Note that on first use, you must enter a verification code on a smartphone version of KakaoTalk in order for the login to complete
|
||||||
|
|
||||||
## systemd
|
## systemd
|
||||||
The [systemd](systemd) directory provides sample service unit configuration files for running the bridge & node-kakao modules:
|
Coming soon!
|
||||||
|
|
||||||
* `matrix-appservice-kakao.service` for the bridge module
|
|
||||||
* `matrix-appservice-kakao-node.service` for the node-kakao module
|
|
||||||
|
|
||||||
To use them as-is, follow these steps after [initial setup](#initial-setup):
|
|
||||||
|
|
||||||
1. Place/link your clone of this repository in `/opt/matrix-appservice-kakaotalk`
|
|
||||||
* If moving your repo directory after having already created a Python virtual environment for the bridge module, re-create the virtual environment after moving to ensure its paths are up-to-date
|
|
||||||
* Alternatively, clone it to `/opt/matrix-appservice-kakaotalk` in the first place
|
|
||||||
1. Install the services as either system or user units
|
|
||||||
* To install as system units:
|
|
||||||
1. Copy/link the service files to a directory in the system unit search path, such as `/etc/systemd/system/`
|
|
||||||
1. Create the services' configuration directory with `sudo mkdir /etc/matrix-appservice-kakaotalk`
|
|
||||||
1. RECOMMENDED: Create the `matrix-appservice-kakaotalk` user on your system with `adduser` or an equivalent command, then uncomment the `User` and `Group` lines in the service files
|
|
||||||
* To install as user units:
|
|
||||||
1. Copy/link the service files to a directory in the user unit search path, such as `~/.config/systemd/user`
|
|
||||||
1. Create the services' configuration directory with `mkdir $XDG_CONFIG_HOME/matrix-appservice-kakaotalk`
|
|
||||||
1. Copy the bridge & node-kakao module configuration files to the services' configuration directory as `config.yaml` and `node-config.json`, respectively
|
|
||||||
1. Start the services now and on every boot boot with `[sudo] systemd [--user] enable --now matrix-appservice-kakaotalk{,-node}`
|
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
Simply `git pull` or `git rebase` the latest changes and rerun any installation commands (`npm install`, `pip install -Ur ...`).
|
Simply `git pull` or `git rebase` the latest changes and rerun any installation commands (`npm install`, `pip install -Ur ...`).
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
__version__ = "0.1.0"
|
__version__ = "0.0.1"
|
||||||
__author__ = "Andrew Ferrazzutti <fair@miscworks.net>"
|
__author__ = "Andrew Ferrazzutti <fair@miscworks.net>"
|
||||||
|
@ -28,7 +28,7 @@ from .puppet import Puppet
|
|||||||
from .user import User
|
from .user import User
|
||||||
from .kt.client import Client as KakaoTalkClient
|
from .kt.client import Client as KakaoTalkClient
|
||||||
from .version import linkified_version, version
|
from .version import linkified_version, version
|
||||||
#from .web import PublicBridgeWebsite
|
from .web import PublicBridgeWebsite
|
||||||
from . import commands as _
|
from . import commands as _
|
||||||
|
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ class KakaoTalkBridge(Bridge):
|
|||||||
|
|
||||||
config: Config
|
config: Config
|
||||||
matrix: MatrixHandler
|
matrix: MatrixHandler
|
||||||
#public_website: PublicBridgeWebsite | None
|
public_website: PublicBridgeWebsite | None
|
||||||
|
|
||||||
def prepare_config(self)->None:
|
def prepare_config(self)->None:
|
||||||
super().prepare_config()
|
super().prepare_config()
|
||||||
@ -55,9 +55,9 @@ class KakaoTalkBridge(Bridge):
|
|||||||
super().prepare_db()
|
super().prepare_db()
|
||||||
init_db(self.db)
|
init_db(self.db)
|
||||||
|
|
||||||
""" TODO Implement web login
|
|
||||||
def prepare_bridge(self) -> None:
|
def prepare_bridge(self) -> None:
|
||||||
super().prepare_bridge()
|
super().prepare_bridge()
|
||||||
|
""" TODO Implement web login
|
||||||
if self.config["appservice.public.enabled"]:
|
if self.config["appservice.public.enabled"]:
|
||||||
secret = self.config["appservice.public.shared_secret"]
|
secret = self.config["appservice.public.shared_secret"]
|
||||||
self.public_website = PublicBridgeWebsite(loop=self.loop, shared_secret=secret)
|
self.public_website = PublicBridgeWebsite(loop=self.loop, shared_secret=secret)
|
||||||
@ -66,7 +66,8 @@ class KakaoTalkBridge(Bridge):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.public_website = None
|
self.public_website = None
|
||||||
"""
|
"""
|
||||||
|
self.public_website = None
|
||||||
|
|
||||||
def prepare_stop(self) -> None:
|
def prepare_stop(self) -> None:
|
||||||
self.log.debug("Stopping RPC connection")
|
self.log.debug("Stopping RPC connection")
|
||||||
@ -86,10 +87,8 @@ class KakaoTalkBridge(Bridge):
|
|||||||
if self.config["bridge.resend_bridge_info"]:
|
if self.config["bridge.resend_bridge_info"]:
|
||||||
self.add_startup_actions(self.resend_bridge_info())
|
self.add_startup_actions(self.resend_bridge_info())
|
||||||
await super().start()
|
await super().start()
|
||||||
""" TODO Implement web login
|
|
||||||
if self.public_website:
|
if self.public_website:
|
||||||
self.public_website.ready_wait.set_result(None)
|
self.public_website.ready_wait.set_result(None)
|
||||||
"""
|
|
||||||
|
|
||||||
async def resend_bridge_info(self) -> None:
|
async def resend_bridge_info(self) -> None:
|
||||||
self.config["bridge.resend_bridge_info"] = False
|
self.config["bridge.resend_bridge_info"] = False
|
||||||
|
@ -13,13 +13,13 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
#import time
|
import time
|
||||||
|
|
||||||
#from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from mautrix.bridge.commands import HelpSection, command_handler
|
from mautrix.bridge.commands import HelpSection, command_handler
|
||||||
from mautrix.errors import MForbidden
|
from mautrix.errors import MForbidden
|
||||||
#from mautrix.util.signed_token import sign_token
|
from mautrix.util.signed_token import sign_token
|
||||||
|
|
||||||
from ..kt.client import Client as KakaoTalkClient
|
from ..kt.client import Client as KakaoTalkClient
|
||||||
from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException
|
from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException
|
||||||
@ -29,7 +29,6 @@ from .typehint import CommandEvent
|
|||||||
|
|
||||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
||||||
|
|
||||||
""" TODO Implement web login
|
|
||||||
web_unsupported = (
|
web_unsupported = (
|
||||||
"This instance of the KakaoTalk bridge does not support the web-based login interface"
|
"This instance of the KakaoTalk bridge does not support the web-based login interface"
|
||||||
)
|
)
|
||||||
@ -41,7 +40,6 @@ forced_web_login = (
|
|||||||
"This instance of the KakaoTalk bridge does not allow in-Matrix login. "
|
"This instance of the KakaoTalk bridge does not allow in-Matrix login. "
|
||||||
"Please use [the web-based login interface]({url})."
|
"Please use [the web-based login interface]({url})."
|
||||||
)
|
)
|
||||||
"""
|
|
||||||
send_password = "Please send your password here to log in"
|
send_password = "Please send your password here to log in"
|
||||||
missing_email = "Please use `$cmdprefix+sp login <email>` to log in here"
|
missing_email = "Please use `$cmdprefix+sp login <email>` to log in here"
|
||||||
try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
|
try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
|
||||||
@ -69,7 +67,6 @@ async def login(evt: CommandEvent) -> None:
|
|||||||
"email": evt.args[0],
|
"email": evt.args[0],
|
||||||
}
|
}
|
||||||
|
|
||||||
""" TODO Implement web login
|
|
||||||
if evt.bridge.public_website:
|
if evt.bridge.public_website:
|
||||||
external_url = URL(evt.config["appservice.public.external"])
|
external_url = URL(evt.config["appservice.public.external"])
|
||||||
token = sign_token(
|
token = sign_token(
|
||||||
@ -90,11 +87,6 @@ async def login(evt: CommandEvent) -> None:
|
|||||||
await evt.reply(f"{missing_email}. {web_unsupported}.")
|
await evt.reply(f"{missing_email}. {web_unsupported}.")
|
||||||
else:
|
else:
|
||||||
await evt.reply(f"{send_password}. {web_unsupported}.")
|
await evt.reply(f"{send_password}. {web_unsupported}.")
|
||||||
"""
|
|
||||||
if not email:
|
|
||||||
await evt.reply(f"{missing_email}.")
|
|
||||||
else:
|
|
||||||
await evt.reply(f"{send_password}.")
|
|
||||||
|
|
||||||
|
|
||||||
async def enter_password(evt: CommandEvent) -> None:
|
async def enter_password(evt: CommandEvent) -> None:
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from mautrix.bridge.commands import HelpSection, command_handler
|
from mautrix.bridge.commands import HelpSection, command_handler
|
||||||
from mautrix.types import SerializerError
|
|
||||||
|
|
||||||
from .typehint import CommandEvent
|
from .typehint import CommandEvent
|
||||||
|
|
||||||
@ -54,36 +53,24 @@ async def disconnect(evt: CommandEvent) -> None:
|
|||||||
needs_auth=True,
|
needs_auth=True,
|
||||||
management_only=True,
|
management_only=True,
|
||||||
help_section=SECTION_CONNECTION,
|
help_section=SECTION_CONNECTION,
|
||||||
help_text="Check if you're logged into KakaoTalk and retrieve your account information",
|
help_text="Check if you're logged into KakaoTalk & connected to chats",
|
||||||
)
|
)
|
||||||
async def whoami(evt: CommandEvent) -> None:
|
async def ping(evt: CommandEvent) -> None:
|
||||||
|
if not await evt.sender.is_logged_in():
|
||||||
|
await evt.reply("You're not logged into KakaoTalk")
|
||||||
|
return
|
||||||
await evt.mark_read()
|
await evt.mark_read()
|
||||||
try:
|
try:
|
||||||
own_info = await evt.sender.get_own_info()
|
own_info = await evt.sender.get_own_info()
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
f"You're logged in as `{own_info.more.uuid}` (nickname: {own_info.more.nickName}, user ID: {evt.sender.ktid})."
|
f"You're logged in as {own_info.nickname} (user ID {evt.sender.ktid})."
|
||||||
)
|
"\n\n"
|
||||||
except SerializerError:
|
f"You are {'connected to' if evt.sender.is_connected else '**disconnected** from'} KakaoTalk chats.\n\n"
|
||||||
evt.sender.log.exception("Failed to deserialize settings struct")
|
|
||||||
await evt.reply(
|
|
||||||
f"You're logged in, but the bridge is unable to retrieve your profile information (user ID: {evt.sender.ktid})."
|
|
||||||
)
|
)
|
||||||
except CommandException as e:
|
except CommandException as e:
|
||||||
await evt.reply(f"Error from KakaoTalk: {e}")
|
await evt.reply(f"Error from KakaoTalk: {e}")
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
management_only=True,
|
|
||||||
help_section=SECTION_CONNECTION,
|
|
||||||
help_text="Check if you're connected to KakaoTalk chats",
|
|
||||||
)
|
|
||||||
async def ping(evt: CommandEvent) -> None:
|
|
||||||
await evt.reply(
|
|
||||||
f"You are {'connected to' if evt.sender.is_connected else '**disconnected** from'} KakaoTalk chats."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(
|
||||||
needs_auth=True,
|
needs_auth=True,
|
||||||
management_only=True,
|
management_only=True,
|
||||||
|
@ -35,13 +35,11 @@ class Config(BaseBridgeConfig):
|
|||||||
return [
|
return [
|
||||||
*super().forbidden_defaults,
|
*super().forbidden_defaults,
|
||||||
ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"),
|
ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"),
|
||||||
# TODO
|
ForbiddenDefault(
|
||||||
#ForbiddenDefault(
|
"appservice.public.external",
|
||||||
# "appservice.public.external",
|
"https://example.com/public",
|
||||||
# "https://example.com/public",
|
condition="appservice.public.enabled",
|
||||||
# condition="appservice.public.enabled",
|
),
|
||||||
#),
|
|
||||||
#
|
|
||||||
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
|
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -52,7 +50,6 @@ class Config(BaseBridgeConfig):
|
|||||||
|
|
||||||
copy("homeserver.asmux")
|
copy("homeserver.asmux")
|
||||||
|
|
||||||
""" TODO
|
|
||||||
copy("appservice.public.enabled")
|
copy("appservice.public.enabled")
|
||||||
copy("appservice.public.prefix")
|
copy("appservice.public.prefix")
|
||||||
copy("appservice.public.external")
|
copy("appservice.public.external")
|
||||||
@ -62,7 +59,6 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("appservice.public.shared_secret")
|
copy("appservice.public.shared_secret")
|
||||||
copy("appservice.public.allow_matrix_login")
|
copy("appservice.public.allow_matrix_login")
|
||||||
copy("appservice.public.segment_key")
|
copy("appservice.public.segment_key")
|
||||||
"""
|
|
||||||
|
|
||||||
copy("metrics.enabled")
|
copy("metrics.enabled")
|
||||||
copy("metrics.listen_port")
|
copy("metrics.listen_port")
|
||||||
|
@ -3,12 +3,13 @@ from mautrix.util.async_db import Database
|
|||||||
from .message import Message
|
from .message import Message
|
||||||
from .portal import Portal
|
from .portal import Portal
|
||||||
from .puppet import Puppet
|
from .puppet import Puppet
|
||||||
|
from .reaction import Reaction
|
||||||
from .upgrade import upgrade_table
|
from .upgrade import upgrade_table
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
def init(db: Database) -> None:
|
def init(db: Database) -> None:
|
||||||
for table in (Portal, Message, User, Puppet):
|
for table in (Portal, Message, Reaction, User, Puppet):
|
||||||
table.db = db
|
table.db = db
|
||||||
|
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ __all__ = [
|
|||||||
"upgrade_table",
|
"upgrade_table",
|
||||||
"init",
|
"init",
|
||||||
"Message",
|
"Message",
|
||||||
|
"Reaction",
|
||||||
"Portal",
|
"Portal",
|
||||||
"ThreadType",
|
"ThreadType",
|
||||||
"Puppet",
|
"Puppet",
|
||||||
|
101
matrix_appservice_kakaotalk/db/reaction.py
Normal file
101
matrix_appservice_kakaotalk/db/reaction.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||||
|
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from asyncpg import Record
|
||||||
|
from attr import dataclass
|
||||||
|
|
||||||
|
from mautrix.types import EventID, RoomID
|
||||||
|
from mautrix.util.async_db import Database
|
||||||
|
|
||||||
|
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Reaction:
|
||||||
|
db: ClassVar[Database] = fake_db
|
||||||
|
|
||||||
|
mxid: EventID
|
||||||
|
mx_room: RoomID
|
||||||
|
kt_msgid: str
|
||||||
|
kt_receiver: int
|
||||||
|
kt_sender: int
|
||||||
|
reaction: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_row(cls, row: Record | None) -> Reaction | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return cls(**row)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_message_ktid(cls, kt_msgid: str, kt_receiver: int) -> dict[int, Reaction]:
|
||||||
|
q = (
|
||||||
|
"SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction "
|
||||||
|
"FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2"
|
||||||
|
)
|
||||||
|
rows = await cls.db.fetch(q, kt_msgid, kt_receiver)
|
||||||
|
row_gen = (cls._from_row(row) for row in rows)
|
||||||
|
return {react.kt_sender: react for react in row_gen}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_ktid(cls, kt_msgid: str, kt_receiver: int, kt_sender: int) -> Reaction | None:
|
||||||
|
q = (
|
||||||
|
"SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction "
|
||||||
|
"FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2 AND kt_sender=$3"
|
||||||
|
)
|
||||||
|
row = await cls.db.fetchrow(q, kt_msgid, kt_receiver, kt_sender)
|
||||||
|
return cls._from_row(row)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
|
||||||
|
q = (
|
||||||
|
"SELECT mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction "
|
||||||
|
"FROM reaction WHERE mxid=$1 AND mx_room=$2"
|
||||||
|
)
|
||||||
|
row = await cls.db.fetchrow(q, mxid, mx_room)
|
||||||
|
return cls._from_row(row)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _values(self):
|
||||||
|
return (
|
||||||
|
self.mxid,
|
||||||
|
self.mx_room,
|
||||||
|
self.kt_msgid,
|
||||||
|
self.kt_receiver,
|
||||||
|
self.kt_sender,
|
||||||
|
self.reaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def insert(self) -> None:
|
||||||
|
q = (
|
||||||
|
"INSERT INTO reaction (mxid, mx_room, kt_msgid, kt_receiver, kt_sender, reaction) "
|
||||||
|
"VALUES ($1, $2, $3, $4, $5, $6)"
|
||||||
|
)
|
||||||
|
await self.db.execute(q, *self._values)
|
||||||
|
|
||||||
|
async def delete(self) -> None:
|
||||||
|
q = "DELETE FROM reaction WHERE kt_msgid=$1 AND kt_receiver=$2 AND kt_sender=$3"
|
||||||
|
await self.db.execute(q, self.kt_msgid, self.kt_receiver, self.kt_sender)
|
||||||
|
|
||||||
|
async def save(self) -> None:
|
||||||
|
q = (
|
||||||
|
"UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$6 "
|
||||||
|
"WHERE kt_msgid=$3 AND kt_receiver=$4 AND kt_sender=$5"
|
||||||
|
)
|
||||||
|
await self.db.execute(q, *self._values)
|
@ -85,3 +85,15 @@ async def create_v1_tables(conn: Connection) -> None:
|
|||||||
UNIQUE (mxid, mx_room)
|
UNIQUE (mxid, mx_room)
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""CREATE TABLE reaction (
|
||||||
|
mxid TEXT,
|
||||||
|
mx_room TEXT,
|
||||||
|
kt_msgid TEXT,
|
||||||
|
kt_receiver BIGINT,
|
||||||
|
kt_sender BIGINT,
|
||||||
|
reaction TEXT,
|
||||||
|
PRIMARY KEY (kt_msgid, kt_receiver, kt_sender),
|
||||||
|
UNIQUE (mxid, mx_room)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
@ -49,6 +49,26 @@ appservice:
|
|||||||
min_size: 5
|
min_size: 5
|
||||||
max_size: 10
|
max_size: 10
|
||||||
|
|
||||||
|
# Public part of web server for out-of-Matrix interaction with the bridge.
|
||||||
|
# TODO Implement web login
|
||||||
|
public:
|
||||||
|
# Whether or not the public-facing endpoints should be enabled.
|
||||||
|
enabled: false
|
||||||
|
# The prefix to use in the public-facing endpoints.
|
||||||
|
prefix: /public
|
||||||
|
# The base URL where the public-facing endpoints are available. The prefix is not added
|
||||||
|
# implicitly.
|
||||||
|
external: https://example.com/public
|
||||||
|
# Shared secret for integration managers such as mautrix-manager.
|
||||||
|
# If set to "generate", a random string will be generated on the next startup.
|
||||||
|
# If null, integration manager access to the API will not be possible.
|
||||||
|
shared_secret: generate
|
||||||
|
# Allow logging in within Matrix. If false, users can only log in using the web interface.
|
||||||
|
allow_matrix_login: true
|
||||||
|
# Segment API key to enable analytics tracking for web server endpoints. Set to null to disable.
|
||||||
|
# Currently the only events are login start, success and fail.
|
||||||
|
segment_key: null
|
||||||
|
|
||||||
# The unique ID of this appservice.
|
# The unique ID of this appservice.
|
||||||
id: kakaotalk
|
id: kakaotalk
|
||||||
# Username of the appservice bot.
|
# Username of the appservice bot.
|
||||||
|
@ -23,7 +23,7 @@ if os.path.exists(".git") and shutil.which("git"):
|
|||||||
git_revision = git_revision[:8]
|
git_revision = git_revision[:8]
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
git_revision = "unknown"
|
git_revision = "unknown"
|
||||||
git_revision_url = None
|
git_revision_url = ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
|
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
|
||||||
@ -31,7 +31,7 @@ if os.path.exists(".git") and shutil.which("git"):
|
|||||||
git_tag = None
|
git_tag = None
|
||||||
else:
|
else:
|
||||||
git_revision = "unknown"
|
git_revision = "unknown"
|
||||||
git_revision_url = None
|
git_revision_url = ""
|
||||||
git_tag = None
|
git_tag = None
|
||||||
|
|
||||||
git_tag_url = f"https://github.com/mautrix/facebook/releases/tag/{git_tag}" if git_tag else None
|
git_tag_url = f"https://github.com/mautrix/facebook/releases/tag/{git_tag}" if git_tag else None
|
||||||
|
@ -32,7 +32,6 @@ from aiohttp import ClientSession
|
|||||||
from aiohttp.client import _RequestContextManager
|
from aiohttp.client import _RequestContextManager
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from mautrix.types import SerializerError
|
|
||||||
from mautrix.util.logging import TraceLogger
|
from mautrix.util.logging import TraceLogger
|
||||||
|
|
||||||
from ...config import Config
|
from ...config import Config
|
||||||
@ -56,12 +55,7 @@ from ..types.request import (
|
|||||||
CommandResultDoneValue
|
CommandResultDoneValue
|
||||||
)
|
)
|
||||||
|
|
||||||
from .types import (
|
from .types import PortalChannelInfo, UserInfoUnion, ChannelProps
|
||||||
ChannelProps,
|
|
||||||
PortalChannelInfo,
|
|
||||||
SettingsStruct,
|
|
||||||
UserInfoUnion,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .errors import InvalidAccessToken, CommandException
|
from .errors import InvalidAccessToken, CommandException
|
||||||
from .error_helper import raise_unsuccessful_response
|
from .error_helper import raise_unsuccessful_response
|
||||||
@ -203,21 +197,17 @@ class Client:
|
|||||||
|
|
||||||
# region post-token commands
|
# region post-token commands
|
||||||
|
|
||||||
async def start(self) -> SettingsStruct | None:
|
async def start(self) -> ProfileStruct:
|
||||||
"""
|
"""
|
||||||
Initialize user-specific bridging & state by providing a token obtained from a prior login.
|
Initialize user-specific bridging & state by providing a token obtained from a prior login.
|
||||||
Receive the user's profile info in response.
|
Receive the user's profile info in response.
|
||||||
"""
|
"""
|
||||||
try:
|
profile_req_struct = await self._api_user_request_result(ProfileReqStruct, "start")
|
||||||
settings_struct = await self._api_user_request_result(SettingsStruct, "start")
|
|
||||||
except SerializerError:
|
|
||||||
self.log.exception("Unable to deserialize settings struct, but starting client anyways")
|
|
||||||
settings_struct = None
|
|
||||||
if not self._rpc_disconnection_task:
|
if not self._rpc_disconnection_task:
|
||||||
self._rpc_disconnection_task = asyncio.create_task(self._rpc_disconnection_handler())
|
self._rpc_disconnection_task = asyncio.create_task(self._rpc_disconnection_handler())
|
||||||
else:
|
else:
|
||||||
self.log.warning("Called \"start\" on an already-started client")
|
self.log.warning("Called \"start\" on an already-started client")
|
||||||
return settings_struct
|
return profile_req_struct.profile
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Immediately stop bridging this user."""
|
"""Immediately stop bridging this user."""
|
||||||
@ -256,9 +246,6 @@ class Client:
|
|||||||
await self._rpc_client.request("disconnect", mxid=self.user.mxid)
|
await self._rpc_client.request("disconnect", mxid=self.user.mxid)
|
||||||
await self._on_disconnect(None)
|
await self._on_disconnect(None)
|
||||||
|
|
||||||
async def get_settings(self) -> SettingsStruct:
|
|
||||||
return await self._api_user_request_result(SettingsStruct, "get_settings")
|
|
||||||
|
|
||||||
async def get_own_profile(self) -> ProfileStruct:
|
async def get_own_profile(self) -> ProfileStruct:
|
||||||
profile_req_struct = await self._api_user_request_result(ProfileReqStruct, "get_own_profile")
|
profile_req_struct = await self._api_user_request_result(ProfileReqStruct, "get_own_profile")
|
||||||
return profile_req_struct.profile
|
return profile_req_struct.profile
|
||||||
|
@ -26,7 +26,6 @@ from mautrix.types import (
|
|||||||
MessageType,
|
MessageType,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..types.api.struct import MoreSettingsStruct, LessSettingsStruct
|
|
||||||
from ..types.bson import Long
|
from ..types.bson import Long
|
||||||
from ..types.channel.channel_info import NormalChannelInfo
|
from ..types.channel.channel_info import NormalChannelInfo
|
||||||
from ..types.channel.channel_type import ChannelType
|
from ..types.channel.channel_type import ChannelType
|
||||||
@ -35,12 +34,6 @@ from ..types.openlink.open_channel_info import OpenChannelInfo
|
|||||||
from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo
|
from ..types.user.channel_user_info import NormalChannelUserInfo, OpenChannelUserInfo
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SettingsStruct(SerializableAttrs):
|
|
||||||
more: MoreSettingsStruct
|
|
||||||
less: LessSettingsStruct
|
|
||||||
|
|
||||||
|
|
||||||
ChannelInfoUnion = NewType("ChannelInfoUnion", Union[NormalChannelInfo, OpenChannelInfo])
|
ChannelInfoUnion = NewType("ChannelInfoUnion", Union[NormalChannelInfo, OpenChannelInfo])
|
||||||
|
|
||||||
@deserializer(ChannelInfoUnion)
|
@deserializer(ChannelInfoUnion)
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
##from .login import *
|
##from .login import *
|
||||||
from .account import *
|
##from .account import *
|
||||||
from .profile import *
|
from .profile import *
|
||||||
from .friends import *
|
from .friends import *
|
||||||
##from .openlink import *
|
##from .openlink import *
|
||||||
|
@ -1,141 +0,0 @@
|
|||||||
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
|
||||||
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import SerializableAttrs
|
|
||||||
|
|
||||||
from ...bson import Long
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OpenChatSettingsStruct(SerializableAttrs):
|
|
||||||
chatMemberMaxJoin: int
|
|
||||||
chatRoomMaxJoin: int
|
|
||||||
createLinkLimit: 10;
|
|
||||||
createCardLinkLimit: 3;
|
|
||||||
numOfStaffLimit: 5;
|
|
||||||
rewritable: bool
|
|
||||||
handoverEnabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
|
||||||
class MoreSettingsStruct(SerializableAttrs):
|
|
||||||
since: int
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ClientConf(SerializableAttrs):
|
|
||||||
osVersion: str
|
|
||||||
clientConf: ClientConf
|
|
||||||
|
|
||||||
available: int
|
|
||||||
available2: int
|
|
||||||
friendsPollingInterval: Optional[int] = None # NOTE Made optional
|
|
||||||
settingsPollingInterval: Optional[int] = None # NOTE Made optional
|
|
||||||
profilePollingInterval: Optional[int] = None # NOTE Made optional
|
|
||||||
moreListPollingInterval: Optional[int] = None # NOTE Made optional
|
|
||||||
morePayPollingInterval: Optional[int] = None # NOTE Made optional
|
|
||||||
daumMediaPollingInterval: Optional[int] = None # NOTE Made optional
|
|
||||||
lessSettingsPollingInterval: Optional[int] = None # NOTE Made optional
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MoreApps(SerializableAttrs):
|
|
||||||
recommend: Optional[list[str]] = None # NOTE From unknown[]
|
|
||||||
all: Optional[list[str]] = None # NOTE From unknown[]
|
|
||||||
moreApps: MoreApps
|
|
||||||
|
|
||||||
shortcuts: Optional[dict[str, int]] = None # NOTE Made optional
|
|
||||||
seasonProfileRev: int
|
|
||||||
seasonNoticeRev: int
|
|
||||||
serviceUserId: Union[Long, int]
|
|
||||||
accountId: int
|
|
||||||
accountDisplayId: str
|
|
||||||
hashedAccountId: str
|
|
||||||
pstnNumber: str
|
|
||||||
formattedPstnNumber: str
|
|
||||||
nsnNumber: str
|
|
||||||
formattedNsnNumber: str
|
|
||||||
contactNameSync: int
|
|
||||||
allowMigration: bool
|
|
||||||
emailStatus: int
|
|
||||||
emailAddress: str
|
|
||||||
emailVerified: bool
|
|
||||||
uuid: str
|
|
||||||
uuidSearchable: bool
|
|
||||||
nickName: str
|
|
||||||
openChat: OpenChatSettingsStruct
|
|
||||||
profileImageUrl: str
|
|
||||||
fullProfileImageUrl: str
|
|
||||||
originalProfileImageUrl: str
|
|
||||||
statusMessage: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
|
||||||
class LessSettingsStruct(SerializableAttrs):
|
|
||||||
kakaoAutoLoginDomain: list[str]
|
|
||||||
daumSsoDomain: list[str]
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GoogleMapsApi(SerializableAttrs):
|
|
||||||
key: str
|
|
||||||
signature: str
|
|
||||||
googleMapsApi: GoogleMapsApi
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ChatReportLimit(SerializableAttrs):
|
|
||||||
chat: int
|
|
||||||
open_chat: int
|
|
||||||
plus_chat: int
|
|
||||||
chat_report_limit: ChatReportLimit
|
|
||||||
|
|
||||||
externalApiList: str # NOTE From unknown
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BirthdayFriends(SerializableAttrs):
|
|
||||||
landing_url: str
|
|
||||||
birthday_friends: BirthdayFriends
|
|
||||||
|
|
||||||
messageDeleteTime: Optional[int] = None # NOTE Made optional
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class VoiceTalk(SerializableAttrs):
|
|
||||||
groupCallMaxParticipants: int
|
|
||||||
voiceTalk: VoiceTalk
|
|
||||||
|
|
||||||
profileActions: bool
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PostExpirationSetting(SerializableAttrs):
|
|
||||||
flagOn: bool
|
|
||||||
newPostTerm: int
|
|
||||||
postExpirationSetting: PostExpirationSetting
|
|
||||||
|
|
||||||
kakaoAlertIds: list[int]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LoginTokenStruct(SerializableAttrs):
|
|
||||||
token: str
|
|
||||||
expires: int
|
|
||||||
|
|
||||||
|
|
||||||
___all___ = [
|
|
||||||
"OpenChatSettingsStruct",
|
|
||||||
"MoreSettingsStruct",
|
|
||||||
"LessSettingsStruct",
|
|
||||||
"LoginTokenStruct",
|
|
||||||
]
|
|
@ -22,8 +22,11 @@ from mautrix.types import (
|
|||||||
Event,
|
Event,
|
||||||
EventID,
|
EventID,
|
||||||
EventType,
|
EventType,
|
||||||
|
ReactionEvent,
|
||||||
|
ReactionEventContent,
|
||||||
ReceiptEvent,
|
ReceiptEvent,
|
||||||
RedactionEvent,
|
RedactionEvent,
|
||||||
|
RelationType,
|
||||||
RoomID,
|
RoomID,
|
||||||
SingleReceiptEventContent,
|
SingleReceiptEventContent,
|
||||||
UserID,
|
UserID,
|
||||||
@ -119,7 +122,6 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
|
|
||||||
await portal.handle_matrix_redaction(user, event_id, redaction_event_id)
|
await portal.handle_matrix_redaction(user, event_id, redaction_event_id)
|
||||||
|
|
||||||
""" TODO
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def handle_reaction(
|
async def handle_reaction(
|
||||||
cls,
|
cls,
|
||||||
@ -145,7 +147,6 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
await portal.handle_matrix_reaction(
|
await portal.handle_matrix_reaction(
|
||||||
user, event_id, content.relates_to.event_id, content.relates_to.key
|
user, event_id, content.relates_to.event_id, content.relates_to.key
|
||||||
)
|
)
|
||||||
"""
|
|
||||||
|
|
||||||
async def handle_read_receipt(
|
async def handle_read_receipt(
|
||||||
self,
|
self,
|
||||||
@ -169,8 +170,6 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
if evt.type == EventType.ROOM_REDACTION:
|
if evt.type == EventType.ROOM_REDACTION:
|
||||||
evt: RedactionEvent
|
evt: RedactionEvent
|
||||||
await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id)
|
await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id)
|
||||||
""" TODO
|
|
||||||
elif evt.type == EventType.REACTION:
|
elif evt.type == EventType.REACTION:
|
||||||
evt: ReactionEvent
|
evt: ReactionEvent
|
||||||
await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content)
|
await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content)
|
||||||
"""
|
|
||||||
|
@ -934,12 +934,10 @@ class Portal(DBPortal, BasePortal):
|
|||||||
raise NotImplementedError("Only message and reaction redactions are supported")
|
raise NotImplementedError("Only message and reaction redactions are supported")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
""" TODO
|
|
||||||
async def handle_matrix_reaction(
|
async def handle_matrix_reaction(
|
||||||
self, sender: u.User, event_id: EventID, reacting_to: EventID, reaction: str
|
self, sender: u.User, event_id: EventID, reacting_to: EventID, reaction: str
|
||||||
) -> None:
|
) -> None:
|
||||||
pass
|
self.log.info("TODO: handle_matrix_reaction")
|
||||||
"""
|
|
||||||
|
|
||||||
async def handle_matrix_leave(self, user: u.User) -> None:
|
async def handle_matrix_leave(self, user: u.User) -> None:
|
||||||
if self.is_direct:
|
if self.is_direct:
|
||||||
|
@ -25,7 +25,6 @@ from mautrix.types import (
|
|||||||
JSON,
|
JSON,
|
||||||
MessageType,
|
MessageType,
|
||||||
RoomID,
|
RoomID,
|
||||||
SerializerError,
|
|
||||||
TextMessageEventContent,
|
TextMessageEventContent,
|
||||||
UserID,
|
UserID,
|
||||||
)
|
)
|
||||||
@ -39,7 +38,7 @@ from .db import User as DBUser
|
|||||||
|
|
||||||
from .kt.client import Client
|
from .kt.client import Client
|
||||||
from .kt.client.errors import AuthenticationRequired, ResponseError
|
from .kt.client.errors import AuthenticationRequired, ResponseError
|
||||||
from .kt.client.types import SettingsStruct
|
from .kt.types.api.struct.profile import ProfileStruct
|
||||||
from .kt.types.bson import Long
|
from .kt.types.bson import Long
|
||||||
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
|
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
|
||||||
from .kt.types.channel.channel_type import ChannelType, KnownChannelType
|
from .kt.types.channel.channel_type import ChannelType, KnownChannelType
|
||||||
@ -97,7 +96,7 @@ class User(DBUser, BaseUser):
|
|||||||
_db_instance: DBUser | None
|
_db_instance: DBUser | None
|
||||||
_sync_lock: SimpleLock
|
_sync_lock: SimpleLock
|
||||||
_is_rpc_reconnecting: bool
|
_is_rpc_reconnecting: bool
|
||||||
_logged_in_info: SettingsStruct | None
|
_logged_in_info: ProfileStruct | None
|
||||||
_logged_in_info_time: float
|
_logged_in_info_time: float
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -254,9 +253,9 @@ class User(DBUser, BaseUser):
|
|||||||
self.log.warning(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}")
|
self.log.warning(f"UUID mismatch: expected {self.uuid}, got {oauth_credential.deviceUUID}")
|
||||||
self.uuid = oauth_credential.deviceUUID
|
self.uuid = oauth_credential.deviceUUID
|
||||||
|
|
||||||
async def get_own_info(self) -> SettingsStruct:
|
async def get_own_info(self) -> ProfileStruct:
|
||||||
if not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic():
|
if not self._logged_in_info or self._logged_in_info_time + 60 * 60 < time.monotonic():
|
||||||
self._logged_in_info = await self.client.get_settings()
|
self._logged_in_info = await self.client.get_own_profile()
|
||||||
self._logged_in_info_time = time.monotonic()
|
self._logged_in_info_time = time.monotonic()
|
||||||
return self._logged_in_info
|
return self._logged_in_info
|
||||||
|
|
||||||
@ -273,7 +272,7 @@ class User(DBUser, BaseUser):
|
|||||||
return False
|
return False
|
||||||
client = Client(self, log=self.log.getChild("ktclient"))
|
client = Client(self, log=self.log.getChild("ktclient"))
|
||||||
user_info = await client.start()
|
user_info = await client.start()
|
||||||
# NOTE On failure, client.start throws instead of returning something falsy
|
# NOTE On failure, client.start throws instead of returning False
|
||||||
self.log.info("Loaded session successfully")
|
self.log.info("Loaded session successfully")
|
||||||
self.client = client
|
self.client = client
|
||||||
self._logged_in_info = user_info
|
self._logged_in_info = user_info
|
||||||
@ -304,12 +303,6 @@ class User(DBUser, BaseUser):
|
|||||||
if self._is_logged_in is None or _override:
|
if self._is_logged_in is None or _override:
|
||||||
try:
|
try:
|
||||||
self._is_logged_in = bool(await self.get_own_info())
|
self._is_logged_in = bool(await self.get_own_info())
|
||||||
except SerializerError:
|
|
||||||
self.log.exception(
|
|
||||||
"Unable to deserialize settings struct, "
|
|
||||||
"but didn't get auth error, so treating user as logged in"
|
|
||||||
)
|
|
||||||
self._is_logged_in = True
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Exception checking login status")
|
self.log.exception("Exception checking login status")
|
||||||
self._is_logged_in = False
|
self._is_logged_in = False
|
||||||
|
1
matrix_appservice_kakaotalk/web/__init__.py
Normal file
1
matrix_appservice_kakaotalk/web/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .public import PublicBridgeWebsite
|
380
matrix_appservice_kakaotalk/web/public.py
Normal file
380
matrix_appservice_kakaotalk/web/public.py
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||||
|
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
|
from mautrix.types import UserID
|
||||||
|
from mautrix.util.signed_token import verify_token
|
||||||
|
|
||||||
|
from .. import puppet as pu, user as u
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTokenError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PublicBridgeWebsite:
|
||||||
|
log: logging.Logger = logging.getLogger("mau.web.public")
|
||||||
|
app: web.Application
|
||||||
|
secret_key: str
|
||||||
|
shared_secret: str
|
||||||
|
ready_wait: asyncio.Future | None
|
||||||
|
|
||||||
|
def __init__(self, shared_secret: str, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
self.app = web.Application()
|
||||||
|
self.ready_wait = loop.create_future()
|
||||||
|
self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
|
||||||
|
self.shared_secret = shared_secret
|
||||||
|
for path in (
|
||||||
|
"whoami",
|
||||||
|
"login",
|
||||||
|
"login/prepare",
|
||||||
|
"login/2fa",
|
||||||
|
"login/check_approved",
|
||||||
|
"login/approved",
|
||||||
|
"logout",
|
||||||
|
"disconnect",
|
||||||
|
"reconnect",
|
||||||
|
"refresh",
|
||||||
|
):
|
||||||
|
self.app.router.add_options(f"/api/{path}", self.login_options)
|
||||||
|
self.app.router.add_get("/api/whoami", self.status)
|
||||||
|
self.app.router.add_post("/api/login/prepare", self.login_prepare)
|
||||||
|
self.app.router.add_post("/api/login", self.login)
|
||||||
|
self.app.router.add_post("/api/login/2fa", self.login_2fa)
|
||||||
|
self.app.router.add_get("/api/login/check_approved", self.login_check_approved)
|
||||||
|
self.app.router.add_post("/api/login/approved", self.login_approved)
|
||||||
|
self.app.router.add_post("/api/logout", self.logout)
|
||||||
|
self.app.router.add_post("/api/disconnect", self.disconnect)
|
||||||
|
self.app.router.add_post("/api/reconnect", self.reconnect)
|
||||||
|
self.app.router.add_post("/api/refresh", self.refresh)
|
||||||
|
self.app.router.add_static(
|
||||||
|
"/", pkg_resources.resource_filename("matrix_appservice_kakaotalk.web", "static/")
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_token(self, token: str) -> UserID:
|
||||||
|
token = verify_token(self.secret_key, token)
|
||||||
|
if token:
|
||||||
|
if token.get("expiry", 0) < int(time.time()):
|
||||||
|
raise InvalidTokenError("Access token has expired")
|
||||||
|
return UserID(token.get("mxid"))
|
||||||
|
raise InvalidTokenError("Access token is invalid")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _acao_headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
**self._acao_headers,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def login_options(self, _: web.Request) -> web.Response:
|
||||||
|
return web.Response(status=200, headers=self._headers)
|
||||||
|
|
||||||
|
async def check_token(self, request: web.Request) -> u.User | None:
|
||||||
|
if self.ready_wait:
|
||||||
|
await self.ready_wait
|
||||||
|
self.ready_wait = None
|
||||||
|
try:
|
||||||
|
token = request.headers["Authorization"]
|
||||||
|
token = token[len("Bearer ") :]
|
||||||
|
except KeyError:
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "Missing Authorization header"}', headers=self._headers
|
||||||
|
)
|
||||||
|
except IndexError:
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "Malformed Authorization header"}',
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
if self.shared_secret and token == self.shared_secret:
|
||||||
|
try:
|
||||||
|
user_id = request.query["user_id"]
|
||||||
|
except KeyError:
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "Missing user_id query param"}',
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
user_id = self.verify_token(token)
|
||||||
|
except InvalidTokenError as e:
|
||||||
|
raise web.HTTPForbidden(
|
||||||
|
text=json.dumps(
|
||||||
|
{"error": f"{e}, please request a new one from the bridge bot"}
|
||||||
|
),
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await u.User.get_by_mxid(user_id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def status(self, request: web.Request) -> web.Response:
|
||||||
|
user = await self.check_token(request)
|
||||||
|
data = {
|
||||||
|
"permissions": user.permission_level,
|
||||||
|
"mxid": user.mxid,
|
||||||
|
"kakaotalk": None,
|
||||||
|
}
|
||||||
|
if user.client:
|
||||||
|
try:
|
||||||
|
info = await user.get_own_info()
|
||||||
|
except Exception:
|
||||||
|
# TODO do something?
|
||||||
|
self.log.warning(
|
||||||
|
"Exception while getting self from status endpoint", exc_info=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
data["kakaotalk"] = info.serialize()
|
||||||
|
data["kakaotalk"]["connected"] = user.is_connected
|
||||||
|
data["kakaotalk"][
|
||||||
|
"device_displayname"
|
||||||
|
] = f"{user.state.device.manufacturer} {user.state.device.name}"
|
||||||
|
return web.json_response(data, headers=self._acao_headers)
|
||||||
|
|
||||||
|
async def login_prepare(self, request: web.Request) -> web.Response:
|
||||||
|
self.log.info("TODO")
|
||||||
|
"""
|
||||||
|
user = await self.check_token(request)
|
||||||
|
state = AndroidState()
|
||||||
|
state.generate(user.mxid)
|
||||||
|
api = AndroidAPI(state, log=user.log.getChild("login-api"))
|
||||||
|
user.command_status = {
|
||||||
|
"action": "Login",
|
||||||
|
"state": state,
|
||||||
|
"api": api,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await api.mobile_config_sessionless()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception(
|
||||||
|
f"Failed to get mobile_config_sessionless to prepare login for {user.mxid}"
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=500)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"status": "login",
|
||||||
|
"password_encryption_key_id": state.session.password_encryption_key_id,
|
||||||
|
"password_encryption_pubkey": state.session.password_encryption_pubkey,
|
||||||
|
},
|
||||||
|
headers=self._acao_headers,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def login(self, request: web.Request) -> web.Response:
|
||||||
|
self.log.info("TODO")
|
||||||
|
"""
|
||||||
|
user = await self.check_token(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email = data["email"]
|
||||||
|
except KeyError:
|
||||||
|
raise web.HTTPBadRequest(text='{"error": "Missing email"}', headers=self._headers)
|
||||||
|
try:
|
||||||
|
password = data["password"]
|
||||||
|
encrypted_password = None
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
encrypted_password = data["encrypted_password"]
|
||||||
|
password = None
|
||||||
|
except KeyError:
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "Missing password"}', headers=self._headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if encrypted_password:
|
||||||
|
if not user.command_status or user.command_status["action"] != "Login":
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "No login in progress"}', headers=self._headers
|
||||||
|
)
|
||||||
|
state: AndroidState = user.command_status["state"]
|
||||||
|
api: AndroidAPI = user.command_status["api"]
|
||||||
|
else:
|
||||||
|
state = AndroidState()
|
||||||
|
state.generate(user.mxid)
|
||||||
|
api = AndroidAPI(state, log=user.log.getChild("login-api"))
|
||||||
|
await api.mobile_config_sessionless()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.log.debug(f"Logging in as {email} for {user.mxid}")
|
||||||
|
resp = await api.login(email, password=password, encrypted_password=encrypted_password)
|
||||||
|
self.log.debug(f"Got successful login response with UID {resp.uid} for {user.mxid}")
|
||||||
|
await user.on_logged_in(state)
|
||||||
|
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
|
||||||
|
except TwoFactorRequired as e:
|
||||||
|
self.log.debug(
|
||||||
|
f"Got 2-factor auth required login error with UID {e.uid} for {user.mxid}"
|
||||||
|
)
|
||||||
|
user.command_status = {
|
||||||
|
"action": "Login",
|
||||||
|
"state": state,
|
||||||
|
"api": api,
|
||||||
|
}
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"status": "two-factor",
|
||||||
|
"error": e.data,
|
||||||
|
},
|
||||||
|
headers=self._acao_headers,
|
||||||
|
)
|
||||||
|
except OAuthException as e:
|
||||||
|
self.log.debug(f"Got OAuthException {e} for {user.mxid}")
|
||||||
|
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def login_2fa(self, request: web.Request) -> web.Response:
|
||||||
|
self.log.info("TODO")
|
||||||
|
"""
|
||||||
|
user = await self.check_token(request)
|
||||||
|
|
||||||
|
if not user.command_status or user.command_status["action"] != "Login":
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "No login in progress"}', headers=self._headers
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email = data["email"]
|
||||||
|
code = data["code"]
|
||||||
|
except KeyError as e:
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text=json.dumps({"error": f"Missing key {e}"}), headers=self._headers
|
||||||
|
)
|
||||||
|
|
||||||
|
state: AndroidState = user.command_status["state"]
|
||||||
|
api: AndroidAPI = user.command_status["api"]
|
||||||
|
try:
|
||||||
|
self.log.debug(f"Sending 2-factor auth code for {user.mxid}")
|
||||||
|
resp = await api.login_2fa(email, code)
|
||||||
|
self.log.debug(
|
||||||
|
f"Got successful login response with UID {resp.uid} for {user.mxid}"
|
||||||
|
" after 2fa login"
|
||||||
|
)
|
||||||
|
await user.on_logged_in(state)
|
||||||
|
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
|
||||||
|
except IncorrectPassword:
|
||||||
|
self.log.debug(f"Got incorrect 2fa code error for {user.mxid}")
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"error": "Incorrect two-factor authentication code",
|
||||||
|
"status": "incorrect-code",
|
||||||
|
},
|
||||||
|
headers=self._acao_headers,
|
||||||
|
status=401,
|
||||||
|
)
|
||||||
|
except OAuthException as e:
|
||||||
|
self.log.debug(f"Got OAuthException {e} for {user.mxid} in 2fa stage")
|
||||||
|
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def login_approved(self, request: web.Request) -> web.Response:
|
||||||
|
self.log.info("TODO")
|
||||||
|
"""
|
||||||
|
user = await self.check_token(request)
|
||||||
|
|
||||||
|
if not user.command_status or user.command_status["action"] != "Login":
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "No login in progress"}', headers=self._headers
|
||||||
|
)
|
||||||
|
|
||||||
|
state: AndroidState = user.command_status["state"]
|
||||||
|
api: AndroidAPI = user.command_status["api"]
|
||||||
|
try:
|
||||||
|
self.log.debug(f"Trying to log in after approval for {user.mxid}")
|
||||||
|
resp = await api.login_approved()
|
||||||
|
self.log.debug(
|
||||||
|
f"Got successful login response with UID {resp.uid} for {user.mxid}"
|
||||||
|
" after approval login"
|
||||||
|
)
|
||||||
|
await user.on_logged_in(state)
|
||||||
|
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
|
||||||
|
except OAuthException as e:
|
||||||
|
self.log.debug(f"Got OAuthException {e} for {user.mxid} in checkpoint login stage")
|
||||||
|
return web.json_response({"error": str(e)}, headers=self._acao_headers, status=401)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def login_check_approved(self, request: web.Request) -> web.Response:
|
||||||
|
self.log.info("TODO")
|
||||||
|
"""
|
||||||
|
user = await self.check_token(request)
|
||||||
|
|
||||||
|
if not user.command_status or user.command_status["action"] != "Login":
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "No login in progress"}', headers=self._headers
|
||||||
|
)
|
||||||
|
|
||||||
|
api: AndroidAPI = user.command_status["api"]
|
||||||
|
approved = await api.check_approved_machine()
|
||||||
|
return web.json_response({"approved": approved}, headers=self._acao_headers)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def logout(self, request: web.Request) -> web.Response:
|
||||||
|
user = await self.check_token(request)
|
||||||
|
|
||||||
|
puppet = await pu.Puppet.get_by_ktid(user.ktid)
|
||||||
|
await user.logout()
|
||||||
|
if puppet.is_real_user:
|
||||||
|
await puppet.switch_mxid(None, None)
|
||||||
|
return web.json_response({}, headers=self._acao_headers)
|
||||||
|
|
||||||
|
async def disconnect(self, request: web.Request) -> web.Response:
|
||||||
|
user = await self.check_token(request)
|
||||||
|
if not user.is_connected:
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text='{"error": "User is not connected"}', headers=self._headers
|
||||||
|
)
|
||||||
|
user.mqtt.disconnect()
|
||||||
|
await user.listen_task
|
||||||
|
return web.json_response({}, headers=self._acao_headers)
|
||||||
|
|
||||||
|
async def reconnect(self, request: web.Request) -> web.Response:
|
||||||
|
user = await self.check_token(request)
|
||||||
|
if user.is_connected:
|
||||||
|
raise web.HTTPConflict(
|
||||||
|
text='{"error": "User is already connected"}', headers=self._headers
|
||||||
|
)
|
||||||
|
user.start_listen()
|
||||||
|
return web.json_response({}, headers=self._acao_headers)
|
||||||
|
|
||||||
|
async def refresh(self, request: web.Request) -> web.Response:
|
||||||
|
user = await self.check_token(request)
|
||||||
|
await user.refresh()
|
||||||
|
return web.json_response({}, headers=self._acao_headers)
|
7
matrix_appservice_kakaotalk/web/static/lib/asn1hex-1.1.min.js
vendored
Normal file
7
matrix_appservice_kakaotalk/web/static/lib/asn1hex-1.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
matrix_appservice_kakaotalk/web/static/lib/htm-3.0.4.min.js
vendored
Normal file
1
matrix_appservice_kakaotalk/web/static/lib/htm-3.0.4.min.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a)}return e},t=new Map;export default function(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}
|
9
matrix_appservice_kakaotalk/web/static/lib/jsbn.min.js
vendored
Normal file
9
matrix_appservice_kakaotalk/web/static/lib/jsbn.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
matrix_appservice_kakaotalk/web/static/lib/milligram-1.4.1.min.css
vendored
Normal file
8
matrix_appservice_kakaotalk/web/static/lib/milligram-1.4.1.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
matrix_appservice_kakaotalk/web/static/lib/normalize-8.0.1.min.css
vendored
Normal file
2
matrix_appservice_kakaotalk/web/static/lib/normalize-8.0.1.min.css
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||||
|
html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}
|
1
matrix_appservice_kakaotalk/web/static/lib/preact-10.5.12.min.js
vendored
Normal file
1
matrix_appservice_kakaotalk/web/static/lib/preact-10.5.12.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
matrix_appservice_kakaotalk/web/static/lib/rng.min.js
vendored
Normal file
9
matrix_appservice_kakaotalk/web/static/lib/rng.min.js
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// From http://www-cs-students.stanford.edu/~tjw/jsbn/rng.js
|
||||||
|
// and http://www-cs-students.stanford.edu/~tjw/jsbn/prng4.js
|
||||||
|
|
||||||
|
// Copyright (c) 2005 Tom Wu
|
||||||
|
// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE
|
||||||
|
|
||||||
|
function Arcfour(){this.i=0,this.j=0,this.S=new Array}function ARC4init(t){var r,n,i;for(r=0;r<256;++r)this.S[r]=r;for(n=0,r=0;r<256;++r)n=n+this.S[r]+t[r%t.length]&255,i=this.S[r],this.S[r]=this.S[n],this.S[n]=i;this.i=0,this.j=0}function ARC4next(){var t;return this.i=this.i+1&255,this.j=this.j+this.S[this.i]&255,t=this.S[this.i],this.S[this.i]=this.S[this.j],this.S[this.j]=t,this.S[t+this.S[this.i]&255]}function prng_newstate(){return new Arcfour}Arcfour.prototype.init=ARC4init,Arcfour.prototype.next=ARC4next;var rng_state,rng_pool,rng_pptr,rng_psize=256;function rng_seed_int(t){rng_pool[rng_pptr++]^=255&t,rng_pool[rng_pptr++]^=t>>8&255,rng_pool[rng_pptr++]^=t>>16&255,rng_pool[rng_pptr++]^=t>>24&255,rng_pptr>=rng_psize&&(rng_pptr-=rng_psize)}function rng_seed_time(){rng_seed_int((new Date).getTime())}if(null==rng_pool){var t;if(rng_pool=new Array,rng_pptr=0,window.crypto&&window.crypto.getRandomValues){var ua=new Uint8Array(32);for(window.crypto.getRandomValues(ua),t=0;t<32;++t)rng_pool[rng_pptr++]=ua[t]}if("Netscape"==navigator.appName&&navigator.appVersion<"5"&&window.crypto){var z=window.crypto.random(32);for(t=0;t<z.length;++t)rng_pool[rng_pptr++]=255&z.charCodeAt(t)}for(;rng_pptr<rng_psize;)t=Math.floor(65536*Math.random()),rng_pool[rng_pptr++]=t>>>8,rng_pool[rng_pptr++]=255&t;rng_pptr=0,rng_seed_time()}function rng_get_byte(){if(null==rng_state){for(rng_seed_time(),(rng_state=prng_newstate()).init(rng_pool),rng_pptr=0;rng_pptr<rng_pool.length;++rng_pptr)rng_pool[rng_pptr]=0;rng_pptr=0}return rng_state.next()}function rng_get_bytes(t){var r;for(r=0;r<t.length;++r)t[r]=rng_get_byte()}function SecureRandom(){}SecureRandom.prototype.nextBytes=rng_get_bytes;
|
||||||
|
|
||||||
|
export default SecureRandom
|
12
matrix_appservice_kakaotalk/web/static/lib/rsa.min.js
vendored
Normal file
12
matrix_appservice_kakaotalk/web/static/lib/rsa.min.js
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// From http://www-cs-students.stanford.edu/~tjw/jsbn/rsa.js
|
||||||
|
// Modified to use Uint8Array instead of UTF-8 strings in pkcs1pad2.
|
||||||
|
|
||||||
|
// Copyright (c) 2005 Tom Wu
|
||||||
|
// http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE
|
||||||
|
|
||||||
|
import BigInteger from "./jsbn.min.js"
|
||||||
|
import SecureRandom from "./rng.min.js"
|
||||||
|
|
||||||
|
function parseBigInt(n,t){return new BigInteger(n,t)}function linebrk(n,t){for(var e="",r=0;r+t<n.length;)e+=n.substring(r,r+t)+"\n",r+=t;return e+n.substring(r,n.length)}function byte2Hex(n){return n<16?"0"+n.toString(16):n.toString(16)}function pkcs1pad2(n,t){if(t<n.length+11)return alert("Message too long for RSA"),null;for(var e=new Array,r=n.length-1;r>=0&&t>0;)e[--t]=n[r--];e[--t]=0;for(var l=new SecureRandom,i=new Array;t>2;){for(i[0]=0;0==i[0];)l.nextBytes(i);e[--t]=i[0]}return e[--t]=2,e[--t]=0,new BigInteger(e)}function RSAKey(){this.n=null,this.e=0,this.d=null,this.p=null,this.q=null,this.dmp1=null,this.dmq1=null,this.coeff=null}function RSASetPublic(n,t){null!=n&&null!=t&&n.length>0&&t.length>0?(this.n=parseBigInt(n,16),this.e=parseInt(t,16)):alert("Invalid RSA public key")}function RSADoPublic(n){return n.modPowInt(this.e,this.n)}function RSAEncrypt(n){var t=pkcs1pad2(n,this.n.bitLength()+7>>3);if(null==t)return null;var e=this.doPublic(t);if(null==e)return null;var r=e.toString(16);return 0==(1&r.length)?r:"0"+r}RSAKey.prototype.doPublic=RSADoPublic,RSAKey.prototype.setPublic=RSASetPublic,RSAKey.prototype.encrypt=RSAEncrypt;
|
||||||
|
|
||||||
|
export default RSAKey
|
80
matrix_appservice_kakaotalk/web/static/lib/spinner.css
Normal file
80
matrix_appservice_kakaotalk/web/static/lib/spinner.css
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
.loader {
|
||||||
|
color: #9b4dca;
|
||||||
|
font-size: 90px;
|
||||||
|
text-indent: -9999em;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 72px auto;
|
||||||
|
position: relative;
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
-ms-transform: translateZ(0);
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-animation: load6 1.7s infinite ease, round 1.7s infinite ease;
|
||||||
|
animation: load6 1.7s infinite ease, round 1.7s infinite ease;
|
||||||
|
}
|
||||||
|
@-webkit-keyframes load6 {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
|
||||||
|
}
|
||||||
|
5%,
|
||||||
|
95% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
|
||||||
|
}
|
||||||
|
10%,
|
||||||
|
59% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em;
|
||||||
|
}
|
||||||
|
38% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes load6 {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
|
||||||
|
}
|
||||||
|
5%,
|
||||||
|
95% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
|
||||||
|
}
|
||||||
|
10%,
|
||||||
|
59% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, -0.087em -0.825em 0 -0.42em, -0.173em -0.812em 0 -0.44em, -0.256em -0.789em 0 -0.46em, -0.297em -0.775em 0 -0.477em;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, -0.338em -0.758em 0 -0.42em, -0.555em -0.617em 0 -0.44em, -0.671em -0.488em 0 -0.46em, -0.749em -0.34em 0 -0.477em;
|
||||||
|
}
|
||||||
|
38% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, -0.377em -0.74em 0 -0.42em, -0.645em -0.522em 0 -0.44em, -0.775em -0.297em 0 -0.46em, -0.82em -0.09em 0 -0.477em;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 -0.83em 0 -0.4em, 0 -0.83em 0 -0.42em, 0 -0.83em 0 -0.44em, 0 -0.83em 0 -0.46em, 0 -0.83em 0 -0.477em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@-webkit-keyframes round {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes round {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
44
matrix_appservice_kakaotalk/web/static/login.html
Normal file
44
matrix_appservice_kakaotalk/web/static/login.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<!--
|
||||||
|
matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||||
|
Copyright (C) 2021 Tulir Asokan
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>matrix-appservice-kakaotalk login</title>
|
||||||
|
|
||||||
|
<link rel="modulepreload" href="lib/jsbn.min.js"/>
|
||||||
|
<link rel="modulepreload" href="lib/rng.min.js"/>
|
||||||
|
<link rel="modulepreload" href="lib/rsa.min.js"/>
|
||||||
|
<link rel="modulepreload" href="lib/asn1hex-1.1.min.js"/>
|
||||||
|
<link rel="modulepreload" href="lib/preact-10.5.12.min.js"/>
|
||||||
|
<link rel="modulepreload" href="lib/htm-3.0.4.min.js"/>
|
||||||
|
<link rel="modulepreload" href="login/crypto.js"/>
|
||||||
|
<link rel="modulepreload" href="login/api.js"/>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="lib/normalize-8.0.1.min.css"/>
|
||||||
|
<link rel="stylesheet" href="lib/milligram-1.4.1.min.css"/>
|
||||||
|
<link rel="stylesheet" href="lib/spinner.css"/>
|
||||||
|
<link rel="stylesheet" href="login/index.css"/>
|
||||||
|
<script src="login/app.js" type="module"></script>
|
||||||
|
<script nomodule>document.body.innerText = "This login page requires modern JavaScript"</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>This login page requires JavaScript</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
62
matrix_appservice_kakaotalk/web/static/login/api.js
Normal file
62
matrix_appservice_kakaotalk/web/static/login/api.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||||
|
// Copyright (C) 2021 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import encryptPassword from "./crypto.js"
|
||||||
|
|
||||||
|
const apiToken = location.hash.slice(1)
|
||||||
|
const headers = { Authorization: `Bearer ${apiToken}` }
|
||||||
|
const jsonHeaders = { ...headers, "Content-Type": "application/json" }
|
||||||
|
const fetchParams = { headers }
|
||||||
|
|
||||||
|
export async function whoami() {
|
||||||
|
const resp = await fetch("api/whoami", fetchParams)
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareLogin() {
|
||||||
|
const resp = await fetch("api/login/prepare", { ...fetchParams, method: "POST" })
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(pubkey, keyID, email, password) {
|
||||||
|
const resp = await fetch("api/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
encrypted_password: await encryptPassword(pubkey, keyID, password),
|
||||||
|
}),
|
||||||
|
headers: jsonHeaders,
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login2FA(email, code) {
|
||||||
|
const resp = await fetch("api/login/2fa", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, code }),
|
||||||
|
headers: jsonHeaders,
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginApproved() {
|
||||||
|
const resp = await fetch("api/login/approved", { method: "POST", headers })
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wasLoginApproved() {
|
||||||
|
const resp = await fetch("api/login/check_approved", fetchParams)
|
||||||
|
return (await resp.json()).approved
|
||||||
|
}
|
200
matrix_appservice_kakaotalk/web/static/login/app.js
Normal file
200
matrix_appservice_kakaotalk/web/static/login/app.js
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||||
|
// Copyright (C) 2021 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { h, Component, render } from "../lib/preact-10.5.12.min.js"
|
||||||
|
import htm from "../lib/htm-3.0.4.min.js"
|
||||||
|
import * as api from "./api.js"
|
||||||
|
|
||||||
|
const html = htm.bind(h)
|
||||||
|
|
||||||
|
class App extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.approveCheckInterval = null
|
||||||
|
this.state = {
|
||||||
|
loading: true,
|
||||||
|
submitting: false,
|
||||||
|
error: null,
|
||||||
|
mxid: null,
|
||||||
|
facebook: null,
|
||||||
|
status: "pre-login",
|
||||||
|
pubkey: null,
|
||||||
|
keyID: null,
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
twoFactorCode: "",
|
||||||
|
twoFactorInfo: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
const { error, mxid, facebook } = await api.whoami()
|
||||||
|
if (error) {
|
||||||
|
this.setState({ error, loading: false })
|
||||||
|
} else {
|
||||||
|
this.setState({ mxid, facebook, loading: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkLoginApproved = async () => {
|
||||||
|
if (!await api.wasLoginApproved()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearInterval(this.approveCheckInterval)
|
||||||
|
this.approveCheckInterval = null
|
||||||
|
const resp = await api.loginApproved()
|
||||||
|
if (resp.status === "logged-in") {
|
||||||
|
this.setState({ status: resp.status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submitNoDefault = evt => {
|
||||||
|
evt.preventDefault()
|
||||||
|
this.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
if (this.approveCheckInterval) {
|
||||||
|
clearInterval(this.approveCheckInterval)
|
||||||
|
this.approveCheckInterval = null
|
||||||
|
}
|
||||||
|
this.setState({ submitting: true })
|
||||||
|
let resp
|
||||||
|
switch (this.state.status) {
|
||||||
|
case "pre-login":
|
||||||
|
resp = await api.prepareLogin()
|
||||||
|
break
|
||||||
|
case "login":
|
||||||
|
resp = await api.login(this.state.pubkey, this.state.keyID,
|
||||||
|
this.state.email, this.state.password)
|
||||||
|
break
|
||||||
|
case "two-factor":
|
||||||
|
resp = await api.login2FA(this.state.email, this.state.twoFactorCode)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const stateUpdate = { submitting: false }
|
||||||
|
if (typeof resp.error === "string") {
|
||||||
|
stateUpdate.error = resp.error
|
||||||
|
} else {
|
||||||
|
stateUpdate.status = resp.status
|
||||||
|
}
|
||||||
|
if (resp.password_encryption_key_id) {
|
||||||
|
stateUpdate.pubkey = resp.password_encryption_pubkey
|
||||||
|
stateUpdate.keyID = resp.password_encryption_key_id
|
||||||
|
}
|
||||||
|
if (resp.status === "two-factor") {
|
||||||
|
this.approveCheckInterval = setInterval(this.checkLoginApproved, 5000)
|
||||||
|
stateUpdate.twoFactorInfo = resp.error
|
||||||
|
} else if (resp.status === "logged-in") {
|
||||||
|
api.whoami().then(({ facebook }) => this.setState({ facebook }))
|
||||||
|
}
|
||||||
|
this.setState(stateUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldChange = evt => {
|
||||||
|
this.setState({ [evt.target.id]: evt.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFields() {
|
||||||
|
switch (this.state.status) {
|
||||||
|
case "pre-login":
|
||||||
|
return null
|
||||||
|
case "login":
|
||||||
|
return html`
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" placeholder="user@example.com" id="email"
|
||||||
|
value=${this.state.email} onChange=${this.fieldChange}/>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" placeholder="correct horse battery staple" id="password"
|
||||||
|
value=${this.state.password} onChange=${this.fieldChange}/>
|
||||||
|
`
|
||||||
|
case "two-factor":
|
||||||
|
return html`
|
||||||
|
<p>${this.state.twoFactorInfo.error_user_msg}</p>
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" placeholder="user@example.com" id="email" disabled
|
||||||
|
value=${this.state.email} onChange=${this.fieldChange}/>
|
||||||
|
<label for="twoFactorCode">Two-factor authentication code</label>
|
||||||
|
<input type="number" placeholder="123456" id="twoFactorCode"
|
||||||
|
value=${this.state.twoFactorCode} onChange=${this.fieldChange}/>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submitButtonText() {
|
||||||
|
switch (this.state.status) {
|
||||||
|
case "pre-login":
|
||||||
|
return "Start"
|
||||||
|
case "login":
|
||||||
|
case "two-factor":
|
||||||
|
return "Sign in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContent() {
|
||||||
|
if (this.state.loading) {
|
||||||
|
return html`
|
||||||
|
<div class="loader">Loading...</div>
|
||||||
|
`
|
||||||
|
} else if (this.state.status === "logged-in") {
|
||||||
|
if (this.state.facebook) {
|
||||||
|
return html`
|
||||||
|
Successfully logged in as ${this.state.facebook.name}. The bridge will appear
|
||||||
|
as ${this.state.facebook.device_displayname} in Facebook security settings.
|
||||||
|
`
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
Successfully logged in
|
||||||
|
`
|
||||||
|
} else if (this.state.facebook) {
|
||||||
|
return html`
|
||||||
|
You're already logged in as ${this.state.facebook.name}. The bridge appears
|
||||||
|
as ${this.state.facebook.device_displayname} in Facebook security settings.
|
||||||
|
`
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
${this.state.error && html`
|
||||||
|
<div class="error button" disabled>${this.state.error}</div>
|
||||||
|
`}
|
||||||
|
<form onSubmit=${this.submitNoDefault}>
|
||||||
|
<fieldset>
|
||||||
|
<label for="mxid">Matrix user ID</label>
|
||||||
|
<input type="text" placeholder="@user:example.com" id="mxid"
|
||||||
|
value=${this.state.mxid} disabled/>
|
||||||
|
${this.renderFields()}
|
||||||
|
<button type="submit" disabled=${this.state.submitting}>
|
||||||
|
${this.state.submitting
|
||||||
|
? "Loading..."
|
||||||
|
: this.submitButtonText()}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<main>
|
||||||
|
<h1>matrix-appservice-kakaotalk login</h1>
|
||||||
|
${this.renderContent()}
|
||||||
|
</main>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
render(html`
|
||||||
|
<${App}/>
|
||||||
|
`, document.body)
|
112
matrix_appservice_kakaotalk/web/static/login/crypto.js
Normal file
112
matrix_appservice_kakaotalk/web/static/login/crypto.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||||
|
// Copyright (C) 2021 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// We have to use this pure-js RSA implementation because SubtleCrypto dropped PKCS#1 v1.5 support.
|
||||||
|
import RSAKey from "../lib/rsa.min.js"
|
||||||
|
import ASN1HEX from "../lib/asn1hex-1.1.min.js"
|
||||||
|
|
||||||
|
function pemToHex(pem) {
|
||||||
|
// Strip pem header
|
||||||
|
pem = pem.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
pem = pem.replace("-----END PUBLIC KEY-----", "")
|
||||||
|
|
||||||
|
// Convert base64 to hex
|
||||||
|
const raw = atob(pem)
|
||||||
|
let result = ""
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const hex = raw.charCodeAt(i).toString(16)
|
||||||
|
result += (hex.length === 2 ? hex : "0" + hex)
|
||||||
|
}
|
||||||
|
return result.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKey(pem) {
|
||||||
|
const keyHex = pemToHex(pem)
|
||||||
|
if (ASN1HEX.isASN1HEX(keyHex) === false) {
|
||||||
|
throw new Error("key is not ASN.1 hex string")
|
||||||
|
} else if (ASN1HEX.getVbyList(keyHex, 0, [0, 0], "06") !== "2a864886f70d010101") {
|
||||||
|
throw new Error("not PKCS8 RSA key")
|
||||||
|
} else if (ASN1HEX.getTLVbyListEx(keyHex, 0, [0, 0]) !== "06092a864886f70d010101") {
|
||||||
|
throw new Error("not PKCS8 RSA public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
const p5hex = ASN1HEX.getTLVbyListEx(keyHex, 0, [1, 0])
|
||||||
|
if (ASN1HEX.isASN1HEX(p5hex) === false) {
|
||||||
|
throw new Error("keyHex is not ASN.1 hex string")
|
||||||
|
}
|
||||||
|
|
||||||
|
const aIdx = ASN1HEX.getChildIdx(p5hex, 0)
|
||||||
|
if (aIdx.length !== 2 || p5hex.substr(aIdx[0], 2) !== "02" || p5hex.substr(aIdx[1], 2) !== "02") {
|
||||||
|
throw new Error("wrong hex for PKCS#5 public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
const hN = ASN1HEX.getV(p5hex, aIdx[0])
|
||||||
|
const hE = ASN1HEX.getV(p5hex, aIdx[1])
|
||||||
|
const key = new RSAKey()
|
||||||
|
key.setPublic(hN, hE)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptPassword encrypts a login password using AES-256-GCM, then encrypts the AES key
|
||||||
|
// for Facebook's RSA-2048 key using PKCS#1 v1.5 padding.
|
||||||
|
//
|
||||||
|
// See https://github.com/mautrix/facebook/blob/v0.3.0/maufbapi/http/login.py#L164-L192
|
||||||
|
// for the Python implementation of the same encryption protocol.
|
||||||
|
async function encryptPassword(pubkey, keyID, password) {
|
||||||
|
// Key and IV for AES encryption
|
||||||
|
const aesKey = await crypto.subtle.generateKey({
|
||||||
|
name: "AES-GCM",
|
||||||
|
length: 256,
|
||||||
|
}, true, ["encrypt", "decrypt"])
|
||||||
|
const aesIV = crypto.getRandomValues(new Uint8Array(12))
|
||||||
|
// Get the actual bytes of the AES key
|
||||||
|
const aesKeyBytes = await crypto.subtle.exportKey("raw", aesKey)
|
||||||
|
|
||||||
|
// Encrypt AES key with Facebook's RSA public key.
|
||||||
|
const rsaKey = getKey(pubkey)
|
||||||
|
const encryptedAESKeyHex = rsaKey.encrypt(new Uint8Array(aesKeyBytes))
|
||||||
|
const encryptedAESKey = new Uint8Array(encryptedAESKeyHex.match(/[0-9A-Fa-f]{2}/g).map(h => parseInt(h, 16)))
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const time = Math.floor(Date.now() / 1000)
|
||||||
|
// Encrypt the password. The result includes the ciphertext and AES MAC auth tag.
|
||||||
|
const encryptedPasswordBuffer = await crypto.subtle.encrypt({
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv: aesIV,
|
||||||
|
// Add the current time to the additional authenticated data (AAD) section
|
||||||
|
additionalData: encoder.encode(time.toString()),
|
||||||
|
tagLength: 128,
|
||||||
|
}, aesKey, encoder.encode(password))
|
||||||
|
// SubtleCrypto returns the auth tag and ciphertext in the wrong order,
|
||||||
|
// so we have to flip them around.
|
||||||
|
const authTag = new Uint8Array(encryptedPasswordBuffer.slice(-16))
|
||||||
|
const encryptedPassword = new Uint8Array(encryptedPasswordBuffer.slice(0, -16))
|
||||||
|
|
||||||
|
const payload = new Uint8Array(2 + aesIV.byteLength + 2 + encryptedAESKey.byteLength + authTag.byteLength + encryptedPassword.byteLength)
|
||||||
|
// 1 is presumably the version
|
||||||
|
payload[0] = 1
|
||||||
|
payload[1] = keyID
|
||||||
|
payload.set(aesIV, 2)
|
||||||
|
// Length of the encrypted AES key as a little-endian 16-bit int
|
||||||
|
payload[aesIV.byteLength + 2] = encryptedAESKey.byteLength & (1 << 8)
|
||||||
|
payload[aesIV.byteLength + 3] = encryptedAESKey.byteLength >> 8
|
||||||
|
payload.set(encryptedAESKey, 4 + aesIV.byteLength)
|
||||||
|
payload.set(authTag, 4 + aesIV.byteLength + encryptedAESKey.byteLength)
|
||||||
|
payload.set(encryptedPassword, 4 + aesIV.byteLength + encryptedAESKey.byteLength + authTag.byteLength)
|
||||||
|
return `#PWD_MSGR:1:${time}:${btoa(String.fromCharCode(...payload))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default encryptPassword
|
10
matrix_appservice_kakaotalk/web/static/login/index.css
Normal file
10
matrix_appservice_kakaotalk/web/static/login/index.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.error {
|
||||||
|
background-color: darkred !important;
|
||||||
|
border-color: darkred !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 50rem;
|
||||||
|
margin: 2rem auto 0;
|
||||||
|
}
|
12
node/package-lock.json
generated
12
node/package-lock.json
generated
@ -1792,9 +1792,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||||
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
|
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
@ -3848,9 +3848,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimist": {
|
"minimist": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||||
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
|
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ms": {
|
"ms": {
|
||||||
|
@ -460,7 +460,8 @@ export default class PeerClient {
|
|||||||
*/
|
*/
|
||||||
userStart = async (req) => {
|
userStart = async (req) => {
|
||||||
const userClient = this.#tryGetUser(req.mxid) || await UserClient.create(req.mxid, req.oauth_credential, this)
|
const userClient = this.#tryGetUser(req.mxid) || await UserClient.create(req.mxid, req.oauth_credential, this)
|
||||||
const res = await this.#getSettings(userClient.serviceClient)
|
// TODO Should call requestMore/LessSettings instead
|
||||||
|
const res = await userClient.serviceClient.requestMyProfile()
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
this.userClients.set(req.mxid, userClient)
|
this.userClients.set(req.mxid, userClient)
|
||||||
}
|
}
|
||||||
@ -495,31 +496,7 @@ export default class PeerClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} req
|
* @param {Object} req
|
||||||
* @param {string} req.mxid
|
* @param {?string} req.mxid
|
||||||
*/
|
|
||||||
getSettings = async (req) => {
|
|
||||||
return await this.#getSettings(this.#getUser(req.mxid).serviceClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {ServiceApiClient} serviceClient
|
|
||||||
*/
|
|
||||||
#getSettings = async (serviceClient) => {
|
|
||||||
const moreRes = await serviceClient.requestMoreSettings()
|
|
||||||
if (!moreRes.success) return moreRes
|
|
||||||
|
|
||||||
const lessRes = await serviceClient.requestLessSettings()
|
|
||||||
if (!lessRes.success) return lessRes
|
|
||||||
|
|
||||||
return makeCommandResult({
|
|
||||||
more: moreRes.result,
|
|
||||||
less: lessRes.result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} req
|
|
||||||
* @param {string} req.mxid
|
|
||||||
*/
|
*/
|
||||||
getOwnProfile = async (req) => {
|
getOwnProfile = async (req) => {
|
||||||
return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
|
return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
|
||||||
@ -527,15 +504,7 @@ export default class PeerClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} req
|
* @param {Object} req
|
||||||
* @param {string} req.mxid
|
* @param {?string} req.mxid
|
||||||
*/
|
|
||||||
getOwnProfile = async (req) => {
|
|
||||||
return await this.#getUser(req.mxid).serviceClient.requestMyProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} req
|
|
||||||
* @param {string} req.mxid
|
|
||||||
* @param {Long} req.user_id
|
* @param {Long} req.user_id
|
||||||
*/
|
*/
|
||||||
getProfile = async (req) => {
|
getProfile = async (req) => {
|
||||||
@ -553,7 +522,7 @@ export default class PeerClient {
|
|||||||
const res = await talkChannel.updateAll()
|
const res = await talkChannel.updateAll()
|
||||||
if (!res.success) return res
|
if (!res.success) return res
|
||||||
|
|
||||||
return makeCommandResult({
|
return this.#makeCommandResult({
|
||||||
name: talkChannel.getDisplayName(),
|
name: talkChannel.getDisplayName(),
|
||||||
participants: Array.from(talkChannel.getAllUserInfo()),
|
participants: Array.from(talkChannel.getAllUserInfo()),
|
||||||
// TODO Image
|
// TODO Image
|
||||||
@ -605,7 +574,7 @@ export default class PeerClient {
|
|||||||
const res = await this.#getUser(req.mxid).serviceClient.findFriendById(req.friend_id)
|
const res = await this.#getUser(req.mxid).serviceClient.findFriendById(req.friend_id)
|
||||||
if (!res.success) return res
|
if (!res.success) return res
|
||||||
|
|
||||||
return makeCommandResult(res.result.friend[propertyName])
|
return this.#makeCommandResult(res.result.friend[propertyName])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -693,6 +662,14 @@ export default class PeerClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#makeCommandResult(result) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: 0,
|
||||||
|
result: result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleUnknownCommand = () => {
|
handleUnknownCommand = () => {
|
||||||
throw new Error("Unknown command")
|
throw new Error("Unknown command")
|
||||||
}
|
}
|
||||||
@ -758,7 +735,6 @@ export default class PeerClient {
|
|||||||
stop: this.userStop,
|
stop: this.userStop,
|
||||||
connect: this.handleConnect,
|
connect: this.handleConnect,
|
||||||
disconnect: this.handleDisconnect,
|
disconnect: this.handleDisconnect,
|
||||||
get_settings: this.getSettings,
|
|
||||||
get_own_profile: this.getOwnProfile,
|
get_own_profile: this.getOwnProfile,
|
||||||
get_profile: this.getProfile,
|
get_profile: this.getProfile,
|
||||||
get_portal_channel_info: this.getPortalChannelInfo,
|
get_portal_channel_info: this.getPortalChannelInfo,
|
||||||
@ -814,17 +790,6 @@ export default class PeerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} result
|
|
||||||
*/
|
|
||||||
function makeCommandResult(result) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
status: 0,
|
|
||||||
result: result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {TalkChannelList} channelList
|
* @param {TalkChannelList} channelList
|
||||||
* @param {ChannelType} channelType
|
* @param {ChannelType} channelType
|
||||||
|
2
setup.py
2
setup.py
@ -51,7 +51,7 @@ setuptools.setup(
|
|||||||
python_requires="~=3.8",
|
python_requires="~=3.8",
|
||||||
|
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 1 - Planning",
|
||||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Node backend for matrix-appservice-kakaotalk
|
|
||||||
After=multi-user.target network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
; User=matrix-appservice-kakaotalk
|
|
||||||
; Group=matrix-appservice-kakaotalk
|
|
||||||
Type=notify
|
|
||||||
NotifyAccess=all
|
|
||||||
WorkingDirectory=/opt/matrix-appservice-kakaotalk/node
|
|
||||||
ConfigurationDirectory=matrix-appservice-kakaotalk
|
|
||||||
RuntimeDirectory=matrix-appservice-kakaotalk
|
|
||||||
ExecStart=/usr/bin/env node src/main.js --config ${CONFIGURATION_DIRECTORY}/node-config.json
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=matrix-appservice-kakaotalk bridge
|
|
||||||
After=multi-user.target network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
; User=matrix-appservice-kakaotalk
|
|
||||||
; Group=matrix-appservice-kakaotalk
|
|
||||||
WorkingDirectory=/opt/matrix-appservice-kakaotalk
|
|
||||||
ConfigurationDirectory=matrix-appservice-kakaotalk
|
|
||||||
RuntimeDirectory=matrix-appservice-kakaotalk
|
|
||||||
ExecStart=/opt/matrix-appservice-kakaotalk/.venv/bin/python -m matrix_appservice_kakaotalk -c ${CONFIGURATION_DIRECTORY}/config.yaml
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
Loading…
x
Reference in New Issue
Block a user