butter-portal/app/routes.py

430 lines
19 KiB
Python
Raw Normal View History

import io
import re
import socket
import subprocess
2026-02-17 08:42:33 +00:00
from app import app
2026-04-01 07:34:01 +01:00
from flask import render_template, flash, redirect, url_for, send_file, send_from_directory
from app.forms import LoginForm, SettingsForm, Step1Form, Step2Form, Step3Form, Step4Form
2026-02-17 08:42:33 +00:00
from flask_login import login_user, current_user, logout_user, login_required
import sqlalchemy as sa
from app import db
from app.models import User, Setting
2026-02-17 13:52:59 +00:00
import os
2026-02-17 08:42:33 +00:00
import json
from flask_babel import _
import base64
2026-02-17 10:23:25 +00:00
import random
from wordfreq import top_n_list
import secrets
import string
2026-02-17 13:52:59 +00:00
import glob
import time
import qrcode
from flask_babel import lazy_gettext as _l
2026-03-30 15:15:22 +01:00
from install_madmail import run_madmail_installer
2026-03-06 12:44:39 +00:00
2026-03-10 10:27:59 +00:00
CHANGES_REQUIRING_RESTART = ['wifi_password', 'ssid', 'enable_access_point', 'enable_chat', 'enable_delta_chat', 'butterbox_hostname', 'ssh_access_settings', 'root_account_settings', 'root_password']
2026-03-30 11:22:30 +01:00
RASPAP_INSTALLED = os.path.exists("/var/www/html/raspap")
2026-02-17 10:23:25 +00:00
def resolve_butterbox_ip() -> str:
"""Best-effort lookup of the address clients should use to reach this box.
``BUTTERBOX_DEFAULT_IP`` is only correct while the box serves its own
access point. When it's joined to another network its address differs, so
try to discover the real one and only fall back to the configured default.
"""
hostname = get_setting('butterbox_hostname') or app.config['BUTTERBOX_HOSTNAME']
# 1. Resolve the box's own mDNS/.local name. This is the same name the
# DeltaChat link uses for the mail host, so avahi/nss-mdns is present.
try:
ip = socket.gethostbyname(f"{hostname}.local")
if ip and not ip.startswith("127."):
return ip
except OSError:
pass
# 2. Fall back to the address of the primary outbound interface. The
# connect() picks a route without sending any packets.
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
if ip and not ip.startswith("127."):
return ip
except OSError:
pass
# 3. Give up and use the configured access-point default.
return app.config['BUTTERBOX_DEFAULT_IP']
2026-02-17 13:52:59 +00:00
def gen_username() -> str:
2026-02-17 10:23:25 +00:00
words = top_n_list("en", 5000)
prefix = random.randint(1000, 9999)
word1 = re.sub(r'[^a-zA-Z ]', '', random.choice(words))
word2 = re.sub(r'[^a-zA-Z ]', '', random.choice(words))
print(word1, word2)
return f"{word1}{word2}{prefix}"
2026-02-17 10:23:25 +00:00
2026-02-17 13:52:59 +00:00
def gen_password() -> str:
2026-02-17 10:23:25 +00:00
characters = string.ascii_letters + string.digits
password = ''.join(secrets.choice(characters) for i in range(20))
return password
2026-02-17 13:52:59 +00:00
def get_file_suffix(path: str) -> str:
return os.path.splitext(path)[1].strip('.') # in case of multiple exts, like .tar.gz, it only splits on the last one
def get_file_icon_url(path: str) -> str:
if os.path.isdir(path):
return url_for("static", filename=f"images/extension-icons/directory.svg")
suffix = get_file_suffix(path)
if suffix:
if suffix in ['apk', 'deb', 'dmg', 'exe', 'jpg', 'mp3', 'pbf', 'pdf', 'png',
'ppt', 'pptx']:
return url_for("static", filename=f"images/extension-icons/ext-{suffix}.svg")
return url_for("static", filename=f"images/extension-icons/ext-unknown.svg")
def get_last_modified(path: str) -> str:
mod_time = time.ctime(os.path.getmtime(path))
#print(mod_time)
#dt = datetime.strptime(mod_time, "%a %b %d %H:%M:%S %Y")
#formatted_time = dt.strftime("%Y-%m-%d %H:%M")
return mod_time
2026-02-17 13:52:59 +00:00
def get_files_in_path(path: str):
file_list = []
if os.path.exists(path):
list_of_files = glob.glob(path + "/*", recursive=True)
file_list = [
{"name": x.replace(path, "").strip("/"), "path": x, "is_file": os.path.isfile(x),
"is_dir": os.path.isdir(x),
"filetype": get_file_suffix(x), "last_modified": get_last_modified(x),
2026-02-17 13:52:59 +00:00
"icon_url": get_file_icon_url(x),
"relative_path": x.replace(app.config["BUTTERBOX_USB_PATH"], "")} for x
in list_of_files]
return file_list
2026-02-17 08:42:33 +00:00
def get_setting(name) -> str:
setting = db.session.scalar(sa.select(Setting).where(Setting.key == name))
return str(setting.value)
def set_setting(name: str, value: str):
setting = db.session.scalar(sa.select(Setting).where(Setting.key == name))
setting.value = value
db.session.add(setting)
def dump_settings(filename: str) -> None:
settings = db.session.execute(sa.select(Setting)).scalars().all()
settings_dict = {s.key: s.value for s in settings}
#settings_dict.pop('admin_password')
2026-02-17 08:42:33 +00:00
with open(filename, "w") as f:
json.dump(settings_dict, f, indent=4)
@app.route('/')
@app.route('/index')
def index():
if get_setting("first_setup") == "true":
return redirect(url_for('first_setup'))
2026-02-17 10:23:25 +00:00
enable_chat = get_setting("enable_chat")
enable_file_viewer = get_setting("enable_file_viewer")
enable_deltachat = get_setting("enable_deltachat")
2026-02-17 08:42:33 +00:00
service_array = []
usb_inserted = False # actual test of whether USB is inserted
2026-02-17 13:52:59 +00:00
if os.path.exists(app.config["BUTTERBOX_USB_PATH"]):
usb_inserted = True
2026-02-17 08:42:33 +00:00
usb_has_maps = False # actual test of whether USB has maps folder
usb_has_appstore = False # actual test of whether USB has an appstore
2026-03-30 11:22:30 +01:00
#enable_wifi_sharing = get_setting("enable_wifi_sharing")
#if enable_wifi_sharing == 'true':
# service_array.append({"name": _l("Share Access"), "image": url_for("static", filename="images/share-icon.svg"), "url": url_for("share")})
2026-02-17 10:23:25 +00:00
if enable_deltachat == 'true':
2026-04-01 07:34:01 +01:00
service_array.append({"name": _l("Secure Messenger"), "image": url_for("static", filename="images/SecureMessenger.svg"), "url": url_for("messaging") })
2026-02-17 10:23:25 +00:00
if enable_chat == 'true':
2026-04-01 07:34:01 +01:00
service_array.append({"name": _l("Local Chat"), "image": url_for("static", filename="images/LocalChat.svg"), "url": f"{app.config["CONVENE_INSTALL_PATH"]}/#/room/join/%23public%3abutterbox.local"})
if enable_file_viewer == 'true' and usb_has_appstore:
2026-04-01 07:34:01 +01:00
service_array.append({"name": _l("Apps"), "image": url_for("static", filename="images/Apps.svg")})
if enable_file_viewer == 'true' and usb_has_maps:
2026-04-01 07:34:01 +01:00
service_array.append({"name": _l("Maps"), "image": url_for("static", filename="images/Maps.svg")})
2026-02-17 10:23:25 +00:00
if enable_file_viewer == 'true':
name = _l("Files")
2026-02-17 08:42:33 +00:00
if not usb_inserted:
name = _l("Insert USB to browse files")
2026-02-17 08:42:33 +00:00
service_array.append({
"name": name,
2026-04-01 07:34:01 +01:00
"image": url_for("static", filename="images/Files.svg"),
2026-02-17 13:52:59 +00:00
"url": url_for("files", path=""),})
return render_template('index.html', title='Home', get_setting=get_setting, services=service_array)
2026-02-17 08:42:33 +00:00
2026-02-17 13:52:59 +00:00
@app.route('/files/', defaults={'path': ''})
@app.route('/files/<path:path>')
def files(path):
base_path = app.config["BUTTERBOX_USB_PATH"]
if not os.path.exists(base_path):
return redirect(url_for('index'))
2026-02-17 13:52:59 +00:00
current_path = os.path.join(base_path, path)
render_files = []
if os.path.exists(current_path):
render_files = get_files_in_path(current_path)
filenames = [x['name'] for x in render_files]
if 'index.html' in filenames:
index_path = render_files[filenames.index('index.html')]['path']
return redirect(url_for('serve_file', filepath=index_path))
2026-02-17 13:52:59 +00:00
else:
return redirect(url_for('files', path=""))
2026-02-17 13:52:59 +00:00
return render_template('usb-file-viewer.html', title='File Viewer', current_path=current_path, render_files=render_files, get_setting=get_setting)
@app.route('/serve_file/<path:filepath>')
def serve_file(filepath):
if not filepath.startswith(app.config["BUTTERBOX_USB_PATH"]):
redirect(url_for('files', path=""))
return send_file("/" + filepath)
2026-02-17 08:42:33 +00:00
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('admin'))
form = LoginForm()
form.remember_me.data = False
if form.validate_on_submit():
user = db.session.scalar(sa.select(User).where(User.username == form.username.data))
if user is None or not user.check_password(form.password.data):
flash(_('Invalid username or password'))
return redirect(url_for('login'))
login_user(user)
return redirect(url_for('admin'))
return render_template('login.html', title='Sign in', form=form, get_setting=get_setting)
@app.route('/first_setup')
def first_setup():
if get_setting("first_setup") == "true":
return render_template('first_setup_main_page.html', get_setting=get_setting)
return redirect(url_for('admin'))
@app.route('/admin_setup')
def admin_setup():
if get_setting("first_setup") == "true":
return render_template('admin_setup.html', get_setting=get_setting)
return redirect(url_for('admin'))
@app.route('/step1', methods=['GET', 'POST'])
def step1():
form = Step1Form()
step1_settings = ['enable_chat', 'enable_deltachat', 'enable_file_viewer']
if not form.is_submitted():
for s in step1_settings:
getattr(form, s).data = (get_setting(s) == "true")
if form.validate_on_submit():
if form.submit.data:
for s in step1_settings:
setting_value = getattr(form, s).data
setting_value = str(setting_value).lower()
set_setting(s, setting_value)
db.session.commit()
return redirect(url_for('step2'))
if get_setting("first_setup") == "true":
return render_template('step1.html', form=form, get_setting=get_setting)
return redirect(url_for('admin'))
@app.route('/step2', methods=['GET', 'POST'])
def step2():
form = Step2Form()
step2_settings = ['butterbox_hostname', 'butterbox_name']
if not form.is_submitted():
for s in step2_settings:
getattr(form, s).data = get_setting(s)
if form.validate_on_submit():
if form.submit.data:
for s in step2_settings:
setting_value = getattr(form, s).data
if s == 'butterbox_hostname':
setting_value = setting_value.lower().replace(" ", "")
set_setting(s, setting_value)
new_logo = form.butterbox_logo.data
if new_logo.filename:
logo_stream = form.butterbox_logo.data.stream
b64_logo = base64.b64encode(logo_stream.read()).decode('utf-8')
file_mimetype = form.butterbox_logo.data.mimetype
new_value = f"data:{file_mimetype};base64,{b64_logo}"
existing_value = get_setting('butterbox_logo')
if new_value != existing_value:
set_setting('butterbox_logo', new_value)
db.session.commit()
linux_date_arg = str(form.butterbox_date.data).replace(" ", "T") + "Z"
2026-04-07 21:30:32 +01:00
output = subprocess.run(["sudo", "/usr/bin/date", "-s", linux_date_arg], capture_output=True, text=True)
if output.returncode != 0:
2026-04-07 21:30:32 +01:00
flash(f"Could not set date. Please set date manually: {output}.", category="error")
return redirect(url_for('step3'))
if get_setting("first_setup") == "true":
return render_template('step2.html', form=form, get_setting=get_setting)
return redirect(url_for('admin'))
@app.route('/step3', methods=['GET', 'POST'])
def step3():
form = Step3Form()
step3_bool_settings = ['enable_wifi_sharing', 'enable_access_point']
step3_settings = ['ssid', 'wifi_password']
if not form.is_submitted():
for s in step3_bool_settings:
getattr(form, s).data = (get_setting(s) == "true")
for s in step3_settings:
getattr(form, s).data = get_setting(s)
if form.validate_on_submit():
if form.submit.data:
2026-03-30 11:22:30 +01:00
for s in (step3_settings + step3_bool_settings):
setting_value = getattr(form, s).data
2026-03-30 11:22:30 +01:00
if s in step3_bool_settings:
setting_value = str(setting_value).lower()
set_setting(s, setting_value)
db.session.commit()
return redirect(url_for('step4'))
if get_setting("first_setup") == "true":
2026-03-30 11:22:30 +01:00
return render_template('step3.html', raspap_installed=RASPAP_INSTALLED, form=form, get_setting=get_setting)
return redirect(url_for('admin'))
@app.route('/step4', methods=['GET', 'POST'])
def step4():
form = Step4Form()
step4_settings = ['root_account_settings', 'ssh_access_settings', 'root_password', 'admin_password']
if not form.is_submitted():
for s in step4_settings:
getattr(form, s).data = get_setting(s)
if form.validate_on_submit():
if form.submit.data:
new_admin_password = form.admin_password.data
if new_admin_password:
admin_user = db.session.scalar(sa.select(User).where(User.username == 'admin'))
if not admin_user.check_password(new_admin_password):
admin_user.set_password(new_admin_password)
db.session.add(admin_user)
step4_settings.remove('admin_password')
for s in step4_settings:
setting_value = getattr(form, s).data
set_setting(s, setting_value)
set_setting('first_setup', "false")
2026-04-08 15:05:45 +01:00
dump_settings("settings.txt")
if get_setting("enable_deltachat") == "true":
run_madmail_installer()
db.session.commit()
return redirect(url_for('setup_complete'))
if get_setting("first_setup") == "true":
return render_template('step4.html', form=form, get_setting=get_setting)
return redirect(url_for('admin'))
@app.route('/setup_complete')
def setup_complete():
if get_setting("first_setup"):
return render_template('setup_complete.html', get_setting=get_setting)
return redirect(url_for('admin'))
2026-02-17 08:42:33 +00:00
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('admin_setup'))
@app.route('/admin', methods=['GET'])
2026-02-17 08:42:33 +00:00
def admin():
if get_setting("first_setup") == "true":
return redirect(url_for('first_setup'))
return redirect(url_for('admin_settings'))
@app.route('/admin_settings', methods=['GET', 'POST'])
@login_required
def admin_settings():
2026-02-17 08:42:33 +00:00
form = SettingsForm()
populate_settings = ['butterbox_name', 'wifi_password', 'ssid', 'root_account_settings', 'ssh_access_settings', 'root_password', 'admin_password']
bool_settings = ['enable_access_point','enable_file_viewer', 'enable_chat', 'enable_deltachat', 'enable_wifi_sharing']
populate_settings.extend(bool_settings)
2026-02-17 08:42:33 +00:00
if not form.is_submitted():
for s in populate_settings:
if s in bool_settings:
getattr(form, s).data = (get_setting(s) == "true")
else:
getattr(form, s).data = get_setting(s)
2026-02-17 10:23:25 +00:00
non_admin_settings_changed = False
if not form.validate_on_submit():
print(form.errors)
2026-02-17 08:42:33 +00:00
if form.validate_on_submit():
if form.submit.data:
deltachat_newly_enabled = False
2026-02-17 08:42:33 +00:00
for s in populate_settings:
new_value = getattr(form, s).data
if s in bool_settings:
new_value = str(new_value).lower() # all settings are str fow now
if s == 'butterbox_hostname':
new_value = new_value.lower().replace(" ", "")
2026-02-17 08:42:33 +00:00
existing_value = get_setting(s)
if new_value != existing_value:
print(f"New value was changed for {s}. Existing value was {existing_value}, new value was {new_value}")
set_setting(s, new_value)
2026-02-17 10:23:25 +00:00
non_admin_settings_changed = True
if s in CHANGES_REQUIRING_RESTART:
2026-02-17 08:42:33 +00:00
app.config['SETTINGS_CHANGED'] = True
if s == 'enable_deltachat' and new_value == "true":
deltachat_newly_enabled = True
if deltachat_newly_enabled:
run_madmail_installer()
2026-02-17 08:42:33 +00:00
new_logo = form.butterbox_logo.data
if new_logo.filename:
logo_stream = form.butterbox_logo.data.stream
b64_logo = base64.b64encode(logo_stream.read()).decode('utf-8')
file_mimetype = form.butterbox_logo.data.mimetype
new_value = f"data:{file_mimetype};base64,{b64_logo}"
existing_value = get_setting('butterbox_logo')
if new_value != existing_value:
set_setting('butterbox_logo', new_value)
2026-02-17 10:23:25 +00:00
non_admin_settings_changed = True
2026-02-17 08:42:33 +00:00
new_admin_password = form.admin_password.data
if new_admin_password:
admin_user = db.session.scalar(sa.select(User).where(User.username == 'admin'))
if not admin_user.check_password(new_admin_password):
admin_user.set_password(new_admin_password)
db.session.add(admin_user)
2026-02-17 10:23:25 +00:00
non_admin_settings_changed = True
2026-02-17 08:42:33 +00:00
if app.config['SETTINGS_CHANGED']:
2026-03-06 12:44:39 +00:00
flash(_("⚠️ Some settings may not fully take effect until the Butter Box restarts. Click 'Apply Changes' to restart."))
2026-02-17 10:23:25 +00:00
else:
if non_admin_settings_changed:
flash(
_("Settings successfully changed."))
2026-02-17 08:42:33 +00:00
db.session.commit()
if form.apply_changes.data:
set_setting('apply_changes', "true")
dump_settings("settings.txt")
2026-03-06 12:44:39 +00:00
flash(_("⚠️ Changes applied! If needed, the system will restart. This may take up to two minutes."))
2026-03-30 11:22:30 +01:00
return render_template('admin.html', raspap_installed=RASPAP_INSTALLED, get_setting=get_setting, form=form)
2026-02-17 10:23:25 +00:00
@app.route('/messaging', methods=['GET', 'POST'])
def messaging():
return render_template('messaging.html', get_setting=get_setting)
@app.route("/deltachat_credentials", methods=["POST"])
def deltachat_credentials():
ip = resolve_butterbox_ip()
2026-02-17 10:23:25 +00:00
username = gen_username()
password = gen_password()
hostname = f"{get_setting('butterbox_hostname')}.local"
2026-04-01 09:46:18 +01:00
dclink = f"dclogin:{username}@{hostname}/?p={password}&v=1&ih={ip}&ip=993&sh={ip}&sp=465&ic=3&ss=default"
img = qrcode.make(dclink)
file_object = io.BytesIO()
img.save(file_object, 'PNG')
base64img = "data:image/png;base64," + base64.b64encode(file_object.getvalue()).decode('ascii')
return render_template('deltachat_creds.html',dclink=dclink, base64img=base64img, get_setting=get_setting)
@app.route('/share')
def share():
display_wifi_password = False
wifi_password = get_setting("wifi_password")
if wifi_password:
display_wifi_password = True
wifi_ssid = get_setting("ssid")
wifi_encryption_type = "WPA2"
img = qrcode.make(f"WIFI:T:{wifi_encryption_type};S:{wifi_ssid};P:{wifi_password};;")
img.save("app/static/images/wifi_qr_code.png")
return render_template('share.html', get_setting=get_setting, display_wifi_password=display_wifi_password)