603 lines
18 KiB
Python
603 lines
18 KiB
Python
import filecmp
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Any, List, Literal, Optional, TypeVar, Union, overload
|
|
from urllib import request
|
|
from urllib.parse import urlencode
|
|
|
|
|
|
class NoRedirect(request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None
|
|
|
|
|
|
class DBType(Enum):
|
|
mysql = "mysql"
|
|
pgsql = "pgsql"
|
|
sqlite = "sqlite"
|
|
|
|
|
|
@dataclass
|
|
class EnvVars:
|
|
prettyurls: bool
|
|
https: bool
|
|
httpsredirect: bool
|
|
sslcertfile: str
|
|
sslcertkeyfile: str
|
|
lang: str
|
|
baseurl: Optional[str]
|
|
dbtype: DBType
|
|
dbhost: Optional[str]
|
|
dbport: str
|
|
dbuser: str
|
|
dbpass: Optional[str]
|
|
dbname: str
|
|
tblpfx: str
|
|
wtuser: Optional[str]
|
|
wtname: Optional[str]
|
|
wtpass: Optional[str]
|
|
wtemail: Optional[str]
|
|
# https://github.com/fisharebest/webtrees/blob/f9a3af650116d75f1a87f454cabff5e9047e43f3/app/Http/Middleware/UseDatabase.php#L71-L82
|
|
dbkey: Optional[str]
|
|
dbcert: Optional[str]
|
|
dbca: Optional[str]
|
|
dbverify: bool
|
|
# php settings
|
|
phpmemorylimit: str
|
|
phpmaxexecutiontime: str
|
|
phppostmaxsize: str
|
|
phpuploadmaxfilesize: str
|
|
# user/group ID
|
|
puid: str
|
|
pgid: str
|
|
|
|
|
|
def truish(value: Optional[str]) -> bool:
|
|
"""
|
|
Check if a value is close enough to true
|
|
"""
|
|
if value is None:
|
|
return False
|
|
|
|
return value.lower().strip() in ["true", "yes", "1"]
|
|
|
|
|
|
def print2(msg: Any) -> None:
|
|
"""
|
|
Print a message to stderr.
|
|
"""
|
|
print(f"[NV_INIT] {msg}", file=sys.stderr)
|
|
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
@overload
|
|
def get_environment_variable(
|
|
key: str, default: None = None, alternates: Optional[List[str]] = None
|
|
) -> Optional[str]: ...
|
|
|
|
|
|
@overload
|
|
def get_environment_variable(
|
|
key: str, default: T = None, alternates: Optional[List[str]] = None
|
|
) -> T: ...
|
|
|
|
|
|
def get_environment_variable(
|
|
key: str, default: Optional[T] = None, alternates: Optional[List[str]] = None
|
|
) -> Union[Optional[str], T]:
|
|
"""
|
|
Try to find the value of an environment variable.
|
|
"""
|
|
key = key.upper()
|
|
|
|
# try to find variable in env
|
|
if key in os.environ:
|
|
value = os.environ[key]
|
|
|
|
print2(f"{key} found in environment variables")
|
|
return value
|
|
|
|
# try to find file version of variable
|
|
file_key = f"{key}_FILE"
|
|
|
|
if file_key in os.environ:
|
|
# file name does not exist
|
|
if not os.path.isfile(os.environ[file_key]):
|
|
print(f"WARNING: {file_key} is not a file: {os.environ[file_key]}")
|
|
return None
|
|
|
|
# read data from file
|
|
with open(os.environ[file_key], "r") as f:
|
|
value = f.read().strip()
|
|
|
|
print2(f"{file_key} found in environment variables")
|
|
return value
|
|
|
|
# try to find alternate variable
|
|
if alternates is not None:
|
|
for a in alternates:
|
|
a_value = get_environment_variable(a)
|
|
if a_value is not None:
|
|
return a_value
|
|
|
|
# return default value
|
|
print2(f"{key} NOT found in environment variables, using default: {default}")
|
|
return default
|
|
|
|
|
|
ENV = EnvVars(
|
|
prettyurls=truish(get_environment_variable("PRETTY_URLS")),
|
|
https=truish(get_environment_variable("HTTPS", alternates=["SSL"])),
|
|
httpsredirect=truish(
|
|
get_environment_variable("HTTPS_REDIRECT", alternates=["SSL_REDIRECT"])
|
|
),
|
|
sslcertfile=get_environment_variable("SSL_CERT_FILE", "/certs/webtrees.crt"),
|
|
sslcertkeyfile=get_environment_variable("SSL_CERT_KEY_FILE", "/certs/webtrees.key"),
|
|
baseurl=get_environment_variable("BASE_URL"),
|
|
lang=get_environment_variable("LANG", "en-US"),
|
|
dbtype=DBType[get_environment_variable("DB_TYPE", "mysql")],
|
|
dbhost=get_environment_variable("DB_HOST"),
|
|
dbport=get_environment_variable("DB_PORT", "3306"),
|
|
dbuser=get_environment_variable(
|
|
"DB_USER",
|
|
"webtrees",
|
|
alternates=["MYSQL_USER", "MARIADB_USER", "POSTGRES_USER"],
|
|
),
|
|
dbpass=get_environment_variable(
|
|
"DB_PASS",
|
|
alternates=["MYSQL_PASSWORD", "MARIADB_PASSWORD", "POSTGRES_PASSWORD"],
|
|
),
|
|
dbname=get_environment_variable(
|
|
"DB_NAME",
|
|
default="webtrees",
|
|
alternates=["MYSQL_DATABASE", "MARIADB_DATABASE", "POSTGRES_DB"],
|
|
),
|
|
tblpfx=get_environment_variable("DB_PREFIX", "wt_"),
|
|
wtuser=get_environment_variable("WT_USER"),
|
|
wtname=get_environment_variable("WT_NAME"),
|
|
wtpass=get_environment_variable("WT_PASS"),
|
|
wtemail=get_environment_variable("WT_EMAIL"),
|
|
dbkey=get_environment_variable("DB_KEY"),
|
|
dbcert=get_environment_variable("DB_CERT"),
|
|
dbca=get_environment_variable("DB_CA"),
|
|
dbverify=truish(get_environment_variable("DB_VERIFY")),
|
|
phpmemorylimit=get_environment_variable("PHP_MEMORY_LIMIT", "1024M"),
|
|
phpmaxexecutiontime=get_environment_variable("PHP_MAX_EXECUTION_TIME", "90"),
|
|
phppostmaxsize=get_environment_variable("PHP_POST_MAX_SIZE", "50M"),
|
|
phpuploadmaxfilesize=get_environment_variable("PHP_UPLOAD_MAX_FILE_SIZE", "50M"),
|
|
puid=get_environment_variable("PUID", "33"), # www-data user
|
|
pgid=get_environment_variable("PGID", "33"),
|
|
)
|
|
|
|
|
|
ROOT = "/var/www/webtrees"
|
|
DATA_DIR = os.path.join(ROOT, "data")
|
|
CONFIG_FILE = os.path.join(DATA_DIR, "config.ini.php")
|
|
PHP_INI_FILE = "/usr/local/etc/php/php.ini"
|
|
|
|
os.chdir(ROOT)
|
|
|
|
|
|
def retry_urlopen(url: str, data: bytes) -> None:
|
|
"""
|
|
Retry a request until a postiive repsonse code is reached. Raises error if it fails.
|
|
"""
|
|
opener = request.build_opener(NoRedirect)
|
|
request.install_opener(opener)
|
|
|
|
for try_ in range(10):
|
|
try:
|
|
# make request
|
|
print2(f"Attempt {try_} for {url}")
|
|
resp = request.urlopen(url, data)
|
|
except urllib.error.HTTPError as e:
|
|
# capture error as well
|
|
resp = e
|
|
print2(f"Recieved HTTP {resp.status} response")
|
|
|
|
# check status code
|
|
# 302 is also accpetable in case the user selected something other than port 80
|
|
if resp.status in (200, 302):
|
|
return
|
|
|
|
# backoff
|
|
time.sleep(try_)
|
|
|
|
raise RuntimeError(f"Could not send a request to {url}")
|
|
|
|
|
|
def add_line_to_file(filename: str, newline: str) -> None:
|
|
"""
|
|
Add a new line to a file. If an existing line is found with the same
|
|
starting string, it will be replaced.
|
|
"""
|
|
newline += "\n"
|
|
|
|
# read file
|
|
with open(filename, "r") as fp:
|
|
lines = fp.readlines()
|
|
|
|
key = newline.split("=")[0]
|
|
|
|
# replace matching line
|
|
found = False
|
|
|
|
for i, line in enumerate(lines):
|
|
if line.startswith(key):
|
|
if line == newline:
|
|
return
|
|
|
|
lines[i] = newline
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
lines.append(newline)
|
|
|
|
# write new contents
|
|
with open(filename, "w") as fp:
|
|
fp.writelines(lines)
|
|
|
|
|
|
def set_config_value(key: str, value: Optional[str]) -> None:
|
|
"""
|
|
In the config file, make sure the given key is set to the given value.
|
|
"""
|
|
if value is None:
|
|
return
|
|
|
|
print2(f"Setting value for {key} in config")
|
|
|
|
if not os.path.isfile(CONFIG_FILE):
|
|
print2(f"WARNING: {CONFIG_FILE} does not exist")
|
|
return
|
|
|
|
add_line_to_file(CONFIG_FILE, f'{key}="{value}"')
|
|
|
|
|
|
def set_php_ini_value(key: str, value: str) -> None:
|
|
"""
|
|
In the php.ini file, make sure the given key is set to the given value.
|
|
"""
|
|
print2(f"Setting value for {key} in php.ini")
|
|
add_line_to_file(PHP_INI_FILE, f"{key} = {value}")
|
|
|
|
|
|
def enable_apache_site(
|
|
enable_sites: List[Literal["webtrees", "webtrees-redir", "webtrees-ssl"]],
|
|
) -> None:
|
|
"""
|
|
Enable an Apache site.
|
|
"""
|
|
|
|
# update ssl apache config with cert path from env
|
|
ssl_site_file = "/etc/apache2/sites-available/webtrees-ssl.conf"
|
|
|
|
# make paths absolute
|
|
if not os.path.isabs(ENV.sslcertfile):
|
|
ENV.sslcertfile = os.path.join(DATA_DIR, ENV.sslcertfile)
|
|
|
|
if not os.path.isabs(ENV.sslcertkeyfile):
|
|
ENV.sslcertkeyfile = os.path.join(DATA_DIR, ENV.sslcertkeyfile)
|
|
|
|
# update file
|
|
with open(ssl_site_file, "r") as fp:
|
|
ssl_site_file_lines = fp.readlines()
|
|
|
|
new_ssl_site_file_lines = []
|
|
for line in ssl_site_file_lines:
|
|
if line.strip().startswith("SSLCertificateFile"):
|
|
line = line.replace(line.split()[1], ENV.sslcertfile)
|
|
elif line.strip().startswith("SSLCertificateKeyFile"):
|
|
line = line.replace(line.split()[1], ENV.sslcertkeyfile)
|
|
|
|
new_ssl_site_file_lines.append(line)
|
|
|
|
with open(ssl_site_file, "w") as fp:
|
|
fp.writelines(new_ssl_site_file_lines)
|
|
|
|
all_sites = ["webtrees", "webtrees-redir", "webtrees-ssl"]
|
|
|
|
# perl complains about locale to stderr, so disable that
|
|
|
|
# disable the other sites
|
|
for s in all_sites:
|
|
if s not in enable_sites:
|
|
print2(f"Disabling site {s}")
|
|
subprocess.check_call(
|
|
["a2dissite", s], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
)
|
|
|
|
# enable the desired sites
|
|
for s in enable_sites:
|
|
print2(f"Enabling site {s}")
|
|
subprocess.check_call(
|
|
["a2ensite", s], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
)
|
|
|
|
|
|
def perms() -> None:
|
|
"""
|
|
Set up folder permissions
|
|
"""
|
|
|
|
print2("Setting up folder permissions for uploads")
|
|
# https://github.com/linuxserver/docker-baseimage-alpine/blob/bef0f4cee208396c92c0fdd1426613de02698301/root/etc/s6-overlay/s6-rc.d/init-adduser/run#L4-L9
|
|
subprocess.check_call(["groupmod", "-o", "-g", ENV.pgid, "www-data"])
|
|
subprocess.check_call(["usermod", "-o", "-u", ENV.puid, "www-data"])
|
|
subprocess.check_call(["chown", "-R", "www-data:www-data", DATA_DIR])
|
|
|
|
if os.path.isfile(CONFIG_FILE):
|
|
subprocess.check_call(["chmod", "700", CONFIG_FILE])
|
|
|
|
|
|
def php_ini() -> None:
|
|
"""
|
|
Update PHP .ini file
|
|
"""
|
|
print2("Updating php.ini")
|
|
|
|
if not os.path.isfile(PHP_INI_FILE):
|
|
print2("Creating php.ini")
|
|
|
|
os.makedirs(os.path.dirname(PHP_INI_FILE), exist_ok=True)
|
|
with open(PHP_INI_FILE, "w") as fp:
|
|
fp.writelines(["[PHP]\n", "\n"])
|
|
|
|
set_php_ini_value("memory_limit", ENV.phpmemorylimit)
|
|
set_php_ini_value("max_execution_time", ENV.phpmaxexecutiontime)
|
|
set_php_ini_value("post_max_size", ENV.phppostmaxsize)
|
|
set_php_ini_value("upload_max_filesize", ENV.phpuploadmaxfilesize)
|
|
|
|
# https://webtrees.net/admin/performance/
|
|
set_php_ini_value("opcache.enable", "1")
|
|
set_php_ini_value(
|
|
"opcache.revalidate_freq", "60"
|
|
) # re check changed files every 60 seconds
|
|
set_php_ini_value("opcache.revalidate_path", "0")
|
|
|
|
|
|
def check_db_variables() -> bool:
|
|
"""
|
|
Check if all required database variables are present
|
|
"""
|
|
try:
|
|
assert ENV.dbtype is not None
|
|
assert ENV.dbname is not None
|
|
assert ENV.tblpfx is not None
|
|
|
|
if ENV.dbtype == DBType.sqlite:
|
|
ENV.dbhost = ""
|
|
ENV.dbport = ""
|
|
ENV.dbuser = ""
|
|
ENV.dbpass = ""
|
|
|
|
assert ENV.dbhost is not None
|
|
assert ENV.dbport is not None
|
|
assert ENV.dbuser is not None
|
|
assert ENV.dbpass is not None
|
|
|
|
except AssertionError:
|
|
print2("WARNING: Not all database variables are set")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def setup_wizard() -> None:
|
|
"""
|
|
Run the setup wizard
|
|
"""
|
|
|
|
if os.path.isfile(CONFIG_FILE):
|
|
return
|
|
|
|
print2("Attempting to automate setup wizard")
|
|
|
|
# make sure all the variables we need are not set to None
|
|
if not check_db_variables():
|
|
return
|
|
|
|
if any(
|
|
v is None
|
|
for v in [ENV.baseurl, ENV.wtname, ENV.wtuser, ENV.wtpass, ENV.wtemail]
|
|
):
|
|
print2("WARNING: Not all required variables were found for setup wizard")
|
|
return
|
|
|
|
assert ENV.baseurl is not None
|
|
if not ENV.baseurl.startswith("http"):
|
|
print2(
|
|
"WARNING: BASE_URL does not start with 'http'. This is likely not what you want."
|
|
)
|
|
|
|
print2("Automating setup wizard")
|
|
print2("Starting Apache in background")
|
|
# set us up to a known HTTP state
|
|
enable_apache_site(["webtrees"])
|
|
# run apache in the background
|
|
apache_proc = subprocess.Popen(["apache2-foreground"], stderr=subprocess.DEVNULL)
|
|
|
|
if ENV.dbtype in [DBType.mysql, DBType.pgsql]:
|
|
# for typing, check_db_variables already does this
|
|
assert ENV.dbhost is not None
|
|
|
|
# try to resolve the host
|
|
# most common error is wrong hostname
|
|
try:
|
|
socket.gethostbyname(ENV.dbhost)
|
|
except socket.gaierror:
|
|
print2(f"ERROR: Could not resolve database host '{ENV.dbhost}'")
|
|
print2(
|
|
"ERROR: You likely have the DBHOST environment variable set incorrectly."
|
|
)
|
|
print2("ERROR: Exiting.")
|
|
|
|
# stop apache
|
|
apache_proc.terminate()
|
|
# die
|
|
sys.exit(1)
|
|
|
|
# wait until database is ready
|
|
if ENV.dbtype == DBType.mysql:
|
|
# https://dev.mysql.com/doc/refman/8.0/en/mysqladmin.html#option_mysqladmin_user
|
|
# don't miss the capital P
|
|
cmd = ["mysqladmin", "ping", "-h", ENV.dbhost, "-P", ENV.dbport, "--silent"]
|
|
name = "MySQL"
|
|
elif ENV.dbtype == DBType.pgsql:
|
|
# https://www.postgresql.org/docs/current/app-pg-isready.html
|
|
cmd = ["pg_isready", "-h", ENV.dbhost, "-p", ENV.dbport, "--quiet"]
|
|
name = "PostgreSQL"
|
|
|
|
while subprocess.run(cmd).returncode != 0: # type: ignore
|
|
print2(f"Waiting for {name} server {ENV.dbhost}:{ENV.dbport} to be ready") # type: ignore
|
|
time.sleep(1)
|
|
|
|
else:
|
|
# sqlite
|
|
# let Apache start up
|
|
time.sleep(2)
|
|
|
|
# send it
|
|
url = "http://127.0.0.1:80/"
|
|
print2(f"Sending setup wizard request to {url}")
|
|
|
|
retry_urlopen(
|
|
url,
|
|
urlencode(
|
|
{
|
|
"lang": ENV.lang,
|
|
"tblpfx": ENV.tblpfx,
|
|
"baseurl": ENV.baseurl,
|
|
"dbtype": ENV.dbtype.value,
|
|
"dbhost": ENV.dbhost,
|
|
"dbport": ENV.dbport,
|
|
"dbuser": ENV.dbuser,
|
|
"dbpass": ENV.dbpass,
|
|
"dbname": ENV.dbname,
|
|
"wtname": ENV.wtname,
|
|
"wtuser": ENV.wtuser,
|
|
"wtpass": ENV.wtpass,
|
|
"wtemail": ENV.wtemail,
|
|
"step": "6",
|
|
}
|
|
).encode("ascii"),
|
|
)
|
|
|
|
print2("Stopping Apache")
|
|
apache_proc.terminate()
|
|
|
|
|
|
def update_config_file() -> None:
|
|
"""
|
|
Update the config file with items set via environment variables
|
|
"""
|
|
print2("Updating config file")
|
|
|
|
if not os.path.isfile(CONFIG_FILE):
|
|
print2(f"Config file not found at {CONFIG_FILE}. Nothing to update.")
|
|
return
|
|
|
|
# update independent values
|
|
set_config_value("rewrite_urls", str(int(ENV.prettyurls)))
|
|
set_config_value("base_url", ENV.baseurl)
|
|
|
|
# update database values as a group
|
|
if check_db_variables():
|
|
set_config_value("dbtype", ENV.dbtype.value)
|
|
set_config_value("dbhost", ENV.dbhost)
|
|
set_config_value("dbport", ENV.dbport)
|
|
set_config_value("dbuser", ENV.dbuser)
|
|
set_config_value("dbpass", ENV.dbpass)
|
|
set_config_value("dbname", ENV.dbname)
|
|
set_config_value("tblpfx", ENV.tblpfx)
|
|
|
|
# update databases verification values
|
|
if ENV.dbtype == DBType.mysql and all(
|
|
v is not None for v in [ENV.dbkey, ENV.dbcert, ENV.dbca]
|
|
):
|
|
set_config_value("dbkey", ENV.dbkey)
|
|
set_config_value("dbcert", ENV.dbcert)
|
|
set_config_value("dbca", ENV.dbca)
|
|
set_config_value("dbverify", str(int(ENV.dbverify)))
|
|
|
|
|
|
def https() -> None:
|
|
"""
|
|
Configure enabled Apache sites
|
|
"""
|
|
print2("Configuring HTTPS")
|
|
|
|
# no https
|
|
if not ENV.https:
|
|
print2("Removing HTTPS")
|
|
enable_apache_site(["webtrees"])
|
|
# https with redirect
|
|
elif ENV.httpsredirect:
|
|
print2("Adding HTTPS, with HTTPS redirect")
|
|
enable_apache_site(["webtrees-ssl", "webtrees-redir"])
|
|
# https no redirect
|
|
else:
|
|
print2("Adding HTTPS, removing HTTPS redirect")
|
|
enable_apache_site(["webtrees", "webtrees-ssl"])
|
|
|
|
|
|
def htaccess() -> None:
|
|
"""
|
|
Recreate .htaccess file if it ever deletes itself in the /data/ directory
|
|
"""
|
|
src_htaccess_file = os.path.join("/bak", "webtrees", "data", ".htaccess")
|
|
htaccess_file = os.path.join(DATA_DIR, ".htaccess")
|
|
|
|
if not os.path.isfile(src_htaccess_file):
|
|
# skip if the backup file does not exist
|
|
print2(
|
|
f"WARNING: {src_htaccess_file} does not exist. Skipping {htaccess_file} integrity check"
|
|
)
|
|
return
|
|
|
|
# https://github.com/NathanVaughn/webtrees-docker/issues/188
|
|
# In the event the htaccess file in the data directory is different
|
|
# from what it's supposed to be, replace it.
|
|
|
|
if os.path.isfile(htaccess_file) and filecmp.cmp(src_htaccess_file, htaccess_file):
|
|
print2(f"{htaccess_file} integrity check succeeded")
|
|
return
|
|
|
|
print2(f"WARNING: {htaccess_file} does not exist or does not match master copy")
|
|
shutil.copyfile(src_htaccess_file, htaccess_file)
|
|
print2(f"Updated {htaccess_file}")
|
|
|
|
|
|
def main() -> None:
|
|
# first, set up permissions
|
|
perms()
|
|
# create php config
|
|
php_ini()
|
|
# run the setup wizard if the config file doesn't exist
|
|
setup_wizard()
|
|
# update the config file
|
|
update_config_file()
|
|
# configure https
|
|
https()
|
|
# make sure .htaccess exists
|
|
htaccess()
|
|
# set up permissions again
|
|
perms()
|
|
|
|
print2("Starting Apache")
|
|
subprocess.run(["apache2-foreground"], stderr=subprocess.DEVNULL)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|