diff --git a/.gitignore b/.gitignore index 1c4a99df0..9e72f21ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +lib/LANraragi/Plugin/Managed + node_modules content thumb diff --git a/lib/LANraragi.pm b/lib/LANraragi.pm index 4c1b6c296..7b574e153 100644 --- a/lib/LANraragi.pm +++ b/lib/LANraragi.pm @@ -24,6 +24,8 @@ use LANraragi::Utils::I18NInitializer; use LANraragi::Model::Search; use LANraragi::Model::Config; +use LANraragi::Model::Plugins; +use LANraragi::Model::Server; use LANraragi::Model::Setup qw(first_install_actions); use LANraragi::Model::Metrics; @@ -138,6 +140,14 @@ sub startup { # through LRR's rotating logger pipeline. $self->log( get_logger( "Mojolicious", "mojo" ) ); + # Reconcile discovered plugins with Redis state. + my $redis_config = $self->LRR_CONF->get_redis_config; + LANraragi::Model::Plugins::scan_plugins($redis_config); + + # Reset restart flag. + LANraragi::Model::Server::clear_restart_pending($redis_config); + $redis_config->quit(); + #Plugin listing my @plugins = get_plugins("metadata"); foreach my $pluginfo (@plugins) { diff --git a/lib/LANraragi/Controller/Api/Other.pm b/lib/LANraragi/Controller/Api/Other.pm index 6aad14a73..e20d7f6f7 100644 --- a/lib/LANraragi/Controller/Api/Other.pm +++ b/lib/LANraragi/Controller/Api/Other.pm @@ -6,7 +6,9 @@ use Redis; use LANraragi::Model::Stats; use LANraragi::Model::Opds; +use LANraragi::Model::Server qw(is_restart_pending); use LANraragi::Utils::Generic qw(render_api_response); +use LANraragi::Utils::Logging qw(get_logger); use LANraragi::Utils::Plugins qw(get_plugin get_plugins use_plugin); sub serve_serverinfo { @@ -14,6 +16,7 @@ sub serve_serverinfo { my $redis = $self->LRR_CONF->get_redis_config; my $last_clear = $redis->hget( "LRR_SEARCHCACHE", "created" ) || time; + my $restart = is_restart_pending($redis); my $arc_stat = LANraragi::Model::Stats::get_archive_count; my $page_stat = LANraragi::Model::Stats::get_page_stat; $redis->quit(); @@ -39,6 +42,7 @@ sub serve_serverinfo { total_pages_read => $page_stat, total_archives => $arc_stat, cache_last_cleared => $last_clear, + restart_required => $restart ? \1 : \0, excluded_namespaces => [ split( /\s*,\s*/, $self->LRR_CONF->get_excludednamespaces ) ] @@ -90,6 +94,8 @@ sub list_plugins { my $type = $self->stash('type'); my @plugins = get_plugins($type); + my $redis = $self->LRR_CONF->get_redis_config; + my $logger = get_logger( "Plugins", "lanraragi" ); foreach my $plugin (@plugins) { if ( ref( $plugin->{parameters} ) eq 'HASH' ) { @@ -99,8 +105,22 @@ sub list_plugins { } $plugin->{parameters} = \@parameters_array; } + + my $namerds = "LRR_PLUGIN_" . uc( $plugin->{namespace} ); + $plugin->{registry} = $redis->hget( $namerds, "installed_registry" ); + $plugin->{sha256} = $redis->hget( $namerds, "installed_sha256" ); + + # Usually installed_version shouldn't be different from version. + my $installed_version = $redis->hget( $namerds, "installed_version" ); + my $plugin_version = $plugin->{version}; + if ( defined $installed_version && $installed_version ne $plugin_version ) { + $logger->warn( + "Plugin $plugin installed_version='$installed_version' but plugin->version='$plugin_version'" + ); + } } + $redis->quit(); $self->render( openapi => \@plugins ); } diff --git a/lib/LANraragi/Controller/Api/Plugins.pm b/lib/LANraragi/Controller/Api/Plugins.pm new file mode 100644 index 000000000..f4965ae9e --- /dev/null +++ b/lib/LANraragi/Controller/Api/Plugins.pm @@ -0,0 +1,59 @@ +package LANraragi::Controller::Api::Plugins; +use Mojo::Base 'Mojolicious::Controller'; + +use LANraragi::Model::Plugins; +use LANraragi::Utils::Generic qw(render_api_response exec_with_lock); + +# Install a managed plugin. +sub install_plugin { + my $self = shift->openapi->valid_input or return; + my $body = $self->req->json; + my $namespace = $body->{namespace}; + my $registry_id = $body->{registry}; + my $version = $body->{version}; + my $force = $body->{force} // 0; + + my $jobid = $self->minion->enqueue( + install_plugin => [ $namespace, $registry_id, $version, $force ] => { priority => 0, attempts => 1 } ); + + $self->render( + openapi => { + operation => "install_plugin", + namespace => $namespace, + success => 1, + job => $jobid, + } + ); +} + +# Uninstall a managed plugin. +sub uninstall_plugin { + my $self = shift->openapi->valid_input or return; + my $namespace = $self->stash('plugin_namespace'); + + return unless exec_with_lock( + $self, + "plugin-write:" . uc($namespace), + "uninstall_plugin", + $namespace, + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $message ) = LANraragi::Model::Plugins::uninstall_plugin( + $namespace, $redis + ); + $redis->quit(); + + unless ( $status == 200 ) { + $self->render( + openapi => { operation => "uninstall_plugin", error => $message, success => 0 }, + status => $status + ); + return; + } + + render_api_response( $self, "uninstall_plugin" ); + } + ); +} + +1; diff --git a/lib/LANraragi/Controller/Api/Registry.pm b/lib/LANraragi/Controller/Api/Registry.pm new file mode 100644 index 000000000..ad0c85bda --- /dev/null +++ b/lib/LANraragi/Controller/Api/Registry.pm @@ -0,0 +1,294 @@ +package LANraragi::Controller::Api::Registry; +use Mojo::Base 'Mojolicious::Controller'; + +use LANraragi::Model::Registry; +use LANraragi::Utils::Generic qw(render_api_response exec_with_lock); +use LANraragi::Utils::Logging qw(get_logger); + +sub list_registries { + my $self = shift->openapi->valid_input or return; + my $redis = $self->LRR_CONF->get_redis_config; + + my @registries = LANraragi::Model::Registry::get_registry_list($redis); + $redis->quit(); + + $self->render( + openapi => { + operation => "list_registries", + success => 1, + registries => \@registries, + } + ); +} + +# create a registry, which can be of provider github, gitea, cdn, or local. +# git providers (github/gitea): require url (https) and ref. +# cdn provider: requires url (http or https base URL). +# local provider: requires path (to local registry directory) +sub create_registry { + my $self = shift->openapi->valid_input or return; + my $body = $self->req->json; + my $provider = $body->{provider}; + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Create registry requested (provider: " . ( defined $provider ? $provider : "" ) . ")."); + + return unless exec_with_lock( + $self, + "registry-create", + "create_registry", + "registry", + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my %config = ( name => $body->{name}, provider => $provider ); + + if ( $provider eq "github" || $provider eq "gitea" ) { + $config{url} = $body->{url}; + $config{ref} = $body->{ref}; + } elsif ( $provider eq "cdn" ) { + $config{url} = $body->{url}; + } elsif ( $provider eq "local" ) { + $config{path} = $body->{path}; + } + + my ( $registry_id, $error ) = LANraragi::Model::Registry::create_registry( \%config, $redis ); + $redis->quit(); + + if ($error) { + render_api_response( $self, "create_registry", $error ); + return; + } + + $self->render( + openapi => { + operation => "create_registry", + success => 1, + error => "", + id => $registry_id, + } + ); + } + ); +} + +# Get registry by its registry ID. +sub get_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $redis = $self->LRR_CONF->get_redis_config; + + my ( $registry, $status, $error ) = LANraragi::Model::Registry::get_registry( $registry_id, $redis ); + $redis->quit(); + + unless ($registry) { + $self->render( + openapi => { + operation => "get_registry", + error => $error, + success => 0, + }, + status => $status + ); + return; + } + + $self->render( + openapi => { + operation => "get_registry", + success => 1, + error => "", + registry => $registry, + } + ); +} + +# Update a registry by its ID. +# The registry must exist (otherwise, use create_registry) +sub update_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $body = $self->req->json; + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Update registry requested for '$registry_id'."); + + return unless exec_with_lock( + $self, + "registry-write:$registry_id", + "update_registry", + $registry_id, + sub { + my %updated_registry; + for my $field (qw(name provider url ref path)) { + $updated_registry{$field} = $body->{$field} if exists $body->{$field}; + } + + unless ( %updated_registry ) { + render_api_response( $self, "update_registry", "Nothing to update." ); + return; + } + + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $error ) = LANraragi::Model::Registry::update_registry( + $registry_id, $redis, %updated_registry + ); + $logger->info("Update registry result for '$registry_id': status=$status"); + $redis->quit(); + + unless ( $status == 200 ) { + $logger->warn("Update registry failed for '$registry_id': $error"); + $self->render( + openapi => { operation => "update_registry", error => $error, success => 0 }, + status => $status + ); + return; + } + + $self->render( + openapi => { + operation => "update_registry", + success => 1, + error => "", + id => $registry_id, + } + ); + } + ); +} + +# Delete registry by its registry ID. +sub delete_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Delete registry requested for '$registry_id'."); + + return unless exec_with_lock( + $self, + "registry-write:$registry_id", + "delete_registry", + $registry_id, + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $error ) = LANraragi::Model::Registry::delete_registry( + $registry_id, $redis + ); + $redis->quit(); + + unless ( $status == 200 ) { + $logger->warn("Delete registry failed for '$registry_id': $error"); + $self->render( + openapi => { operation => "delete_registry", error => $error, success => 0 }, + status => $status + ); + return; + } + + render_api_response( $self, "delete_registry" ); + } + ); +} + +sub get_ougi { + my $self = shift->openapi->valid_input or return; + my $redis = $self->LRR_CONF->get_redis_config; + + my $registry_id = LANraragi::Model::Registry::get_ougi($redis); + $redis->quit(); + + $self->render( + openapi => { + operation => "get_ougi", + success => 1, + id => $registry_id, + } + ); +} + +sub update_ougi { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Update default registry to '$registry_id' requested."); + + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $reg_id, $message ) = + LANraragi::Model::Registry::update_ougi( $registry_id, $redis ); + $redis->quit(); + + unless ( $status == 200 ) { + return $self->render( + openapi => { + operation => "update_ougi", + success => 0, + error => $message, + }, + status => $status, + ); + } + + return $self->render( + openapi => { + operation => "update_ougi", + success => 1, + id => $reg_id, + }, + status => 200, + ); +} + +sub remove_ougi { + my $self = shift->openapi->valid_input or return; + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Remove default registry requested."); + + my $redis = $self->LRR_CONF->get_redis_config; + my $registry_id = LANraragi::Model::Registry::remove_ougi($redis); + $redis->quit(); + + return $self->render( + openapi => { + operation => "remove_ougi", + success => 1, + id => $registry_id, + } + ); +} + +# Refresh registry and return registry.json index. +sub refresh_registry { + my $self = shift->openapi->valid_input or return; + my $registry_id = $self->stash('id'); + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Refresh registry requested for '$registry_id'."); + + return unless exec_with_lock( + $self, + "registry-write:$registry_id", + "refresh_registry", + $registry_id, + sub { + my $redis = $self->LRR_CONF->get_redis_config; + my ( $status, $registry_index, $message ) = LANraragi::Model::Registry::refresh_registry( + $registry_id, $redis + ); + $redis->quit(); + + unless ( $status == 200 ) { + $self->render( + openapi => { operation => "refresh_registry", error => $message, success => 0 }, + status => $status + ); + return; + } + + $self->render( + openapi => { + operation => "refresh_registry", + success => 1, + index => $registry_index, + } + ); + } + ); +} + +1; diff --git a/lib/LANraragi/Controller/Plugins.pm b/lib/LANraragi/Controller/Plugins.pm index 7dd44d56d..ff5d6cf65 100644 --- a/lib/LANraragi/Controller/Plugins.pm +++ b/lib/LANraragi/Controller/Plugins.pm @@ -141,9 +141,10 @@ sub save_config { } elsif ( ref( $pluginfo->{parameters} ) eq 'HASH' ) { - # TODO: remove this line (and the ARRAY check above) - # after plugins with array parameters are deprecated - $redis->del($namerds); + # TODO: del nukes plugin on config save, hence commented out + # # TODO: remove this line (and the ARRAY check above) + # # after plugins with array parameters are deprecated + # $redis->del($namerds); #Loop through the namespaced request parameters foreach my $key ( keys %{ $pluginfo->{parameters} } ) { diff --git a/lib/LANraragi/Model/Plugins.pm b/lib/LANraragi/Model/Plugins.pm index ae59ba43f..d2248c839 100644 --- a/lib/LANraragi/Model/Plugins.pm +++ b/lib/LANraragi/Model/Plugins.pm @@ -8,8 +8,12 @@ use warnings; use utf8; use feature 'fc'; +use Cwd qw(getcwd); +use Digest::SHA qw(sha256_hex); +use File::Path qw(make_path); use Redis; use Encode; +use Mojo::File; use Mojo::JSON qw(decode_json encode_json); use Mojo::UserAgent; use Data::Dumper; @@ -20,9 +24,22 @@ use LANraragi::Utils::Generic qw(exec_with_lock_pure); use LANraragi::Utils::Archive qw(extract_thumbnail); use LANraragi::Utils::Logging qw(get_logger); use LANraragi::Utils::Tags qw(rewrite_tags split_tags_to_array); -use LANraragi::Utils::Plugins qw(get_plugin_parameters get_plugin); +use LANraragi::Utils::Plugins qw(get_plugin_parameters get_plugin register_plugin unregister_plugin check_plugin_loads); use LANraragi::Utils::Redis qw(redis_decode); -use LANraragi::Utils::Path qw(create_path); +use LANraragi::Utils::Path qw(create_path package_to_path); +use LANraragi::Utils::Registry qw( + fetch_registry_resource + find_package_conflict + find_namespace_conflict + validate_registry_artifact_path + resolve_max_version + MANAGED_TYPE_DIRS +); +use LANraragi::Model::Registry; +use LANraragi::Model::Server qw(set_restart_pending); + +# Max plugin file size for slurp (files will/should never reach this size anyways but stops OOM) +use constant MAX_PLUGIN_FILE_SIZE => 1 * 1024 * 1024; # 1 MB # Sub used by Auto-Plugin. sub exec_enabled_plugins_on_file ($id) { @@ -314,4 +331,500 @@ sub has_old_style_params (%params) { return ( exists $params{'customargs'} ); } +# Install a plugin from a registry whose index has been refreshed and cached. +# namespace, registry_id, version are required to identify the plugin to install. +# LRR assumes managed plugins are hash-style, and doesn't handle signature +# changes from one plugin version to another. +sub install_plugin { + my ( $namespace, $redis, $registry_id, $version, $force ) = @_; + my $logger = get_logger( "Registry", "lanraragi" ); + my $namerds = "LRR_PLUGIN_" . uc($namespace); + + # Since a plugin namespace can be built-in or managed, we'll need to check redis first. + # If a plugin exists and is not managed, installation may not continue. + # The user will have to remove/uninstall the existing plugin before installing a managed plugin + # by the same namespace. + if ( $redis->hexists( $namerds, "installed_path" ) ) { + my $source = infer_plugin_origin( $namerds, $redis ); + my $currentreg = $redis->hget( $namerds, "installed_registry" ); + my $currentver = $redis->hget( $namerds, "installed_version" ); + + # only managed plugins can be upgraded. + if ( $source ne "managed" ) { + return ( 400, undef, + "Plugin '$namespace' already exists as a $source plugin. Remove it first before installing from a registry." ); + } + + # cross-registry overwrites require force installation. + my $is_cross_registry = $currentreg && $currentreg ne $registry_id; + if ( $is_cross_registry && !$force ) { + return ( 400, undef, + "Plugin '$namespace' already installed from '$currentreg' (v$currentver). Use force to overwrite." ); + } + } + + # registry validation + # check that registry exists, and that registry index exists. + my ( $registry, $lookup_status, $lookup_error ) = + LANraragi::Model::Registry::get_registry( $registry_id, $redis ); + return ( $lookup_status, undef, $lookup_error ) unless $registry; + my ($registry_timestamp) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$registry_timestamp"; + unless ( $redis->exists($registry_index_key) ) { + return ( 409, undef, "No registry index cached. Run refresh first." ); + } + + # Plugin installable validation + my $registry_index_json = $redis->get($registry_index_key); + my $registry_index = decode_json($registry_index_json); + my $registry_plugins = $registry_index->{plugins}; + unless ( $registry_plugins->{$namespace} ) { + return ( 404, undef, "Plugin '$namespace' not found in registry." ); + } + my $plugin_record = $registry_plugins->{$namespace}; + unless ( ref $plugin_record->{versions} eq "HASH" && keys %{ $plugin_record->{versions} } ) { + return ( 404, undef, "No versions found for plugin '$namespace'." ); + } + $version = resolve_max_version($plugin_record) unless defined $version; + unless ( $plugin_record->{versions}{$version} ) { + return ( 404, undef, "Version '$version' not found for plugin '$namespace'." ); + } + + # Get the plugin metadata by version and validate artifact path + my $plugin_metadata = $plugin_record->{versions}{$version}; + my $artifact_path = $plugin_metadata->{artifact}; + my ( $artifact_valid, $artifact_error ) = validate_registry_artifact_path($artifact_path); + unless ( $artifact_valid ) { + return ( 400, undef, $artifact_error ); + } + + # Whether this namespace was already registered before this install. + my $was_registered = $redis->hexists( $namerds, "installed_path" ) ? 1 : 0; + + my $abs_installed_path; + if ($was_registered) { + $abs_installed_path = resolve_installed_path( $redis->hget( $namerds, "installed_path" ) ); + } + + # Retrieve get the plugin contents + my ( $fetch_status, $plugin_content, $fetch_error ) = + fetch_registry_resource( $registry, $artifact_path, MAX_PLUGIN_FILE_SIZE ); + unless ( $fetch_status == 200 ) { + $logger->warn( + "Failed to fetch plugin artifact '$artifact_path' for '$namespace' from registry '$registry_id': $fetch_error" + ); + return ( $fetch_status, undef, $fetch_error ); + } + + # Post-retrieval validation and extract installation data + my $plugin_type = $plugin_record->{type}; + my ( $validated, $error ) = validate_managed_plugin( $plugin_content, $namespace, $plugin_metadata, $plugin_type, $abs_installed_path ); + if ($error) { + $logger->warn("Managed plugin validation failed for '$namespace' from registry '$registry_id': $error"); + return ( 422, undef, $error ); + } + my $install_dir = $validated->{install_dir}; + my $install_path = $validated->{install_path}; + my $install_relpath = package_to_path( $validated->{package} ); + + # Begin installation with rollback + $logger->info("Installing plugin '$namespace' v$version from registry '$registry_id'"); + make_path($install_dir) unless -d $install_dir; + my $op_desc = "install of plugin '$namespace' (version=$version, registry=$registry_id)"; + my @undo; + my $do_rollback = sub { + my ($reason) = @_; + $logger->error("$op_desc failed: $reason; attempting rollback"); + while ( my $entry = pop @undo ) { + my ( $stage, $undo_sub ) = @$entry; + my $undo_err = $undo_sub->(); + if ( defined $undo_err ) { + $logger->error("rollback of $op_desc failed during $stage: $undo_err"); + return ( 500, undef, + "Plugin '$namespace' failed and rollback was incomplete; manual cleanup may be required." ); + } + } + return; + }; + + my $backup_path; + if ( -e $install_path ) { + # stage: back up the existing artifact + # rollback: restore it from the backup + $backup_path = "$install_path.rollback"; + unlink $backup_path if -e $backup_path; + unless ( rename $install_path, $backup_path ) { + my $err = "$!"; + $logger->error("Cannot back up existing artifact at $install_path: $err"); + return ( 500, undef, "Cannot back up existing artifact: $err" ); + } + push @undo, [ + "restore previous artifact from $backup_path", + sub { + rename( $backup_path, $install_path ) ? undef : "$!"; + }, + ]; + } + + # stage: ensure new plugin is written to install_path + # rollback: remove install_path file + eval { Mojo::File->new($install_path)->spew($plugin_content) }; + if ($@) { + my $err = $@; + if ( my @resp = $do_rollback->("Cannot write plugin file: $err") ) { + return @resp; + } + return ( 500, undef, "Cannot write plugin file during installation: $err" ); + } + push @undo, [ + "unlink artifact at $install_path", + sub { + return unless -e $install_path; + unlink($install_path) ? undef : "$!"; + }, + ]; + + # At this point, the file operation part of installation is complete! + $logger->info("Installed plugin '$namespace' to $install_path"); + + # stage: update database with new plugin provenance + # rollback: restore old plugin provenance + my %old_plugin_metadata; + for my $field (qw(installed_path installed_version installed_registry installed_sha256 type)) { + my $val = $redis->hget( $namerds, $field ); + $old_plugin_metadata{$field} = $val if defined $val; + } + my $provenance_script = <<~'LUA'; + if redis.call("EXISTS", KEYS[1]) == 0 then + return 0 + end + redis.call("HSET", KEYS[2], "installed_path", ARGV[1]) + redis.call("HSET", KEYS[2], "installed_version", ARGV[2]) + redis.call("HSET", KEYS[2], "installed_registry", ARGV[3]) + redis.call("HSET", KEYS[2], "installed_sha256", ARGV[4]) + redis.call("HSET", KEYS[2], "type", ARGV[5]) + return 1 + LUA + my $restore_script = <<~'LUA'; + redis.call("HDEL", KEYS[1], "installed_path", "installed_version", "installed_registry", "installed_sha256", "type") + for i = 1, #ARGV, 2 do + redis.call("HSET", KEYS[1], ARGV[i], ARGV[i + 1]) + end + return 1 + LUA + my $provenance_written = eval { + $redis->eval( + $provenance_script, 2, $registry_id, $namerds, + $install_relpath, + $plugin_metadata->{version}, + $registry_id, + $plugin_metadata->{sha256}, + $plugin_type + ); + }; + if ($@) { + my $err = $@; + if ( my @resp = $do_rollback->("Redis error during provenance write: $err") ) { + return @resp; + } + return ( 500, undef, "Redis error while writing provenance." ); + } + unless ($provenance_written) { + if ( my @resp = $do_rollback->("Registry was deleted during install") ) { + return @resp; + } + return ( 409, undef, "Registry was deleted during install." ); + } + push @undo, [ + ( exists $old_plugin_metadata{installed_path} + ? "restore old_plugin_metadata provenance for $namerds" + : "clear provenance for $namerds" ), + sub { + my @argv; + for my $field (qw(installed_path installed_version installed_registry installed_sha256 type)) { + push @argv, $field, $old_plugin_metadata{$field} if exists $old_plugin_metadata{$field}; + } + eval { $redis->eval( $restore_script, 1, $namerds, @argv ) }; + $@ ? "$@" : undef; + }, + ]; + + # Check the artifact loads + my ( $check_status, $check_error ) = check_plugin_loads($install_relpath); + if ( $check_status ne 'ok' ) { + my ( $code, $reason ) = + $check_status eq 'invalid' + ? ( 422, "Plugin '$namespace' failed to load: $check_error" ) + : ( 500, "Plugin '$namespace' load check failed: $check_error" ); + if ( my @resp = $do_rollback->($reason) ) { + return @resp; + } + return ( $code, undef, $reason ); + } + + if ( $backup_path && -e $backup_path ) { + unlink $backup_path or $logger->warn("Could not remove rollback backup at $backup_path: $!"); + } + set_restart_pending($redis) if $was_registered; + + my %installed_meta = ( + name => $plugin_metadata->{name}, + version => $plugin_metadata->{version}, + registry => $registry_id, + sha256 => $plugin_metadata->{sha256}, + ); + return ( 200, \%installed_meta, undef ); +} + +# Uninstall a plugin by deleting it from disk and cleaning up Redis. +# Does not remove configuration settings. +sub uninstall_plugin { + my ( $namespace, $redis ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + my $namerds = "LRR_PLUGIN_" . uc($namespace); + $logger->info("Uninstalling plugin '$namespace'"); + + # Ensure an existing install path for uninstall + my $abs_installed_path; + if ( $redis->hexists( $namerds, "installed_path" ) ) { + $abs_installed_path = resolve_installed_path( $redis->hget( $namerds, "installed_path" ) ); + } + unless ($abs_installed_path) { + return ( 404, "Plugin '$namespace' has no install path recorded." ); + } + + # We don't touch builtin plugins! + my $plugin_origin = infer_plugin_origin( $namerds, $redis ); + if ( $plugin_origin eq "builtin" ) { + return ( 403, "Cannot uninstall built-in plugin '$namespace'." ); + } + + if ( -e $abs_installed_path ) { + unlink $abs_installed_path or do { + return ( 500, "Couldn't delete plugin file: $!" ); + }; + $logger->info("Deleted plugin file: $abs_installed_path"); + } else { + $logger->warn("Plugin '$namespace' file not found at $abs_installed_path -- cleaning up Redis only."); + } + + unregister_plugin( $redis, $namespace ); + $logger->info("Uninstalled plugin: '$namespace'"); + set_restart_pending($redis); + + return ( 200, undef ); +} + +# Reconcile discovered plugins with Redis registration state. +sub scan_plugins { + my ($redis) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->info("Scanning plugins..."); + + # Build a hash of discovered namespace -> arrayref of class/file_path hashrefs, + # skip on validation/general failures + my %discovered_ns_map; + my @discovered_plugins = LANraragi::Utils::Plugins::plugins(); + foreach my $class (@discovered_plugins) { + # Module::Pluggable may discover non-plugin classes; skip those + unless ( $class->can('plugin_info') ) { + $logger->warn("Non-plugin class detected; skipping."); + next; + } + my %plugin_info; + eval { %plugin_info = $class->plugin_info() }; + if ($@) { + $logger->warn("Plugin $class failed plugin_info(): $@"); + next; + } + my $namespace = $plugin_info{namespace}; + unless ($namespace) { + $logger->warn("Plugin $class has no namespace, skipping."); + next; + } + my $plugin_type = $plugin_info{type}; + unless ($plugin_type) { + $logger->warn("Plugin $class (namespace '$namespace') has no type, skipping."); + next; + } + my $filepath = package_to_path($class); + push @{ $discovered_ns_map{$namespace} }, { class => $class, file_path => $filepath, type => $plugin_type }; + } + $logger->debug("Discovered " . scalar( keys %discovered_ns_map ) . " plugin namespace(s)."); + + # Warn on filepath duplicates per namespace + foreach my $namespace ( keys %discovered_ns_map ) { + if ( @{ $discovered_ns_map{$namespace} } > 1 ) { + my $duplicate_paths = join( ", ", map { $_->{file_path} } @{ $discovered_ns_map{$namespace} } ); + $logger->warn("Duplicate namespace '$namespace' found in: $duplicate_paths"); + } + } + + # Warn on case collisions (Redis keys are case-insensitive by convention) + my %uc_map; + foreach my $namespace ( keys %discovered_ns_map ) { + push @{ $uc_map{ uc($namespace) } }, $namespace; + } + foreach my $uc_key ( keys %uc_map ) { + if ( @{ $uc_map{$uc_key} } > 1 ) { + my $namespaces = join( ", ", @{ $uc_map{$uc_key} } ); + $logger->warn("Namespace case collision (shared Redis key LRR_PLUGIN_$uc_key): $namespaces"); + } + } + + # Register first plugin of every discovered namespace + foreach my $namespace ( keys %discovered_ns_map ) { + next if @{ $discovered_ns_map{$namespace} } > 1; + + my $first_entry = $discovered_ns_map{$namespace}[0]; + my $plugin_path = $first_entry->{file_path}; + my $plugin_type = $first_entry->{type}; + my $namerds = "LRR_PLUGIN_" . uc($namespace); + my $recorded_path = $redis->hget( $namerds, "installed_path" ); + my $recorded_type = $redis->hget( $namerds, "type" ); + + if ( defined $recorded_path + && $recorded_path eq $plugin_path + && defined $recorded_type + && $recorded_type eq $plugin_type ) { + $logger->debug("Plugin already registered and consistent, skipping: $namespace"); + next; # skip if database already tracks said path and type + } + + $logger->debug("Plugin '$namespace': setting installed_path to '$plugin_path' (type=$plugin_type)."); + register_plugin( $redis, $namespace, $plugin_path, $plugin_type ); + } + + # Clean up orphaned Redis keys (installed_path set, but no matching discovered plugin) + my @all_plugin_keys = $redis->keys("LRR_PLUGIN_*"); + my %discovereduc = map { uc($_) => 1 } keys %discovered_ns_map; + $logger->debug("Orphan scan: " . scalar @all_plugin_keys . " Redis keys, " . scalar( keys %discovereduc ) . " discovered."); + + foreach my $plugin_key (@all_plugin_keys) { + + # extract the namespace from redis key + my ($nspart) = $plugin_key =~ /^LRR_PLUGIN_(.+)$/; + unless ($nspart) { + $logger->warn("Unexpected Redis key format: '$plugin_key', skipping."); + next; + } + + # if plugin in redis is not discovered on disk, remove provenance from redis. + my $discovered = $discovereduc{$nspart}; + unless ( $discovered ) { + if ( $redis->hexists( $plugin_key, "installed_path" ) ) { + my $installed_path = $redis->hget( $plugin_key, "installed_path" ); + if ( -e resolve_installed_path($installed_path) ) { + $logger->warn("Plugin key '$plugin_key' (installed_path: $installed_path) not discovered but file exists -- skipping removal."); + next; + } + $logger->warn("Orphaned plugin key '$plugin_key' (installed_path: $installed_path) -- plugin not discovered. Clearing provenance."); + } else { + $logger->warn("Orphaned plugin key '$plugin_key' -- plugin not discovered. Clearing provenance."); + } + unregister_plugin( $redis, $nspart ); + } + } + + $logger->info("Plugin scan complete."); +} + +# Infer plugin origin from the recorded install path. +# Returns one of "managed", "sideloaded", or "builtin". +sub infer_plugin_origin { + my ( $namerds, $redis ) = @_; + + if ( $redis->hexists( $namerds, "installed_path" ) ) { + my $path = $redis->hget( $namerds, "installed_path" ); + if ( $path ) { + return "managed" if $path =~ m{Plugin/Managed/}; + return "sideloaded" if $path =~ m{Plugin/Sideloaded/}; + } + } + + return "builtin"; +} + +# Validate downloaded plugin content against registry metadata and filesystem state. +# Covers managed plugins only (installed via registry into Plugin/Managed/). +sub validate_managed_plugin { + my ( $content, $namespace, $plugmeta, $plugin_type, $abs_installed_path ) = @_; + + my $plugname = $plugmeta->{name}; + my $plugver = $plugmeta->{version}; + my $artifact_path = $plugmeta->{artifact}; + my $expected_checksum = $plugmeta->{sha256}; + + return ( undef, "Plugin '$namespace' is missing required field 'name'." ) unless $plugname; + return ( undef, "Plugin '$namespace' is missing required field 'version'." ) unless $plugver; + return ( undef, "Plugin '$namespace' is missing required field 'artifact'." ) unless $artifact_path; + return ( undef, "Plugin '$namespace' is missing required field 'type'." ) unless $plugin_type; + return ( undef, "Plugin '$namespace' is missing required field 'sha256'." ) unless defined $expected_checksum && $expected_checksum ne ""; + + my $actual_checksum = sha256_hex($content); + if ( $actual_checksum ne $expected_checksum ) { + return ( undef, "SHA-256 mismatch: expected $expected_checksum, got $actual_checksum" ); + } + + # A valid package name does not guarantee valid syntax; + # post-install require (in install_plugin) catches that at load time. + my ($pkg) = $content =~ /^package\s+(LANraragi::Plugin::\S+)\s*;/m; + unless ($pkg) { + return ( undef, "Plugin file doesn't declare a LANraragi::Plugin:: package." ); + } + + my $typedir = MANAGED_TYPE_DIRS->{$plugin_type}; + unless ($typedir) { + return ( undef, "Unknown plugin type '$plugin_type'." ); + } + + # Extract filename (basename via regex; File::Basename not imported here). + my ($filename) = $artifact_path =~ m{([^/]+)$}; + unless ($filename) { + return ( undef, "Can't extract filename from path: $artifact_path" ); + } + unless ( $filename =~ /^[A-Za-z0-9_-]+\.pm$/ ) { + return ( undef, "Invalid plugin filename: $filename" ); + } + + my $install_dir = getcwd() . "/lib/LANraragi/Plugin/Managed/$typedir"; + my $install_path = "$install_dir/$filename"; + + if ( defined $abs_installed_path && $abs_installed_path ne $install_path ) { + return ( undef, + "Plugin '$namespace' changed type between installed and registry versions; registry violates type invariance." ); + } + + my ($stem) = $filename =~ /^(.+)\.pm$/; + my $expectedpkg = "LANraragi::Plugin::Managed::${typedir}::${stem}"; + if ( $pkg ne $expectedpkg ) { + return ( undef, "Package mismatch -- declared '$pkg' but expected '$expectedpkg'." ); + } + + # Skip install_path itself so upgrades don't self-conflict. + my $conflict = find_package_conflict( $pkg, $install_path ); + if ($conflict) { + return ( undef, "Package '$pkg' already exists in $conflict." ); + } + + my $nsconflict = find_namespace_conflict( $namespace, $install_path ); + if ($nsconflict) { + return ( undef, "Namespace '$namespace' already exists in $nsconflict." ); + } + + if ( -e $install_path && ( !defined $abs_installed_path || $abs_installed_path ne $install_path ) ) { + return ( undef, "Install path is already occupied: $install_path" ); + } + + return ( { install_path => $install_path, install_dir => $install_dir, package => $pkg }, undef ); +} + +# Get absolute path from an installed_path +sub resolve_installed_path { + my ($installed_path) = @_; + return getcwd() . "/lib/" . $installed_path; +} + 1; diff --git a/lib/LANraragi/Model/Registry.pm b/lib/LANraragi/Model/Registry.pm new file mode 100644 index 000000000..796ace83b --- /dev/null +++ b/lib/LANraragi/Model/Registry.pm @@ -0,0 +1,312 @@ +package LANraragi::Model::Registry; + +use strict; +use warnings; +use utf8; + +use Mojo::JSON qw(decode_json); + +use LANraragi::Utils::Logging qw(get_logger); +use LANraragi::Utils::Registry qw( + fetch_registry_resource + validate_registry_index +); + +# Max registry index size for slurp (files will/should never reach this size anyways but stops OOM) +use constant MAX_REGISTRY_INDEX_SIZE => 100 * 1024 * 1024; # 100 MB + +# Fields valid per registry provider. +my %PROVIDER_FIELDS = ( + github => [qw(name provider url ref)], + gitea => [qw(name provider url ref)], + cdn => [qw(name provider url)], + local => [qw(name provider path)], +); + +# Fields that must be removed when switching providers. +# Computed as: source fields minus the target provider's valid fields. +my %STALE_FIELDS = do { + my @source_fields = qw(provider url ref path); + map { + my $provider = $_; + my %valid_set = map { $_ => 1 } @{ $PROVIDER_FIELDS{$provider} }; + $provider => [ grep { !$valid_set{$_} } @source_fields ]; + } keys %PROVIDER_FIELDS; +}; + +# Create a registry entry with a generated REG_{timestamp} ID. +# Returns ( $registry_id, undef ) or ( undef, $error_message ). +sub create_registry { + my ( $config, $redis ) = @_; + my $name = $config->{name}; + my $provider = $config->{provider}; + + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->debug("Creating registry (provider: $provider)"); + + # Atomically claim an unused ID and populate registry hash in one go. + my $script = <<~'LUA'; + if redis.call("EXISTS", KEYS[1]) == 1 then + return 0 + end + redis.call("HSET", KEYS[1], unpack(ARGV)) + return 1 + LUA + + # Prepare fields + my @field_args; + my @valid_fields = @{ $PROVIDER_FIELDS{$provider} }; + foreach my $field (@valid_fields) { + push @field_args, $field, $config->{$field}; + } + my $now = time; + push @field_args, "created", $now, "updated", $now; + + # Start running script until a registry is created + my $registry_id; + my $offset = 0; + until ( $registry_id ) { + my $candidate = "REG_" . ( time() + $offset ); + my $claimed = eval { $redis->eval( + $script, 1, $candidate, + @field_args + ) }; + if ($@) { + $logger->error("Redis error during registry id claim: $@"); + return ( undef, "Redis error while creating registry." ); + } + if ($claimed) { + $registry_id = $candidate; + } else { + $offset++; + } + } + $logger->info("Created registry '$registry_id' (name: $name, provider: $provider)"); + return ( $registry_id, undef ); +} + +# Returns ( \%config, $status, $message ) +sub get_registry { + my ( $registry_id, $redis ) = @_; + + unless ( defined $registry_id && $registry_id =~ /^REG_\d{10}$/ ) { + return ( undef, 400, "Registry ID is malformed." ); + } + my %config = $redis->hgetall($registry_id); + return ( undef, 404, "This registry doesn't exist." ) unless %config; + $config{id} = $registry_id; + return ( \%config, 200, undef ); +} + +sub get_registry_list { + my ($redis) = @_; + + # Sort by timestamp for deterministic order across multiple registries. + my @result; + my @reg_ids = $redis->keys("REG_??????????"); + foreach my $key ( sort @reg_ids ) { + my ( $config ) = get_registry( $key, $redis ); + push @result, $config if $config; # skip if deleted between keys() and hgetall + } + + return @result; +} + +# Update mutable fields on an existing registry. +# Partial updates through updated_registry are accepted. +sub update_registry { + my ( $registry_id, $redis, %updated_registry ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + $logger->debug("Updating registry '$registry_id'"); + + # Argument validation + if ( !%updated_registry ) { + return ( 400, "No fields provided to update."); + } + + # Target registry existence validation + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, $lookup_error ) unless $registry; + my ($suffix) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$suffix"; + + # Source registry field validation + my %current_registry = %$registry; + my $updated_registry_provider = $updated_registry{provider}; + my $current_registry_provider = $current_registry{provider}; + my $target_registry_provider = defined $updated_registry_provider ? $updated_registry_provider : $current_registry_provider; + my %valid_set = map { $_ => 1 } @{ $PROVIDER_FIELDS{$target_registry_provider} }; + my @invalid_fields = sort grep { !$valid_set{$_} } keys %updated_registry; + if (@invalid_fields) { + return ( 400, "Fields not valid for provider '$target_registry_provider': " . join( ", ", @invalid_fields ) ); + } + + # Partial updates may omit fields already stored; merge before validating. + my %merged = ( %current_registry, %updated_registry ); + if ( $target_registry_provider eq "github" || $target_registry_provider eq "gitea" ) { + return ( 400, "Git registry needs a URL." ) unless $merged{url}; + return ( 400, "Git registry needs a ref." ) unless $merged{ref}; + } elsif ( $target_registry_provider eq "cdn" ) { + return ( 400, "CDN registry needs a URL." ) unless $merged{url}; + } elsif ( $target_registry_provider eq "local" ) { + return ( 400, "Local registry needs a path." ) unless $merged{path}; + } + + # Prepare fields to remove/set. + my @fields_to_remove; # remove stale fields whenever provider changes + my @fields_to_set; # apply only changes from updated_registry + if ( exists $updated_registry{provider} && $updated_registry_provider ne $current_registry_provider ) { + $logger->info("Provider change on '$registry_id': '$current_registry_provider' -> '$target_registry_provider'; removing stale fields."); + @fields_to_remove = @{ $STALE_FIELDS{$target_registry_provider} }; + } + foreach my $field ( keys %updated_registry ) { + my $updated_value = $updated_registry{$field}; + my $current_value = $current_registry{$field}; + if ( defined $current_value && $current_value eq $updated_value ) { + $logger->debug("Skipping unchanged field '$field' on '$registry_id'"); + next; + } + $logger->debug("Setting field '$field' on '$registry_id'"); + push @fields_to_set, $field, $updated_value; + } + if ( !@fields_to_set && !@fields_to_remove ) { + $logger->debug("No fields to update."); + return ( 200, undef ); + } + push @fields_to_set, "updated", time; + + # Remove from fields_to_remove, + # set from fields_to_set, + # remove index. + my $script = <<~'LUA'; + local remove_count = tonumber(ARGV[1]) + local idx = 2 + for _ = 1, remove_count do + redis.call("HDEL", KEYS[1], ARGV[idx]) + idx = idx + 1 + end + while idx + 1 <= #ARGV do + redis.call("HSET", KEYS[1], ARGV[idx], ARGV[idx + 1]) + idx = idx + 2 + end + redis.call("DEL", KEYS[2]) + LUA + eval { + $redis->eval( + $script, 2, $registry_id, $registry_index_key, + scalar @fields_to_remove, + @fields_to_remove, + @fields_to_set + ); + }; + if ( my $err = $@ ) { + $logger->error("Redis error during registry update for '$registry_id': $err"); + return ( 500, "Redis error while updating registry." ); + } + + return ( 200, undef ); +} + +# Delete a registry and its cached index. +sub delete_registry { + my ( $registry_id, $redis ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, $lookup_error ) unless $registry; + + my ($suffix) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$suffix"; + + # Delete registry + index key + my $script = <<~'LUA'; + redis.call("DEL", KEYS[1]) + redis.call("DEL", KEYS[2]) + LUA + + eval { $redis->eval( $script, 2, $registry_id, $registry_index_key ) }; + if ($@) { + $logger->error("Redis error during registry delete for '$registry_id': $@"); + return ( 500, "Redis error while deleting registry." ); + } + + if ( ( $redis->hget( 'LRR_CONFIG', 'ougi' ) || "" ) eq $registry_id ) { + $redis->hdel( 'LRR_CONFIG', 'ougi' ); + $logger->info("Cleared default-registry pointer that referenced deleted '$registry_id'."); + } + + $logger->info("Deleted registry '$registry_id'."); + + return ( 200, undef ); +} + +# Get the configured default registry id, or empty string if unset. +sub get_ougi { + my ($redis) = @_; + return $redis->hget( 'LRR_CONFIG', 'ougi' ) || ""; +} + +# Set the configured default registry to $registry_id. +# Returns ( $status_code, $registry_id, $message ). +sub update_ougi { + my ( $registry_id, $redis ) = @_; + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, $registry_id, $lookup_error ) unless $registry; + $redis->hset( 'LRR_CONFIG', 'ougi', $registry_id ); + return ( 200, $registry_id, "success" ); +} + +# Clear the configured default registry. Returns the previously-set id (empty string if unset). +sub remove_ougi { + my ($redis) = @_; + my $registry_id = $redis->hget( 'LRR_CONFIG', 'ougi' ) || ""; + $redis->hdel( 'LRR_CONFIG', 'ougi' ); + return $registry_id; +} + +sub refresh_registry { + my ( $registry_id, $redis ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + my ( $registry, $lookup_status, $lookup_error ) = get_registry( $registry_id, $redis ); + return ( $lookup_status, undef, $lookup_error ) unless $registry; + my ($suffix) = $registry_id =~ /^REG_(\d{10})$/; + my $registry_index_key = "REG_INDEX_$suffix"; + + # Fetch and validate registry index + my ( $status, $registry_content, $message ) = fetch_registry_index(%$registry); + unless ( $status == 200 ) { + return ( $status, undef, $message ); + } + my $registry_index = eval { decode_json($registry_content) }; + if ($@) { + my $error = "Invalid registry.json: $@"; + $logger->warn("Registry '$registry_id': failed to decode registry index: $@"); + return ( 400, undef, $error ); + } + my $validation_error = validate_registry_index($registry_index); + if ($validation_error) { + $logger->warn("Registry '$registry_id': registry index failed validation: $validation_error"); + return ( 400, undef, $validation_error ); + } + + # Update registry index + eval { $redis->set( $registry_index_key, $registry_content ) }; + if ($@) { + $logger->error("Redis error during registry refresh for '$registry_id': $@"); + return ( 500, undef, "Redis error while refreshing registry." ); + } + + return ( 200, $registry_index, undef ); +} + +# Fetch registry.json from a configured registry source. +sub fetch_registry_index { + my %registry_config = @_; + return fetch_registry_resource( \%registry_config, "registry.json", MAX_REGISTRY_INDEX_SIZE ); +} + +1; diff --git a/lib/LANraragi/Model/Server.pm b/lib/LANraragi/Model/Server.pm new file mode 100644 index 000000000..2ba2e0065 --- /dev/null +++ b/lib/LANraragi/Model/Server.pm @@ -0,0 +1,30 @@ +package LANraragi::Model::Server; + +use strict; +use warnings; +use utf8; + +use Exporter 'import'; +our @EXPORT_OK = qw(set_restart_pending clear_restart_pending is_restart_pending); + +use constant SERVER_KEY => "LRR_SERVER"; + +# Signal that LRR needs a restart. +sub set_restart_pending { + my ($redis) = @_; + $redis->hset( SERVER_KEY, "restart_pending", 1 ); +} + +# Clear need-restart flag. +sub clear_restart_pending { + my ($redis) = @_; + $redis->hdel( SERVER_KEY, "restart_pending" ); +} + +# Whether a restart is currently pending. Returns 1 or 0. +sub is_restart_pending { + my ($redis) = @_; + return $redis->hget( SERVER_KEY, "restart_pending" ) ? 1 : 0; +} + +1; diff --git a/lib/LANraragi/Utils/Minion.pm b/lib/LANraragi/Utils/Minion.pm index 666a8077f..82761f836 100644 --- a/lib/LANraragi/Utils/Minion.pm +++ b/lib/LANraragi/Utils/Minion.pm @@ -13,6 +13,7 @@ use MCE::Loop; use MCE::Shared; use Config; +use LANraragi::Utils::Generic qw(exec_with_lock_pure); use LANraragi::Utils::Logging qw(get_logger); use LANraragi::Utils::Redis qw(redis_decode); use LANraragi::Utils::Archive qw(extract_thumbnail); @@ -26,6 +27,7 @@ use LANraragi::Model::Stats; use LANraragi::Model::Backup; use constant IS_UNIX => ( $Config{osname} ne 'MSWin32' ); +use constant INSTALL_LOCK_TTL => 300; # Add Tasks to the Minion instance. sub add_tasks { @@ -556,6 +558,60 @@ sub add_tasks { } ); + $minion->add_task( + install_plugin => sub { + my ( $job, @args ) = @_; + my ( $namespace, $registry_id, $version, $force ) = @args; + + my $logger = get_logger( "Minion", "minion" ); + $logger->info("Installing managed plugin '$namespace' from registry '$registry_id'..."); + + my $redis = LANraragi::Model::Config->get_redis_config; + my ( $acquired, $result ) = eval { + exec_with_lock_pure( + [ "plugin-write:" . uc($namespace) ], + sub { + my ( $status, $plugmeta, $message ) = + LANraragi::Model::Plugins::install_plugin( $namespace, $redis, $registry_id, $version, $force ); + return { status => $status, plugmeta => $plugmeta, message => $message }; + }, + $redis, + INSTALL_LOCK_TTL + ); + }; + my $err = $@; + eval { $redis->quit(); 1 } or $logger->warn("Failed to close Redis connection after install of '$namespace': $@"); + + if ($err) { + $logger->error("install_plugin failed for '$namespace': $err"); + return $job->fail( { error => "Plugin installation failed." } ); + } + + unless ($acquired) { + return $job->finish( + { success => 0, error => "Another plugin operation is already in progress for '$namespace'." } ); + } + + my ( $status, $plugmeta, $message ) = ( $result->{status}, $result->{plugmeta}, $result->{message} ); + if ( $status != 200 ) { + return $job->finish( { success => 0, error => $message } ); + } + + $job->finish( + { success => 1, + error => undef, + data => { + name => $plugmeta->{name}, + namespace => $namespace, + version => $plugmeta->{version}, + registry => $plugmeta->{registry}, + sha256 => $plugmeta->{sha256}, + } + } + ); + } + ); + $minion->add_task( backup_json => sub { my ( $job, @args ) = @_; diff --git a/lib/LANraragi/Utils/Path.pm b/lib/LANraragi/Utils/Path.pm index 9a03431c6..7ec1c6bac 100644 --- a/lib/LANraragi/Utils/Path.pm +++ b/lib/LANraragi/Utils/Path.pm @@ -15,7 +15,7 @@ use POSIX qw(strerror); use constant IS_UNIX => ( $Config{osname} ne 'MSWin32' ); use Exporter 'import'; -our @EXPORT_OK = qw(create_path create_path_or_die open_path open_path_or_die date_modified compat_path unlink_path find_path get_archive_path rename_path move_path); +our @EXPORT_OK = qw(create_path create_path_or_die open_path open_path_or_die date_modified compat_path unlink_path find_path get_archive_path rename_path move_path package_to_path path_to_package); BEGIN { if ( !IS_UNIX ) { @@ -108,6 +108,19 @@ sub get_archive_path ( $redis, $id ) { return create_path( $redis->hget( $id, "file" ) ); } +# Convert a Perl package name to a filesystem path (e.g. "LANraragi::Plugin::Foo" -> "LANraragi/Plugin/Foo.pm"). +sub package_to_path( $package ) { + return join( "/", split( /::/, $package ) ) . ".pm"; +} + +# Convert a filesystem path to a Perl package name (e.g. "LANraragi/Plugin/Foo.pm" -> "LANraragi::Plugin::Foo"). +sub path_to_package( $path ) { + my $package = $path; + $package =~ s/\.pm$//; + $package =~ s|/|::|g; + return $package; +} + # Build the error message for a failed file operation containing details about the file's properties # including any hidden Windows-side errors. # Usage: die "Failed operation for $file: " . _file_error_msg($file); diff --git a/lib/LANraragi/Utils/Plugins.pm b/lib/LANraragi/Utils/Plugins.pm index 92bb8fba8..5548dfc33 100644 --- a/lib/LANraragi/Utils/Plugins.pm +++ b/lib/LANraragi/Utils/Plugins.pm @@ -5,8 +5,18 @@ use warnings; use utf8; use Mojo::JSON qw(decode_json); -use LANraragi::Utils::Redis qw(redis_decode); +use Cwd qw(getcwd); +use Config; +use IPC::Cmd qw(run); use LANraragi::Utils::Logging qw(get_logger); +use LANraragi::Utils::Path qw(path_to_package); +use LANraragi::Utils::Redis qw(redis_decode); + +use constant IS_UNIX => ( $Config{osname} ne 'MSWin32' ); + +BEGIN { + require Win32::Process if !IS_UNIX; +} # Plugin system ahoy - this makes the LANraragi::Utils::Plugins::plugins method available # Don't call this method directly - Rely on LANraragi::Utils::Plugins::get_plugins instead @@ -16,19 +26,35 @@ use Module::Pluggable require => 1, search_path => ['LANraragi::Plugin']; # This mostly contains the glue for parameters w/ Redis, the meat of Plugin execution is in Model::Plugins. use Exporter 'import'; our @EXPORT_OK = - qw(get_plugins get_downloader_for_url get_plugin get_enabled_plugins get_plugin_parameters is_plugin_enabled use_plugin); + qw(get_plugins get_downloader_for_url get_plugin get_enabled_plugins get_plugin_parameters is_plugin_enabled use_plugin register_plugin unregister_plugin read_registered_plugins check_plugin_loads); -# Get metadata of all plugins with the defined type. Returns an array of hashes. +# Get metadata of all registered plugins with the defined type. Returns an array of hashes. sub get_plugins { my $type = shift; - my @plugins = plugins; + my $redis = LANraragi::Model::Config->get_redis_config; + my $logger = get_logger( "Plugin System", "lanraragi" ); + my %registered = read_registered_plugins($redis); my @validplugins; - foreach my $plugin (@plugins) { + + foreach my $ns_uc ( sort keys %registered ) { + my $installed_path = $registered{$ns_uc}; + my $plugin = path_to_package($installed_path); + + my $loaded = eval { require $installed_path; 1; }; + unless ($loaded) { + $logger->warn("Skipping plugin '$plugin' while listing type '$type': require '$installed_path' failed: $@"); + next; + } # Check that the metadata sub is there before invoking it if ( $plugin->can('plugin_info') ) { - my %pluginfo = $plugin->plugin_info(); + my %pluginfo; + eval { %pluginfo = $plugin->plugin_info() }; + if ($@) { + $logger->warn("Skipping plugin '$plugin' while listing type '$type': plugin_info() failed: $@"); + next; + } if ( $type eq 'script' ) { next if ( !$plugin->can('run_script') ); } elsif ( $type eq 'metadata' ) { next if ( !$plugin->can('get_tags') ); } @@ -36,9 +62,12 @@ sub get_plugins { elsif ( $type eq 'login' ) { next if ( !$plugin->can('do_login') ); } if ( $pluginfo{type} eq $type || $type eq "all" ) { push( @validplugins, \%pluginfo ); } + } else { + $logger->warn("Skipping plugin '$plugin' while listing type '$type': class has no plugin_info()."); } } + $redis->quit(); return @validplugins; } @@ -78,27 +107,88 @@ sub get_enabled_plugins { return @enabled; } -#Look for a plugin by namespace. +# Look up an installed plugin by namespace for invocation. sub get_plugin { - my $name = shift; + my $name = shift; + my $logger = get_logger( "Plugin System", "lanraragi" ); + + # Plugin must have a recorded installed_path to be callable. + # Uninstall hdels installed_path while preserving user config; gating on + # key-existence alone would let uninstalled namespaces remain callable. + my $redis = LANraragi::Model::Config->get_redis_config; + my $installed_path = read_registered_plugin_path( $redis, $name ); + unless ($installed_path) { + $redis->quit(); + return 0; + } - #Go through plugins to find one with a matching namespace - my @plugins = plugins; + my $loaded = eval { require $installed_path; 1; }; + $redis->quit(); + unless ($loaded) { + $logger->warn("Failed to load plugin '$name': $@"); + return 0; + } + + return path_to_package($installed_path); +} - foreach my $plugin (@plugins) { - my $namespace = ""; - eval { - my %pluginfo = $plugin->plugin_info(); - $namespace = $pluginfo{namespace}; - }; +sub check_plugin_loads { + my ($install_relpath) = @_; + my $script = getcwd() . "/script/check_plugin_loads.pl"; + my $timeout = 20; - if ( $name eq $namespace ) { - return $plugin; - } + return IS_UNIX + ? check_plugin_loads_unix( $script, $install_relpath, $timeout ) + : check_plugin_loads_win32( $script, $install_relpath, $timeout ); +} + +sub check_plugin_loads_unix { + my ( $script, $install_relpath, $timeout ) = @_; + + my ( $ok, $err, undef, $stdout_buf, $stderr_buf ) = run( + command => [ $Config{perlpath}, $script, $install_relpath ], + timeout => $timeout, + verbose => 0, + ); + return ( 'ok', undef ) if $ok; + + if ( defined $err && $err =~ /\bIPC::Cmd::TimeOut\b/ ) { + return ( 'error', "timed out after ${timeout}s: $err" ); + } + + my $stdout = join( "", @{ $stdout_buf // [] } ); + my $detail = join( "", @{ $stderr_buf // [] } ); + $detail =~ s/\s+\z//; + + return ( 'invalid', $detail || "plugin failed to load" ) if $stdout =~ /^PLUGIN_INVALID$/m; + return ( 'error', $detail || ( $err // "no diagnostic output" ) ); +} + +sub check_plugin_loads_win32 { + my ( $script, $install_relpath, $timeout ) = @_; + + my $proc; + my $created = Win32::Process::Create( + $proc, undef, + "perl \"$script\" \"$install_relpath\"", + 0, Win32::Process::NORMAL_PRIORITY_CLASS(), "." + ); + return ( 'error', "could not start load check (Win32::Process::Create failed)" ) unless $created; + + unless ( $proc->Wait( $timeout * 1000 ) ) { + $proc->Kill(1); + return ( 'error', "timed out after ${timeout}s" ); } - return 0; + my $exit; + unless ( $proc->GetExitCode($exit) ) { + return ( 'error', "could not read load check exit code" ); + } + + return ( 'ok', undef ) if $exit == 0; + return ( 'invalid', "plugin failed to load" ) if $exit == 1; + return ( 'error', "load check exited with code $exit" ); } # Get the parameters for the specified plugin, either default values or input by the user in the settings page. @@ -166,18 +256,78 @@ sub get_plugin_parameters { return %args; } +# Register a validated plugin into the database. +# A plugin should be registered after any type of discovery or installation, +sub register_plugin { + my ( $redis, $namespace, $installed_path, $type ) = @_; + + # enforce relative path being under LANraragi/Plugin + # basically should never happen + unless ( $installed_path =~ m{^LANraragi/Plugin/} ) { + die "register_plugin: installed_path must be under LANraragi/Plugin/, got '$installed_path'"; + } + + my $namerds = "LRR_PLUGIN_" . uc($namespace); + $redis->hset( $namerds, "installed_path", $installed_path, "type", $type ); + return $installed_path; +} + +# Unregister a registered plugin from the database. +# Should be called during removal of a plugin or when the plugin +# could no longer be found. +sub unregister_plugin { + my ( $redis, $namespace ) = @_; + + my $namerds = "LRR_PLUGIN_" . uc($namespace); + $redis->hdel( + $namerds, + "installed_path", + "installed_version", + "installed_registry", + "installed_sha256", + "type", + ); +} + +# Return a map of namespace -> installed_path of all registered plugins. +sub read_registered_plugins { + my $redis = shift; + + my @keys = $redis->keys("LRR_PLUGIN_*"); + my %registered; + foreach my $key (@keys) { + next unless $redis->hexists( $key, "installed_path" ); + my ($namespace_uc) = $key =~ /^LRR_PLUGIN_(.+)$/; + next unless defined $namespace_uc; + $registered{$namespace_uc} = $redis->hget( $key, "installed_path" ); + } + + return %registered; +} + +# Return the installed_path for a registered plugin. +sub read_registered_plugin_path { + my $redis = shift; + my $namespace = shift; + + my $namerds = "LRR_PLUGIN_" . uc($namespace); + return unless $redis->hexists( $namerds, "installed_path" ); + return $redis->hget( $namerds, "installed_path" ); +} + sub is_plugin_enabled { my $namespace = shift; my $redis = LANraragi::Model::Config->get_redis_config; my $namerds = "LRR_PLUGIN_" . uc($namespace); + my $enabled = 0; if ( $redis->hexists( $namerds, "enabled" ) ) { - return ( $redis->hget( $namerds, "enabled" ) ); + $enabled = $redis->hget( $namerds, "enabled" ); } $redis->quit(); - return 0; + return $enabled; } # Shorthand method to use a plugin by name. diff --git a/lib/LANraragi/Utils/Registry.pm b/lib/LANraragi/Utils/Registry.pm new file mode 100644 index 000000000..7fbac6d20 --- /dev/null +++ b/lib/LANraragi/Utils/Registry.pm @@ -0,0 +1,319 @@ +package LANraragi::Utils::Registry; + +use strict; +use warnings; +use utf8; + +use Cwd qw(abs_path getcwd); +use File::Find; +use Mojo::Util qw(url_escape); + +use Mojo::File; +use Mojo::UserAgent; +use SemVer; + +use LANraragi::Utils::Logging qw(get_logger); + +use Exporter 'import'; +our @EXPORT_OK = qw( + fetch_registry_resource + find_namespace_conflict + find_package_conflict + resolve_max_version + validate_registry_artifact_path + validate_registry_index + MANAGED_TYPE_DIRS +); + +# Maps plugin_info type values to directory names under Plugin/Managed/. +use constant MANAGED_TYPE_DIRS => { + metadata => "Metadata", + download => "Download", + login => "Login", + script => "Scripts", +}; + +# Allowed-field whitelists for registry.json schema. +my @ALLOWED_ROOT_FIELDS = qw(version generated_at plugins); +my @ALLOWED_PLUGIN_FIELDS = qw(namespace type versions); +my @ALLOWED_VERSION_FIELDS = qw(version name author description artifact sha256 published_at); +my @REQUIRED_VERSION_FIELDS = qw(name author description artifact sha256 published_at); + +# Resolve a git URL to a raw file URL for a given provider. +# Supports github and gitea/codeberg providers. +sub resolve_git_raw_url { + my ( $provider, $url, $ref, $path ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + my ( $host, $owner, $repo ); + # TODO(REVIEW): audit + if ( $url =~ m{^https?://([^/]+)/(.+)/([^/]+?)(?:\.git)?$} ) { + ( $host, $owner, $repo ) = ( $1, $2, $3 ); + } else { + $logger->error("Cannot parse git URL: $url"); + return; + } + + my $escaped_path = join( "/", map { url_escape($_) } split( m{/}, $path ) ); # TODO(REVIEW): audit + my $escaped_ref = url_escape($ref); + return "https://raw.githubusercontent.com/$owner/$repo/$escaped_ref/$escaped_path" if ( $provider eq "github" ); + return "https://$host/api/v1/repos/$owner/$repo/raw/$escaped_path?ref=$escaped_ref" if ( $provider eq "gitea" ); + + $logger->error("Unknown registry provider '$provider' for URL: $url"); + return; +} + +# Resolve a CDN registry base URL plus a registry-relative path into a fetchable URL. +# Accepts http:// or https://. Trailing slashes on the base are tolerated. +sub resolve_cdn_artifact_url { + my ( $base_url, $path ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + + # TODO(REVIEW): audit + unless ( defined $base_url && $base_url =~ m{^https?://}i ) { + $logger->error( "CDN base URL must use http or https scheme: " . ( $base_url // "" ) ); + return; + } + + $base_url =~ s{/+\z}{}; # strip the URL of its trailing slashes + my $escaped_path = join( "/", map { url_escape($_) } grep { length $_ } split( m{/}, $path ) ); + return "$base_url/$escaped_path"; +} + +# Transport adapter: fetch a registry-relative resource from any registry provider. +# Returns ( $status, $body, $error ) + $status 200 on success. +sub fetch_registry_resource { + my ( $registry_config, $relpath, $max_size ) = @_; + + my $logger = get_logger( "Registry", "lanraragi" ); + my $provider = $registry_config->{provider}; + + if ( $provider eq "local" ) { + my ( $file_canon, $resolve_error ) = + resolve_local_registry_artifact_path( $registry_config->{path}, $relpath ); + if ($resolve_error) { + $logger->warn("Local registry resolution failed for '$relpath': $resolve_error"); + return ( 400, undef, $resolve_error ); + } + return ( 400, undef, "Resource is not a regular file: $file_canon" ) unless ( -f $file_canon ); + my $filesize = -s $file_canon; + + return ( 400, undef, "Resource is empty: $file_canon" ) if ( $filesize == 0 ); + return ( 400, undef, "Resource too large: $file_canon ($filesize bytes, max $max_size)" ) if ( defined $max_size && $filesize > $max_size ); + + my $content = eval { Mojo::File->new($file_canon)->slurp }; + return ( 500, undef, "Cannot read resource: $@" ) unless ( defined $content ); + return ( 200, $content, undef ); + } + + if ( $provider eq "github" || $provider eq "gitea" ) { + my $url = resolve_git_raw_url( + $provider, $registry_config->{url}, + $registry_config->{ref}, $relpath + ); + return ( 400, undef, "Cannot resolve git URL for $relpath" ) unless ($url); + + $logger->info("Fetching registry resource from $url"); + my $ua = Mojo::UserAgent->new; + $ua->max_response_size($max_size) if defined $max_size; + my $res = eval { $ua->get($url)->result }; + return ( 502, undef, "Cannot reach registry: $@" ) unless ( defined $res ); + return ( 502, undef, "Failed to fetch resource: HTTP " . $res->code ) unless ( $res->is_success ); + return ( 200, $res->body, undef ); + } + + if ( $provider eq "cdn" ) { + my $url = resolve_cdn_artifact_url( $registry_config->{url}, $relpath ); + return ( 400, undef, "Cannot resolve CDN URL for $relpath" ) unless ($url); + + $logger->info("Fetching registry resource from $url"); + my $ua = Mojo::UserAgent->new; + $ua->max_response_size($max_size) if defined $max_size; + my $res = eval { $ua->get($url)->result }; + return ( 502, undef, "Cannot reach registry: $@" ) unless ( defined $res ); + return ( 502, undef, "Failed to fetch resource: HTTP " . $res->code ) unless ( $res->is_success ); + return ( 200, $res->body, undef ); + } + + return ( 400, undef, "Unknown registry provider: $provider" ); +} + +# Scan Plugin/ for a .pm file declaring the given package name +sub find_package_conflict { + my ( $package_name, $skip_path ) = @_; + + return _find_conflict( + $skip_path, + sub { + my ($filepath) = @_; + my $content = eval { Mojo::File->new($filepath)->slurp } or return; + return $content =~ /^package\s+\Q$package_name\E\s*;/m; + } + ); +} + +# Scan Plugin/ for a .pm file declaring the given namespace +sub find_namespace_conflict { + my ( $namespace, $skip_path ) = @_; + + return _find_conflict( + $skip_path, + sub { + my ($filepath) = @_; + my $content = eval { Mojo::File->new($filepath)->slurp } or return; + return $content =~ /namespace\s*=>\s*['"]\Q$namespace\E['"]/i; + } + ); +} + +# strictly check registry index satisfies a bunch of registry spec-related conditions... +sub validate_registry_index { + my ( $index ) = @_; + + return "Invalid registry.json: root must be an object." unless ( ref $index eq "HASH" ); + + my %allowed_root = map { $_ => 1 } @ALLOWED_ROOT_FIELDS; + foreach my $field ( keys %{$index} ) { + return "Invalid registry.json: unknown root field '$field'." unless ( $allowed_root{$field} ); + } + + return "Invalid registry.json: registry version must be 1." unless ( defined $index->{version} && $index->{version} == 1 ); + return "Invalid registry.json: 'generated_at' is required." unless ( defined $index->{generated_at} && $index->{generated_at} ne "" ); + return "Invalid registry.json: 'generated_at' must be a UTC RFC3339 timestamp." unless ( is_valid_registry_timestamp( $index->{generated_at} ) ); + return "Invalid registry.json: 'plugins' must be an object." unless ( ref $index->{plugins} eq "HASH" ); + + foreach my $namespace ( sort keys %{ $index->{plugins} } ) { + my $plugin = $index->{plugins}{$namespace}; + return "Invalid registry.json: plugin '$namespace' must be an object." unless ( ref $plugin eq "HASH" ); + + my %allowed_plugin = map { $_ => 1 } @ALLOWED_PLUGIN_FIELDS; + foreach my $field ( keys %{$plugin} ) { + return "Invalid registry.json: plugin '$namespace' has unknown field '$field'." unless ( $allowed_plugin{$field} ); + } + + return "Invalid registry.json: plugin key '$namespace' must match inner namespace." unless ( defined $plugin->{namespace} && $plugin->{namespace} eq $namespace ); + return "Invalid registry.json: plugin namespace '$namespace'" . + " must match ^[a-z0-9_-]+\$ (lowercase only)." unless ( $namespace =~ /\A[a-z0-9_-]+\z/ ); + return "Invalid registry.json: plugin '$namespace' has invalid type '$plugin->{type}'." unless ( defined $plugin->{type} && MANAGED_TYPE_DIRS->{ $plugin->{type} } ); + return "Invalid registry.json: plugin '$namespace' 'versions' must be a non-empty object." unless ( ref $plugin->{versions} eq "HASH" && keys %{ $plugin->{versions} } ); + + foreach my $version_key ( sort keys %{ $plugin->{versions} } ) { + + # Explicitly enforce SemVer 2.0.0 syntax. + return "Invalid registry.json: plugin '$namespace'" . + " version key '$version_key' is not a valid SemVer 2.0.0 string." if ( $version_key =~ /^v/i ); + my $semver_ok = eval { SemVer->new($version_key); 1 }; + return "Invalid registry.json: plugin '$namespace'" . + " version key '$version_key' is not a valid SemVer 2.0.0 string." unless ($semver_ok); + my $version = $plugin->{versions}{$version_key}; + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' must be an object." unless ( ref $version eq "HASH" ); + + my %allowed_version = map { $_ => 1 } @ALLOWED_VERSION_FIELDS; + foreach my $field ( keys %{$version} ) { + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' has unknown field '$field'." unless ( $allowed_version{$field} ); + } + + return "Invalid registry.json: plugin '$namespace'" . + " version key '$version_key' must match inner version." unless ( defined $version->{version} && $version->{version} eq $version_key ); + foreach my $required (@REQUIRED_VERSION_FIELDS) { + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' is missing '$required'." unless ( defined $version->{$required} && $version->{$required} ne "" ); + } + + my ( $artifact_valid, $artifact_error ) = validate_registry_artifact_path( $version->{artifact} ); + return $artifact_error unless ($artifact_valid); + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' sha256 must be 64 lowercase hexadecimal characters." unless ( $version->{sha256} =~ /\A[a-f0-9]{64}\z/ ); + return "Invalid registry.json: plugin '$namespace'" . + " version '$version_key' published_at must be a UTC RFC3339 timestamp." unless ( is_valid_registry_timestamp( $version->{published_at} ) ); + } + } + + return; +} + +sub validate_registry_artifact_path { + my ($plugpath) = @_; + + return ( undef, "Invalid registry.json: version artifact is required." ) unless ( defined $plugpath && $plugpath ne "" ); + return ( undef, "Invalid registry.json: artifact path contains a null byte." ) if ( index( $plugpath, "\0" ) >= 0 ); + return ( undef, "Invalid registry.json: artifact path must be relative." ) if ( Mojo::File->new($plugpath)->is_abs ); + return ( undef, "Invalid registry.json: artifact path" . + " must not contain '.' or '..' segments." ) if ( grep { $_ eq "." || $_ eq ".." } @{ Mojo::File->new($plugpath)->to_array } ); + + return ( 1, undef ); +} + +# Resolve local registry root (the directory), including through any symlinks. +# abs_path is used for symlink canonicalization. +sub resolve_local_registry_artifact_path { + my ( $registry_root, $plugpath ) = @_; + + my $resolved_registry_root = abs_path($registry_root); + return ( undef, "Invalid local registry path: $registry_root" ) unless ( $resolved_registry_root && -d $resolved_registry_root ); + + my $candidate = Mojo::File->new($resolved_registry_root)->child( @{ Mojo::File->new($plugpath)->to_array } )->to_string; + return ( undef, "Plugin file not found: $candidate" ) unless ( -e $candidate ); + + my $resolved_artifact = abs_path($candidate); + return ( undef, "Failed to resolve plugin artifact path: $plugpath" ) unless ( $resolved_artifact ); + + my $root_prefix = $resolved_registry_root =~ m{/\z} ? $resolved_registry_root : "$resolved_registry_root/"; + return ( undef, "Plugin artifact path escapes registry root: $plugpath" ) unless ( index( $resolved_artifact, $root_prefix ) == 0 ); + + return ( $resolved_artifact, undef ); +} + +# Check timestamp is (stylistically) of the form "9999-99-99T99:99:99Z". +sub is_valid_registry_timestamp { + my ($timestamp) = @_; + return $timestamp =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/; +} + +# Return the SemVer-greatest version key from a plugin record's versions map. +# $plugin_record: the plugin record hashref (must have a non-empty 'versions' map). +# Pure function, no Redis. +sub resolve_max_version { + my ($plugin_record) = @_; + + my @keys = keys %{ $plugin_record->{versions} }; + my ($max) = sort { SemVer->new($b) <=> SemVer->new($a) } @keys; + return $max; +} + + +# Scan Plugin/ directory for a .pm file matching the given criteria. +# $skip_path: optional absolute filepath to exclude +# $match_fn: coderef($filepath, $fh) -> bool; return true if filepath conflicts. +sub _find_conflict { + my ( $skip_path, $match_fn ) = @_; + + my $plugin_dir = getcwd() . "/lib/LANraragi/Plugin"; + my $conflict; + + return unless -d $plugin_dir; + + find( + { wanted => sub { + return if $conflict; + return unless /\.pm$/; + return if $skip_path && $_ eq $skip_path; + + if ( $match_fn->($_) ) { + $conflict = $_; + } + }, + no_chdir => 1, + follow_fast => 1, + }, + $plugin_dir + ); + + return $conflict; +} + +1; diff --git a/script/check_plugin_loads.pl b/script/check_plugin_loads.pl new file mode 100644 index 000000000..566138220 --- /dev/null +++ b/script/check_plugin_loads.pl @@ -0,0 +1,71 @@ +#!/usr/bin/env perl + +# Check that a managed plugin artifact loads and satisfies basic type contracts. +# +# Usage: perl script/check_plugin_loads.pl +# e.g. perl script/check_plugin_loads.pl LANraragi/Plugin/Managed/Metadata/Foo.pm +# +# Exit codes: +# 0 artifact loads, implements plugin_info(), and the entry point its type requires +# 1 artifact failed to load, or does not satisfy the plugin contract +# 2 usage / bad argument + +use strict; +use warnings; +use utf8; + +use FindBin; + +BEGIN { unshift @INC, "$FindBin::Bin/../lib"; } + +use LANraragi::Utils::Path qw(path_to_package); + +my $relpath = shift @ARGV; +unless ( defined $relpath && length $relpath ) { + print STDERR "usage: check_plugin_loads.pl \n"; + exit 2; +} + +# Load the artifact like a worker +my $loaded = eval { require $relpath; 1 }; +unless ($loaded) { + print STDOUT "PLUGIN_INVALID\n"; + print STDERR "failed to load '$relpath': " . ( $@ || "unknown error" ); + exit 1; +} + +# The plugin must implement plugin_info() +my $package = path_to_package($relpath); +unless ( $package && $package->can('plugin_info') ) { + print STDOUT "PLUGIN_INVALID\n"; + print STDERR "'$relpath' does not implement plugin_info()\n"; + exit 1; +} +my %info = eval { $package->plugin_info() }; +if ($@) { + print STDOUT "PLUGIN_INVALID\n"; + print STDERR "plugin_info() failed for '$relpath': $@"; + exit 1; +} + +# The plugin must have the required method corresponding to its plugin type +my %type_method = ( + metadata => 'get_tags', + download => 'provide_url', + login => 'do_login', + script => 'run_script', +); +my $type = $info{type} // ''; +my $method = $type_method{$type}; +unless ($method) { + print STDOUT "PLUGIN_INVALID\n"; + print STDERR "'$relpath' declares unknown plugin type '$type'\n"; + exit 1; +} +unless ( $package->can($method) ) { + print STDOUT "PLUGIN_INVALID\n"; + print STDERR "'$relpath' (type '$type') does not implement '$method'\n"; + exit 1; +} + +exit 0; diff --git a/tests/LANraragi/Utils/Path.t b/tests/LANraragi/Utils/Path.t new file mode 100644 index 000000000..3cac584b1 --- /dev/null +++ b/tests/LANraragi/Utils/Path.t @@ -0,0 +1,48 @@ +use strict; +use warnings; +use utf8; + +use Cwd qw(getcwd); +use Test::More; + +my $cwd = getcwd(); +require "$cwd/tests/mocks.pl"; +setup_redis_mock(); + +BEGIN { use_ok('LANraragi::Utils::Path'); } + +note('testing package_to_path...'); + +{ + my $result = LANraragi::Utils::Path::package_to_path("LANraragi::Plugin::Metadata::Example"); + is( $result, "LANraragi/Plugin/Metadata/Example.pm", "multi-segment package" ); +} + +{ + my $result = LANraragi::Utils::Path::package_to_path("Foo::Bar"); + is( $result, "Foo/Bar.pm", "two-segment package" ); +} + +{ + my $result = LANraragi::Utils::Path::package_to_path("Single"); + is( $result, "Single.pm", "single-segment package" ); +} + +note('testing path_to_package...'); + +{ + my $result = LANraragi::Utils::Path::path_to_package("LANraragi/Plugin/Metadata/Example.pm"); + is( $result, "LANraragi::Plugin::Metadata::Example", "multi-segment path" ); +} + +{ + my $result = LANraragi::Utils::Path::path_to_package("Foo/Bar.pm"); + is( $result, "Foo::Bar", "two-segment path" ); +} + +{ + my $result = LANraragi::Utils::Path::path_to_package("Single.pm"); + is( $result, "Single", "single-segment path" ); +} + +done_testing(); diff --git a/tests/LANraragi/Utils/Registry.t b/tests/LANraragi/Utils/Registry.t new file mode 100644 index 000000000..0190b3d74 --- /dev/null +++ b/tests/LANraragi/Utils/Registry.t @@ -0,0 +1,775 @@ +use strict; +use warnings; +use utf8; + +use Cwd qw(abs_path getcwd); +use File::Path qw(make_path); +use File::Temp qw(tempdir); +use Mojo::File; +use Test::More; + +my $cwd = getcwd(); +require "$cwd/tests/mocks.pl"; +setup_redis_mock(); + +BEGIN { use_ok('LANraragi::Utils::Registry'); } + +sub make_valid_index { + return { + version => 1, + generated_at => "2026-03-23T00:00:00Z", + plugins => { + "sample-downloader" => { + namespace => "sample-downloader", + type => "download", + versions => { + "1.0.0" => { + version => "1.0.0", + name => "Sample Downloader", + author => "koyomi", + description => "Downloads a sample archive.", + artifact => "artifacts/sample-downloader/1.0.0/SampleDownload.pm", + sha256 => "a" x 64, + published_at => "2026-03-20T00:00:00Z", + }, + }, + }, + }, + }; +} + +note('testing resolve_git_raw_url for github...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "github", "https://github.com/owner/repo.git", "main", "registry.json" + ); + is( $result, "https://raw.githubusercontent.com/owner/repo/main/registry.json", "github registry.json" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "github", "https://github.com/owner/repo.git", "v2.0", "Plugin/Download/Foo.pm" + ); + is( $result, "https://raw.githubusercontent.com/owner/repo/v2.0/Plugin/Download/Foo.pm", "github plugin path with tag ref" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "github", "https://github.com/owner/repo", "main", "registry.json" + ); + is( $result, "https://raw.githubusercontent.com/owner/repo/main/registry.json", "github without .git suffix" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "github", "http://github.com/owner/repo.git", "main", "registry.json" + ); + is( $result, "https://raw.githubusercontent.com/owner/repo/main/registry.json", "github http url produces https raw url" ); +} + +note('testing resolve_git_raw_url for gitea...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "gitea", "https://codeberg.org/owner/repo.git", "main", "registry.json" + ); + is( $result, "https://codeberg.org/api/v1/repos/owner/repo/raw/registry.json?ref=main", "gitea codeberg registry.json" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "gitea", "https://git.local/owner/repo", "v1.0", "Plugin/Scripts/Baz.pm" + ); + is( $result, "https://git.local/api/v1/repos/owner/repo/raw/Plugin/Scripts/Baz.pm?ref=v1.0", "gitea self-hosted plugin path" ); +} + +note('testing resolve_git_raw_url outputs https regardless of input scheme for gitea...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "gitea", "http://git.local/owner/repo", "main", "registry.json" + ); + is( $result, "https://git.local/api/v1/repos/owner/repo/raw/registry.json?ref=main", "gitea http url upgraded to https" ); +} + +note('testing resolve_git_raw_url escapes path segments...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( + "github", "https://github.com/owner/repo.git", "main", "Plugin/Foo Bar.pm" + ); + is( $result, "https://raw.githubusercontent.com/owner/repo/main/Plugin/Foo%20Bar.pm", "github path with space is percent-encoded per segment" ); +} + +note('testing resolve_git_raw_url with invalid input...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( "github", "not-a-url", "main", "registry.json" ); + is( $result, undef, "malformed url returns undef" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_git_raw_url( "unknown", "https://example.com/owner/repo.git", "main", "registry.json" ); + is( $result, undef, "unknown provider returns undef" ); +} + +note('testing resolve_cdn_artifact_url accepts https and http schemes...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( + "https://cdn.example.com/registry", "registry.json" + ); + is( $result, "https://cdn.example.com/registry/registry.json", "https base joins relpath" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( + "http://cdn.example.com/registry", "registry.json" + ); + is( $result, "http://cdn.example.com/registry/registry.json", "http base accepted for CDN" ); +} + +note('testing resolve_cdn_artifact_url tolerates trailing slashes...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( + "https://cdn.example.com/registry/", "registry.json" + ); + is( $result, "https://cdn.example.com/registry/registry.json", "single trailing slash stripped" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( + "https://cdn.example.com/registry///", "registry.json" + ); + is( $result, "https://cdn.example.com/registry/registry.json", "multiple trailing slashes stripped" ); +} + +note('testing resolve_cdn_artifact_url url-escapes path segments...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( + "https://cdn.example.com/r", "artifacts/Foo Bar/1.0.0/Plug In.pm" + ); + is( $result, "https://cdn.example.com/r/artifacts/Foo%20Bar/1.0.0/Plug%20In.pm", "spaces in path are percent-encoded per segment" ); +} + +note('testing resolve_cdn_artifact_url filters empty path segments...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( + "https://cdn.example.com/r", "/registry.json" + ); + is( $result, "https://cdn.example.com/r/registry.json", "leading slash on path does not produce double slash" ); +} + +note('testing resolve_cdn_artifact_url rejects invalid scheme...'); + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( "ftp://cdn.example.com/r", "registry.json" ); + is( $result, undef, "ftp scheme rejected" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( "file:///etc", "registry.json" ); + is( $result, undef, "file scheme rejected" ); +} + +{ + my $result = LANraragi::Utils::Registry::resolve_cdn_artifact_url( undef, "registry.json" ); + is( $result, undef, "undef base url rejected" ); +} + +note('testing fetch_registry_resource (local) reads existing file...'); + +{ + my $tmp = tempdir( CLEANUP => 1 ); + Mojo::File->new("$tmp/registry.json")->spew('{"version":1}'); + my ( $status, $body, $err ) = LANraragi::Utils::Registry::fetch_registry_resource( + { provider => "local", path => $tmp }, "registry.json", 1024 + ); + is( $status, 200, "local fetch succeeds" ); + is( $body, '{"version":1}', "local fetch returns file content" ); + is( $err, undef, "no error on success" ); +} + +note('testing fetch_registry_resource (local) returns 400 for missing file...'); + +{ + my $tmp = tempdir( CLEANUP => 1 ); + my ( $status, $body, $err ) = LANraragi::Utils::Registry::fetch_registry_resource( + { provider => "local", path => $tmp }, "registry.json", 1024 + ); + is( $status, 400, "missing file returns 400 (consistent with install path)" ); + is( $body, undef, "no body on missing file" ); + like( $err, qr/not found/i, "error mentions not found" ); +} + +note('testing fetch_registry_resource (local) rejects oversized file...'); + +{ + my $tmp = tempdir( CLEANUP => 1 ); + Mojo::File->new("$tmp/registry.json")->spew( "x" x 100 ); + my ( $status, $body, $err ) = LANraragi::Utils::Registry::fetch_registry_resource( + { provider => "local", path => $tmp }, "registry.json", 10 + ); + is( $status, 400, "oversized file returns 400" ); + is( $body, undef, "no body on oversized" ); + like( $err, qr/too large/i, "error mentions too large" ); +} + +note('testing fetch_registry_resource (local) rejects empty file...'); + +{ + my $tmp = tempdir( CLEANUP => 1 ); + Mojo::File->new("$tmp/registry.json")->spew(""); + my ( $status, $body, $err ) = LANraragi::Utils::Registry::fetch_registry_resource( + { provider => "local", path => $tmp }, "registry.json", 1024 + ); + is( $status, 400, "empty file returns 400" ); + like( $err, qr/empty/i, "error mentions empty" ); +} + +note('testing fetch_registry_resource returns 400 for unknown provider...'); + +{ + my ( $status, $body, $err ) = LANraragi::Utils::Registry::fetch_registry_resource( + { provider => "unknown" }, "registry.json", 1024 + ); + is( $status, 400, "unknown provider returns 400" ); + is( $body, undef, "no body on unknown provider" ); + like( $err, qr/unknown registry provider/i, "error mentions unknown provider" ); +} + +note('testing fetch_registry_resource (cdn) returns 400 on invalid scheme...'); + +{ + my ( $status, $body, $err ) = LANraragi::Utils::Registry::fetch_registry_resource( + { provider => "cdn", url => "ftp://cdn.example.com/r" }, "registry.json", 1024 + ); + is( $status, 400, "cdn with non-http(s) scheme returns 400" ); + like( $err, qr/cannot resolve cdn url/i, "error mentions cdn URL resolution failure" ); +} + +note('testing is_valid_registry_timestamp accepts spec format...'); + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp("2026-03-23T00:00:00Z"); + is( $result, 1, "spec example timestamp accepted" ); +} + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp("2026-12-31T23:59:59Z"); + is( $result, 1, "end-of-year timestamp accepted" ); +} + +note('testing is_valid_registry_timestamp rejects non-spec formats...'); + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp("2026-03-23T00:00:00"); + is( $result, "", "missing trailing Z is rejected" ); +} + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp("2026-03-23T00:00:00z"); + is( $result, "", "lowercase z is rejected" ); +} + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp("2026-03-23T00:00:00+00:00"); + is( $result, "", "explicit zero offset is rejected" ); +} + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp("2026-03-23T00:00:00.000Z"); + is( $result, "", "fractional seconds are rejected" ); +} + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp("2026-03-23 00:00:00Z"); + is( $result, "", "space separator is rejected" ); +} + +{ + my $result = LANraragi::Utils::Registry::is_valid_registry_timestamp(""); + is( $result, "", "empty string is rejected" ); +} + +note('testing validate_registry_artifact_path accepts valid relative paths...'); + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path("artifacts/foo/1.0.0/Foo.pm"); + is( $ok, 1, "nested relative path accepted (ok)" ); + is( $err, undef, "nested relative path accepted (no error)" ); +} + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path("Foo.pm"); + is( $ok, 1, "single-segment relative path accepted (ok)" ); + is( $err, undef, "single-segment relative path accepted (no error)" ); +} + +note('testing validate_registry_artifact_path rejects each violation...'); + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path(undef); + is( $ok, undef, "undef rejected (ok)" ); + is( $err, "Invalid registry.json: version artifact is required.", "undef rejected (error)" ); +} + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path(""); + is( $ok, undef, "empty string rejected (ok)" ); + is( $err, "Invalid registry.json: version artifact is required.", "empty string rejected (error)" ); +} + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path("foo\0bar.pm"); + is( $ok, undef, "embedded null byte rejected (ok)" ); + is( $err, "Invalid registry.json: artifact path contains a null byte.", "embedded null byte rejected (error)" ); +} + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path("/etc/passwd"); + is( $ok, undef, "absolute path rejected (ok)" ); + is( $err, "Invalid registry.json: artifact path must be relative.", "absolute path rejected (error)" ); +} + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path("artifacts/../etc/passwd"); + is( $ok, undef, ".. segment rejected (ok)" ); + is( $err, "Invalid registry.json: artifact path must not contain '.' or '..' segments.", ".. segment rejected (error)" ); +} + +{ + my ( $ok, $err ) = LANraragi::Utils::Registry::validate_registry_artifact_path("./Foo.pm"); + is( $ok, undef, ". segment rejected (ok)" ); + is( $err, "Invalid registry.json: artifact path must not contain '.' or '..' segments.", ". segment rejected (error)" ); +} + +note('testing resolve_local_registry_artifact_path accepts valid containment...'); + +{ + my $root = tempdir( CLEANUP => 1 ); + make_path("$root/artifacts/foo"); + open( my $fh, '>', "$root/artifacts/foo/Foo.pm" ) or die $!; + close $fh; + + my ( $file_canon, $err ) = + LANraragi::Utils::Registry::resolve_local_registry_artifact_path( $root, "artifacts/foo/Foo.pm" ); + is( $err, undef, "valid containment (no error)" ); + is( $file_canon, abs_path("$root/artifacts/foo/Foo.pm"), "valid containment (file_canon matches)" ); +} + +note('testing resolve_local_registry_artifact_path rejects each violation...'); + +{ + my ( $file_canon, $err ) = + LANraragi::Utils::Registry::resolve_local_registry_artifact_path( "/nonexistent/path/that/does/not/exist", "Foo.pm" ); + is( $file_canon, undef, "non-existent root (file_canon undef)" ); + is( $err, "Invalid local registry path: /nonexistent/path/that/does/not/exist", "non-existent root (error)" ); +} + +{ + my $root = tempdir( CLEANUP => 1 ); + my ( $file_canon, $err ) = + LANraragi::Utils::Registry::resolve_local_registry_artifact_path( $root, "missing.pm" ); + is( $file_canon, undef, "missing artifact (file_canon undef)" ); + is( $err, "Plugin file not found: " . abs_path($root) . "/missing.pm", "missing artifact (error)" ); +} + +SKIP: { + skip "symlink not supported on this platform", 4 unless eval { symlink( "", "" ); 1 }; + + { + my $root = tempdir( CLEANUP => 1 ); + my $escape = tempdir( CLEANUP => 1 ); + open( my $fh, '>', "$escape/Outside.pm" ) or die $!; + close $fh; + symlink( "$escape/Outside.pm", "$root/Escape.pm" ) or die $!; + + my ( $file_canon, $err ) = + LANraragi::Utils::Registry::resolve_local_registry_artifact_path( $root, "Escape.pm" ); + is( $file_canon, undef, "symlink escaping root (file_canon undef)" ); + is( $err, "Plugin artifact path escapes registry root: Escape.pm", "symlink escaping root (error)" ); + } + + { + my $root = tempdir( CLEANUP => 1 ); + make_path("$root/artifacts/foo"); + open( my $fh, '>', "$root/artifacts/foo/Foo.pm" ) or die $!; + close $fh; + symlink( "$root/artifacts/foo/Foo.pm", "$root/Inside.pm" ) or die $!; + + my ( $file_canon, $err ) = + LANraragi::Utils::Registry::resolve_local_registry_artifact_path( $root, "Inside.pm" ); + is( $err, undef, "symlink staying inside root (no error)" ); + is( $file_canon, abs_path("$root/artifacts/foo/Foo.pm"), "symlink staying inside root (file_canon resolves to target)" ); + } +} + +note('testing validate_registry_index accepts a valid index...'); + +{ + my $err = LANraragi::Utils::Registry::validate_registry_index( make_valid_index() ); + is( $err, undef, "valid index returns undef" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins} = {}; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, undef, "empty plugins hash accepted" ); +} + +note('testing validate_registry_index rejects root-level violations...'); + +{ + my $err = LANraragi::Utils::Registry::validate_registry_index("not a hash"); + is( $err, "Invalid registry.json: root must be an object.", "non-hash root rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{unknown_field} = 1; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: unknown root field 'unknown_field'.", "unknown root field rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{version} = 2; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: registry version must be 1.", "version != 1 rejected" ); +} + +{ + my $idx = make_valid_index(); + delete $idx->{version}; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: registry version must be 1.", "missing version rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{generated_at} = ""; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: 'generated_at' is required.", "empty generated_at rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{generated_at} = "2026-03-23"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: 'generated_at' must be a UTC RFC3339 timestamp.", "malformed generated_at rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins} = []; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: 'plugins' must be an object.", "non-hash plugins rejected" ); +} + +note('testing validate_registry_index rejects plugin-level violations...'); + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"} = "not a hash"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' must be an object.", "non-hash plugin rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{unknown_field} = 1; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' has unknown field 'unknown_field'.", "unknown plugin field rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{channels} = { latest => "1.0.0" }; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' has unknown field 'channels'.", "channels field rejected as unknown" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{namespace} = "different-name"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin key 'sample-downloader' must match inner namespace.", "key/inner namespace mismatch rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"SampleDownloader"} = delete $idx->{plugins}{"sample-downloader"}; + $idx->{plugins}{"SampleDownloader"}{namespace} = "SampleDownloader"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin namespace 'SampleDownloader' must match ^[a-z0-9_-]+\$ (lowercase only).", "mixed-case namespace rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"SAMPLE-DOWNLOADER"} = delete $idx->{plugins}{"sample-downloader"}; + $idx->{plugins}{"SAMPLE-DOWNLOADER"}{namespace} = "SAMPLE-DOWNLOADER"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin namespace 'SAMPLE-DOWNLOADER' must match ^[a-z0-9_-]+\$ (lowercase only).", "uppercase namespace rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{type} = "spreadsheet"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' has invalid type 'spreadsheet'.", "invalid type rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions} = {}; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' 'versions' must be a non-empty object.", "empty versions rejected" ); +} + +note('testing validate_registry_index rejects non-SemVer version keys...'); + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0"} = { + version => "1.0", + name => "Sample Downloader", + author => "koyomi", + description => "Downloads a sample archive.", + artifact => "artifacts/sample-downloader/1.0/SampleDownload.pm", + sha256 => "a" x 64, + published_at => "2026-03-20T00:00:00Z", + }; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( + $err, + "Invalid registry.json: plugin 'sample-downloader' version key '1.0' is not a valid SemVer 2.0.0 string.", + "short dotted version key rejected" + ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"not-a-version"} = { + version => "not-a-version", + name => "Sample Downloader", + author => "koyomi", + description => "Downloads a sample archive.", + artifact => "artifacts/sample-downloader/not-a-version/SampleDownload.pm", + sha256 => "a" x 64, + published_at => "2026-03-20T00:00:00Z", + }; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( + $err, + "Invalid registry.json: plugin 'sample-downloader' version key 'not-a-version' is not a valid SemVer 2.0.0 string.", + "non-semver string version key rejected" + ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"v1.0.0"} = { + version => "v1.0.0", + name => "Sample Downloader", + author => "koyomi", + description => "Downloads a sample archive.", + artifact => "artifacts/sample-downloader/v1.0.0/SampleDownload.pm", + sha256 => "a" x 64, + published_at => "2026-03-20T00:00:00Z", + }; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( + $err, + "Invalid registry.json: plugin 'sample-downloader' version key 'v1.0.0' is not a valid SemVer 2.0.0 string.", + "v-prefixed version key rejected" + ); +} + +note('testing validate_registry_index accepts valid SemVer version keys...'); + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"2.0.0"} = { + version => "2.0.0", + name => "Sample Downloader", + author => "koyomi", + description => "Downloads a sample archive.", + artifact => "artifacts/sample-downloader/2.0.0/SampleDownload.pm", + sha256 => "b" x 64, + published_at => "2026-03-23T00:00:00Z", + }; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, undef, "multiple valid SemVer version keys accepted" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0-alpha.1"} = { + version => "1.0.0-alpha.1", + name => "Sample Downloader", + author => "koyomi", + description => "Downloads a sample archive.", + artifact => "artifacts/sample-downloader/1.0.0-alpha.1/SampleDownload.pm", + sha256 => "c" x 64, + published_at => "2026-03-19T00:00:00Z", + }; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, undef, "SemVer prerelease version key accepted" ); +} + +note('testing validate_registry_index rejects version-level violations...'); + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"} = "not a hash"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' version '1.0.0' must be an object.", "non-hash version rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"}{unknown_field} = 1; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' version '1.0.0' has unknown field 'unknown_field'.", "unknown version field rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"}{version} = "9.9.9"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' version key '1.0.0' must match inner version.", "key/inner version mismatch rejected" ); +} + +foreach my $field (qw(name author description artifact sha256 published_at)) { + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"}{$field} = ""; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( + $err, + "Invalid registry.json: plugin 'sample-downloader' version '1.0.0' is missing '$field'.", + "empty required field '$field' rejected" + ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"}{artifact} = "/etc/passwd"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: artifact path must be relative.", "non-empty invalid artifact delegates to validate_registry_artifact_path" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"}{sha256} = "z" x 64; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' version '1.0.0' sha256 must be 64 lowercase hexadecimal characters.", "non-hex sha256 rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"}{sha256} = "a" x 63; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' version '1.0.0' sha256 must be 64 lowercase hexadecimal characters.", "63-char sha256 rejected" ); +} + +{ + my $idx = make_valid_index(); + $idx->{plugins}{"sample-downloader"}{versions}{"1.0.0"}{published_at} = "yesterday"; + my $err = LANraragi::Utils::Registry::validate_registry_index($idx); + is( $err, "Invalid registry.json: plugin 'sample-downloader' version '1.0.0' published_at must be a UTC RFC3339 timestamp.", "malformed published_at rejected" ); +} + +note('testing resolve_max_version returns SemVer-greatest key...'); + +{ + my $plugin_root = { + versions => { + "1.0.0" => {}, + "1.1.0" => {}, + "2.0.0" => {}, + }, + }; + my $max = LANraragi::Utils::Registry::resolve_max_version($plugin_root); + is( $max, "2.0.0", "greatest version among three is selected" ); +} + +{ + my $plugin_root = { + versions => { + "1.0.0" => {}, + }, + }; + my $max = LANraragi::Utils::Registry::resolve_max_version($plugin_root); + is( $max, "1.0.0", "single version is returned as max" ); +} + +{ + my $plugin_root = { + versions => { + "1.0.0" => {}, + "1.0.0-alpha" => {}, + }, + }; + my $max = LANraragi::Utils::Registry::resolve_max_version($plugin_root); + is( $max, "1.0.0", "release version is greater than prerelease by SemVer" ); +} + +{ + my $plugin_root = { + versions => { + "1.9.0" => {}, + "1.10.0" => {}, + }, + }; + my $max = LANraragi::Utils::Registry::resolve_max_version($plugin_root); + is( $max, "1.10.0", "SemVer comparator correctly orders 1.10.0 > 1.9.0" ); +} + +note('testing find_package_conflict...'); + +{ + my $result = LANraragi::Utils::Registry::find_package_conflict("LANraragi::Plugin::Metadata::Chaika"); + is( $result, "$cwd/lib/LANraragi/Plugin/Metadata/Chaika.pm", "existing package detected" ); +} + +{ + my $skip = "$cwd/lib/LANraragi/Plugin/Metadata/Chaika.pm"; + my $result = LANraragi::Utils::Registry::find_package_conflict( "LANraragi::Plugin::Metadata::Chaika", $skip ); + is( $result, undef, "skip_path excludes own file" ); +} + +{ + my $result = LANraragi::Utils::Registry::find_package_conflict("LANraragi::Plugin::Nonexistent::Synthetic"); + is( $result, undef, "non-existent package returns undef" ); +} + +note('testing find_namespace_conflict...'); + +{ + my $result = LANraragi::Utils::Registry::find_namespace_conflict("trabant"); + is( $result, "$cwd/lib/LANraragi/Plugin/Metadata/Chaika.pm", "existing namespace detected" ); +} + +{ + my $result = LANraragi::Utils::Registry::find_namespace_conflict("TRABANT"); + is( $result, "$cwd/lib/LANraragi/Plugin/Metadata/Chaika.pm", "namespace match is case-insensitive" ); +} + +{ + my $skip = "$cwd/lib/LANraragi/Plugin/Metadata/Chaika.pm"; + my $result = LANraragi::Utils::Registry::find_namespace_conflict( "trabant", $skip ); + is( $result, undef, "skip_path excludes own file" ); +} + +{ + my $result = LANraragi::Utils::Registry::find_namespace_conflict("definitely-not-a-real-namespace"); + is( $result, undef, "non-existent namespace returns undef" ); +} + +done_testing(); diff --git a/tests/modules.t b/tests/modules.t index d32fbe0b2..6d08329de 100644 --- a/tests/modules.t +++ b/tests/modules.t @@ -21,6 +21,7 @@ my @modules = ( "LANraragi::Controller::Api::Search", "LANraragi::Controller::Api::Category", "LANraragi::Controller::Api::Database", "LANraragi::Controller::Api::Shinobu", "LANraragi::Controller::Api::Minion", "LANraragi::Controller::Api::Other", + "LANraragi::Controller::Api::Registry", "LANraragi::Controller::Api::Plugins", "LANraragi::Controller::Backup", "LANraragi::Controller::Batch", "LANraragi::Controller::Config", "LANraragi::Controller::Edit", "LANraragi::Controller::Index", "LANraragi::Controller::Logging", @@ -32,6 +33,7 @@ my @modules = ( "LANraragi::Model::Reader", "LANraragi::Model::Search", "LANraragi::Model::Stats", "LANraragi::Model::Category", "LANraragi::Model::Upload", "LANraragi::Model::Opds", + "LANraragi::Model::Registry", "LANraragi::Model::Server", "LANraragi::Plugin::Metadata::Chaika", "LANraragi::Plugin::Metadata::CopyTags", "LANraragi::Plugin::Metadata::DateAdded", "LANraragi::Plugin::Metadata::EHentai", "LANraragi::Plugin::Metadata::Eze", "LANraragi::Plugin::Metadata::HDoujin", @@ -46,7 +48,7 @@ my @modules = ( "LANraragi::Plugin::Metadata::ChaikaFile", "LANraragi::Plugin::Metadata::Ksk", "LANraragi::Plugin::Metadata::HatH", "LANraragi::Plugin::Metadata::CopyArchiveTags", "LANraragi::Plugin::Login::Pixiv", "LANraragi::Plugin::Metadata::Pixiv", - "LANraragi::Plugin::Metadata::EHDLInfo", + "LANraragi::Plugin::Metadata::EHDLInfo", "LANraragi::Utils::Registry", ); # Test all modules load properly diff --git a/tools/Documentation/SUMMARY.md b/tools/Documentation/SUMMARY.md index 60de754f1..7c4269f38 100644 --- a/tools/Documentation/SUMMARY.md +++ b/tools/Documentation/SUMMARY.md @@ -34,6 +34,7 @@ * [📱 Using External Readers](advanced-usage/external-readers.md) * [🌐 Network Interface Setup](advanced-usage/network-interfaces.md) * [🕵️ Proxy Setup](advanced-usage/proxy-setup.md) +* [📦 Plugin Registries](advanced-usage/plugin-registries.md) ## Developer Guide @@ -51,6 +52,7 @@ * [Tankoubon API](api-documentation/tankoubon-api.md) * [Stamps API](api-documentation/stamp-api.md) * [Plugin API](api-documentation/plugin-api.md) +* [Registry API](api-documentation/registry-api.md) * [Shinobu API](api-documentation/shinobu-api.md) * [Minion API](api-documentation/minion-api.md) * [OPDS Catalog](api-documentation/opds-catalog.md) diff --git a/tools/Documentation/advanced-usage/plugin-registries.md b/tools/Documentation/advanced-usage/plugin-registries.md new file mode 100644 index 000000000..491fdaa30 --- /dev/null +++ b/tools/Documentation/advanced-usage/plugin-registries.md @@ -0,0 +1,71 @@ +--- +description: Configure registries and install plugins +--- + +# 📦 Plugin Registries + +*Plugin registries* allow you to extend LRR's capabilities through third-party plugins. + +{% hint style="warning" %} +Currently registry and managed plugin operations are only supported through the API. +{% endhint %} + +## Adding a Registry + +Three types of registries are supported: + +- A **git-based registry** is a git repository on GitHub, or a self-hosted Gitea or Forgejo instance. Configure it with the repository's HTTPS URL, a branch/tag/commit, and the provider (github or gitea). +- A **CDN-based registry** is a folder served on a static file service like nginx. Configure it with the folder's HTTP or HTTPS base URL. +- A **filesystem-based** (local) registry is a folder on disk. Configure it with its absolute path. + +A registry's plugins only become available after it is refreshed, which loads its index. + +## Plugin Management + +Plugins installed through a registry are managed entirely by LRR. Install, upgrade, and uninstall plugins through the plugin API. + + + +## Creating Your Own Registry + +A registry must have the following file structure: + +```text +|- registry.json +|- artifacts +| |- plugin-1 + |- 1.0.0 + |- Plugin1.pm + |- 1.1.0 + |- 2.0.0 + |- ... +| |- plugin-2 +| |- plugin-3 +| |- ... +``` + +The `registry.json` holds info about all plugins contained in a registry (you can also generate it with [generate_registry.pl](https://github.com/psilabs-dev/lrr-plugins-demo/blob/main/generate_registry.pl)): + +```json +{ + "generated_at": "...", + "plugins": { + "sample-downloader" : { + "namespace" : "plugin-1", + "type" : "download", + "versions" : { + "1.0.0" : { + "artifact" : "artifacts/plugin-1/1.0.0/Plugin1.pm", + "author" : "koyomi", + "description" : "Description for Plugin1", + "name" : "Plugin 1", + "published_at" : "2026-05-05T21:31:12Z", + "sha256" : "b706314ae4800568968e82d248789dc33a705f620283d26c7d13e7ad866aee93", + "version" : "1.0.0" + } + } + }, + // ... + } +} +``` diff --git a/tools/Documentation/api-documentation/plugin-api.md b/tools/Documentation/api-documentation/plugin-api.md index 423780f7a..4886caf56 100644 --- a/tools/Documentation/api-documentation/plugin-api.md +++ b/tools/Documentation/api-documentation/plugin-api.md @@ -15,3 +15,11 @@ description: APIs to list and execute Plugins. {% openapi-operation spec="lanraragi-api" path="/plugins/queue" method="post" %} [OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) {% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/plugins/install" method="post" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/plugins/installed/{plugin_namespace}" method="delete" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} diff --git a/tools/Documentation/api-documentation/registry-api.md b/tools/Documentation/api-documentation/registry-api.md new file mode 100644 index 000000000..94568a075 --- /dev/null +++ b/tools/Documentation/api-documentation/registry-api.md @@ -0,0 +1,41 @@ +--- +description: APIs to manage Plugin Registries. +--- + +# Registry API + +{% openapi-operation spec="lanraragi-api" path="/registries" method="get" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries" method="post" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries/{id}" method="get" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries/{id}" method="put" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries/{id}" method="delete" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries/{id}/refresh" method="post" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries/ougi" method="get" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries/ougi/{id}" method="put" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} + +{% openapi-operation spec="lanraragi-api" path="/registries/ougi" method="delete" %} +[OpenAPI lanraragi-api](https://raw.githubusercontent.com/Difegue/LANraragi/refs/heads/dev/tools/openapi.yaml) +{% endopenapi-operation %} diff --git a/tools/Documentation/extending-lanraragi/architecture.md b/tools/Documentation/extending-lanraragi/architecture.md index f491ea74e..a09f9e700 100644 --- a/tools/Documentation/extending-lanraragi/architecture.md +++ b/tools/Documentation/extending-lanraragi/architecture.md @@ -207,7 +207,28 @@ The base architecture is as follows: -Redis Database 2 - Configuration | -|- LRR_PLUGIN_xxxxxxx <- Settings for a plugin with namespace xxxxxxx +|- LRR_PLUGIN_xxxxxxx <- Settings and provenance for a plugin with namespace xxxxxxx +| |- enabled <- Whether a metadata plugin runs automatically on new archives +| |- customargs <- Saved plugin argument values (legacy storage) +| |- installed_path <- Package path of the plugin's .pm file +| |- installed_version <- Installed version string (managed plugins only) +| |- installed_registry <- REG_ id the plugin was installed from (managed plugins only) +| |- installed_sha256 <- SHA-256 of the installed artifact bytes (managed plugins only) +| +- type <- Plugin type: metadata, login, download or script +| +|- REG_xxxxxxxxxx <- A plugin registry. REG_<10-digit epoch timestamp>. +| |- name <- Display name of the registry, as set by the User +| |- provider <- Registry type: github, gitea, cdn or local +| |- url <- Base URL of the registry (github / gitea / cdn providers) +| |- ref <- Git branch, tag or commit to read from (github / gitea providers) +| |- path <- Absolute filesystem path to the registry root (local provider) +| |- created <- Creation time, in epoch seconds +| +- updated <- Last modification time, in epoch seconds +| +|- REG_INDEX_xxxxxxxxxx <- Cached registry.json manifest for the matching REG_xxxxxxxxxx +| +|- LRR_SERVER <- Runtime server state +| +- restart_pending <- Set to 1 on plugin upgrade or uninstall | |- LRR_TOTALPAGESTAT <- Total pages read | @@ -232,7 +253,8 @@ The base architecture is as follows: | |- enablepass <- Enable/Disable Password Authentication. | |- nofunmode <- Whether No-Fun Mode is enabled | |- pagesize <- Amount of archives per Index page -| +- apikey <- Key for API requests +| |- apikey <- Key for API requests +| +- ougi <- Ougi, the default registry pre-selected in plugin-install dialogs | |- LRR_DUPLICATE_GROUPS <- Duplicate groups found by duplicate detection | +- dupgp_xxxxxx <- A group of dupe IDs, as a JSON list diff --git a/tools/build/docker/Dockerfile b/tools/build/docker/Dockerfile index 2a34e3f9e..e65cf024f 100644 --- a/tools/build/docker/Dockerfile +++ b/tools/build/docker/Dockerfile @@ -129,3 +129,4 @@ VOLUME [ "/home/koyomi/lanraragi/content" ] VOLUME [ "/home/koyomi/lanraragi/thumb" ] VOLUME [ "/home/koyomi/lanraragi/database"] VOLUME [ "/home/koyomi/lanraragi/lib/LANraragi/Plugin/Sideloaded" ] +VOLUME [ "/home/koyomi/lanraragi/lib/LANraragi/Plugin/Managed" ] diff --git a/tools/build/docker/s6/cont-init.d/01-lrr-setup b/tools/build/docker/s6/cont-init.d/01-lrr-setup index 7bd27ee5b..5c7c3f2c9 100755 --- a/tools/build/docker/s6/cont-init.d/01-lrr-setup +++ b/tools/build/docker/s6/cont-init.d/01-lrr-setup @@ -34,6 +34,10 @@ if [ "$FIX_PERMS" -eq 1 ]; then chmod u+rwx /home/koyomi/lanraragi/lib/LANraragi/Plugin/Sideloaded find /home/koyomi/lanraragi/lib/LANraragi/Plugin/Sideloaded -type f -exec chmod u+rwx {} \; + chown -R koyomi /home/koyomi/lanraragi/lib/LANraragi/Plugin/Managed + chmod u+rwx /home/koyomi/lanraragi/lib/LANraragi/Plugin/Managed + find /home/koyomi/lanraragi/lib/LANraragi/Plugin/Managed -type f -exec chmod u+rwx {} \; + # Ensure the rest of the content folder is at least readable find /home/koyomi/lanraragi/content -name thumb -prune -o -type f -exec chmod u+r {} \; find /home/koyomi/lanraragi/content -name thumb -prune -o -type d -exec chmod u+rx {} \; diff --git a/tools/cpanfile b/tools/cpanfile index 2c4d3b033..04b92da15 100755 --- a/tools/cpanfile +++ b/tools/cpanfile @@ -59,6 +59,7 @@ requires 'File::ChangeNotify', 0.31; # Plugin system requires 'Module::Pluggable', 5.2; +requires 'SemVer', 0.10.0; # Eze plugin/Timestamp calculation requires 'Time::Local', 1.30; diff --git a/tools/openapi.yaml b/tools/openapi.yaml index 8a4c88f9a..d134ee3e6 100644 --- a/tools/openapi.yaml +++ b/tools/openapi.yaml @@ -17,6 +17,8 @@ tags: description: Endpoints related to Categories. - name: tankoubons description: Endpoints related to Tankoubons. + - name: registries + description: Plugin registry management APIs. - name: plugins description: APIs to list and execute Plugins. - name: shinobu @@ -392,7 +394,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ServerInfo' - + /plugins/{type}: get: security: @@ -561,6 +563,730 @@ paths: operation: queue_plugin_exec success: 1 + /registries: + get: + security: + - api_key: [] + operationId: listRegistries + x-mojo-to: api-registry#list_registries + summary: 🔑 List Registries + description: Get all configured registries. + tags: + - registries + responses: + '200': + description: List of registries + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - list_registries + success: + type: integer + enum: + - 1 + registries: + type: array + items: + $ref: '#/components/schemas/RegistryMetadata' + post: + security: + - api_key: [] + operationId: createRegistry + x-mojo-to: api-registry#create_registry + summary: 🔑 Create a Registry + description: Add a new registry. + tags: + - registries + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - provider + properties: + name: + type: string + minLength: 1 + description: User-friendly alias for this registry. + provider: + type: string + enum: + - github + - gitea + - cdn + - local + description: Registry source provider. + url: + type: string + format: uri + description: | + Registry URL. For git providers (github, gitea), must be an HTTPS git + repository URL. For 'cdn', must be an http:// or https:// base URL of a + folder serving registry.json and artifacts. + ref: + type: string + minLength: 1 + description: Git ref (branch, tag, or commit). Required for git providers. + path: + type: string + minLength: 1 + pattern: '^(/|[A-Za-z]:[\\/]|\\\\)' + description: | + Local filesystem path. Required when provider is 'local'. + Must be an absolute path. + allOf: + - if: + required: + - provider + properties: + provider: + enum: + - github + - gitea + then: + required: + - url + - ref + properties: + url: + pattern: "^https://" + - if: + required: + - provider + properties: + provider: + const: github + then: + properties: + url: + pattern: '^https://github\.com/[^/]+/[^/]+' + - if: + required: + - provider + properties: + provider: + const: cdn + then: + required: + - url + properties: + url: + pattern: "^https?://" + - if: + required: + - provider + properties: + provider: + const: local + then: + required: + - path + responses: + '200': + description: Registry created + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - create_registry + success: + type: integer + enum: + - 1 + error: + type: string + nullable: true + id: + type: string + description: Generated registry ID. + minLength: 14 + maxLength: 14 + '400': + description: Registry creation failed (invalid path or required field) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: Locked resource response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + + /registries/ougi: + get: + security: + - api_key: [] + operationId: getOugi + x-mojo-to: api-registry#get_ougi + summary: Get Ougi + description: >- + Retrieves the id of the default registry currently designated as Ougi. + + Returns an empty string if no registry is designated. + tags: + - registries + responses: + '200': + description: Ougi id + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - get_ougi + success: + type: integer + enum: + - 1 + id: + description: Ougi registry ID, or empty string if unset. + anyOf: + - type: string + minLength: 14 + maxLength: 14 + - type: string + enum: + - '' + delete: + security: + - api_key: [] + operationId: removeOugi + x-mojo-to: api-registry#remove_ougi + summary: 🔑 Clear Ougi + description: >- + Clears the Ougi designation. + + Returns the id of the previously-designated registry, or an empty string if none was set. + tags: + - registries + responses: + '200': + description: Ougi cleared + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - remove_ougi + success: + type: integer + enum: + - 1 + id: + description: Id of the previously-designated registry. + anyOf: + - type: string + minLength: 14 + maxLength: 14 + - type: string + enum: + - '' + /registries/ougi/{id}: + put: + security: + - api_key: [] + operationId: updateOugi + x-mojo-to: api-registry#update_ougi + summary: 🔑 Set Ougi + description: >- + Designates the registry with the given id as Ougi, the default. + + The frontend uses this designation to pre-select a registry in install/refresh dialogs. + tags: + - registries + parameters: + - name: id + in: path + required: true + schema: + type: string + minLength: 14 + maxLength: 14 + description: Registry id to designate as Ougi, the default. + responses: + '200': + description: Ougi set + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - update_ougi + success: + type: integer + enum: + - 1 + id: + type: string + minLength: 14 + maxLength: 14 + '400': + description: Invalid registry id format + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '404': + description: Registry with the specified id does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + + /registries/{id}: + get: + security: + - api_key: [] + operationId: getRegistry + x-mojo-to: api-registry#get_registry + summary: 🔑 Get a Registry + description: Get configuration data for a specific registry. + tags: + - registries + parameters: + - name: id + in: path + required: true + schema: + type: string + minLength: 14 + maxLength: 14 + description: Registry ID. + responses: + '200': + description: Registry configuration + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - get_registry + success: + type: integer + enum: + - 1 + error: + type: string + nullable: true + registry: + $ref: '#/components/schemas/RegistryMetadata' + '404': + description: Registry not found + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + put: + security: + - api_key: [] + operationId: updateRegistry + x-mojo-to: api-registry#update_registry + summary: 🔑 Update a Registry + description: Update registry configuration. Only provided fields are changed. Clears cached index. + tags: + - registries + parameters: + - name: id + in: path + required: true + schema: + type: string + minLength: 14 + maxLength: 14 + description: Registry ID. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + provider: + type: string + enum: + - github + - gitea + - cdn + - local + url: + type: string + format: uri + pattern: "^https?://" + ref: + type: string + minLength: 1 + path: + type: string + minLength: 1 + pattern: '^(/|[A-Za-z]:[\\/]|\\\\)' + allOf: + - if: + required: + - provider + properties: + provider: + enum: + - github + - gitea + then: + properties: + url: + pattern: "^https://" + - if: + required: + - provider + properties: + provider: + const: github + then: + properties: + url: + pattern: '^https://github\.com/[^/]+/[^/]+' + - if: + required: + - provider + properties: + provider: + const: cdn + then: + properties: + url: + pattern: "^https?://" + responses: + '200': + description: Registry updated + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - update_registry + success: + type: integer + enum: + - 1 + error: + type: string + nullable: true + id: + type: string + minLength: 14 + maxLength: 14 + '400': + description: Update failed (missing required fields, invalid path) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '404': + description: Registry not found + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: Locked resource response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '500': + description: Redis error during update + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + delete: + security: + - api_key: [] + operationId: deleteRegistry + x-mojo-to: api-registry#delete_registry + summary: 🔑 Delete a Registry + description: Remove a registry and its cached index. Does not remove installed plugins. + tags: + - registries + parameters: + - name: id + in: path + required: true + schema: + type: string + minLength: 14 + maxLength: 14 + description: Registry ID. + responses: + '200': + description: Registry removed + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - delete_registry + success: + type: integer + enum: + - 1 + error: + type: string + nullable: true + successMessage: + type: string + '404': + description: Registry not found + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: Locked resource response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '500': + description: Redis error during delete + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + + /registries/{id}/refresh: + post: + security: + - api_key: [] + operationId: refreshRegistry + x-mojo-to: api-registry#refresh_registry + summary: 🔑 Refresh Registry Index + description: Fetch registry.json from the configured source and cache it. + tags: + - registries + parameters: + - name: id + in: path + required: true + schema: + type: string + minLength: 14 + maxLength: 14 + description: Registry ID. + responses: + '200': + description: Registry index refreshed + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - refresh_registry + success: + type: integer + enum: + - 1 + error: + type: string + nullable: true + index: + type: object + nullable: true + description: Parsed registry.json content + '400': + description: Refresh failed (invalid registry.json, missing file, validation failure) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '404': + description: Registry not found + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: Locked resource response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '500': + description: Redis error or read-failure during refresh + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '502': + description: Failed to fetch registry index from upstream + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + + /plugins/install: + post: + security: + - api_key: [] + operationId: installPlugin + x-mojo-to: api-plugins#install_plugin + summary: 🔑 Install a Plugin + description: Install or upgrade a plugin from a registry by namespace. + tags: + - plugins + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - namespace + - registry + properties: + namespace: + type: string + pattern: "^[a-z0-9_-]+$" + description: Namespace of the plugin to install. Registry namespaces are lowercase. + registry: + type: string + description: Registry ID to install from. + minLength: 14 + maxLength: 14 + version: + type: string + minLength: 1 + description: Explicit version key to install. If omitted, LRR installs the max version from the registry index. + force: + type: boolean + description: Force install even if the plugin is already installed from a different managed registry. Built-in plugins cannot be overwritten. + responses: + '200': + description: Install job enqueued + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - install_plugin + namespace: + type: string + success: + type: integer + enum: + - 1 + job: + type: integer + description: ID of the enqueued Minion job. + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + + /plugins/installed/{plugin_namespace}: + delete: + security: + - api_key: [] + operationId: uninstallPlugin + x-mojo-to: api-plugins#uninstall_plugin + summary: 🔑 Uninstall a Plugin + description: Remove an installed plugin from disk and clean up its configuration. + tags: + - plugins + parameters: + - name: plugin_namespace + in: path + required: true + schema: + type: string + pattern: "^[a-zA-Z0-9_-]+$" + description: Namespace of the plugin to uninstall. + responses: + '200': + description: Plugin uninstalled + content: + application/json: + schema: + type: object + properties: + operation: + type: string + enum: + - uninstall_plugin + success: + type: integer + enum: + - 1 + error: + type: string + nullable: true + successMessage: + type: string + '403': + description: Plugin cannot be uninstalled (built-in plugin) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '404': + description: Plugin has no install path recorded + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '423': + description: Locked resource response + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + '500': + description: Server error (file deletion failed) + content: + application/json: + schema: + $ref: '#/components/schemas/OperationResponse' + /tempfolder: delete: security: @@ -4221,6 +4947,9 @@ components: cache_last_cleared: type: integer description: Timestamp for last time the search cache was wiped + restart_required: + type: boolean + description: Whether LRR needs a restart excluded_namespaces: type: array items: @@ -4237,6 +4966,7 @@ components: server_resizes_images: false server_tracks_progress: true authenticated_progress: false + restart_required: false total_archives: 104 total_pages_read: 252 version: 0.9.30 @@ -4288,6 +5018,14 @@ components: login_from: type: [string, "null"] description: Namespace of the login plugin this plugin depends on + registry: + type: string + nullable: true + description: Registry ID the plugin was installed from, or null if not registry-managed + sha256: + type: string + nullable: true + description: SHA-256 recorded for the installed artifact, or null if not registry-managed example: author: Difegue description: Searches chaika.moe for tags matching your archive. @@ -4645,5 +5383,77 @@ components: type: array items: type: string + RegistryMetadata: + type: object + description: Registry configuration with ID. + required: + - id + - name + - provider + - created + - updated + properties: + id: + type: string + description: Registry ID. + minLength: 14 + maxLength: 14 + name: + type: string + minLength: 1 + description: User-friendly alias for this registry. + provider: + type: string + enum: + - github + - gitea + - cdn + - local + description: Registry source provider. + created: + type: integer + description: Unix timestamp of registry creation. + updated: + type: integer + description: Unix timestamp of last update. + url: + type: string + format: uri + pattern: "^https?://" + description: | + Registry URL. HTTPS git repository URL for git providers (github, gitea), + or http://-or-https:// CDN base URL for 'cdn'. + ref: + type: string + minLength: 1 + description: Git ref (branch, tag, or commit). Present for git providers. + path: + type: string + minLength: 1 + pattern: '^(/|[A-Za-z]:[\\/]|\\\\)' + description: Local filesystem path to the registry. Present when provider is 'local'. Always absolute. + allOf: + - if: + required: + - provider + properties: + provider: + enum: + - github + - gitea + then: + properties: + url: + pattern: "^https://" + - if: + required: + - provider + properties: + provider: + const: github + then: + properties: + url: + pattern: '^https://github\.com/[^/]+/[^/]+' servers: - url: https://lrr.tvc-16.science/api