fishctl/src/command/config/update/v1.rs
2024-06-21 13:16:16 +09:00

468 lines
15 KiB
Rust

//! `config update v1` subcommand
//! <https://firefish.dev/firefish/firefish/-/issues/10947>
use crate::{
command::config::{read_file_as_string, ReadError},
config::{client, server, Revision, CLIENT_CONFIG_PATH, SERVER_CONFIG_PATH},
};
use color_print::cprintln;
use sqlx::{postgres::PgConnectOptions, ConnectOptions};
use std::{
collections::HashMap,
fs,
io::{self, Write},
path::Path,
};
use url::Url;
use yaml_rust::{Yaml, YamlLoader};
/// Errors that can happen in `config update v1` subcommand
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("failed to parse the old config file (.config/default.yml)")]
ReadYaml(#[from] ReadYamlConfigError),
#[error(transparent)]
WriteToml(#[from] WriteTomlConfigError),
#[error("failed to read the meta table")]
ReadMeta(#[from] sqlx::Error),
#[error("invalid config ({0})")]
InvalidConfig(&'static str),
#[error("failed to parse server URL")]
InvalidUrl(#[from] url::ParseError),
}
/// Errors that can happen while reading `.config/default.yml`
#[derive(thiserror::Error, Debug)]
pub(crate) enum ReadYamlConfigError {
#[error(transparent)]
ReadFile(#[from] ReadError),
#[error(transparent)]
Yaml(#[from] yaml_rust::ScanError),
#[error("invalid config ({0})")]
InvalidConfig(String),
}
/// Errors that can happen while writing `config/{server,client}.toml`
#[derive(thiserror::Error, Debug)]
pub(crate) enum WriteTomlConfigError {
#[error("failed to serialize the new server config into TOML format")]
ServerSer(#[source] toml::ser::Error),
#[error("failed to serialize the new client config into TOML format")]
ClientSer(#[source] toml::ser::Error),
#[error("failed to create {}", SERVER_CONFIG_PATH)]
ServerWrite(#[source] io::Error),
#[error("failed to create {}", CLIENT_CONFIG_PATH)]
ClientWrite(#[source] io::Error),
#[error("failed to create the config directory")]
Mkdir(#[source] io::Error),
}
fn read_default_yml() -> Result<HashMap<String, Yaml>, ReadYamlConfigError> {
let content = YamlLoader::load_from_str(&read_file_as_string(".config/default.yml")?)?;
if content.is_empty() {
return Err(ReadYamlConfigError::InvalidConfig(
"file is empty".to_string(),
));
}
if content.len() > 2 || content[0].is_array() {
return Err(ReadYamlConfigError::InvalidConfig(
"top-level should not be an array".to_string(),
));
}
let content = content[0]
.clone()
.into_hash()
.ok_or(ReadYamlConfigError::InvalidConfig(
"invalid format".to_string(),
))?;
let mut res = HashMap::new();
for (key, val) in content {
let Some(key) = key.as_str() else {
return Err(ReadYamlConfigError::InvalidConfig(format!(
"non-string key: {:?}",
key
)));
};
res.insert(key.to_owned(), val);
}
Ok(res)
}
#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "camelCase")]
struct Meta {
name: Option<String>,
description: Option<String>,
maintainer_name: Option<String>,
maintainer_email: Option<String>,
disable_registration: bool,
disable_local_timeline: bool,
disable_global_timeline: bool,
banner_url: Option<String>,
error_image_url: Option<String>,
icon_url: Option<String>,
cache_remote_files: bool,
enable_recaptcha: bool,
recaptcha_site_key: Option<String>,
recaptcha_secret_key: Option<String>,
local_drive_capacity_mb: i32,
remote_drive_capacity_mb: i32,
summaly_proxy: Option<String>,
enable_email: bool,
email: Option<String>,
smtp_secure: bool,
smtp_host: Option<String>,
smtp_port: Option<i32>,
smtp_user: Option<String>,
smtp_pass: Option<String>,
enable_service_worker: bool,
sw_public_key: Option<String>,
sw_private_key: Option<String>,
pinned_users: Vec<String>,
tos_url: Option<String>,
repository_url: String,
feedback_url: Option<String>,
use_object_storage: bool,
object_storage_bucket: Option<String>,
object_storage_prefix: Option<String>,
object_storage_base_url: Option<String>,
object_storage_endpoint: Option<String>,
object_storage_region: Option<String>,
object_storage_access_key: Option<String>,
object_storage_secret_key: Option<String>,
object_storage_port: Option<i32>,
object_storage_use_ssl: bool,
object_storage_set_public_read: bool,
object_storage_s3_force_path_style: bool,
object_storage_use_proxy: bool,
proxy_account_id: Option<String>,
enable_hcaptcha: bool,
hcaptcha_site_key: Option<String>,
hcaptcha_secret_key: Option<String>,
background_image_url: Option<String>,
logo_image_url: Option<String>,
pinned_clip_id: Option<String>,
allowed_hosts: Vec<String>,
secure_mode: bool,
private_mode: bool,
deepl_auth_key: Option<String>,
deepl_is_pro: bool,
email_required_for_signup: bool,
theme_color: Option<String>,
default_light_theme: Option<String>,
default_dark_theme: Option<String>,
enable_ip_logging: bool,
enable_active_email_validation: bool,
custom_motd: Vec<String>,
custom_splash_icons: Vec<String>,
disable_recommended_timeline: bool,
recommended_instances: Vec<String>,
enable_guest_timeline: bool,
default_reaction: String,
libre_translate_api_url: Option<String>,
libre_translate_api_key: Option<String>,
enable_server_machine_stats: bool,
enable_identicon_generation: bool,
donation_link: Option<String>,
mark_local_files_nsfw_by_default: bool,
antenna_limit: i32,
// TODO:
// more_urls: jsonb
// experimental_features: jsonb
}
async fn read_meta_table(
host: &str,
port: u16,
username: &str,
password: &str,
database: &str,
) -> Result<Meta, sqlx::Error> {
let mut conn = PgConnectOptions::new()
.host(host)
.port(port)
.username(username)
.password(password)
.database(database)
.connect()
.await?;
let meta: Meta = sqlx::query_as(r#"SELECT * FROM "meta""#)
.fetch_one(&mut conn)
.await?;
Ok(meta)
}
async fn read_old_config() -> Result<(HashMap<String, Yaml>, Meta), Error> {
let default_yml = read_default_yml()?;
let db = default_yml
.get("db")
.and_then(|db| db.as_hash())
.ok_or(Error::InvalidConfig("`db` is missing"))?;
let meta = read_meta_table(
db.get(&Yaml::String("host".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.host"))?,
db.get(&Yaml::String("port".to_string()))
.and_then(|v| v.as_i64())
.ok_or(Error::InvalidConfig("db.port"))? as u16,
db.get(&Yaml::String("user".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.user"))?,
db.get(&Yaml::String("pass".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.pass"))?,
db.get(&Yaml::String("db".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.db"))?,
)
.await?;
Ok((default_yml, meta))
}
fn create_new_server_config(
default_yml: HashMap<String, Yaml>,
meta: &Meta,
) -> Result<server::Config, Error> {
let db = default_yml
.get("db")
.and_then(|db| db.as_hash())
.ok_or(Error::InvalidConfig("`db` is missing"))?;
let redis = default_yml
.get("redis")
.and_then(|redis| redis.as_hash())
.ok_or(Error::InvalidConfig("`redis` is missing"))?;
let server_url = default_yml
.get("url")
.and_then(|url| url.as_str())
.ok_or(Error::InvalidConfig("`url` is missing"))?;
let parsed_server_url = Url::parse(server_url)?;
let protocol = parsed_server_url.scheme().to_owned();
if protocol != "https" && protocol != "http" {
return Err(Error::InvalidConfig(
"server URL must start with http:// or https://",
));
}
let hostname = parsed_server_url.host_str().ok_or(Error::InvalidConfig(
"hostname is missing in the server url",
))?;
let host = match parsed_server_url.port() {
Some(port) => format!("{}:{}", hostname, port),
None => hostname.to_owned(),
};
let repository_url = match meta.repository_url.as_ref() {
"https://codeberg.org/calckey/calckey"
| "https://codeberg.org/firefish/firefish"
| "https://git.joinfirefish.org/firefish/firefish"
| "https://firefish.dev/firefish/firefish" => None,
url => Some(url.to_owned()),
};
let mut server_config = server::Config {
config_revision: Revision::V1,
info: Some(server::Info {
name: meta.name.to_owned(),
description: meta.description.to_owned(),
maintainer_name: meta.maintainer_name.to_owned(),
contact_info: meta.maintainer_email.to_owned(),
open_registrations: !meta.disable_registration,
repository_url,
}),
timelines: Some(server::Timelines {
local: !meta.disable_local_timeline,
global: !meta.disable_global_timeline,
recommended: !meta.disable_recommended_timeline,
guest: meta.enable_guest_timeline,
}),
network: server::Network {
protocol: match protocol.as_str() {
"http" => Some(server::HttpProtocol::Http),
_ => None,
},
host,
port: default_yml
.get("port")
.and_then(|v| v.as_i64())
.ok_or(Error::InvalidConfig("port"))? as u16,
},
database: server::Database {
host: db
.get(&Yaml::String("host".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.host"))?
.to_string(),
port: db
.get(&Yaml::String("port".to_string()))
.and_then(|v| v.as_i64())
.ok_or(Error::InvalidConfig("db.port"))? as u16,
user: db
.get(&Yaml::String("user".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.user"))?
.to_string(),
password: db
.get(&Yaml::String("pass".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.pass"))?
.to_string(),
name: db
.get(&Yaml::String("db".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("db.db"))?
.to_string(),
},
cache_server: server::CacheServer {
host: redis
.get(&Yaml::String("host".to_string()))
.and_then(|v| v.as_str())
.ok_or(Error::InvalidConfig("redis.host"))?
.to_string(),
port: redis
.get(&Yaml::String("port".to_string()))
.and_then(|v| v.as_i64())
.ok_or(Error::InvalidConfig("redis.port"))? as u16,
user: match redis.get(&Yaml::String("user".to_string())) {
Some(user) => Some(
user.as_str()
.ok_or(Error::InvalidConfig("redis.user"))?
.to_string(),
),
None => None,
},
password: match redis.get(&Yaml::String("pass".to_string())) {
Some(pass) => Some(
pass.as_str()
.ok_or(Error::InvalidConfig("redis.pass"))?
.to_string(),
),
None => None,
},
index: match redis.get(&Yaml::String("db".to_string())) {
Some(db) => Some(db.as_i64().ok_or(Error::InvalidConfig("redis.db"))? as u8),
None => None,
},
prefix: match redis.get(&Yaml::String("prefix".to_string())) {
Some(prefix) => Some(
prefix
.as_str()
.ok_or(Error::InvalidConfig("redis.prefix"))?
.to_string(),
),
None => None,
},
},
id: None,
};
if let Some(id) = default_yml.get("cuid") {
let id = id.as_hash().ok_or(Error::InvalidConfig("cuid"))?;
server_config.id = Some(server::Id {
length: match id.get(&Yaml::String("length".to_string())) {
Some(length) => {
Some(length.as_i64().ok_or(Error::InvalidConfig("cuid.length"))? as u8)
}
None => None,
},
fingerprint: match id.get(&Yaml::String("fingerprint".to_string())) {
Some(fingerprint) => Some(
fingerprint
.as_str()
.ok_or(Error::InvalidConfig("cuid.fingerprint"))?
.to_string(),
),
None => None,
},
});
}
Ok(server_config)
}
fn create_new_client_config(meta: Meta) -> Result<client::Config, Error> {
let mut config = client::Config {
config_revision: Revision::V1,
theme: None,
image: None,
pinned_links: None,
};
if meta.theme_color.is_some()
|| meta.default_light_theme.is_some()
|| meta.default_dark_theme.is_some()
{
config.theme = Some(client::Theme {
light: meta.default_light_theme,
dark: meta.default_dark_theme,
color: meta.theme_color,
});
}
if meta.logo_image_url.is_some()
|| meta.icon_url.is_some()
|| meta.banner_url.is_some()
|| meta.background_image_url.is_some()
|| (meta.error_image_url.is_some()
&& meta.error_image_url.as_ref().unwrap() != "/static-assets/badges/error.webp")
{
config.image = Some(client::Image {
logo: meta.logo_image_url,
icon: meta.icon_url,
banner: meta.banner_url,
background: meta.background_image_url,
error: meta.error_image_url,
});
}
Ok(config)
}
pub(super) async fn run() -> Result<(), Error> {
println!("Updating the config to revision 1...");
let (default_yml, meta) = read_old_config().await?;
let server_config = create_new_server_config(default_yml, &meta)?;
let client_config = create_new_client_config(meta)?;
if !Path::new("config").exists() {
fs::create_dir("config").map_err(WriteTomlConfigError::Mkdir)?;
}
let mut server_toml =
fs::File::create(SERVER_CONFIG_PATH).map_err(WriteTomlConfigError::ServerWrite)?;
server_toml
.write(
toml::to_string_pretty(&server_config)
.map_err(WriteTomlConfigError::ServerSer)?
.as_bytes(),
)
.map_err(WriteTomlConfigError::ServerWrite)?;
cprintln!("<bold>{}</> has been created!", SERVER_CONFIG_PATH);
let mut client_toml =
fs::File::create(CLIENT_CONFIG_PATH).map_err(WriteTomlConfigError::ClientWrite)?;
client_toml
.write(
toml::to_string_pretty(&client_config)
.map_err(WriteTomlConfigError::ClientSer)?
.as_bytes(),
)
.map_err(WriteTomlConfigError::ClientWrite)?;
cprintln!("<bold>{}</> has been created!", CLIENT_CONFIG_PATH);
Ok(())
}