Files
mattermost-dl/mattermost-dl.py
T
Сергей Маринкевич 3b332d2e0c add gitlab login
2026-06-05 13:39:54 +07:00

488 lines
18 KiB
Python

import os
import pickle
import sqlite3
from datetime import datetime, date
from typing import Tuple, Dict, List, Optional
import getpass
import requests
from bs4 import BeautifulSoup
from mattermostdriver import Driver
import pathlib
import json
GITLAB_URL_DEFAULT = "https://mmgit.eltex-co.ru"
COOKIE_FILE_DEFAULT = "cookies.pkl"
def connect(host: str, login_token: str = None, username: str = None, password: str = None) -> Driver:
d = Driver({
"url": host,
"port": 443,
"token": login_token,
"username": username,
"password": password
})
d.login()
return d
def _mattermost_url(host: str) -> str:
return f"https://{host}"
def _save_cookies(session: requests.Session, filename: str) -> None:
try:
with open(filename, "wb") as f:
pickle.dump(session.cookies, f)
except OSError as e:
print(f"Warning: failed to save cookies to {filename}: {e}")
def _load_cookies(session: requests.Session, filename: str) -> bool:
if not os.path.exists(filename):
return False
try:
with open(filename, "rb") as f:
session.cookies.update(pickle.load(f))
return True
except (OSError, pickle.PickleError) as e:
print(f"Warning: failed to load cookies from {filename}: {e}")
return False
def _remove_cookie_file(filename: str) -> None:
try:
if os.path.exists(filename):
os.remove(filename)
print(f"Removed invalid cookie file: {filename}")
except OSError as e:
print(f"Warning: failed to remove cookie file {filename}: {e}")
def _get_current_user(session: requests.Session, host: str) -> Optional[dict]:
try:
response = session.get(f"{_mattermost_url(host)}/api/v4/users/me", timeout=10)
return response.json() if response.status_code == 200 else None
except requests.exceptions.RequestException:
return None
def _login_to_mattermost_via_gitlab(session: requests.Session, host: str, gitlab_url: str,
username: str, password: str) -> bool:
login_url = f"{_mattermost_url(host)}/oauth/gitlab/login"
headers = {"User-Agent": "Mozilla/5.0"}
try:
response = session.get(login_url, headers=headers, allow_redirects=True, timeout=15)
response.raise_for_status()
if "/users/sign_in" in response.url or "/users/auth/ldapmain" in response.url:
soup = BeautifulSoup(response.text, "html.parser")
auth_form = soup.find("form", {"id": "new_ldap_user"})
if not auth_form:
return False
auth_token_input = auth_form.find("input", {"name": "authenticity_token"})
if not auth_token_input:
return False
auth_token = auth_token_input["value"]
payload = {"authenticity_token": auth_token, "username": username, "password": password}
callback_url = f"{gitlab_url}/users/auth/ldapmain/callback"
response = session.post(
callback_url, data=payload, headers={"Referer": response.url},
allow_redirects=True, timeout=15
)
response.raise_for_status()
if response.status_code == 200 and "/oauth/authorize" in response.url:
soup = BeautifulSoup(response.text, "html.parser")
redirect_link = soup.find("a", href=True)
if not redirect_link:
return False
response = session.get(
redirect_link["href"], headers={"Referer": response.url},
allow_redirects=True, timeout=15
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"GitLab login failed: {e}")
return False
return "MMAUTHTOKEN" in session.cookies
def get_token_via_gitlab(host: str, gitlab_url: str, username: str, password: str,
cookie_file: str = COOKIE_FILE_DEFAULT) -> Optional[str]:
session = requests.Session()
if _load_cookies(session, cookie_file):
print("Checking saved GitLab/Mattermost session...")
if _get_current_user(session, host):
token = session.cookies.get("MMAUTHTOKEN")
if token:
print("Authenticated using saved cookies.")
return token
print("Saved cookies are invalid or expired.")
_remove_cookie_file(cookie_file)
print("Logging in to Mattermost via GitLab...")
if not _login_to_mattermost_via_gitlab(session, host, gitlab_url, username, password):
print("Failed to authenticate via GitLab.")
return None
_save_cookies(session, cookie_file)
print(f"Session saved to {cookie_file}")
return session.cookies.get("MMAUTHTOKEN")
def get_users(d: Driver) -> Tuple[Dict[str, str], str]:
my_user = d.users.get_user("me")
my_username = my_user["username"]
my_user_id = my_user["id"]
print(f"Successfully logged in as {my_username} ({my_user_id})")
# Get all usernames as we want to use those instead of the user ids
user_id_to_name = {}
page = 0
print("Downloading all user information... ", end="")
while True:
users_resp = d.users.get_users(params={"per_page": 200, "page": page})
if len(users_resp) == 0:
break
for user in users_resp:
user_id_to_name[user["id"]] = user["username"]
page += 1
print(f"Found {len(user_id_to_name)} users!")
return user_id_to_name, my_user_id
def select_team(d: Driver, my_user_id: str) -> str:
print("Downloading all team information... ", end="")
teams = d.teams.get_user_teams(my_user_id)
print(f"Found {len(teams)} teams!")
for i_team, team in enumerate(teams):
print(f"{i_team}\t{team['name']}\t({team['id']})")
team_idx = int(input("Select team by idx: "))
team = teams[team_idx]
print(f"Selected team {team['name']}")
return team
def select_channel(d: Driver, team: str, my_user_id: str, user_id_to_name: Dict[str, str],
verbose: bool = False) -> List[str]:
print("Downloading all channel information... ", end="")
channels = d.channels.get_channels_for_user(my_user_id, team["id"])
# Add display name to direct messages
for channel in channels:
if channel["type"] != "D":
continue
# The channel name consists of two user ids connected by a double underscore
user_ids = channel["name"].split("__")
other_user_id = user_ids[1] if user_ids[0] == my_user_id else user_ids[0]
channel["display_name"] = user_id_to_name[other_user_id]
# Sort channels by name for easier search
channels = sorted(channels, key=lambda x: x["display_name"].lower())
print(f"Found {len(channels)} channels!")
for i_channel, channel in enumerate(channels):
if verbose:
channel_id = f"\t({channel['id']})"
else:
channel_id = ""
print(f"{i_channel}\t{channel['display_name']}{channel_id}")
channel_input = input("Select channels by idx separated by comma or type 'all' for downloading all channels: ")
if channel_input == "all":
channel_idxs = list(range(len(channels)))
else:
channel_idxs = channel_input.replace(" ", "").split(",")
selected_channels = [channels[int(idx)] for idx in channel_idxs]
print("Selected channel(s):", ", ".join([channel["display_name"] for channel in selected_channels]))
return selected_channels
def export_channel(d: Driver, channel: str, user_id_to_name: Dict[str, str], output_base: str,
download_files: bool = True, before: str = None, after: str = None):
# Sanitize channel name
channel_name = channel["display_name"].replace("\\", "").replace("/", "")
print("Exporting channel", channel_name)
if after:
after = datetime.strptime(after, '%Y-%m-%d').timestamp()
if before:
before = datetime.strptime(before, '%Y-%m-%d').timestamp()
# Get all posts for selected channel
page = 0
all_posts = []
while True:
print(f"Requesting channel page {page}")
posts = d.posts.get_posts_for_channel(channel["id"], params={"per_page": 200, "page": page})
if len(posts["posts"]) == 0:
# If no posts are returned, we have reached the end
break
all_posts.extend([posts["posts"][post] for post in posts["order"]])
page += 1
print(f"Found {len(all_posts)} posts")
# Create output directory
output_base = pathlib.Path(output_base) / channel_name
output_base.mkdir(parents=True, exist_ok=True)
# Simplify all posts to contain only username, date, message and files in chronological order
simple_posts = []
for i_post, post in enumerate(reversed(all_posts)):
# Filter posts by date range
created = post["create_at"] / 1000
if (before and created > before) or (after and created < after):
continue
user_id = post["user_id"]
if user_id not in user_id_to_name:
user_id_to_name[user_id] = d.users.get_user(user_id)["username"]
username = user_id_to_name[user_id]
created = datetime.utcfromtimestamp(post["create_at"] / 1000).strftime('%Y-%m-%dT%H:%M:%SZ')
message = post["message"]
simple_post = dict(idx=i_post, id=post["id"], created=created, username=username, message=message)
# If a code block is given in the message, dump it to file
if message.count("```") > 1:
start_pos = message.find("```") + 3
end_pos = message.rfind("```")
cut = message[start_pos:end_pos]
if not len(cut):
print("Code has no length")
else:
filename = "%03d" % i_post + "_code.txt"
with open(output_base / filename, "wb") as f:
f.write(cut.encode())
# If any files are attached to the message, download each
if "files" in post["metadata"]:
filenames = []
for file in post["metadata"]["files"]:
if download_files:
filename = "%03d" % i_post + "_" + file["name"]
print("Downloading", file["name"])
while True:
try:
resp = d.files.get_file(file["id"])
break
except:
print("Downloading file failed")
# Mattermost Driver unfortunately parses json files to dicts
if isinstance(resp, dict):
with open(output_base / filename, "w") as f:
json.dump(resp, f)
else:
with open(output_base / filename, "wb") as f:
f.write(resp.content)
filenames.append(file["name"])
simple_post["files"] = filenames
simple_posts.append(simple_post)
output = {
"channel": {
"name": channel["name"],
"display_name": channel["display_name"],
"header": channel["header"],
"id": channel["id"],
"team": d.teams.get_team(channel["team_id"])["name"],
"team_id": channel["team_id"],
"exported_at": datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
},
"posts": simple_posts
}
# Export posts to json file
filtered_channel_name = ''.join(filter(lambda ch: ch not in "?!/\\.;:*\"<>|", channel_name))
output_filename = filtered_channel_name + ".json"
output_filepath = output_base / output_filename
with open(output_filepath, "w", encoding='utf8') as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"Exported channel data to '{output_filepath}'")
def get_config_from_json(config_filename: str = "config.json") -> dict:
config_path = pathlib.Path(config_filename)
if not config_path.exists():
return {}
with config_path.open() as f:
config = json.load(f)
return config
def complete_config(config: dict, config_filename: str = "config.json") -> dict:
config_changed = False
if config.get("host", False):
print(f"Using host '{config['host']}' from config")
else:
config["host"] = input("Please input host/server address (without https://): ")
config_changed = True
if config.get("login_mode", False):
print(f"Using login mode '{config['login_mode']}' from config")
else:
login_mode = ""
while login_mode not in ["password", "token", "gitlab"]:
login_mode = input(
"Please input login_mode 'password', 'token', or 'gitlab' (GitLab OAuth): "
)
config["login_mode"] = login_mode
config_changed = True
password = None
if config["login_mode"] == "password":
if config.get("username", False):
print(f"Using username '{config['username']}' from config")
else:
config["username"] = input("Please input your username: ")
config_changed = True
password = getpass.getpass("Enter your password (hidden): ")
elif config["login_mode"] == "gitlab":
if config.get("gitlab_url", False):
print(f"Using GitLab URL '{config['gitlab_url']}' from config")
else:
config["gitlab_url"] = input(
f"Please input GitLab URL [{GITLAB_URL_DEFAULT}]: "
) or GITLAB_URL_DEFAULT
config_changed = True
if config.get("username", False):
print(f"Using username '{config['username']}' from config")
else:
config["username"] = input("Please input your GitLab username: ")
config_changed = True
password = getpass.getpass("Enter your GitLab password (hidden): ")
token = get_token_via_gitlab(
config["host"], config["gitlab_url"], config["username"], password,
config.get("cookie_file", COOKIE_FILE_DEFAULT)
)
if not token:
raise SystemExit("GitLab authentication failed.")
config["token"] = token
config_changed = True
else:
if config.get("token", False):
print(f"Using token '{config['token']}' from config")
else:
print("Token not found in config. Choose how to obtain MMAUTHTOKEN:")
print(" g - login via GitLab (username/password)")
print(" f - extract from Firefox cookies")
print(" m - enter token manually")
dec = ""
while dec not in ["g", "f", "m"]:
dec = input("Choice [g/f/m]: ")
token = None
if dec == "g":
if config.get("gitlab_url", False):
print(f"Using GitLab URL '{config['gitlab_url']}' from config")
else:
config["gitlab_url"] = input(
f"Please input GitLab URL [{GITLAB_URL_DEFAULT}]: "
) or GITLAB_URL_DEFAULT
config_changed = True
if config.get("username", False):
print(f"Using username '{config['username']}' from config")
else:
config["username"] = input("Please input your GitLab username: ")
config_changed = True
password = getpass.getpass("Enter your GitLab password (hidden): ")
token = get_token_via_gitlab(
config["host"], config["gitlab_url"], config["username"], password,
config.get("cookie_file", COOKIE_FILE_DEFAULT)
)
if not token:
raise SystemExit("GitLab authentication failed.")
elif dec == "f":
token = find_mmauthtoken_firefox(config["host"])
if not token:
token = input("Please input your login token (MMAUTHTOKEN): ")
config["token"] = token
config_changed = True
if "download_files" in config:
print(f"Download files set to '{config['download_files']}' from config")
else:
dec = ""
while not (dec == "y" or dec == "n"):
dec = input("Should files be downloaded? y/n: ")
config["download_files"] = dec == "y"
config_changed = True
if config_changed:
dec = ""
while not (dec == "y" or dec == "n"):
dec = input("Config changed! Would you like to store your config (without password) to file? y/n: ")
if dec == "y":
with open(config_filename, "w") as f:
json.dump(config, f, indent=2)
print(f"Stored new config to {config_filename}")
config["password"] = password
return config
def find_mmauthtoken_firefox(host):
appdata_dir = pathlib.Path(os.environ["APPDATA"])
profiles_dir = appdata_dir / "Mozilla/Firefox/Profiles"
cookie_files = profiles_dir.rglob("cookies.sqlite")
all_tokens = []
for cookie_file in cookie_files:
print(f"Opening {cookie_file}")
connection = sqlite3.connect(str(cookie_file))
cursor = connection.cursor()
rows = cursor.execute("SELECT host, value FROM moz_cookies WHERE name = 'MMAUTHTOKEN'").fetchall()
all_tokens.extend(rows)
all_tokens = [token for token in all_tokens if host in token[0]]
print(f"Found {len(all_tokens)} token for {host}")
for token in all_tokens:
print(f"{token[0]}: {token[1]}")
if len(all_tokens) > 1:
print("Using first token!")
if len(all_tokens):
return all_tokens[0][1]
else:
return None
if __name__ == '__main__':
config = get_config_from_json()
config = complete_config(config)
output_base = "results/" + date.today().strftime("%Y%m%d")
print(f"Storing downloaded data in {output_base}")
# Range of posts to be exported as string in format "YYYY-MM-DD". Use None if no filter should be applied
after = config.get("after", None)
before = config.get("before", None)
d = connect(config["host"], config.get("token", None),
config.get("username", None), config.get("password", None))
user_id_to_name, my_user_id = get_users(d)
team = select_team(d, my_user_id)
channels = select_channel(d, team, my_user_id, user_id_to_name)
for i_channel, channel in enumerate(channels):
print(f"Start export of channel {i_channel + 1}/{len(channels)}")
export_channel(d, channel, user_id_to_name, output_base, config["download_files"],
before, after)
print("Finished export")