Compare commits

..

6 commits

11 changed files with 227 additions and 15 deletions

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# Fingerlink
General purpose WebFinger daemon and utilities for managing its served resources written in Rust.
## Basic usage
The program is split into two halves: the server daemon and the editing tools.
There are lots of command line parameters built in, so always check the subcommand's
help page with `fingerlink help [subcommand]` before using.
Every command that can alter the database defaults to dry-run mode where the actual database
isn't altered. To actually save the result of a command, supply the `--save` flag. The program
also always keeps one backup of the previous database state in case something goes wrong during
writing. The editing processes can also try to reload the running server process when
the `--reload-server` flag is supplied.
## Subcommands
### `serve`
Starts serving the database. By default, it binds to 127.0.0.1:8080, but these values
can be changed by using the `--bind-ip` and `--bind-port` parameters.
The server keeps a PID file around to enable the optional auto-refresh functionality of
the editing tools and to prevent accidentally running multiple instances of the server
at once.
### `fetch`
The simplest way to get new records into the database. By supplying a list of
Fediverse-compatible handles, their resource data can be quickly and easily imported
into the database.
The command tries to prevent multiple resources from taking the same query strings,
and to help with this, there are multiple implemented strategies to handle collisions.
- `terminate` simply stops the import process if a collision is detected. The resources
processed up to the collision are still inserted.
- `skip` skips over every resource that would collide with another resource already
in the database
- `overwrite-single-skip-multiple` overwrites the old resource with the new one, but only
if there's just a single already saved resource that the new resource will collide with.
Otherwise, it skips. This is the default, as it's the most useful for updating old records.
- `overwrite-multiple` overwrites the old resource or resources that the new record collides
with. Can be highly destructive, be careful!
In case you want to batch-import handles from a file, you can use the `--handles-are-files` flag.
When it's enabled, the command opens the provided handles as files and reads their contents.
One line, one query.
It can be very useful to replace the old domain in the `subject` field with the one that the
service will run on. In this case you can supply the `--new-domain` parameter. It runs a very
simple process
1. Takes the `subject` field of the returned resource and cuts off everything after the last @ sign.
2. Appends the string provided with the `--new-domain` parameter.
3. Puts it in the `subject` field instead of the old subject
4. Appends the old subject to the `aliases` array
### `query`
A simple command to get the response JSON to a query without `curl`. It pretty-prints the JSON.
### `editor`
Uses the editor defined in `$EDITOR` (or `vi` if the environment variable isn't set) to edit
the JSON record returned to the provided query. Like every editor command, it runs in dry-run
mode and requires the `--save` flag to be present to actually modify the database.
If the `--create` flag is present, then if the query doesn't return a resource, it creates a
new blank resource to fill out and insert into the database.
### `init`
Creates a new blank database file. If one is already present, it requires the `--force` flag
to be present otherwise it refuses to delete the database.

View file

@ -66,7 +66,7 @@ pub struct SaveSettings {
pub save: bool, pub save: bool,
/// Save behaviour when encountering a collision /// Save behaviour when encountering a collision
#[arg(long, short = 'c', default_value = "overwrite-single-skip-multiple")] #[arg(long, default_value = "overwrite-single-skip-multiple")]
pub collision_handling: CollisionHandling, pub collision_handling: CollisionHandling,
} }
@ -100,6 +100,9 @@ pub enum Command {
save: SaveSettings, save: SaveSettings,
/// The resource to query for and edit /// The resource to query for and edit
resource: String, resource: String,
/// Create a blank resource with RESOURCE as the subject if the query fails
#[arg(long, short = 'c')]
create: bool,
#[command(flatten)] #[command(flatten)]
server_reload: ServerReloadOptions, server_reload: ServerReloadOptions,
}, },

View file

@ -1,18 +1,31 @@
use crate::args_parser::SaveSettings; use crate::args_parser::SaveSettings;
use crate::editor::open_in_editor::open_resource_in_editor; use crate::editor::open_in_editor::open_resource_in_editor;
use crate::schema::lookup_handler::LookupHandler; use crate::schema::lookup_handler::LookupHandler;
use crate::schema::resource::Resource;
use std::path::PathBuf; use std::path::PathBuf;
use tracing::info; use tracing::{error, info};
pub fn editor(database_path: PathBuf, save_settings: SaveSettings, resource: String) { pub fn editor(database_path: PathBuf, save_settings: SaveSettings, resource: String, create: bool) {
let resources = LookupHandler::load(&database_path).unwrap(); let resources = LookupHandler::load(&database_path).unwrap();
let (index, resource) = resources let (index, resource) = match resources.lookup_with_index(resource.as_str()) {
.lookup_with_index(resource.as_str()) None => {
.expect("Couldn't find a resource for that query"); if create {
(None, Resource::new(resource))
} else {
error!("Couldn't find a resource for the query \"{resource}\"");
return;
}
}
Some((index, resource)) => (Some(index), resource.clone()),
};
let resource = open_resource_in_editor(resource).unwrap(); let resource = open_resource_in_editor(resource).unwrap();
if save_settings.save { if save_settings.save {
let mut resources = resources.into_inner(); let mut resources = resources.into_inner();
resources.0[index] = resource; if let Some(index) = index {
resources.0[index] = resource;
} else {
resources.0.push(resource);
}
resources.save(database_path).unwrap(); resources.save(database_path).unwrap();
} else { } else {
info!("To save edits, run this command with the -s flag") info!("To save edits, run this command with the -s flag")

View file

@ -4,7 +4,7 @@ use std::path::PathBuf;
pub fn query(database_path: PathBuf, handle: String) { pub fn query(database_path: PathBuf, handle: String) {
let data = LookupHandler::load(database_path).unwrap(); let data = LookupHandler::load(database_path).unwrap();
let resource = data.lookup(handle.trim()).unwrap(); let resource = data.lookup(handle.trim().to_lowercase().as_str()).unwrap();
serde_json::to_writer_pretty(stdout(), resource).unwrap(); serde_json::to_writer_pretty(stdout(), resource).unwrap();
println!() println!()
} }

View file

@ -1,4 +1,5 @@
use crate::schema::resource::Resource; use crate::schema::resource::Resource;
use crate::schema::resource_complete_serialisation::ResourceComplete;
use std::fmt::{Debug, Display}; use std::fmt::{Debug, Display};
use std::io::{Read, Seek, SeekFrom, Write}; use std::io::{Read, Seek, SeekFrom, Write};
use std::process::Command; use std::process::Command;
@ -80,9 +81,11 @@ pub enum ResourceEditingError {
/// Opens the provided `resource` in the system editor and returns the edited version or an error if something goes wrong. /// Opens the provided `resource` in the system editor and returns the edited version or an error if something goes wrong.
#[instrument(skip(resource))] #[instrument(skip(resource))]
pub fn open_resource_in_editor(resource: &Resource) -> Result<Resource, ResourceEditingError> { pub fn open_resource_in_editor(resource: Resource) -> Result<Resource, ResourceEditingError> {
use ResourceEditingError::*; use ResourceEditingError::*;
let printed = serde_json::to_string_pretty(resource).map_err(PrettyPrintFailed)?; let resource = ResourceComplete::from_resource(resource);
let printed = serde_json::to_string_pretty(&resource).map_err(PrettyPrintFailed)?;
let edited = spawn_editor(printed)?; let edited = spawn_editor(printed)?;
serde_json::from_str(edited.as_str()).map_err(ParseFailed) let parsed: ResourceComplete = serde_json::from_str(edited.as_str()).map_err(ParseFailed)?;
Ok(parsed.into_resource())
} }

View file

@ -57,9 +57,10 @@ fn main() {
Command::Editor { Command::Editor {
save, save,
resource, resource,
create,
server_reload, server_reload,
} => { } => {
editor(data_paths.database_path, save, resource); editor(data_paths.database_path, save, resource, create);
reload(data_paths.pid_file_path, server_reload); reload(data_paths.pid_file_path, server_reload);
} }

View file

@ -1,3 +1,4 @@
pub mod lookup_handler; pub mod lookup_handler;
pub mod resource; pub mod resource;
pub mod resource_complete_serialisation;
pub mod resource_list; pub mod resource_list;

View file

@ -23,6 +23,15 @@ impl Display for Resource {
} }
impl Resource { impl Resource {
pub fn new(subject: String) -> Self {
Self {
subject,
aliases: None,
properties: None,
links: None,
}
}
/// Returns the aliases of the given record. If the `aliases` field is /// Returns the aliases of the given record. If the `aliases` field is
/// entirely missing, returns &[]. /// entirely missing, returns &[].
pub fn keys(&self) -> impl Iterator<Item = &String> { pub fn keys(&self) -> impl Iterator<Item = &String> {

View file

@ -0,0 +1,112 @@
use crate::schema::resource::{Link, Resource};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Same as `Resource`, but doesn't have any of the skip_serializing_if fields and options set so the structure is more friendly to editing through the `editor` command
#[derive(Serialize, Deserialize)]
pub struct ResourceComplete {
subject: String,
aliases: Vec<String>,
properties: HashMap<String, Option<String>>,
links: Vec<LinkComplete>,
}
impl ResourceComplete {
pub fn from_resource(value: Resource) -> Self {
let Resource {
subject,
aliases,
properties,
links,
} = value;
Self {
subject,
aliases: aliases.unwrap_or_default(),
properties: properties.unwrap_or_default(),
links: links
.map(|vec| vec.into_iter().map(LinkComplete::from_link).collect())
.unwrap_or_default(),
}
}
pub fn into_resource(self) -> Resource {
let ResourceComplete {
subject,
aliases,
properties,
links,
} = self;
Resource {
subject,
aliases: if aliases.is_empty() {
None
} else {
Some(aliases)
},
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
links: if links.is_empty() {
None
} else {
Some(links.into_iter().map(LinkComplete::into_link).collect())
},
}
}
}
/// Same as `Link`, but doesn't have any of the skip_serializing_if fields set so the structure is more friendly to editing through the `editor` command
#[derive(Serialize, Deserialize)]
pub struct LinkComplete {
rel: String,
media_type: Option<String>,
href: Option<String>,
titles: HashMap<String, String>,
properties: HashMap<String, Option<String>>,
}
impl LinkComplete {
fn from_link(value: Link) -> Self {
let Link {
rel,
media_type,
href,
titles,
properties,
} = value;
Self {
rel,
media_type,
href,
titles: titles.unwrap_or_default(),
properties: properties.unwrap_or_default(),
}
}
fn into_link(self) -> Link {
let LinkComplete {
rel,
media_type,
href,
titles,
properties,
} = self;
Link {
rel,
media_type,
href,
titles: if titles.is_empty() {
None
} else {
Some(titles)
},
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
}
}
}

View file

@ -50,7 +50,8 @@ impl ResourceList {
pub fn save(&self, path: impl AsRef<Path> + Debug) -> Result<(), ResourceSaveError> { pub fn save(&self, path: impl AsRef<Path> + Debug) -> Result<(), ResourceSaveError> {
info!("Creating backup before writing..."); info!("Creating backup before writing...");
let path = path.as_ref(); let path = path.as_ref();
std::fs::copy(path, path.with_extension("bak")).map_err(ResourceSaveError::BackupFailed)?; std::fs::rename(path, path.with_extension("bak"))
.map_err(ResourceSaveError::BackupFailed)?;
info!("Writing data to {path:?}..."); info!("Writing data to {path:?}...");
let file = std::fs::File::create(path).map_err(ResourceSaveError::FileOpen)?; let file = std::fs::File::create(path).map_err(ResourceSaveError::FileOpen)?;
self.save_to_writer(file) self.save_to_writer(file)

View file

@ -81,7 +81,11 @@ async fn run_webfinger_query(
info!("Received query with {uri}"); info!("Received query with {uri}");
let query = uri.query().ok_or(StatusCode::BAD_REQUEST)?; let query = uri.query().ok_or(StatusCode::BAD_REQUEST)?;
debug!("Query string is {query}"); debug!("Query string is {query}");
let query = urlencoding::decode(query).map_err(|e| StatusCode::BAD_REQUEST)?; let query = urlencoding::decode(query).map_err(|e| {
debug!("Failed to decode query string with error {e}");
StatusCode::BAD_REQUEST
})?;
debug!("Decoded query string is {query}");
let params = query let params = query
.split('&') .split('&')
.filter_map(|query_part| { .filter_map(|query_part| {
@ -107,7 +111,7 @@ async fn run_webfinger_query(
let resource = datastore let resource = datastore
.read() .read()
.await .await
.lookup(resource_query) .lookup(resource_query.to_lowercase().as_str())
.ok_or(StatusCode::NOT_FOUND)? .ok_or(StatusCode::NOT_FOUND)?
.clone(); .clone();
debug!("Found resource: {resource:?}"); debug!("Found resource: {resource:?}");