From 2753285b7722fdb47f0ebb2180e997cf72f65d1a Mon Sep 17 00:00:00 2001 From: Alex S Date: Sun, 29 Sep 2019 11:17:38 +0300 Subject: [PATCH 01/46] config editing through database --- config/description.exs | 527 +++++++++++------- docs/API/admin_api.md | 120 ++-- docs/admin/config.md | 59 ++ lib/mix/tasks/pleroma/config.ex | 107 ++-- lib/pleroma/config/transfer_task.ex | 59 +- lib/pleroma/docs/generator.ex | 136 +++-- lib/pleroma/docs/json.ex | 20 +- ...olicy.ex => media_proxy_warming_policy.ex} | 0 .../mrf/{noop_policy.ex => no_op_policy.ex} | 0 ...st_policy.ex => user_allow_list_policy.ex} | 0 .../web/activity_pub/mrf/vocabulary_policy.ex | 3 +- .../web/admin_api/admin_api_controller.ex | 44 +- lib/pleroma/web/admin_api/config.ex | 120 ++-- lib/pleroma/web/router.ex | 1 + test/config/transfer_task_test.exs | 14 +- test/docs/generator_test.exs | 211 +++++++ test/support/factory.ex | 4 +- test/tasks/config_test.exs | 40 +- .../admin_api/admin_api_controller_test.exs | 112 ++-- test/web/admin_api/config_test.exs | 28 +- 20 files changed, 1133 insertions(+), 472 deletions(-) create mode 100644 docs/admin/config.md rename lib/pleroma/web/activity_pub/mrf/{mediaproxy_warming_policy.ex => media_proxy_warming_policy.ex} (100%) rename lib/pleroma/web/activity_pub/mrf/{noop_policy.ex => no_op_policy.ex} (100%) rename lib/pleroma/web/activity_pub/mrf/{user_allowlist_policy.ex => user_allow_list_policy.ex} (100%) create mode 100644 test/docs/generator_test.exs diff --git a/config/description.exs b/config/description.exs index 45e4b43f1..eeb4a6fe9 100644 --- a/config/description.exs +++ b/config/description.exs @@ -23,17 +23,17 @@ key: :uploader, type: :module, description: "Module which will be used for uploads", - suggestions: [ - Generator.uploaders_list() - ] + suggestions: [Pleroma.Uploaders.Local, Pleroma.Uploaders.MDII, Pleroma.Uploaders.S3] }, %{ key: :filters, type: {:list, :module}, description: "List of filter modules for uploads", - suggestions: [ - Generator.filters_list() - ] + suggestions: + Generator.list_modules_in_dir( + "lib/pleroma/upload/filter", + "Elixir.Pleroma.Upload.Filter." + ) }, %{ key: :link_name, @@ -58,7 +58,50 @@ %{ key: :proxy_opts, type: :keyword, - description: "Proxy options, see `Pleroma.ReverseProxy` documentation" + description: "Options for Pleroma.ReverseProxy", + suggestions: [ + redirect_on_failure: false, + max_body_length: 25 * 1_048_576, + http: [ + follow_redirect: true, + pool: :media + ] + ], + children: [ + %{ + key: :redirect_on_failure, + type: :boolean, + description: + "Redirects the client to the real remote URL if there's any HTTP errors. " <> + "Any error during body processing will not be redirected as the response is chunked" + }, + %{ + key: :max_body_length, + type: :integer, + description: + "limits the content length to be approximately the " <> + "specified length. It is validated with the `content-length` header and also verified when proxying" + }, + %{ + key: :http, + type: :keyword, + description: "HTTP options", + children: [ + %{ + key: :adapter, + type: :keyword, + description: "Adapter specific options" + }, + %{ + key: :proxy_url, + label: "Proxy URL", + type: [:string, :tuple], + description: "Proxy URL", + suggestions: ["127.0.0.1:8123", {:socks5, :localhost, 9050}] + } + ] + } + ] } ] }, @@ -131,9 +174,8 @@ description: "List of actions for the mogrify command", suggestions: [ "strip", - ["strip", "auto-orient"], - [{"implode", "1"}], - ["strip", "auto-orient", {"implode", "1"}] + "auto-orient", + {"implode", "1"} ] } ] @@ -151,8 +193,7 @@ "Text to replace filenames in links. If no setting, {random}.extension will be used. You can get the original" <> " filename extension by using {extension}, for example custom-file-name.{extension}", suggestions: [ - "custom-file-name.{extension}", - nil + "custom-file-name.{extension}" ] } ] @@ -213,12 +254,14 @@ %{ group: {:subgroup, Swoosh.Adapters.SMTP}, key: :ssl, + label: "SSL", type: :boolean, description: "`Swoosh.Adapters.SMTP` adapter specific setting" }, %{ group: {:subgroup, Swoosh.Adapters.SMTP}, key: :tls, + label: "TLS", type: :atom, description: "`Swoosh.Adapters.SMTP` adapter specific setting", suggestions: [:always, :never, :if_available] @@ -247,12 +290,14 @@ %{ group: {:subgroup, Swoosh.Adapters.SMTP}, key: :no_mx_lookups, + label: "No MX lookups", type: :boolean, description: "`Swoosh.Adapters.SMTP` adapter specific setting" }, %{ group: {:subgroup, Swoosh.Adapters.Sendgrid}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.Sendgrid` adapter specific setting", suggestions: ["my-api-key"] @@ -280,6 +325,7 @@ %{ group: {:subgroup, Swoosh.Adapters.Mandrill}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.Mandrill` adapter specific setting", suggestions: ["my-api-key"] @@ -287,6 +333,7 @@ %{ group: {:subgroup, Swoosh.Adapters.Mailgun}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.Mailgun` adapter specific setting", suggestions: ["my-api-key"] @@ -301,6 +348,7 @@ %{ group: {:subgroup, Swoosh.Adapters.Mailjet}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.Mailjet` adapter specific setting", suggestions: ["my-api-key"] @@ -315,6 +363,7 @@ %{ group: {:subgroup, Swoosh.Adapters.Postmark}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.Postmark` adapter specific setting", suggestions: ["my-api-key"] @@ -322,6 +371,7 @@ %{ group: {:subgroup, Swoosh.Adapters.SparkPost}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.SparkPost` adapter specific setting", suggestions: ["my-api-key"] @@ -336,7 +386,7 @@ %{ group: {:subgroup, Swoosh.Adapters.AmazonSES}, key: :region, - type: {:string}, + type: :string, description: "`Swoosh.Adapters.AmazonSES` adapter specific setting", suggestions: ["us-east-1", "us-east-2"] }, @@ -357,6 +407,7 @@ %{ group: {:subgroup, Swoosh.Adapters.Dyn}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.Dyn` adapter specific setting", suggestions: ["my-api-key"] @@ -370,6 +421,7 @@ %{ group: {:subgroup, Swoosh.Adapters.SocketLabs}, key: :api_key, + label: "API key", type: :string, description: "`Swoosh.Adapters.SocketLabs` adapter specific setting" }, @@ -392,22 +444,20 @@ type: {:list, :string}, description: "List of the scheme part that is considered valid to be an URL", suggestions: [ - [ - "https", - "http", - "dat", - "dweb", - "gopher", - "ipfs", - "ipns", - "irc", - "ircs", - "magnet", - "mailto", - "mumble", - "ssb", - "xmpp" - ] + "https", + "http", + "dat", + "dweb", + "gopher", + "ipfs", + "ipns", + "irc", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + "xmpp" ] } ] @@ -578,7 +628,7 @@ }, %{ key: :federation_publisher_modules, - type: [:list, :module], + type: {:list, :module}, description: "List of modules for federation publishing", suggestions: [ Pleroma.Web.ActivityPub.Publisher @@ -591,12 +641,13 @@ }, %{ key: :rewrite_policy, - type: {:list, :module}, + type: [:module, {:list, :module}], description: "A list of MRF policies enabled", - suggestions: [ - Pleroma.Web.ActivityPub.MRF.NoOpPolicy, - Generator.mrf_list() - ] + suggestions: + Generator.list_modules_in_dir( + "lib/pleroma/web/activity_pub/mrf", + "Elixir.Pleroma.Web.ActivityPub.MRF." + ) }, %{ key: :public, @@ -644,17 +695,19 @@ }, %{ key: :mrf_transparency, + label: "MRF transparency", type: :boolean, description: "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" }, %{ key: :mrf_transparency_exclusions, + label: "MRF transparency exclusions", type: {:list, :string}, description: "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value", suggestions: [ - ["exclusion.com"] + "exclusion.com" ] }, %{ @@ -698,8 +751,7 @@ description: "A message that will be send to a newly registered users as a direct message", suggestions: [ - "Hi, @username! Welcome to the board!", - nil + "Hi, @username! Welcome on board!" ] }, %{ @@ -707,8 +759,7 @@ type: :string, description: "The nickname of the local user that sends the welcome message", suggestions: [ - "lain", - nil + "lain" ] }, %{ @@ -829,7 +880,7 @@ type: [:atom, :tuple, :module], description: "Where logs will be send, :console - send logs to stdout, {ExSyslogger, :ex_syslogger} - to syslog, Quack.Logger - to Slack.", - suggestions: [[:console, {ExSyslogger, :ex_syslogger}, Quack.Logger]] + suggestions: [:console, {ExSyslogger, :ex_syslogger}, Quack.Logger] } ] }, @@ -861,7 +912,7 @@ %{ key: :metadata, type: {:list, :atom}, - suggestions: [[:request_id]] + suggestions: [:request_id] } ] }, @@ -886,7 +937,7 @@ %{ key: :metadata, type: {:list, :atom}, - suggestions: [[:request_id]] + suggestions: [:request_id] } ] }, @@ -931,10 +982,14 @@ group: :pleroma, key: :frontend_configurations, type: :group, - description: "A keyword list that keeps the configuration data for any kind of frontend", + description: + "This form can be used to configure a keyword list that keeps the configuration data for any " <> + "kind of frontend. By default, settings for pleroma_fe and masto_fe are configured. If you want to " <> + "add your own configuration your settings need to be complete as they will override the defaults.", children: [ %{ key: :pleroma_fe, + label: "Pleroma FE", type: :map, description: "Settings for Pleroma FE", suggestions: [ @@ -977,6 +1032,7 @@ }, %{ key: :redirectRootNoLogin, + label: "Redirect root no login", type: :string, description: "relative URL which indicates where to redirect when a user isn't logged in", @@ -984,6 +1040,7 @@ }, %{ key: :redirectRootLogin, + label: "Redirect root login", type: :string, description: "relative URL which indicates where to redirect when a user is logged in", @@ -991,44 +1048,52 @@ }, %{ key: :showInstanceSpecificPanel, + label: "Show instance specific panel", type: :boolean, description: "Whenether to show the instance's specific panel" }, %{ key: :scopeOptionsEnabled, + label: "Scope options enabled", type: :boolean, description: "Enable setting an notice visibility and subject/CW when posting" }, %{ key: :formattingOptionsEnabled, + label: "Formatting options enabled", type: :boolean, description: "Enable setting a formatting different than plain-text (ie. HTML, Markdown) when posting, relates to :instance, allowed_post_formats" }, %{ key: :collapseMessageWithSubject, + label: "Collapse message with subject", type: :boolean, description: "When a message has a subject(aka Content Warning), collapse it by default" }, %{ key: :hidePostStats, + label: "Hide post stats", type: :boolean, description: "Hide notices statistics(repeats, favorites, ...)" }, %{ key: :hideUserStats, + label: "Hide user stats", type: :boolean, description: "Hide profile statistics(posts, posts per day, followers, followings, ...)" }, %{ key: :scopeCopy, + label: "Scope copy", type: :boolean, description: "Copy the scope (private/unlisted/public) in replies to posts by default" }, %{ key: :subjectLineBehavior, + label: "Subject line behavior", type: :string, description: "Allows changing the default behaviour of subject lines in replies. `email`: Copy and preprend re:, as in email, @@ -1038,6 +1103,7 @@ }, %{ key: :alwaysShowSubjectInput, + label: "Always show subject input", type: :boolean, description: "When set to false, auto-hide the subject field when it's empty" } @@ -1045,6 +1111,7 @@ }, %{ key: :masto_fe, + label: "Masto FE", type: :map, description: "Settings for Masto FE", suggestions: [ @@ -1055,6 +1122,7 @@ children: [ %{ key: :showInstanceSpecificPanel, + label: "Show instance specific panel", type: :boolean, description: "Whenether to show the instance's specific panel" } @@ -1071,20 +1139,18 @@ children: [ %{ key: :mascots, - type: :keyword, + type: {:keyword, :map}, description: "Keyword of mascots, each element MUST contain both a url and a mime_type key", suggestions: [ - [ - pleroma_fox_tan: %{ - url: "/images/pleroma-fox-tan-smol.png", - mime_type: "image/png" - }, - pleroma_fox_tan_shy: %{ - url: "/images/pleroma-fox-tan-shy.png", - mime_type: "image/png" - } - ] + pleroma_fox_tan: %{ + url: "/images/pleroma-fox-tan-smol.png", + mime_type: "image/png" + }, + pleroma_fox_tan_shy: %{ + url: "/images/pleroma-fox-tan-shy.png", + mime_type: "image/png" + } ] }, %{ @@ -1140,6 +1206,7 @@ %{ group: :pleroma, key: :mrf_simple, + label: "MRF simple", type: :group, description: "Message Rewrite Facility", children: [ @@ -1151,6 +1218,7 @@ }, %{ key: :media_nsfw, + label: "Media NSFW", type: {:list, :string}, description: "List of instances to put medias as NSFW(sensitive) from", suggestions: ["example.com", "*.example.com"] @@ -1197,6 +1265,7 @@ %{ group: :pleroma, key: :mrf_subchain, + label: "MRF subchain", type: :group, description: "This policy processes messages through an alternate pipeline when a given message matches certain criteria." <> @@ -1217,10 +1286,14 @@ %{ group: :pleroma, key: :mrf_rejectnonpublic, + description: + "MRF RejectNonPublic settings. RejectNonPublic drops posts with non-public visibility settings.", + label: "MRF reject non public", type: :group, children: [ %{ key: :allow_followersonly, + label: "Allow followers-only", type: :boolean, description: "whether to allow followers-only posts" }, @@ -1234,6 +1307,7 @@ %{ group: :pleroma, key: :mrf_hellthread, + label: "MRF hellthread", type: :group, description: "Block messages with too much mentions", children: [ @@ -1257,6 +1331,7 @@ %{ group: :pleroma, key: :mrf_keyword, + label: "MRF keyword", type: :group, description: "Reject or Word-Replace messages with a keyword or regex", children: [ @@ -1276,9 +1351,9 @@ }, %{ key: :replace, - type: [{:string, :string}, {:regex, :string}], + type: [{:tuple, :string, :string}, {:tuple, :regex, :string}], description: - "A list of patterns which result in message being removed from federated timelines (a.k.a unlisted), each pattern can be a string or a regular expression", + "A list of tuples containing {pattern, replacement}, pattern can be a string or a regular expression.", suggestions: [{"foo", "bar"}, {~r/foo/iu, "bar"}] } ] @@ -1286,6 +1361,7 @@ %{ group: :pleroma, key: :mrf_mention, + label: "MRF mention", type: :group, description: "Block messages which mention a user", children: [ @@ -1293,13 +1369,14 @@ key: :actors, type: {:list, :string}, description: "A list of actors, for which to drop any posts mentioning", - suggestions: [["actor1", "actor2"]] + suggestions: ["actor1", "actor2"] } ] }, %{ group: :pleroma, key: :mrf_vocabulary, + label: "MRF vocabulary", type: :group, description: "Filter messages which belong to certain activity vocabularies", children: [ @@ -1308,14 +1385,14 @@ type: {:list, :string}, description: "A list of ActivityStreams terms to accept. If empty, all supported messages are accepted", - suggestions: [["Create", "Follow", "Mention", "Announce", "Like"]] + suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] }, %{ key: :reject, type: {:list, :string}, description: "A list of ActivityStreams terms to reject. If empty, no messages are rejected", - suggestions: [["Create", "Follow", "Mention", "Announce", "Like"]] + suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] } ] }, @@ -1355,7 +1432,49 @@ key: :proxy_opts, type: :keyword, description: "Options for Pleroma.ReverseProxy", - suggestions: [[max_body_length: 25 * 1_048_576, redirect_on_failure: false]] + suggestions: [ + redirect_on_failure: false, + max_body_length: 25 * 1_048_576, + http: [ + follow_redirect: true, + pool: :media + ] + ], + children: [ + %{ + key: :redirect_on_failure, + type: :boolean, + description: + "Redirects the client to the real remote URL if there's any HTTP errors. " <> + "Any error during body processing will not be redirected as the response is chunked" + }, + %{ + key: :max_body_length, + type: :integer, + description: + "limits the content length to be approximately the " <> + "specified length. It is validated with the `content-length` header and also verified when proxying" + }, + %{ + key: :http, + type: :keyword, + description: "HTTP options", + children: [ + %{ + key: :adapter, + type: :keyword, + description: "Adapter specific options" + }, + %{ + key: :proxy_url, + label: "Proxy URL", + type: [:string, :tuple], + description: "Proxy URL", + suggestions: ["127.0.0.1:8123", {:socks5, :localhost, 9050}] + } + ] + } + ] }, %{ key: :whitelist, @@ -1404,10 +1523,12 @@ children: [ %{ key: :http, - type: :keyword, + label: "HTTP", + type: {:keyword, :integer, :tuple}, description: "http protocol configuration", suggestions: [ - [port: 8080, ip: {127, 0, 0, 1}] + port: 8080, + ip: {127, 0, 0, 1} ], children: [ %{ @@ -1415,21 +1536,20 @@ type: {:list, :tuple}, description: "dispatch settings", suggestions: [ - [ - {:_, - [ - {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, - {"/websocket", Phoenix.Endpoint.CowboyWebSocket, - {Phoenix.Transports.WebSocket, - {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, websocket_config}}}, - {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}} - ]} - # end copied from config.exs - ] + {:_, + [ + {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, + {"/websocket", Phoenix.Endpoint.CowboyWebSocket, + {Phoenix.Transports.WebSocket, + {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, websocket_config}}}, + {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}} + ]} + # end copied from config.exs ] }, %{ key: :ip, + label: "IP", type: :tuple, description: "ip", suggestions: [ @@ -1448,10 +1568,13 @@ }, %{ key: :url, - type: :keyword, + label: "URL", + type: {:keyword, :string, :integer}, description: "configuration for generating urls", suggestions: [ - [host: "example.com", port: 2020, scheme: "https"] + host: "example.com", + port: 2020, + scheme: "https" ], children: [ %{ @@ -1504,7 +1627,7 @@ %{ key: :render_errors, type: :keyword, - suggestions: [[view: Pleroma.Web.ErrorView, accepts: ~w(json)]], + suggestions: [view: Pleroma.Web.ErrorView, accepts: ~w(json)], children: [ %{ key: :view, @@ -1521,7 +1644,7 @@ %{ key: :pubsub, type: :keyword, - suggestions: [[name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2]], + suggestions: [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2], children: [ %{ key: :name, @@ -1588,17 +1711,20 @@ }, %{ key: :sts, + label: "STS", type: :boolean, description: "Whether to additionally send a Strict-Transport-Security header" }, %{ key: :sts_max_age, + label: "STS max age", type: :integer, description: "The maximum age for the Strict-Transport-Security header if sent", suggestions: [31_536_000] }, %{ key: :ct_max_age, + label: "CT max age", type: :integer, description: "The maximum age for the Expect-CT header if sent", suggestions: [2_592_000] @@ -1611,6 +1737,7 @@ }, %{ key: :report_uri, + label: "Report URI", type: :string, description: "Adds the specified url to report-uri and report-to group in CSP header", suggestions: ["https://example.com/report-uri"] @@ -1754,20 +1881,18 @@ }, %{ key: :queues, - type: :keyword, + type: {:keyword, :integer}, description: "Background jobs queues (keys: queues, values: max numbers of concurrent jobs)", suggestions: [ - [ - activity_expiration: 10, - background: 5, - federator_incoming: 50, - federator_outgoing: 50, - mailer: 10, - scheduled_activities: 10, - transmogrifier: 20, - web_push: 50 - ] + activity_expiration: 10, + background: 5, + federator_incoming: 50, + federator_outgoing: 50, + mailer: 10, + scheduled_activities: 10, + transmogrifier: 20, + web_push: 50 ], children: [ %{ @@ -1830,7 +1955,7 @@ children: [ %{ key: :retries, - type: :keyword, + type: {:keyword, :integer}, description: "Max retry attempts for failed jobs, per `Oban` queue", suggestions: [ [ @@ -1845,22 +1970,21 @@ group: :pleroma, key: Pleroma.Web.Metadata, type: :group, - decsription: "Metadata-related settings", + description: "Metadata-related settings", children: [ %{ key: :providers, type: {:list, :module}, description: "List of metadata providers to enable", suggestions: [ - [ - Pleroma.Web.Metadata.Providers.OpenGraph, - Pleroma.Web.Metadata.Providers.TwitterCard, - Pleroma.Web.Metadata.Providers.RelMe - ] + Pleroma.Web.Metadata.Providers.OpenGraph, + Pleroma.Web.Metadata.Providers.TwitterCard, + Pleroma.Web.Metadata.Providers.RelMe ] }, %{ key: :unfurl_nsfw, + label: "Unfurl NSFW", type: :boolean, description: "If set to true nsfw attachments will be shown in previews" } @@ -1870,39 +1994,45 @@ group: :pleroma, key: :rich_media, type: :group, + description: + "If enabled the instance will parse metadata from attached links to generate link previews.", children: [ %{ key: :enabled, type: :boolean, - description: - "if enabled the instance will parse metadata from attached links to generate link previews" + description: "Enables/disables RichMedia." }, %{ key: :ignore_hosts, type: {:list, :string}, - description: "list of hosts which will be ignored by the metadata parser", - suggestions: [["accounts.google.com", "xss.website"]] + description: "List of hosts which will be ignored by the metadata parser.", + suggestions: ["accounts.google.com", "xss.website"] }, %{ key: :ignore_tld, + label: "Ignore TLD", type: {:list, :string}, - description: "list TLDs (top-level domains) which will ignore for parse metadata", - suggestions: [["local", "localdomain", "lan"]] + description: "List TLDs (top-level domains) which will ignore for parse metadata.", + suggestions: ["local", "localdomain", "lan"] }, %{ key: :parsers, type: {:list, :module}, - description: "list of Rich Media parsers", + description: "List of Rich Media parsers.", suggestions: [ - Generator.richmedia_parsers() + Pleroma.Web.RichMedia.Parsers.MetaTagsParser, + Pleroma.Web.RichMedia.Parsers.OEmbed, + Pleroma.Web.RichMedia.Parsers.OGP, + Pleroma.Web.RichMedia.Parsers.TwitterCard ] }, %{ key: :ttl_setters, + label: "TTL setters", type: {:list, :module}, - description: "list of rich media ttl setters", + description: "List of rich media ttl setters.", suggestions: [ - [Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl] + Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl ] } ] @@ -2055,23 +2185,57 @@ }, %{ key: :ssl, + label: "SSL", type: :boolean, description: "true to use SSL, usually implies the port 636" }, %{ key: :sslopts, + label: "SSL options", type: :keyword, - description: "additional SSL options" + description: "additional SSL options", + suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + children: [ + %{ + key: :cacertfile, + type: :string, + description: "Path to file with PEM encoded cacerts", + suggestions: ["path/to/file/with/PEM/cacerts"] + }, + %{ + key: :verify, + type: :atom, + description: "Type of cert verification", + suggestions: [:verify_peer] + } + ] }, %{ key: :tls, + label: "TLS", type: :boolean, description: "true to start TLS, usually implies the port 389" }, %{ key: :tlsopts, + label: "TLS options", type: :keyword, - description: "additional TLS options" + description: "additional TLS options", + suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + children: [ + %{ + key: :cacertfile, + type: :string, + description: "Path to file with PEM encoded cacerts", + suggestions: ["path/to/file/with/PEM/cacerts"] + }, + %{ + key: :verify, + type: :atom, + description: "Type of cert verification", + suggestions: [:verify_peer] + } + ] }, %{ key: :base, @@ -2120,7 +2284,7 @@ }, %{ key: :oauth_consumer_strategies, - type: :string, + type: {:list, :string}, description: "the list of enabled OAuth consumer strategies; by default it's set by OAUTH_CONSUMER_STRATEGIES environment variable." <> " Each entry in this space-delimited string should be of format or :" <> @@ -2163,7 +2327,7 @@ }, %{ key: :interval, - type: :ininteger, + type: :integer, description: "Minimum interval between digest emails to one user", suggestions: [7] }, @@ -2185,9 +2349,9 @@ children: [ %{ key: :logo, - type: [:string, nil], + type: :string, description: "a path to a custom logo. Set it to nil to use the default Pleroma logo", - suggestions: ["some/path/logo.png", nil] + suggestions: ["some/path/logo.png"] }, %{ key: :styling, @@ -2279,26 +2443,24 @@ key: :shortcode_globs, type: {:list, :string}, description: "Location of custom emoji files. * can be used as a wildcard", - suggestions: [["/emoji/custom/**/*.png"]] + suggestions: ["/emoji/custom/**/*.png"] }, %{ key: :pack_extensions, type: {:list, :string}, description: "A list of file extensions for emojis, when no emoji.txt for a pack is present", - suggestions: [[".png", ".gif"]] + suggestions: [".png", ".gif"] }, %{ key: :groups, - type: :keyword, + type: {:keyword, :string, {:list, :string}}, description: "Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the groupname" <> " and the value the location or array of locations. * can be used as a wildcard", suggestions: [ - [ - # Put groups that have higher priority than defaults here. Example in `docs/config/custom_emoji.md` - Custom: ["/emoji/*.png", "/emoji/**/*.png"] - ] + # Put groups that have higher priority than defaults here. Example in `docs/config/custom_emoji.md` + Custom: ["/emoji/*.png", "/emoji/**/*.png"] ] }, %{ @@ -2389,7 +2551,8 @@ group: :esshd, type: :group, description: - "To enable simple command line interface accessible over ssh, add a setting like this to your configuration file", + "Before enabling this you must add :esshd to mix.exs as one of the extra_applications " <> + "and generate host keys in your priv dir with ssh-keygen -m PEM -N \"\" -b 2048 -t rsa -f ssh_host_rsa_key", children: [ %{ key: :enabled, @@ -2443,27 +2606,27 @@ %{ key: "application/xml", type: {:list, :string}, - suggestions: [["xml"]] + suggestions: ["xml"] }, %{ key: "application/xrd+xml", type: {:list, :string}, - suggestions: [["xrd+xml"]] + suggestions: ["xrd+xml"] }, %{ key: "application/jrd+json", type: {:list, :string}, - suggestions: [["jrd+json"]] + suggestions: ["jrd+json"] }, %{ key: "application/activity+json", type: {:list, :string}, - suggestions: [["activity+json"]] + suggestions: ["activity+json"] }, %{ key: "application/ld+json", type: {:list, :string}, - suggestions: [["activity+json"]] + suggestions: ["activity+json"] } ] } @@ -2560,6 +2723,8 @@ %{ group: :pleroma, key: Pleroma.Uploaders.MDII, + description: + "Uploader for https://github.com/hakaba-hitoyo/minimal-digital-image-infrastructure", type: :group, children: [ %{ @@ -2582,8 +2747,10 @@ children: [ %{ key: :proxy_url, - type: [:string, :atom, nil], - suggestions: ["localhost:9020", {:socks5, :localhost, 3090}, nil] + label: "Proxy URL", + type: [:string, :tuple], + description: "Proxy URL", + suggestions: ["localhost:9020", {:socks5, :localhost, 3090}] }, %{ key: :send_user_agent, @@ -2592,16 +2759,8 @@ %{ key: :adapter, type: :keyword, - suggestions: [ - [ - ssl_options: [ - # Workaround for remote server certificate chain issues - partial_chain: &:hackney_connect.partial_chain/1, - # We don't support TLS v1.3 yet - versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] - ] - ] - ] + description: "Adapter specific options", + suggestions: [] } ] }, @@ -2629,7 +2788,7 @@ %{ key: :scrub_policy, type: {:list, :module}, - suggestions: [[Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default]] + suggestions: [Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default] } ] }, @@ -2647,6 +2806,8 @@ %{ group: :pleroma, key: :mrf_normalize_markup, + label: "MRF normalize markup", + description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.", type: :group, children: [ %{ @@ -2665,38 +2826,36 @@ key: :restricted_nicknames, type: {:list, :string}, suggestions: [ - [ - ".well-known", - "~", - "about", - "activities", - "api", - "auth", - "check_password", - "dev", - "friend-requests", - "inbox", - "internal", - "main", - "media", - "nodeinfo", - "notice", - "oauth", - "objects", - "ostatus_subscribe", - "pleroma", - "proxy", - "push", - "registration", - "relay", - "settings", - "status", - "tag", - "user-search", - "user_exists", - "users", - "web" - ] + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "check_password", + "dev", + "friend-requests", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "ostatus_subscribe", + "pleroma", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "user-search", + "user_exists", + "users", + "web" ] } ] @@ -2713,20 +2872,18 @@ %{ key: :methods, type: {:list, :string}, - suggestions: [["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"]] + suggestions: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"] }, %{ key: :expose, - type: :string, + type: {:list, :string}, suggestions: [ - [ - "Link", - "X-RateLimit-Reset", - "X-RateLimit-Limit", - "X-RateLimit-Remaining", - "X-Request-Id", - "Idempotency-Key" - ] + "Link", + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + "Idempotency-Key" ] }, %{ @@ -2736,7 +2893,7 @@ %{ key: :headers, type: {:list, :string}, - suggestions: [["Authorization", "Content-Type", "Idempotency-Key"]] + suggestions: ["Authorization", "Content-Type", "Idempotency-Key"] } ] }, @@ -2745,16 +2902,14 @@ key: Pleroma.Plugs.RemoteIp, type: :group, description: """ - **If your instance is not behind at least one reverse proxy, you should not enable this plug.** - `Pleroma.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + **If your instance is not behind at least one reverse proxy, you should not enable this plug.** """, children: [ %{ key: :enabled, type: :boolean, - description: "Enable/disable the plug. Defaults to `false`.", - suggestions: [true, false] + description: "Enable/disable the plug. Defaults to `false`." }, %{ key: :headers, @@ -2788,7 +2943,7 @@ type: :integer, description: "activity pub routes (except question activities). Defaults to `nil` (no expiration).", - suggestions: [30_000, nil] + suggestions: [30_000] }, %{ key: :activity_pub_question, diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index d98a78af0..851c526d6 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -669,7 +669,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ### Run mix task pleroma.config migrate_to_db -Copy settings on key `:pleroma` to DB. +Copies `pleroma` environment settings to the database. - Params: none - Response: @@ -682,7 +682,7 @@ Copy settings on key `:pleroma` to DB. ### Run mix task pleroma.config migrate_from_db -Copy all settings from DB to `config/prod.exported_from_db.secret.exs` with deletion from DB. +Copies all settings from database to `config/{env}.exported_from_db.secret.exs` with deletion from the table. Where `{env}` is the environment in which `pleroma` is running. - Params: none - Response: @@ -693,9 +693,9 @@ Copy all settings from DB to `config/prod.exported_from_db.secret.exs` with dele ## `GET /api/pleroma/admin/config` -### List config settings +### Get saved config settings -List config settings only works with `:pleroma => :instance => :dynamic_configuration` setting to `true`. +**Only works when `:dynamic_configuration` is `true`.** - Params: none - Response: @@ -704,9 +704,9 @@ List config settings only works with `:pleroma => :instance => :dynamic_configur { configs: [ { - "group": string, - "key": string or string with leading `:` for atoms, - "value": string or {} or [] or {"tuple": []} + "group": ":pleroma", + "key": "Pleroma.Upload", + "value": [] } ] } @@ -716,44 +716,61 @@ List config settings only works with `:pleroma => :instance => :dynamic_configur ### Update config settings -Updating config settings only works with `:pleroma => :instance => :dynamic_configuration` setting to `true`. -Module name can be passed as string, which starts with `Pleroma`, e.g. `"Pleroma.Upload"`. -Atom keys and values can be passed with `:` in the beginning, e.g. `":upload"`. -Tuples can be passed as `{"tuple": ["first_val", Pleroma.Module, []]}`. -`{"tuple": ["some_string", "Pleroma.Some.Module", []]}` will be converted to `{"some_string", Pleroma.Some.Module, []}`. -Keywords can be passed as lists with 2 child tuples, e.g. -`[{"tuple": ["first_val", Pleroma.Module]}, {"tuple": ["second_val", true]}]`. +**Only works when `:dynamic_configuration` is `true`.** -If value contains list of settings `[subkey: val1, subkey2: val2, subkey3: val3]`, it's possible to remove only subkeys instead of all settings passing `subkeys` parameter. E.g.: -{"group": "pleroma", "key": "some_key", "delete": "true", "subkeys": [":subkey", ":subkey3"]}. +Some modifications are necessary to save the config settings correctly: -Compile time settings (need instance reboot): -- all settings by this keys: +- strings which start with `Pleroma.`, `Phoenix.`, `Tesla.` or strings like `Oban`, `Ueberauth` will be converted to modules; +``` +"Pleroma.Upload" -> Pleroma.Upload +"Oban" -> Oban +``` +- strings starting with `:` will be converted to atoms; +``` +":pleroma" -> :pleroma +``` +- objects with `tuple` key and array value will be converted to atoms; +``` +{"tuple": ["string", "Pleroma.Upload", []]} -> {"string", Pleroma.Upload, []} +``` +- arrays with *tuple objects* and 2 childs in array will be converted to keywords; +``` +[{"tuple": [":key1", "value"]}, {"tuple": [":key2", "value"]}] -> [key1: "value", key2: "value"] +``` + +Most of the settings will be applied in `runtime`, this means that you don't need to restart the instance. But some settings are applied in `compile time` and require a reboot of the instance, such as: +- all settings inside these keys: - `:hackney_pools` - `:chat` - `Pleroma.Web.Endpoint` - - `Pleroma.Repo` -- part settings: - - `Pleroma.Captcha` -> `:seconds_valid` - - `Pleroma.Upload` -> `:proxy_remote` - - `:instance` -> `:upload_limit` +- partially settings inside these keys: + - `:seconds_valid` in `Pleroma.Captcha` + - `:proxy_remote` in `Pleroma.Upload` + - `:upload_limit` in `:instance` - Params: - - `configs` => [ - - `group` (string) - - `key` (string or string with leading `:` for atoms) - - `value` (string, [], {} or {"tuple": []}) - - `delete` = true (optional, if parameter must be deleted) - - `subkeys` [(string with leading `:` for atoms)] (optional, works only if `delete=true` parameter is passed, otherwise will be ignored) - ] + - `configs` - array of config objects + - config object params: + - `group` - string (**required**) + - `key` - string (**required**) + - `value` - string, [], {} or {"tuple": []} (**required**) + - `delete` - true (*optional*, if setting must be deleted) + - `subkeys` - array of strings (*optional*, only works when `delete=true` parameter is passed, otherwise will be ignored) -- Request (example): +*When a value have several nested settings, you can delete only some nested settings by passing a parameter `subkeys`, without deleting all settings by key.* +``` +[subkey: val1, subkey2: val2, subkey3: val3] \\ initial value +{"group": ":pleroma", "key": "some_key", "delete": true, "subkeys": [":subkey", ":subkey3"]} \\ passing json for deletion +[subkey2: val2] \\ value after deletion +``` + +- Request: ```json { configs: [ { - "group": "pleroma", + "group": ":pleroma", "key": "Pleroma.Upload", "value": [ {"tuple": [":uploader", "Pleroma.Uploaders.Local"]}, @@ -784,14 +801,47 @@ Compile time settings (need instance reboot): { configs: [ { - "group": string, - "key": string or string with leading `:` for atoms, - "value": string or {} or [] or {"tuple": []} + "group": ":pleroma", + "key": "Pleroma.Upload", + "value": [...] } ] } ``` +## ` GET /api/pleroma/admin/config/descriptions` + +### Get JSON with config descriptions. +Loads json generated from `config/descriptions.exs`. + +- Params: none +- Response: + +```json +[{ + "group": ":pleroma", // string + "key": "ModuleName", // string + "type": "group", // string or list with possible values, + "description": "Upload general settings", // string + "children": [ + { + "key": ":uploader", // string or module name `Pleroma.Upload` + "type": "module", + "description": "Module which will be used for uploads", + "suggestions": ["module1", "module2"] + }, + { + "key": ":filters", + "type": ["list", "module"], + "description": "List of filter modules for uploads", + "suggestions": [ + "module1", "module2", "module3" + ] + } + ] +}] +``` + ## `GET /api/pleroma/admin/moderation_log` ### Get moderation log diff --git a/docs/admin/config.md b/docs/admin/config.md new file mode 100644 index 000000000..f42ec8975 --- /dev/null +++ b/docs/admin/config.md @@ -0,0 +1,59 @@ +# Configuring instance +You can configure your instance from admin interface. You need account with admin rights and little change in config file, which will allow settings dynamic configuration from database. + +```elixir +config :pleroma, :instance, + dynamic_configuration: true +``` + +## How it works +Settings are stored in database and are applied in `runtime` after each change. Most of the settings take effect immediately, except some, which need instance reboot. These settings are needed in `compile time`, that's why settings are duplicated to the file. + +File with duplicated settings is located in `config/{env}.exported_from_db.exs`. For prod env it will be `config/prod.exported_from_db.exs`. + +## How to set it up +You need to migrate your existing settings to the database. You can do this with mix task (all config files will remain untouched): +```bash +mix pleroma.config migrate_to_db +``` +Now you can change settings in admin interface. After each save, settings are duplicated to the `config/{env}.exported_from_db.exs` file. + +**ATTENTION** + +**Be careful while changing the settings. Every inaccurate configuration change can break the federation or the instance load.** + +*Compile time settings, which require instance reboot and can break instance loading:* +- all settings inside these keys: + - `:hackney_pools` + - `:chat` + - `Pleroma.Web.Endpoint` +- partially settings inside these keys: + - `:seconds_valid` in `Pleroma.Captcha` + - `:proxy_remote` in `Pleroma.Upload` + - `:upload_limit` in `:instance` + +## How to remove it + +1. Truncate or delete all values from `config` table +```sql +TRUNCATE TABLE config; +``` +2. Delete `config/{env}.exported_from_db.exs`. + +For `prod` env: +```bash +cd /opt/pleroma +cp config/prod.exported_from_db.exs config/exported_from_db.back +rm -rf config/prod.exported_from_db.exs +``` +*If you don't want to backup settings, you can skip step with `cp` command.* + +3. Set dynamic configuration to `false`. +```elixir +config :pleroma, :instance, + dynamic_configuration: false +``` +4. Restart pleroma instance +```bash +sudo service pleroma restart +``` diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 590c7a914..bb126463c 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -9,27 +9,29 @@ defmodule Mix.Tasks.Pleroma.Config do alias Pleroma.Web.AdminAPI.Config @shortdoc "Manages the location of the config" @moduledoc File.read!("docs/administration/CLI_tasks/config.md") + + @groups [ + :pleroma, + :logger, + :quack, + :mime, + :tesla, + :phoenix, + :cors_plug, + :auto_linker, + :esshd, + :ueberauth, + :prometheus, + :http_signatures, + :web_push_encryption, + :joken + ] + def run(["migrate_to_db"]) do start_pleroma() if Pleroma.Config.get([:instance, :dynamic_configuration]) do - Application.get_all_env(:pleroma) - |> Enum.reject(fn {k, _v} -> k in [Pleroma.Repo, :env] end) - |> Enum.each(fn {k, v} -> - key = to_string(k) |> String.replace("Elixir.", "") - - key = - if String.starts_with?(key, "Pleroma.") do - key - else - ":" <> key - end - - {:ok, _} = Config.update_or_create(%{group: "pleroma", key: key, value: v}) - Mix.shell().info("#{key} is migrated.") - end) - - Mix.shell().info("Settings migrated.") + Enum.each(@groups, &load_and_create(&1)) else Mix.shell().info( "Migration is not allowed by config. You can change this behavior in instance settings." @@ -37,38 +39,63 @@ def run(["migrate_to_db"]) do end end - def run(["migrate_from_db", env, delete?]) do + def run(["migrate_from_db" | options]) do start_pleroma() - delete? = if delete? == "true", do: true, else: false + {opts, _} = + OptionParser.parse!(options, + strict: [env: :string, delete_from_db: :boolean], + aliases: [d: :delete_from_db] + ) - if Pleroma.Config.get([:instance, :dynamic_configuration]) do - config_path = "config/#{env}.exported_from_db.secret.exs" - - {:ok, file} = File.open(config_path, [:write, :utf8]) + with {:active?, true} <- {:active?, Pleroma.Config.get([:instance, :dynamic_configuration])}, + env_path when is_binary(env_path) <- opts[:env], + config_path <- "config/#{env_path}.exported_from_db.secret.exs", + {:ok, file} <- File.open(config_path, [:write, :utf8]) do IO.write(file, "use Mix.Config\r\n") - Repo.all(Config) - |> Enum.each(fn config -> - IO.write( - file, - "config :#{config.group}, #{config.key}, #{ - inspect(Config.from_binary(config.value), limit: :infinity) - }\r\n\r\n" - ) - - if delete? do - {:ok, _} = Repo.delete(config) - Mix.shell().info("#{config.key} deleted from DB.") - end - end) + Config + |> Repo.all() + |> Enum.each(&write_to_file_with_deletion(&1, file, opts[:delete_from_db])) File.close(file) System.cmd("mix", ["format", config_path]) else - Mix.shell().info( - "Migration is not allowed by config. You can change this behavior in instance settings." - ) + {:active?, false} -> + Mix.shell().info( + "migration is not allowed by config. You can change this behavior in instance settings." + ) + + error -> + Mix.shell().info("error occuried while opening file. #{inspect(error)}") + end + end + + defp load_and_create(group) do + group + |> Application.get_all_env() + |> Enum.reject(fn {k, _v} -> k in [Pleroma.Repo, :env] end) + |> Enum.each(fn {key, value} -> + key_str = inspect(key) + + {:ok, _} = Config.update_or_create(%{group: ":#{group}", key: key_str, value: value}) + Mix.shell().info("settings for key #{key_str} migrated.") + end) + + Mix.shell().info("settings for group :#{group} migrated.") + end + + defp write_to_file_with_deletion(config, file, with_deletion) do + IO.write( + file, + "config #{config.group}, #{config.key}, #{ + inspect(Config.from_binary(config.value), limit: :infinity) + }\r\n\r\n" + ) + + if with_deletion do + {:ok, _} = Repo.delete(config) + Mix.shell().info("#{config.key} deleted from DB.") end end end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 3214c9951..0bc4c4029 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -4,56 +4,59 @@ defmodule Pleroma.Config.TransferTask do use Task + + require Logger + + alias Pleroma.Repo alias Pleroma.Web.AdminAPI.Config def start_link(_) do load_and_update_env() - if Pleroma.Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Pleroma.Repo) + if Pleroma.Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) :ignore end def load_and_update_env do - if Pleroma.Config.get([:instance, :dynamic_configuration]) and - Ecto.Adapters.SQL.table_exists?(Pleroma.Repo, "config") do - for_restart = - Pleroma.Repo.all(Config) - |> Enum.map(&update_env(&1)) - + with true <- Pleroma.Config.get([:instance, :dynamic_configuration]), + true <- Ecto.Adapters.SQL.table_exists?(Repo, "config"), + started_applications <- Application.started_applications() do # We need to restart applications for loaded settings take effect - for_restart - |> Enum.reject(&(&1 in [:pleroma, :ok])) - |> Enum.each(fn app -> - Application.stop(app) - :ok = Application.start(app) - end) + Config + |> Repo.all() + |> Enum.map(&update_env(&1)) + |> Enum.uniq() + # TODO: some problem with prometheus after restart! + |> Enum.reject(&(&1 in [:pleroma, nil, :prometheus])) + |> Enum.each(&restart(started_applications, &1)) end end defp update_env(setting) do try do - key = - if String.starts_with?(setting.key, "Pleroma.") do - "Elixir." <> setting.key - else - String.trim_leading(setting.key, ":") - end + key = Config.from_string(setting.key) + group = Config.from_string(setting.group) + value = Config.from_binary(setting.value) - group = String.to_existing_atom(setting.group) - - Application.put_env( - group, - String.to_existing_atom(key), - Config.from_binary(setting.value) - ) + :ok = Application.put_env(group, key, value) group rescue e -> - require Logger - Logger.warn( "updating env causes error, key: #{inspect(setting.key)}, error: #{inspect(e)}" ) + + nil + end + end + + defp restart(started_applications, app) do + with {^app, _, _} <- List.keyfind(started_applications, app, 0), + :ok <- Application.stop(app) do + :ok = Application.start(app) + else + nil -> Logger.warn("#{app} is not started.") + error -> Logger.warn(inspect(error)) end end end diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex index aa578eee2..b57e47e8b 100644 --- a/lib/pleroma/docs/generator.ex +++ b/lib/pleroma/docs/generator.ex @@ -6,68 +6,108 @@ def process(implementation, descriptions) do implementation.process(descriptions) end - @spec uploaders_list() :: [module()] - def uploaders_list do - {:ok, modules} = :application.get_key(:pleroma, :modules) - - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) - - List.starts_with?(name_as_list, ["Pleroma", "Uploaders"]) and - List.last(name_as_list) != "Uploader" - end) + @spec list_modules_in_dir(String.t(), String.t()) :: [module()] + def list_modules_in_dir(dir, start) do + with {:ok, files} <- File.ls(dir) do + files + |> Enum.filter(&String.ends_with?(&1, ".ex")) + |> Enum.map(fn filename -> + module = filename |> String.trim_trailing(".ex") |> Macro.camelize() + String.to_existing_atom(start <> module) + end) + end end - @spec filters_list() :: [module()] - def filters_list do - {:ok, modules} = :application.get_key(:pleroma, :modules) - - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) - - List.starts_with?(name_as_list, ["Pleroma", "Upload", "Filter"]) - end) + @doc """ + Converts: + - atoms to strings with leading `:` + - module names to strings, without leading `Elixir.` + - add humanized labels to `keys` if label is not defined, e.g. `:instance` -> `Instance` + """ + @spec convert_to_strings([map()]) :: [map()] + def convert_to_strings(descriptions) do + Enum.map(descriptions, &format_entity(&1)) end - @spec mrf_list() :: [module()] - def mrf_list do - {:ok, modules} = :application.get_key(:pleroma, :modules) - - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) - - List.starts_with?(name_as_list, ["Pleroma", "Web", "ActivityPub", "MRF"]) and - length(name_as_list) > 4 - end) + defp format_entity(entity) do + entity + |> format_key() + |> Map.put(:group, atom_to_string(entity[:group])) + |> format_children() end - @spec richmedia_parsers() :: [module()] - def richmedia_parsers do - {:ok, modules} = :application.get_key(:pleroma, :modules) - - Enum.filter(modules, fn module -> - name_as_list = Module.split(module) - - List.starts_with?(name_as_list, ["Pleroma", "Web", "RichMedia", "Parsers"]) and - length(name_as_list) == 5 - end) + defp format_key(%{key: key} = entity) do + entity + |> Map.put(:key, atom_to_string(key)) + |> Map.put(:label, entity[:label] || humanize(key)) end + + defp format_key(%{group: group} = entity) do + Map.put(entity, :label, entity[:label] || humanize(group)) + end + + defp format_key(entity), do: entity + + defp format_children(%{children: children} = entity) do + Map.put(entity, :children, Enum.map(children, &format_child(&1))) + end + + defp format_children(entity), do: entity + + defp format_child(%{suggestions: suggestions} = entity) do + entity + |> Map.put(:suggestions, format_suggestions(suggestions)) + |> format_key() + |> format_children() + end + + defp format_child(entity) do + entity + |> format_key() + |> format_children() + end + + defp atom_to_string(entity) when is_binary(entity), do: entity + + defp atom_to_string(entity) when is_atom(entity), do: inspect(entity) + + defp humanize(entity) do + string = inspect(entity) + + if String.starts_with?(string, ":"), + do: Phoenix.Naming.humanize(entity), + else: string + end + + defp format_suggestions([]), do: [] + + defp format_suggestions([suggestion | tail]) do + [format_suggestion(suggestion) | format_suggestions(tail)] + end + + defp format_suggestion(entity) when is_atom(entity) do + atom_to_string(entity) + end + + defp format_suggestion([head | tail] = entity) when is_list(entity) do + [format_suggestion(head) | format_suggestions(tail)] + end + + defp format_suggestion(entity) when is_tuple(entity) do + format_suggestions(Tuple.to_list(entity)) |> List.to_tuple() + end + + defp format_suggestion(entity), do: entity end defimpl Jason.Encoder, for: Tuple do - def encode(tuple, opts) do - Jason.Encode.list(Tuple.to_list(tuple), opts) - end + def encode(tuple, opts), do: Jason.Encode.list(Tuple.to_list(tuple), opts) end defimpl Jason.Encoder, for: [Regex, Function] do - def encode(term, opts) do - Jason.Encode.string(inspect(term), opts) - end + def encode(term, opts), do: Jason.Encode.string(inspect(term), opts) end defimpl String.Chars, for: Regex do - def to_string(term) do - inspect(term) - end + def to_string(term), do: inspect(term) end diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex index f2a56d845..f191b6013 100644 --- a/lib/pleroma/docs/json.ex +++ b/lib/pleroma/docs/json.ex @@ -3,18 +3,22 @@ defmodule Pleroma.Docs.JSON do @spec process(keyword()) :: {:ok, String.t()} def process(descriptions) do - config_path = "docs/generate_config.json" - - with {:ok, file} <- File.open(config_path, [:write, :utf8]), - json <- generate_json(descriptions), + with path <- "docs/generated_config.json", + {:ok, file} <- File.open(path, [:write, :utf8]), + formatted_descriptions <- + Pleroma.Docs.Generator.convert_to_strings(descriptions), + json <- Jason.encode!(formatted_descriptions), :ok <- IO.write(file, json), :ok <- File.close(file) do - {:ok, config_path} + {:ok, path} end end - @spec generate_json([keyword()]) :: String.t() - def generate_json(descriptions) do - Jason.encode!(descriptions) + def compile do + with {config, _paths} <- Mix.Config.eval!("config/description.exs") do + config[:pleroma][:config_description] + |> Pleroma.Docs.Generator.convert_to_strings() + |> Jason.encode!() + end end end diff --git a/lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex similarity index 100% rename from lib/pleroma/web/activity_pub/mrf/mediaproxy_warming_policy.ex rename to lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex diff --git a/lib/pleroma/web/activity_pub/mrf/noop_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex similarity index 100% rename from lib/pleroma/web/activity_pub/mrf/noop_policy.ex rename to lib/pleroma/web/activity_pub/mrf/no_op_policy.ex diff --git a/lib/pleroma/web/activity_pub/mrf/user_allowlist_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex similarity index 100% rename from lib/pleroma/web/activity_pub/mrf/user_allowlist_policy.ex rename to lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex index 4eaea00d8..9a03d67c0 100644 --- a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex @@ -19,8 +19,7 @@ def filter(%{"type" => "Undo", "object" => child_message} = message) do def filter(%{"type" => message_type} = message) do with accepted_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :accept]), rejected_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :reject]), - true <- - length(accepted_vocabulary) == 0 || Enum.member?(accepted_vocabulary, message_type), + true <- accepted_vocabulary == [] || Enum.member?(accepted_vocabulary, message_type), false <- length(rejected_vocabulary) > 0 && Enum.member?(rejected_vocabulary, message_type), {:ok, _} <- filter(message["object"]) do diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index c8abeff06..376f88061 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -4,6 +4,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + alias Pleroma.Activity alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug @@ -25,10 +28,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.Router - import Pleroma.Web.ControllerHelper, only: [json_response: 3] - require Logger + @descriptions_json Pleroma.Docs.JSON.compile() + @users_page_size 50 + plug( OAuthScopesPlug, %{scopes: ["read:accounts"], admin: true} @@ -93,8 +97,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do when action in [:relay_follow, :relay_unfollow, :config_update] ) - @users_page_size 50 - action_fallback(:errors) def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do @@ -782,10 +784,22 @@ def migrate_to_db(conn, _params) do end def migrate_from_db(conn, _params) do - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", Pleroma.Config.get(:env), "true"]) + Mix.Tasks.Pleroma.Config.run([ + "migrate_from_db", + "--env", + to_string(Pleroma.Config.get(:env)), + "-d" + ]) + json(conn, %{}) end + def config_descriptions(conn, _params) do + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, @descriptions_json) + end + def config_show(conn, _params) do configs = Pleroma.Repo.all(Config) @@ -800,17 +814,27 @@ def config_update(conn, %{"configs" => configs}) do updated = Enum.map(configs, fn %{"group" => group, "key" => key, "delete" => "true"} = params -> - {:ok, config} = Config.delete(%{group: group, key: key, subkeys: params["subkeys"]}) - config + with {:ok, config} <- + Config.delete(%{group: group, key: key, subkeys: params["subkeys"]}) do + config + end %{"group" => group, "key" => key, "value" => value} -> - {:ok, config} = Config.update_or_create(%{group: group, key: key, value: value}) - config + with {:ok, config} <- + Config.update_or_create(%{group: group, key: key, value: value}) do + config + end end) |> Enum.reject(&is_nil(&1)) Pleroma.Config.TransferTask.load_and_update_env() - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", Pleroma.Config.get(:env), "false"]) + + Mix.Tasks.Pleroma.Config.run([ + "migrate_from_db", + "--env", + to_string(Pleroma.Config.get(:env)) + ]) + updated else [] diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index 1917a5580..a74acfbc6 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -24,6 +24,8 @@ def get_by_params(params), do: Repo.get_by(Config, params) @spec changeset(Config.t(), map()) :: Changeset.t() def changeset(config, params \\ %{}) do + params = Map.put(params, :value, transform(params[:value])) + config |> cast(params, [:key, :group, :value]) |> validate_required([:key, :group, :value]) @@ -33,42 +35,43 @@ def changeset(config, params \\ %{}) do @spec create(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} def create(params) do %Config{} - |> changeset(Map.put(params, :value, transform(params[:value]))) + |> changeset(params) |> Repo.insert() end @spec update(Config.t(), map()) :: {:ok, Config} | {:error, Changeset.t()} def update(%Config{} = config, %{value: value}) do config - |> change(value: transform(value)) + |> changeset(%{value: value}) |> Repo.update() end @spec update_or_create(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} def update_or_create(params) do - with %Config{} = config <- Config.get_by_params(Map.take(params, [:group, :key])) do + search_opts = Map.take(params, [:group, :key]) + + with %Config{} = config <- Config.get_by_params(search_opts) do Config.update(config, params) else nil -> Config.create(params) end end - @spec delete(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} + @spec delete(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} | {:ok, nil} def delete(params) do - with %Config{} = config <- Config.get_by_params(Map.delete(params, :subkeys)) do - if params[:subkeys] do - updated_value = - Keyword.drop( - :erlang.binary_to_term(config.value), - Enum.map(params[:subkeys], &do_transform_string(&1)) - ) + search_opts = Map.delete(params, :subkeys) - Config.update(config, %{value: updated_value}) - else + with %Config{} = config <- Config.get_by_params(search_opts), + {config, sub_keys} when is_list(sub_keys) <- {config, params[:subkeys]}, + old_value <- :erlang.binary_to_term(config.value), + keys <- Enum.map(sub_keys, &do_transform_string(&1)), + new_value <- Keyword.drop(old_value, keys) do + Config.update(config, %{value: new_value}) + else + {config, nil} -> Repo.delete(config) {:ok, nil} - end - else + nil -> err = dgettext("errors", "Config with params %{params} not found", params: inspect(params)) @@ -82,10 +85,22 @@ def from_binary(binary), do: :erlang.binary_to_term(binary) @spec from_binary_with_convert(binary()) :: any() def from_binary_with_convert(binary) do - from_binary(binary) + binary + |> from_binary() |> do_convert() end + @spec from_string(String.t()) :: atom() | no_return() + def from_string(":" <> entity), do: String.to_existing_atom(entity) + + def from_string(entity) when is_binary(entity) do + if is_module_name?(entity) do + String.to_existing_atom("Elixir.#{entity}") + else + entity + end + end + defp do_convert(entity) when is_list(entity) do for v <- entity, into: [], do: do_convert(v) end @@ -97,6 +112,7 @@ defp do_convert(entity) when is_map(entity) do end defp do_convert({:dispatch, [entity]}), do: %{"tuple" => [":dispatch", [inspect(entity)]]} + # TODO: will become useless after removing hackney defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]} defp do_convert(entity) when is_tuple(entity), @@ -105,21 +121,15 @@ defp do_convert(entity) when is_tuple(entity), defp do_convert(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity), do: entity - defp do_convert(entity) when is_atom(entity) do - string = to_string(entity) - - if String.starts_with?(string, "Elixir."), - do: do_convert(string), - else: ":" <> string - end - - defp do_convert("Elixir." <> module_name), do: module_name + defp do_convert(entity) when is_atom(entity), do: inspect(entity) defp do_convert(entity) when is_binary(entity), do: entity - @spec transform(any()) :: binary() + @spec transform(any()) :: binary() | no_return() def transform(entity) when is_binary(entity) or is_map(entity) or is_list(entity) do - :erlang.term_to_binary(do_transform(entity)) + entity + |> do_transform() + |> :erlang.term_to_binary() end def transform(entity), do: :erlang.term_to_binary(entity) @@ -131,6 +141,7 @@ defp do_transform(%{"tuple" => [":dispatch", [entity]]}) do {:dispatch, [dispatch_settings]} end + # TODO: will become useless after removing hackney defp do_transform(%{"tuple" => [":partial_chain", entity]}) do {partial_chain, []} = do_eval(entity) {:partial_chain, partial_chain} @@ -149,34 +160,63 @@ defp do_transform(entity) when is_list(entity) do end defp do_transform(entity) when is_binary(entity) do - String.trim(entity) + entity + |> String.trim() |> do_transform_string() end defp do_transform(entity), do: entity - defp do_transform_string("~r/" <> pattern) do - modificator = String.split(pattern, "/") |> List.last() - pattern = String.trim_trailing(pattern, "/" <> modificator) + @delimiters ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] - case modificator do - "" -> ~r/#{pattern}/ - "i" -> ~r/#{pattern}/i - "u" -> ~r/#{pattern}/u - "s" -> ~r/#{pattern}/s + defp find_valid_delimiter([], _string, _), + do: raise(ArgumentError, message: "valid delimiter for Regex expression not found") + + defp find_valid_delimiter([{leading, closing} = delimiter | others], pattern, regex_delimiter) + when is_tuple(delimiter) do + if String.contains?(pattern, closing) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {leading, closing}} + end + end + + defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do + if String.contains?(pattern, delimiter) do + find_valid_delimiter(others, pattern, regex_delimiter) + else + {:ok, {delimiter, delimiter}} + end + end + + @regex_parts ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u + + defp do_transform_string("~r" <> _pattern = regex) do + with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- + Regex.named_captures(@regex_parts, regex), + {:ok, {leading, closing}} <- find_valid_delimiter(@delimiters, pattern, regex_delimiter), + {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do + result end end defp do_transform_string(":" <> atom), do: String.to_atom(atom) defp do_transform_string(value) do - if String.starts_with?(value, "Pleroma") or String.starts_with?(value, "Phoenix"), - do: String.to_existing_atom("Elixir." <> value), - else: value + if is_module_name?(value) do + String.to_existing_atom("Elixir." <> value) + else + value + end + end + + @spec is_module_name?(String.t()) :: boolean() + def is_module_name?(string) do + Regex.match?(~r/^(Pleroma|Phoenix|Tesla)\./, string) or string in ["Oban", "Ueberauth"] end defp do_eval(entity) do cleaned_string = String.replace(entity, ~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") - Code.eval_string(cleaned_string, [], requires: [], macros: []) + Code.eval_string(cleaned_string) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index f6c128283..a182e90e7 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -195,6 +195,7 @@ defmodule Pleroma.Web.Router do get("/config", AdminAPIController, :config_show) post("/config", AdminAPIController, :config_update) + get("/config/descriptions", AdminAPIController, :config_descriptions) get("/config/migrate_to_db", AdminAPIController, :migrate_to_db) get("/config/migrate_from_db", AdminAPIController, :migrate_from_db) diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 9074f3b97..4b3dd8bbd 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -14,14 +14,14 @@ test "transfer config values from db to env" do refute Application.get_env(:idna, :test_key) Pleroma.Web.AdminAPI.Config.create(%{ - group: "pleroma", - key: "test_key", + group: ":pleroma", + key: ":test_key", value: [live: 2, com: 3] }) Pleroma.Web.AdminAPI.Config.create(%{ - group: "idna", - key: "test_key", + group: ":idna", + key: ":test_key", value: [live: 15, com: 35] }) @@ -38,14 +38,14 @@ test "transfer config values from db to env" do test "non existing atom" do Pleroma.Web.AdminAPI.Config.create(%{ - group: "pleroma", - key: "undefined_atom_key", + group: ":pleroma", + key: ":undefined_atom_key", value: [live: 2, com: 3] }) assert ExUnit.CaptureLog.capture_log(fn -> Pleroma.Config.TransferTask.start_link([]) end) =~ - "updating env causes error, key: \"undefined_atom_key\", error: %ArgumentError{message: \"argument error\"}" + "updating env causes error, key: \":undefined_atom_key\", error: %ArgumentError{message: \"argument error\"}" end end diff --git a/test/docs/generator_test.exs b/test/docs/generator_test.exs new file mode 100644 index 000000000..42e7c32c8 --- /dev/null +++ b/test/docs/generator_test.exs @@ -0,0 +1,211 @@ +defmodule Pleroma.Docs.GeneratorTest do + use ExUnit.Case, async: true + alias Pleroma.Docs.Generator + + @descriptions [ + %{ + group: :pleroma, + key: Pleroma.Upload, + type: :group, + description: "", + children: [ + %{ + key: :uploader, + type: :module, + description: "", + suggestions: + Generator.list_modules_in_dir( + "lib/pleroma/upload/filter", + "Elixir.Pleroma.Upload.Filter." + ) + }, + %{ + key: :filters, + type: {:list, :module}, + description: "", + suggestions: + Generator.list_modules_in_dir( + "lib/pleroma/web/activity_pub/mrf", + "Elixir.Pleroma.Web.ActivityPub.MRF." + ) + }, + %{ + key: Pleroma.Upload, + type: :string, + description: "", + suggestions: [""] + }, + %{ + key: :some_key, + type: :keyword, + description: "", + suggestions: [], + children: [ + %{ + key: :another_key, + type: :integer, + description: "", + suggestions: [5] + }, + %{ + key: :another_key_with_label, + label: "Another label", + type: :integer, + description: "", + suggestions: [7] + } + ] + }, + %{ + key: :key1, + type: :atom, + description: "", + suggestions: [ + :atom, + Pleroma.Upload, + {:tuple, "string", 8080}, + [:atom, Pleroma.Upload, {:atom, Pleroma.Upload}] + ] + }, + %{ + key: Pleroma.Upload, + label: "Special Label", + type: :string, + description: "", + suggestions: [""] + }, + %{ + group: {:subgroup, Swoosh.Adapters.SMTP}, + key: :auth, + type: :atom, + description: "`Swoosh.Adapters.SMTP` adapter specific setting", + suggestions: [:always, :never, :if_available] + }, + %{ + key: "application/xml", + type: {:list, :string}, + suggestions: ["xml"] + } + ] + }, + %{ + group: :tesla, + key: :adapter, + type: :group, + description: "" + }, + %{ + group: :cors_plug, + type: :group, + children: [%{key: :key1, type: :string, suggestions: [""]}] + }, + %{group: "Some string group", key: "Some string key", type: :group} + ] + + describe "convert_to_strings/1" do + test "group, key, label" do + [desc1, desc2 | _] = Generator.convert_to_strings(@descriptions) + + assert desc1[:group] == ":pleroma" + assert desc1[:key] == "Pleroma.Upload" + assert desc1[:label] == "Pleroma.Upload" + + assert desc2[:group] == ":tesla" + assert desc2[:key] == ":adapter" + assert desc2[:label] == "Adapter" + end + + test "group without key" do + descriptions = Generator.convert_to_strings(@descriptions) + desc = Enum.at(descriptions, 2) + + assert desc[:group] == ":cors_plug" + refute desc[:key] + assert desc[:label] == "Cors plug" + end + + test "children key, label, type" do + [%{children: [child1, child2, child3, child4 | _]} | _] = + Generator.convert_to_strings(@descriptions) + + assert child1[:key] == ":uploader" + assert child1[:label] == "Uploader" + assert child1[:type] == :module + + assert child2[:key] == ":filters" + assert child2[:label] == "Filters" + assert child2[:type] == {:list, :module} + + assert child3[:key] == "Pleroma.Upload" + assert child3[:label] == "Pleroma.Upload" + assert child3[:type] == :string + + assert child4[:key] == ":some_key" + assert child4[:label] == "Some key" + assert child4[:type] == :keyword + end + + test "child with predefined label" do + [%{children: children} | _] = Generator.convert_to_strings(@descriptions) + child = Enum.at(children, 5) + assert child[:key] == "Pleroma.Upload" + assert child[:label] == "Special Label" + end + + test "subchild" do + [%{children: children} | _] = Generator.convert_to_strings(@descriptions) + child = Enum.at(children, 3) + %{children: [subchild | _]} = child + + assert subchild[:key] == ":another_key" + assert subchild[:label] == "Another key" + assert subchild[:type] == :integer + end + + test "subchild with predefined label" do + [%{children: children} | _] = Generator.convert_to_strings(@descriptions) + child = Enum.at(children, 3) + %{children: subchildren} = child + subchild = Enum.at(subchildren, 1) + + assert subchild[:key] == ":another_key_with_label" + assert subchild[:label] == "Another label" + end + + test "module suggestions" do + [%{children: [%{suggestions: suggestions} | _]} | _] = + Generator.convert_to_strings(@descriptions) + + Enum.each(suggestions, fn suggestion -> + assert String.starts_with?(suggestion, "Pleroma.") + end) + end + + test "atoms in suggestions with leading `:`" do + [%{children: children} | _] = Generator.convert_to_strings(@descriptions) + %{suggestions: suggestions} = Enum.at(children, 4) + assert Enum.at(suggestions, 0) == ":atom" + assert Enum.at(suggestions, 1) == "Pleroma.Upload" + assert Enum.at(suggestions, 2) == {":tuple", "string", 8080} + assert Enum.at(suggestions, 3) == [":atom", "Pleroma.Upload", {":atom", "Pleroma.Upload"}] + + %{suggestions: suggestions} = Enum.at(children, 6) + assert Enum.at(suggestions, 0) == ":always" + assert Enum.at(suggestions, 1) == ":never" + assert Enum.at(suggestions, 2) == ":if_available" + end + + test "group, key as string in main desc" do + descriptions = Generator.convert_to_strings(@descriptions) + desc = Enum.at(descriptions, 3) + assert desc[:group] == "Some string group" + assert desc[:key] == "Some string key" + end + + test "key as string subchild" do + [%{children: children} | _] = Generator.convert_to_strings(@descriptions) + child = Enum.at(children, 7) + assert child[:key] == "application/xml" + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 314f26ec9..a7aa54f73 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -377,8 +377,8 @@ def registration_factory do def config_factory do %Pleroma.Web.AdminAPI.Config{ - key: sequence(:key, &"some_key_#{&1}"), - group: "pleroma", + key: sequence(:key, &":some_key_#{&1}"), + group: ":pleroma", value: sequence( :value, diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index fab9d6e9a..055f678b9 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -9,16 +9,14 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do setup_all do Mix.shell(Mix.Shell.Process) - temp_file = "config/temp.exported_from_db.secret.exs" on_exit(fn -> Mix.shell(Mix.Shell.IO) Application.delete_env(:pleroma, :first_setting) Application.delete_env(:pleroma, :second_setting) - :ok = File.rm(temp_file) end) - {:ok, temp_file: temp_file} + :ok end clear_config_all([:instance, :dynamic_configuration]) do @@ -28,38 +26,44 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do test "settings are migrated to db" do assert Repo.all(Config) == [] - Application.put_env(:pleroma, :first_setting, key: "value", key2: [Pleroma.Repo]) - Application.put_env(:pleroma, :second_setting, key: "value2", key2: [Pleroma.Activity]) + Application.put_env(:pleroma, :first_setting, key: "value", key2: [Repo]) + Application.put_env(:pleroma, :second_setting, key: "value2", key2: ["Activity"]) Mix.Tasks.Pleroma.Config.run(["migrate_to_db"]) - first_db = Config.get_by_params(%{group: "pleroma", key: ":first_setting"}) - second_db = Config.get_by_params(%{group: "pleroma", key: ":second_setting"}) - refute Config.get_by_params(%{group: "pleroma", key: "Pleroma.Repo"}) + config1 = Config.get_by_params(%{group: ":pleroma", key: ":first_setting"}) + config2 = Config.get_by_params(%{group: ":pleroma", key: ":second_setting"}) + refute Config.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"}) - assert Config.from_binary(first_db.value) == [key: "value", key2: [Pleroma.Repo]] - assert Config.from_binary(second_db.value) == [key: "value2", key2: [Pleroma.Activity]] + assert Config.from_binary(config1.value) == [key: "value", key2: [Repo]] + assert Config.from_binary(config2.value) == [key: "value2", key2: ["Activity"]] end - test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do + test "settings are migrated to file and deleted from db" do + env = "temp" + config_file = "config/#{env}.exported_from_db.secret.exs" + + on_exit(fn -> + :ok = File.rm(config_file) + end) + Config.create(%{ - group: "pleroma", + group: ":pleroma", key: ":setting_first", - value: [key: "value", key2: [Pleroma.Activity]] + value: [key: "value", key2: ["Activity"]] }) Config.create(%{ - group: "pleroma", + group: ":pleroma", key: ":setting_second", - value: [key: "valu2", key2: [Pleroma.Repo]] + value: [key: "value2", key2: [Repo]] }) - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "temp", "true"]) + Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", env, "-d"]) assert Repo.all(Config) == [] - assert File.exists?(temp_file) - {:ok, file} = File.read(temp_file) + file = File.read!(config_file) assert file =~ "config :pleroma, :setting_first," assert file =~ "config :pleroma, :setting_second," end diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 49ff005b6..fd54504ac 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1950,6 +1950,7 @@ test "with settings in db", %{conn: conn} do %{ "configs" => [ %{ + "group" => ":pleroma", "key" => key1, "value" => _ }, @@ -1995,15 +1996,15 @@ test "create new config setting in db", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/config", %{ configs: [ - %{group: "pleroma", key: "key1", value: "value1"}, + %{group: ":pleroma", key: ":key1", value: "value1"}, %{ - group: "ueberauth", + group: ":ueberauth", key: "Ueberauth.Strategy.Twitter.OAuth", value: [%{"tuple" => [":consumer_secret", "aaaa"]}] }, %{ - group: "pleroma", - key: "key2", + group: ":pleroma", + key: ":key2", value: %{ ":nested_1" => "nested_value1", ":nested_2" => [ @@ -2013,21 +2014,21 @@ test "create new config setting in db", %{conn: conn} do } }, %{ - group: "pleroma", - key: "key3", + group: ":pleroma", + key: ":key3", value: [ %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, %{"nested_4" => true} ] }, %{ - group: "pleroma", - key: "key4", + group: ":pleroma", + key: ":key4", value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"} }, %{ - group: "idna", - key: "key5", + group: ":idna", + key: ":key5", value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} } ] @@ -2036,18 +2037,18 @@ test "create new config setting in db", %{conn: conn} do assert json_response(conn, 200) == %{ "configs" => [ %{ - "group" => "pleroma", - "key" => "key1", + "group" => ":pleroma", + "key" => ":key1", "value" => "value1" }, %{ - "group" => "ueberauth", + "group" => ":ueberauth", "key" => "Ueberauth.Strategy.Twitter.OAuth", "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}] }, %{ - "group" => "pleroma", - "key" => "key2", + "group" => ":pleroma", + "key" => ":key2", "value" => %{ ":nested_1" => "nested_value1", ":nested_2" => [ @@ -2057,21 +2058,21 @@ test "create new config setting in db", %{conn: conn} do } }, %{ - "group" => "pleroma", - "key" => "key3", + "group" => ":pleroma", + "key" => ":key3", "value" => [ %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, %{"nested_4" => true} ] }, %{ - "group" => "pleroma", - "key" => "key4", + "group" => ":pleroma", + "key" => ":key4", "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"} }, %{ - "group" => "idna", - "key" => "key5", + "group" => ":idna", + "key" => ":key5", "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} } ] @@ -2101,8 +2102,8 @@ test "create new config setting in db", %{conn: conn} do end test "update config setting & delete", %{conn: conn} do - config1 = insert(:config, key: "keyaa1") - config2 = insert(:config, key: "keyaa2") + config1 = insert(:config, key: ":keyaa1") + config2 = insert(:config, key: ":keyaa2") insert(:config, group: "ueberauth", @@ -2126,7 +2127,7 @@ test "update config setting & delete", %{conn: conn} do assert json_response(conn, 200) == %{ "configs" => [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => config1.key, "value" => "another_value" } @@ -2138,11 +2139,14 @@ test "update config setting & delete", %{conn: conn} do end test "common config example", %{conn: conn} do + adapter = Application.get_env(:tesla, :adapter) + on_exit(fn -> Application.put_env(:tesla, :adapter, adapter) end) + conn = post(conn, "/api/pleroma/admin/config", %{ configs: [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => "Pleroma.Captcha.NotReal", "value" => [ %{"tuple" => [":enabled", false]}, @@ -2154,16 +2158,21 @@ test "common config example", %{conn: conn} do %{"tuple" => [":regex1", "~r/https:\/\/example.com/"]}, %{"tuple" => [":regex2", "~r/https:\/\/example.com/u"]}, %{"tuple" => [":regex3", "~r/https:\/\/example.com/i"]}, - %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]} + %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} ] - } + }, + %{"group" => ":tesla", "key" => ":adapter", "value" => "Tesla.Adapter.Httpc"} ] }) + assert Application.get_env(:tesla, :adapter) == Tesla.Adapter.Httpc + assert Pleroma.Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" + assert json_response(conn, 200) == %{ "configs" => [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => "Pleroma.Captcha.NotReal", "value" => [ %{"tuple" => [":enabled", false]}, @@ -2175,9 +2184,11 @@ test "common config example", %{conn: conn} do %{"tuple" => [":regex1", "~r/https:\\/\\/example.com/"]}, %{"tuple" => [":regex2", "~r/https:\\/\\/example.com/u"]}, %{"tuple" => [":regex3", "~r/https:\\/\\/example.com/i"]}, - %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]} + %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} ] - } + }, + %{"group" => ":tesla", "key" => ":adapter", "value" => "Tesla.Adapter.Httpc"} ] } end @@ -2187,7 +2198,7 @@ test "tuples with more than two values", %{conn: conn} do post(conn, "/api/pleroma/admin/config", %{ configs: [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => "Pleroma.Web.Endpoint.NotReal", "value" => [ %{ @@ -2251,7 +2262,7 @@ test "tuples with more than two values", %{conn: conn} do assert json_response(conn, 200) == %{ "configs" => [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => "Pleroma.Web.Endpoint.NotReal", "value" => [ %{ @@ -2318,7 +2329,7 @@ test "settings with nesting map", %{conn: conn} do post(conn, "/api/pleroma/admin/config", %{ configs: [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => ":key1", "value" => [ %{"tuple" => [":key2", "some_val"]}, @@ -2348,7 +2359,7 @@ test "settings with nesting map", %{conn: conn} do %{ "configs" => [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => ":key1", "value" => [ %{"tuple" => [":key2", "some_val"]}, @@ -2380,7 +2391,7 @@ test "value as map", %{conn: conn} do post(conn, "/api/pleroma/admin/config", %{ configs: [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => ":key1", "value" => %{"key" => "some_val"} } @@ -2391,7 +2402,7 @@ test "value as map", %{conn: conn} do %{ "configs" => [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => ":key1", "value" => %{"key" => "some_val"} } @@ -2404,7 +2415,7 @@ test "dispatch setting", %{conn: conn} do post(conn, "/api/pleroma/admin/config", %{ configs: [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => "Pleroma.Web.Endpoint.NotReal", "value" => [ %{ @@ -2437,7 +2448,7 @@ test "dispatch setting", %{conn: conn} do assert json_response(conn, 200) == %{ "configs" => [ %{ - "group" => "pleroma", + "group" => ":pleroma", "key" => "Pleroma.Web.Endpoint.NotReal", "value" => [ %{ @@ -2467,7 +2478,7 @@ test "queues key as atom", %{conn: conn} do post(conn, "/api/pleroma/admin/config", %{ configs: [ %{ - "group" => "oban", + "group" => ":oban", "key" => ":queues", "value" => [ %{"tuple" => [":federator_incoming", 50]}, @@ -2485,7 +2496,7 @@ test "queues key as atom", %{conn: conn} do assert json_response(conn, 200) == %{ "configs" => [ %{ - "group" => "oban", + "group" => ":oban", "key" => ":queues", "value" => [ %{"tuple" => [":federator_incoming", 50]}, @@ -2504,7 +2515,7 @@ test "queues key as atom", %{conn: conn} do test "delete part of settings by atom subkeys", %{conn: conn} do config = insert(:config, - key: "keyaa1", + key: ":keyaa1", value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") ) @@ -2524,8 +2535,8 @@ test "delete part of settings by atom subkeys", %{conn: conn} do json_response(conn, 200) == %{ "configs" => [ %{ - "group" => "pleroma", - "key" => "keyaa1", + "group" => ":pleroma", + "key" => ":keyaa1", "value" => [%{"tuple" => [":subkey2", "val2"]}] } ] @@ -3099,6 +3110,21 @@ test "it deletes the note", %{admin: admin, report_id: report_id} do assert ReportNote |> Repo.all() |> length() == 1 end end + + test "GET /api/pleroma/admin/config/descriptions", %{conn: conn} do + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + assert [child | _others] = json_response(conn, 200) + + assert child["children"] + assert child["key"] + assert String.starts_with?(child["group"], ":") + assert child["description"] + end end # Needed for testing diff --git a/test/web/admin_api/config_test.exs b/test/web/admin_api/config_test.exs index 204446b79..bff31bb85 100644 --- a/test/web/admin_api/config_test.exs +++ b/test/web/admin_api/config_test.exs @@ -91,14 +91,26 @@ test "pleroma module" do assert Config.from_binary(binary) == Pleroma.Bookmark end + test "pleroma string" do + binary = Config.transform("Pleroma") + assert binary == :erlang.term_to_binary("Pleroma") + assert Config.from_binary(binary) == "Pleroma" + end + test "phoenix module" do binary = Config.transform("Phoenix.Socket.V1.JSONSerializer") assert binary == :erlang.term_to_binary(Phoenix.Socket.V1.JSONSerializer) assert Config.from_binary(binary) == Phoenix.Socket.V1.JSONSerializer end + test "tesla module" do + binary = Config.transform("Tesla.Adapter.Hackney") + assert binary == :erlang.term_to_binary(Tesla.Adapter.Hackney) + assert Config.from_binary(binary) == Tesla.Adapter.Hackney + end + test "sigil" do - binary = Config.transform("~r/comp[lL][aA][iI][nN]er/") + binary = Config.transform("~r[comp[lL][aA][iI][nN]er]") assert binary == :erlang.term_to_binary(~r/comp[lL][aA][iI][nN]er/) assert Config.from_binary(binary) == ~r/comp[lL][aA][iI][nN]er/ end @@ -109,10 +121,10 @@ test "link sigil" do assert Config.from_binary(binary) == ~r/https:\/\/example.com/ end - test "link sigil with u modifier" do - binary = Config.transform("~r/https:\/\/example.com/u") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/u) - assert Config.from_binary(binary) == ~r/https:\/\/example.com/u + test "link sigil with um modifiers" do + binary = Config.transform("~r/https:\/\/example.com/um") + assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/um) + assert Config.from_binary(binary) == ~r/https:\/\/example.com/um end test "link sigil with i modifier" do @@ -127,6 +139,12 @@ test "link sigil with s modifier" do assert Config.from_binary(binary) == ~r/https:\/\/example.com/s end + test "raise if valid delimiter not found" do + assert_raise ArgumentError, "valid delimiter for Regex expression not found", fn -> + Config.transform("~r/https://[]{}<>\"'()|example.com/s") + end + end + test "2 child tuple" do binary = Config.transform(%{"tuple" => ["v1", ":v2"]}) assert binary == :erlang.term_to_binary({"v1", :v2}) From 0656816c77875d87d64d89e0e549f73104104cfb Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Dec 2019 08:21:30 +0300 Subject: [PATCH 02/46] tests for setttings without an explicit key --- lib/mix/tasks/pleroma/config.ex | 6 +-- test/config/transfer_task_test.exs | 17 +++++-- test/tasks/config_test.exs | 8 ++++ .../admin_api/admin_api_controller_test.exs | 46 +++++++++++++++++-- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index bb126463c..cef02b864 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -76,10 +76,10 @@ defp load_and_create(group) do |> Application.get_all_env() |> Enum.reject(fn {k, _v} -> k in [Pleroma.Repo, :env] end) |> Enum.each(fn {key, value} -> - key_str = inspect(key) + key = inspect(key) + {:ok, _} = Config.update_or_create(%{group: inspect(group), key: key, value: value}) - {:ok, _} = Config.update_or_create(%{group: ":#{group}", key: key_str, value: value}) - Mix.shell().info("settings for key #{key_str} migrated.") + Mix.shell().info("settings for key #{key} migrated.") end) Mix.shell().info("settings for group :#{group} migrated.") diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 4b3dd8bbd..d1314cf99 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -5,6 +5,8 @@ defmodule Pleroma.Config.TransferTaskTest do use Pleroma.DataCase + alias Pleroma.Web.AdminAPI.Config + clear_config([:instance, :dynamic_configuration]) do Pleroma.Config.put([:instance, :dynamic_configuration], true) end @@ -12,32 +14,41 @@ defmodule Pleroma.Config.TransferTaskTest do test "transfer config values from db to env" do refute Application.get_env(:pleroma, :test_key) refute Application.get_env(:idna, :test_key) + refute Application.get_env(:quack, :test_key) - Pleroma.Web.AdminAPI.Config.create(%{ + Config.create(%{ group: ":pleroma", key: ":test_key", value: [live: 2, com: 3] }) - Pleroma.Web.AdminAPI.Config.create(%{ + Config.create(%{ group: ":idna", key: ":test_key", value: [live: 15, com: 35] }) + Config.create(%{ + group: ":quack", + key: ":test_key", + value: [:test_value1, :test_value2] + }) + Pleroma.Config.TransferTask.start_link([]) assert Application.get_env(:pleroma, :test_key) == [live: 2, com: 3] assert Application.get_env(:idna, :test_key) == [live: 15, com: 35] + assert Application.get_env(:quack, :test_key) == [:test_value1, :test_value2] on_exit(fn -> Application.delete_env(:pleroma, :test_key) Application.delete_env(:idna, :test_key) + Application.delete_env(:quack, :test_key) end) end test "non existing atom" do - Pleroma.Web.AdminAPI.Config.create(%{ + Config.create(%{ group: ":pleroma", key: ":undefined_atom_key", value: [live: 2, com: 3] diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index 055f678b9..dfe3904ca 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -24,19 +24,24 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do end test "settings are migrated to db" do + initial = Application.get_all_env(:quack) + on_exit(fn -> Application.put_all_env([{:quack, initial}]) end) assert Repo.all(Config) == [] Application.put_env(:pleroma, :first_setting, key: "value", key2: [Repo]) Application.put_env(:pleroma, :second_setting, key: "value2", key2: ["Activity"]) + Application.put_env(:quack, :level, :info) Mix.Tasks.Pleroma.Config.run(["migrate_to_db"]) config1 = Config.get_by_params(%{group: ":pleroma", key: ":first_setting"}) config2 = Config.get_by_params(%{group: ":pleroma", key: ":second_setting"}) + config3 = Config.get_by_params(%{group: ":quack", key: ":level"}) refute Config.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"}) assert Config.from_binary(config1.value) == [key: "value", key2: [Repo]] assert Config.from_binary(config2.value) == [key: "value2", key2: ["Activity"]] + assert Config.from_binary(config3.value) == :info end test "settings are migrated to file and deleted from db" do @@ -59,6 +64,8 @@ test "settings are migrated to file and deleted from db" do value: [key: "value2", key2: [Repo]] }) + Config.create(%{group: ":quack", key: ":level", value: :info}) + Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", env, "-d"]) assert Repo.all(Config) == [] @@ -66,6 +73,7 @@ test "settings are migrated to file and deleted from db" do file = File.read!(config_file) assert file =~ "config :pleroma, :setting_first," assert file =~ "config :pleroma, :setting_second," + assert file =~ "config :quack, :level, :info" end test "load a settings with large values and pass to file", %{temp_file: temp_file} do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index fd54504ac..1372edcab 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1970,8 +1970,6 @@ test "with settings in db", %{conn: conn} do setup %{conn: conn} do admin = insert(:user, is_admin: true) - temp_file = "config/test.exported_from_db.secret.exs" - on_exit(fn -> Application.delete_env(:pleroma, :key1) Application.delete_env(:pleroma, :key2) @@ -1981,7 +1979,7 @@ test "with settings in db", %{conn: conn} do Application.delete_env(:pleroma, :keyaa2) Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) - :ok = File.rm(temp_file) + :ok = File.rm("config/test.exported_from_db.secret.exs") end) %{conn: assign(conn, :user, admin)} @@ -2101,6 +2099,48 @@ test "create new config setting in db", %{conn: conn} do assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []} end + test "save config setting without key", %{conn: conn} do + initial = Application.get_all_env(:quack) + on_exit(fn -> Application.put_all_env([{:quack, initial}]) end) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":quack", + key: ":level", + value: ":info" + }, + %{ + group: ":quack", + key: ":meta", + value: [":none"] + }, + %{ + group: ":quack", + key: ":webhook_url", + value: "https://hooks.slack.com/services/KEY" + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{"group" => ":quack", "key" => ":level", "value" => ":info"}, + %{"group" => ":quack", "key" => ":meta", "value" => [":none"]}, + %{ + "group" => ":quack", + "key" => ":webhook_url", + "value" => "https://hooks.slack.com/services/KEY" + } + ] + } + + assert Application.get_env(:quack, :level) == :info + assert Application.get_env(:quack, :meta) == [:none] + assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" + end + test "update config setting & delete", %{conn: conn} do config1 = insert(:config, key: ":keyaa1") config2 = insert(:config, key: ":keyaa2") From 5cacb988b99347b228a30743fbcf310c9479b3f9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Dec 2019 15:12:56 +0300 Subject: [PATCH 03/46] partially settings update --- docs/API/admin_api.md | 35 +++++++ lib/pleroma/web/admin_api/config.ex | 49 ++++++++-- test/support/factory.ex | 8 +- .../admin_api/admin_api_controller_test.exs | 55 ++++++++++- test/web/admin_api/config_test.exs | 94 ++++++++++++++++--- 5 files changed, 218 insertions(+), 23 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 851c526d6..dff12db56 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -764,6 +764,41 @@ Most of the settings will be applied in `runtime`, this means that you don't nee [subkey2: val2] \\ value after deletion ``` +*Most of the settings can be partially updated through merge old values with new values, except settings value of which is list or is not keyword.* + +Example of setting without keyword in value: +```elixir +config :tesla, :adapter, Tesla.Adapter.Hackney +``` + +List of settings which have list in value: +```elixir +@full_key_update [ + {:pleroma, :ecto_repos}, + {:quack, :meta}, + {:mime, :types}, + {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:auto_linker, :opts}, + {:swarm, :node_blacklist} + ] +``` + +*Settings without explicit key must be sended in separate config object params.* +```elixir +config :quack, + level: :debug, + meta: [:all], + ... +``` +```json +{ + configs: [ + {"group": ":quack", "key": ":level", "value": ":debug"}, + {"group": ":quack", "key": ":meta", "value": [":all"]}, + ... + ] +} +``` - Request: ```json diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index a74acfbc6..e141a13da 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -46,14 +46,48 @@ def update(%Config{} = config, %{value: value}) do |> Repo.update() end + @full_key_update [ + {:pleroma, :ecto_repos}, + {:quack, :meta}, + {:mime, :types}, + {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:auto_linker, :opts}, + {:swarm, :node_blacklist} + ] + + defp only_full_update?(%Config{} = config) do + config_group = Config.from_string(config.group) + config_key = Config.from_string(config.key) + + Enum.any?(@full_key_update, fn + {group, key} when is_list(key) -> + config_group == group and config_key in key + + {group, key} -> + config_group == group and config_key == key + end) + end + + defp can_be_partially_updated?(%Config{} = config), do: not only_full_update?(config) + @spec update_or_create(map()) :: {:ok, Config.t()} | {:error, Changeset.t()} def update_or_create(params) do search_opts = Map.take(params, [:group, :key]) - with %Config{} = config <- Config.get_by_params(search_opts) do - Config.update(config, params) + with %Config{} = config <- Config.get_by_params(search_opts), + {:partial_update, true, config} <- + {:partial_update, can_be_partially_updated?(config), config}, + old_value <- from_binary(config.value), + transformed_value <- do_transform(params[:value]), + {:can_be_merged, true, config} <- {:can_be_merged, is_list(transformed_value), config}, + new_value <- Keyword.merge(old_value, transformed_value) do + Config.update(config, %{value: new_value, transformed?: true}) else - nil -> Config.create(params) + {reason, false, config} when reason in [:partial_update, :can_be_merged] -> + Config.update(config, params) + + nil -> + Config.create(params) end end @@ -63,7 +97,7 @@ def delete(params) do with %Config{} = config <- Config.get_by_params(search_opts), {config, sub_keys} when is_list(sub_keys) <- {config, params[:subkeys]}, - old_value <- :erlang.binary_to_term(config.value), + old_value <- from_binary(config.value), keys <- Enum.map(sub_keys, &do_transform_string(&1)), new_value <- Keyword.drop(old_value, keys) do Config.update(config, %{value: new_value}) @@ -129,10 +163,13 @@ defp do_convert(entity) when is_binary(entity), do: entity def transform(entity) when is_binary(entity) or is_map(entity) or is_list(entity) do entity |> do_transform() - |> :erlang.term_to_binary() + |> to_binary() end - def transform(entity), do: :erlang.term_to_binary(entity) + def transform(entity), do: to_binary(entity) + + @spec to_binary(any()) :: binary() + def to_binary(entity), do: :erlang.term_to_binary(entity) defp do_transform(%Regex{} = entity), do: entity diff --git a/test/support/factory.ex b/test/support/factory.ex index a7aa54f73..c16cbc9d7 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -377,7 +377,13 @@ def registration_factory do def config_factory do %Pleroma.Web.AdminAPI.Config{ - key: sequence(:key, &":some_key_#{&1}"), + key: + sequence(:key, fn key -> + # Atom dynamic registration hack in tests + "some_key_#{key}" + |> String.to_atom() + |> inspect() + end), group: ":pleroma", value: sequence( diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 1372edcab..e2e10d3f8 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1979,6 +1979,7 @@ test "with settings in db", %{conn: conn} do Application.delete_env(:pleroma, :keyaa2) Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) + Application.put_env(:tesla, :adapter, Tesla.Mock) :ok = File.rm("config/test.exported_from_db.secret.exs") end) @@ -2141,14 +2142,64 @@ test "save config setting without key", %{conn: conn} do assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" end + test "saving config with partial update", %{conn: conn} do + config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key2", 2]}, + %{"tuple" => [":key3", 3]} + ] + } + ] + } + end + + test "saving full setting if value is not keyword", %{conn: conn} do + config = + insert(:config, + group: ":tesla", + key: ":adapter", + value: :erlang.term_to_binary(Tesla.Adapter.Hackey) + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":tesla", + "key" => ":adapter", + "value" => "Tesla.Adapter.Httpc" + } + ] + } + end + test "update config setting & delete", %{conn: conn} do config1 = insert(:config, key: ":keyaa1") config2 = insert(:config, key: ":keyaa2") insert(:config, group: "ueberauth", - key: "Ueberauth.Strategy.Microsoft.OAuth", - value: :erlang.term_to_binary([]) + key: "Ueberauth.Strategy.Microsoft.OAuth" ) conn = diff --git a/test/web/admin_api/config_test.exs b/test/web/admin_api/config_test.exs index bff31bb85..c37eff092 100644 --- a/test/web/admin_api/config_test.exs +++ b/test/web/admin_api/config_test.exs @@ -26,26 +26,92 @@ test "update/1" do assert loaded == updated end - test "update_or_create/1" do - config = insert(:config) - key2 = "another_key" + describe "update_or_create/1" do + test "common" do + config = insert(:config) + key2 = "another_key" - params = [ - %{group: "pleroma", key: key2, value: "another_value"}, - %{group: config.group, key: config.key, value: "new_value"} - ] + params = [ + %{group: "pleroma", key: key2, value: "another_value"}, + %{group: config.group, key: config.key, value: "new_value"} + ] - assert Repo.all(Config) |> length() == 1 + assert Repo.all(Config) |> length() == 1 - Enum.each(params, &Config.update_or_create(&1)) + Enum.each(params, &Config.update_or_create(&1)) - assert Repo.all(Config) |> length() == 2 + assert Repo.all(Config) |> length() == 2 - config1 = Config.get_by_params(%{group: config.group, key: config.key}) - config2 = Config.get_by_params(%{group: "pleroma", key: key2}) + config1 = Config.get_by_params(%{group: config.group, key: config.key}) + config2 = Config.get_by_params(%{group: "pleroma", key: key2}) - assert config1.value == Config.transform("new_value") - assert config2.value == Config.transform("another_value") + assert config1.value == Config.transform("new_value") + assert config2.value == Config.transform("another_value") + end + + test "partial update" do + config = insert(:config, value: Config.to_binary(key1: "val1", key2: :val2)) + + {:ok, _config} = + Config.update_or_create(%{ + group: config.group, + key: config.key, + value: [key1: :val1, key3: :val3] + }) + + updated = Config.get_by_params(%{group: config.group, key: config.key}) + + value = Config.from_binary(updated.value) + assert length(value) == 3 + assert value[:key1] == :val1 + assert value[:key2] == :val2 + assert value[:key3] == :val3 + end + + test "only full update for some keys" do + config1 = insert(:config, key: ":ecto_repos", value: Config.to_binary(repo: Pleroma.Repo)) + config2 = insert(:config, group: ":cors_plug", key: ":max_age", value: Config.to_binary(18)) + + {:ok, _config} = + Config.update_or_create(%{ + group: config1.group, + key: config1.key, + value: [another_repo: [Pleroma.Repo]] + }) + + {:ok, _config} = + Config.update_or_create(%{ + group: config2.group, + key: config2.key, + value: 777 + }) + + updated1 = Config.get_by_params(%{group: config1.group, key: config1.key}) + updated2 = Config.get_by_params(%{group: config2.group, key: config2.key}) + + assert Config.from_binary(updated1.value) == [another_repo: [Pleroma.Repo]] + assert Config.from_binary(updated2.value) == 777 + end + + test "full update if value is not keyword" do + config = + insert(:config, + group: ":tesla", + key: ":adapter", + value: Config.to_binary(Tesla.Adapter.Hackney) + ) + + {:ok, _config} = + Config.update_or_create(%{ + group: config.group, + key: config.key, + value: Tesla.Adapter.Httpc + }) + + updated = Config.get_by_params(%{group: config.group, key: config.key}) + + assert Config.from_binary(updated.value) == Tesla.Adapter.Httpc + end end test "delete/1" do From fea734ca703b686701b87c8c4c4969deb05d1f92 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Dec 2019 17:50:53 +0300 Subject: [PATCH 04/46] errors on endpoints --- docs/API/admin_api.md | 21 ++-- .../web/admin_api/admin_api_controller.ex | 106 +++++++++--------- test/tasks/config_test.exs | 4 +- .../admin_api/admin_api_controller_test.exs | 67 ++++++++--- 4 files changed, 118 insertions(+), 80 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index dff12db56..98af8e8f3 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -665,19 +665,6 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - 404 Not Found `"Not found"` - On success: 200 OK `{}` -## `GET /api/pleroma/admin/config/migrate_to_db` - -### Run mix task pleroma.config migrate_to_db - -Copies `pleroma` environment settings to the database. - -- Params: none -- Response: - -```json -{} -``` - ## `GET /api/pleroma/admin/config/migrate_from_db` ### Run mix task pleroma.config migrate_from_db @@ -686,6 +673,8 @@ Copies all settings from database to `config/{env}.exported_from_db.secret.exs` - Params: none - Response: + - On failure: + - 400 Bad Request `"To use this endpoint you need to enable dynamic configuration."` ```json {} @@ -699,6 +688,9 @@ Copies all settings from database to `config/{env}.exported_from_db.secret.exs` - Params: none - Response: + - On failure: + - 400 Bad Request `"To use this endpoint you need to enable dynamic configuration."` + - 400 Bad Request `"To use dynamic configuration migrate your settings to database."` ```json { @@ -831,7 +823,8 @@ config :quack, ``` - Response: - + - On failure: + - 400 Bad Request `"To use this endpoint you need to enable dynamic configuration."` ```json { configs: [ diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 376f88061..23dcbedba 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -778,71 +778,77 @@ def list_log(conn, params) do |> render("index.json", %{log: log}) end - def migrate_to_db(conn, _params) do - Mix.Tasks.Pleroma.Config.run(["migrate_to_db"]) - json(conn, %{}) - end - - def migrate_from_db(conn, _params) do - Mix.Tasks.Pleroma.Config.run([ - "migrate_from_db", - "--env", - to_string(Pleroma.Config.get(:env)), - "-d" - ]) - - json(conn, %{}) - end - def config_descriptions(conn, _params) do conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, @descriptions_json) end - def config_show(conn, _params) do - configs = Pleroma.Repo.all(Config) + def migrate_from_db(conn, _params) do + with :ok <- check_dynamic_configuration(conn) do + Mix.Tasks.Pleroma.Config.run([ + "migrate_from_db", + "--env", + to_string(Pleroma.Config.get(:env)), + "-d" + ]) - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: configs}) + json(conn, %{}) + end + end + + def config_show(conn, _params) do + with :ok <- check_dynamic_configuration(conn) do + configs = Pleroma.Repo.all(Config) + + if configs == [] do + errors(conn, {:error, "To use dynamic configuration migrate your settings to database."}) + else + conn + |> put_view(ConfigView) + |> render("index.json", %{configs: configs}) + end + end end def config_update(conn, %{"configs" => configs}) do - updated = - if Pleroma.Config.get([:instance, :dynamic_configuration]) do - updated = - Enum.map(configs, fn - %{"group" => group, "key" => key, "delete" => "true"} = params -> - with {:ok, config} <- - Config.delete(%{group: group, key: key, subkeys: params["subkeys"]}) do - config - end + with :ok <- check_dynamic_configuration(conn) do + updated = + Enum.map(configs, fn + %{"group" => group, "key" => key, "delete" => "true"} = params -> + with {:ok, config} <- + Config.delete(%{group: group, key: key, subkeys: params["subkeys"]}) do + config + end - %{"group" => group, "key" => key, "value" => value} -> - with {:ok, config} <- - Config.update_or_create(%{group: group, key: key, value: value}) do - config - end - end) - |> Enum.reject(&is_nil(&1)) + %{"group" => group, "key" => key, "value" => value} -> + with {:ok, config} <- + Config.update_or_create(%{group: group, key: key, value: value}) do + config + end + end) + |> Enum.reject(&is_nil(&1)) - Pleroma.Config.TransferTask.load_and_update_env() + Pleroma.Config.TransferTask.load_and_update_env() - Mix.Tasks.Pleroma.Config.run([ - "migrate_from_db", - "--env", - to_string(Pleroma.Config.get(:env)) - ]) + Mix.Tasks.Pleroma.Config.run([ + "migrate_from_db", + "--env", + to_string(Pleroma.Config.get(:env)) + ]) - updated - else - [] - end + conn + |> put_view(ConfigView) + |> render("index.json", %{configs: updated}) + end + end - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: updated}) + defp check_dynamic_configuration(conn) do + if Pleroma.Config.get([:instance, :dynamic_configuration]) do + :ok + else + errors(conn, {:error, "To use this endpoint you need to enable dynamic configuration."}) + end end def reload_emoji(conn, _params) do diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index dfe3904ca..74451a9e8 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -24,8 +24,8 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do end test "settings are migrated to db" do - initial = Application.get_all_env(:quack) - on_exit(fn -> Application.put_all_env([{:quack, initial}]) end) + initial = Application.get_env(:quack, :level) + on_exit(fn -> Application.put_env(:quack, :level, initial) end) assert Repo.all(Config) == [] Application.put_env(:pleroma, :first_setting, key: "value", key2: [Repo]) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index e2e10d3f8..41d2c4212 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1929,16 +1929,31 @@ test "returns error when status is not exist", %{conn: conn} do end describe "GET /api/pleroma/admin/config" do + clear_config([:instance, :dynamic_configuration]) do + Pleroma.Config.put([:instance, :dynamic_configuration], true) + end + setup %{conn: conn} do admin = insert(:user, is_admin: true) %{conn: assign(conn, :user, admin)} end + test "when dynamic configuration is off", %{conn: conn} do + initial = Pleroma.Config.get([:instance, :dynamic_configuration]) + Pleroma.Config.put([:instance, :dynamic_configuration], false) + on_exit(fn -> Pleroma.Config.put([:instance, :dynamic_configuration], initial) end) + conn = get(conn, "/api/pleroma/admin/config") + + assert json_response(conn, 400) == + "To use this endpoint you need to enable dynamic configuration." + end + test "without any settings in db", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/config") - assert json_response(conn, 200) == %{"configs" => []} + assert json_response(conn, 400) == + "To use dynamic configuration migrate your settings to database." end test "with settings in db", %{conn: conn} do @@ -1966,6 +1981,18 @@ test "with settings in db", %{conn: conn} do end end + test "POST /api/pleroma/admin/config error" do + admin = insert(:user, is_admin: true) + + conn = + build_conn() + |> assign(:user, admin) + |> post("/api/pleroma/admin/config", %{"configs" => []}) + + assert json_response(conn, 400) == + "To use this endpoint you need to enable dynamic configuration." + end + describe "POST /api/pleroma/admin/config" do setup %{conn: conn} do admin = insert(:user, is_admin: true) @@ -2101,8 +2128,15 @@ test "create new config setting in db", %{conn: conn} do end test "save config setting without key", %{conn: conn} do - initial = Application.get_all_env(:quack) - on_exit(fn -> Application.put_all_env([{:quack, initial}]) end) + level = Application.get_env(:quack, :level) + meta = Application.get_env(:quack, :meta) + webhook_url = Application.get_env(:quack, :webhook_url) + + on_exit(fn -> + Application.put_env(:quack, :level, level) + Application.put_env(:quack, :meta, meta) + Application.put_env(:quack, :webhook_url, webhook_url) + end) conn = post(conn, "/api/pleroma/admin/config", %{ @@ -2640,16 +2674,13 @@ test "delete part of settings by atom subkeys", %{conn: conn} do setup %{conn: conn} do admin = insert(:user, is_admin: true) - temp_file = "config/test.exported_from_db.secret.exs" - Mix.shell(Mix.Shell.Quiet) on_exit(fn -> Mix.shell(Mix.Shell.IO) - :ok = File.rm(temp_file) end) - %{conn: assign(conn, :user, admin), admin: admin} + %{conn: assign(conn, :user, admin)} end clear_config([:instance, :dynamic_configuration]) do @@ -2660,20 +2691,28 @@ test "delete part of settings by atom subkeys", %{conn: conn} do Pleroma.Config.put([:feed, :post_title], %{max_length: 100, omission: "…"}) end - test "transfer settings to DB and to file", %{conn: conn, admin: admin} do + test "transfer settings to DB and to file", %{conn: conn} do + on_exit(fn -> :ok = File.rm("config/test.exported_from_db.secret.exs") end) assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) == [] - conn = get(conn, "/api/pleroma/admin/config/migrate_to_db") - assert json_response(conn, 200) == %{} + Mix.Tasks.Pleroma.Config.run(["migrate_to_db"]) assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) > 0 - conn = - build_conn() - |> assign(:user, admin) - |> get("/api/pleroma/admin/config/migrate_from_db") + conn = get(conn, "/api/pleroma/admin/config/migrate_from_db") assert json_response(conn, 200) == %{} assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) == [] end + + test "returns error if dynamic configuration is off", %{conn: conn} do + initial = Pleroma.Config.get([:instance, :dynamic_configuration]) + on_exit(fn -> Pleroma.Config.put([:instance, :dynamic_configuration], initial) end) + Pleroma.Config.put([:instance, :dynamic_configuration], false) + + conn = get(conn, "/api/pleroma/admin/config/migrate_from_db") + + assert json_response(conn, 400) == + "To use this endpoint you need to enable dynamic configuration." + end end describe "GET /api/pleroma/admin/users/:nickname/statuses" do From e412d2f1525b2ee38b1544f69b9e8ba60419a348 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 10 Dec 2019 09:54:10 +0300 Subject: [PATCH 05/46] test fixes after rebase --- test/tasks/config_test.exs | 199 +++++++++++++++++++------------------ 1 file changed, 102 insertions(+), 97 deletions(-) diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index 74451a9e8..c95db534d 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -44,115 +44,120 @@ test "settings are migrated to db" do assert Config.from_binary(config3.value) == :info end - test "settings are migrated to file and deleted from db" do - env = "temp" - config_file = "config/#{env}.exported_from_db.secret.exs" + describe "with deletion temp file" do + setup do + temp_file = "config/temp.exported_from_db.secret.exs" - on_exit(fn -> - :ok = File.rm(config_file) - end) + on_exit(fn -> + :ok = File.rm(temp_file) + end) - Config.create(%{ - group: ":pleroma", - key: ":setting_first", - value: [key: "value", key2: ["Activity"]] - }) + {:ok, temp_file: temp_file} + end - Config.create(%{ - group: ":pleroma", - key: ":setting_second", - value: [key: "value2", key2: [Repo]] - }) + test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do + Config.create(%{ + group: ":pleroma", + key: ":setting_first", + value: [key: "value", key2: ["Activity"]] + }) - Config.create(%{group: ":quack", key: ":level", value: :info}) + Config.create(%{ + group: ":pleroma", + key: ":setting_second", + value: [key: "value2", key2: [Repo]] + }) - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", env, "-d"]) + Config.create(%{group: ":quack", key: ":level", value: :info}) - assert Repo.all(Config) == [] + Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) - file = File.read!(config_file) - assert file =~ "config :pleroma, :setting_first," - assert file =~ "config :pleroma, :setting_second," - assert file =~ "config :quack, :level, :info" - end + assert Repo.all(Config) == [] - test "load a settings with large values and pass to file", %{temp_file: temp_file} do - Config.create(%{ - group: "pleroma", - key: ":instance", - value: [ - name: "Pleroma", - email: "example@example.com", - notify_email: "noreply@example.com", - description: "A Pleroma instance, an alternative fediverse server", - limit: 5_000, - chat_limit: 5_000, - remote_limit: 100_000, - upload_limit: 16_000_000, - avatar_upload_limit: 2_000_000, - background_upload_limit: 4_000_000, - banner_upload_limit: 4_000_000, - poll_limits: %{ - max_options: 20, - max_option_chars: 200, - min_expiration: 0, - max_expiration: 365 * 24 * 60 * 60 - }, - registrations_open: true, - federating: true, - federation_incoming_replies_max_depth: 100, - federation_reachability_timeout_days: 7, - federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher], - allow_relay: true, - rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, - public: true, - quarantined_instances: [], - managed_config: true, - static_dir: "instance/static/", - allowed_post_formats: ["text/plain", "text/html", "text/markdown", "text/bbcode"], - mrf_transparency: true, - mrf_transparency_exclusions: [], - autofollowed_nicknames: [], - max_pinned_statuses: 1, - no_attachment_links: true, - welcome_user_nickname: nil, - welcome_message: nil, - max_report_comment_size: 1000, - safe_dm_mentions: false, - healthcheck: false, - remote_post_retention_days: 90, - skip_thread_containment: true, - limit_to_local_content: :unauthenticated, - dynamic_configuration: false, - user_bio_length: 5000, - user_name_length: 100, - max_account_fields: 10, - max_remote_account_fields: 20, - account_field_name_length: 512, - account_field_value_length: 2048, - external_user_synchronization: true, - extended_nickname_format: true, - multi_factor_authentication: [ - totp: [ - # digits 6 or 8 - digits: 6, - period: 30 - ], - backup_codes: [ - number: 2, - length: 6 + file = File.read!(temp_file) + assert file =~ "config :pleroma, :setting_first," + assert file =~ "config :pleroma, :setting_second," + assert file =~ "config :quack, :level, :info" + end + + test "load a settings with large values and pass to file", %{temp_file: temp_file} do + Config.create(%{ + group: ":pleroma", + key: ":instance", + value: [ + name: "Pleroma", + email: "example@example.com", + notify_email: "noreply@example.com", + description: "A Pleroma instance, an alternative fediverse server", + limit: 5_000, + chat_limit: 5_000, + remote_limit: 100_000, + upload_limit: 16_000_000, + avatar_upload_limit: 2_000_000, + background_upload_limit: 4_000_000, + banner_upload_limit: 4_000_000, + poll_limits: %{ + max_options: 20, + max_option_chars: 200, + min_expiration: 0, + max_expiration: 365 * 24 * 60 * 60 + }, + registrations_open: true, + federating: true, + federation_incoming_replies_max_depth: 100, + federation_reachability_timeout_days: 7, + federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher], + allow_relay: true, + rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, + public: true, + quarantined_instances: [], + managed_config: true, + static_dir: "instance/static/", + allowed_post_formats: ["text/plain", "text/html", "text/markdown", "text/bbcode"], + mrf_transparency: true, + mrf_transparency_exclusions: [], + autofollowed_nicknames: [], + max_pinned_statuses: 1, + no_attachment_links: true, + welcome_user_nickname: nil, + welcome_message: nil, + max_report_comment_size: 1000, + safe_dm_mentions: false, + healthcheck: false, + remote_post_retention_days: 90, + skip_thread_containment: true, + limit_to_local_content: :unauthenticated, + dynamic_configuration: false, + user_bio_length: 5000, + user_name_length: 100, + max_account_fields: 10, + max_remote_account_fields: 20, + account_field_name_length: 512, + account_field_value_length: 2048, + external_user_synchronization: true, + extended_nickname_format: true, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 2, + length: 6 + ] ] ] - ] - }) + }) - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "temp", "true"]) + Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) - assert Repo.all(Config) == [] - assert File.exists?(temp_file) - {:ok, file} = File.read(temp_file) + assert Repo.all(Config) == [] + assert File.exists?(temp_file) + {:ok, file} = File.read(temp_file) - assert file == - "use Mix.Config\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n mrf_transparency: true,\n mrf_transparency_exclusions: [],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n no_attachment_links: true,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n dynamic_configuration: false,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" + assert file == + "use Mix.Config\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n mrf_transparency: true,\n mrf_transparency_exclusions: [],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n no_attachment_links: true,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n dynamic_configuration: false,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" + end end end From a71393dd29488eb86d6da23250b05a9b5b04eb81 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 10 Dec 2019 12:00:40 +0300 Subject: [PATCH 06/46] fix for endpoints after env update --- lib/mix/tasks/pleroma/config.ex | 4 +++- lib/pleroma/config/transfer_task.ex | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index cef02b864..257a0dfe5 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -74,7 +74,9 @@ def run(["migrate_from_db" | options]) do defp load_and_create(group) do group |> Application.get_all_env() - |> Enum.reject(fn {k, _v} -> k in [Pleroma.Repo, :env] end) + |> Enum.reject(fn {k, _v} -> + k in [Pleroma.Repo, :env] or (group == :phoenix and k == :serve_endpoints) + end) |> Enum.each(fn {key, value} -> key = inspect(key) {:ok, _} = Config.update_or_create(%{group: inspect(group), key: key, value: value}) diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 0bc4c4029..c89c1fcc8 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -37,7 +37,9 @@ defp update_env(setting) do group = Config.from_string(setting.group) value = Config.from_binary(setting.value) - :ok = Application.put_env(group, key, value) + if group != :phoenix and key != :serve_endpoints do + :ok = Application.put_env(group, key, value) + end group rescue From 4306769671e4859bc9b4c8ca52c186819aae3aea Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 10 Dec 2019 12:16:19 +0300 Subject: [PATCH 07/46] adapter children --- config/description.exs | 54 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/config/description.exs b/config/description.exs index eeb4a6fe9..9e8c114b5 100644 --- a/config/description.exs +++ b/config/description.exs @@ -90,7 +90,23 @@ %{ key: :adapter, type: :keyword, - description: "Adapter specific options" + description: "Adapter specific options", + children: [ + %{ + key: :ssl_options, + type: :keyword, + label: "SSL Options", + description: "SSL options for HTTP adapter", + children: [ + %{ + key: :versions, + type: {:list, :atom}, + descriptions: "List of TLS version to use", + suggestions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + } + ] + } + ] }, %{ key: :proxy_url, @@ -1463,7 +1479,23 @@ %{ key: :adapter, type: :keyword, - description: "Adapter specific options" + description: "Adapter specific options", + children: [ + %{ + key: :ssl_options, + type: :keyword, + label: "SSL Options", + description: "SSL options for HTTP adapter", + children: [ + %{ + key: :versions, + type: {:list, :atom}, + descriptions: "List of TLS version to use", + suggestions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + } + ] + } + ] }, %{ key: :proxy_url, @@ -2760,7 +2792,23 @@ key: :adapter, type: :keyword, description: "Adapter specific options", - suggestions: [] + suggestions: [], + children: [ + %{ + key: :ssl_options, + type: :keyword, + label: "SSL Options", + description: "SSL options for HTTP adapter", + children: [ + %{ + key: :versions, + type: {:list, :atom}, + descriptions: "List of TLS version to use", + suggestions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + } + ] + } + ] } ] }, From bb9a43c3ae7c63b21b687d018b9497f3614566bf Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 12 Dec 2019 16:44:24 +0300 Subject: [PATCH 08/46] typo fix --- docs/API/admin_api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 98af8e8f3..e0eda0841 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -807,7 +807,7 @@ config :quack, {"tuple": [":proxy_opts", [ {"tuple": [":redirect_on_failure", false]}, {"tuple": [":max_body_length", 1048576]}, - {"tuple": [":http": [ + {"tuple": [":http", [ {"tuple": [":follow_redirect", true]}, {"tuple": [":pool", ":upload"]}, ]]} From f9d01068cf0d47040abc3d51f8ea8a3a264c027f Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 16 Dec 2019 18:13:22 +0300 Subject: [PATCH 09/46] suggestions fix --- config/description.exs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/config/description.exs b/config/description.exs index 9e8c114b5..3abcc6266 100644 --- a/config/description.exs +++ b/config/description.exs @@ -701,12 +701,10 @@ type: {:list, :string}, description: "MIME-type list of formats allowed to be posted (transformed into HTML)", suggestions: [ - [ - "text/plain", - "text/html", - "text/markdown", - "text/bbcode" - ] + "text/plain", + "text/html", + "text/markdown", + "text/bbcode" ] }, %{ @@ -1990,10 +1988,8 @@ type: {:keyword, :integer}, description: "Max retry attempts for failed jobs, per `Oban` queue", suggestions: [ - [ - federator_incoming: 5, - federator_outgoing: 5 - ] + federator_incoming: 5, + federator_outgoing: 5 ] } ] From 583cee46072cda6b3ed07f4ce09b09db9e2b0af1 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 17 Dec 2019 19:51:01 +0300 Subject: [PATCH 10/46] parsing proxy url setting --- lib/pleroma/web/admin_api/config.ex | 45 ++++++++ .../admin_api/admin_api_controller_test.exs | 104 ++++++++++++++++-- test/web/admin_api/config_test.exs | 30 +++++ 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index e141a13da..acc5a5183 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -145,6 +145,33 @@ defp do_convert(entity) when is_map(entity) do for {k, v} <- entity, into: %{}, do: {do_convert(k), do_convert(v)} end + defp do_convert({:proxy_url, {type, :localhost, port}}) do + %{"tuple" => [":proxy_url", %{"tuple" => [do_convert(type), "localhost", port]}]} + end + + defp do_convert({:proxy_url, {type, host, port}}) when is_tuple(host) do + ip = + host + |> :inet_parse.ntoa() + |> to_string() + + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [do_convert(type), ip, port]} + ] + } + end + + defp do_convert({:proxy_url, {type, host, port}}) do + %{ + "tuple" => [ + ":proxy_url", + %{"tuple" => [do_convert(type), to_string(host), port]} + ] + } + end + defp do_convert({:dispatch, [entity]}), do: %{"tuple" => [":dispatch", [inspect(entity)]]} # TODO: will become useless after removing hackney defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]} @@ -173,6 +200,10 @@ def to_binary(entity), do: :erlang.term_to_binary(entity) defp do_transform(%Regex{} = entity), do: entity + defp do_transform(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do + {:proxy_url, {do_transform_string(type), parse_host(host), port}} + end + defp do_transform(%{"tuple" => [":dispatch", [entity]]}) do {dispatch_settings, []} = do_eval(entity) {:dispatch, [dispatch_settings]} @@ -204,6 +235,20 @@ defp do_transform(entity) when is_binary(entity) do defp do_transform(entity), do: entity + defp parse_host("localhost"), do: :localhost + + defp parse_host(host) do + charlist = to_charlist(host) + + case :inet.parse_address(charlist) do + {:error, :einval} -> + charlist + + {:ok, ip} -> + ip + end + end + @delimiters ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] defp find_valid_delimiter([], _string, _), diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 41d2c4212..06b3266c1 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1997,6 +1997,8 @@ test "POST /api/pleroma/admin/config error" do setup %{conn: conn} do admin = insert(:user, is_admin: true) + http = Application.get_env(:pleroma, :http) + on_exit(fn -> Application.delete_env(:pleroma, :key1) Application.delete_env(:pleroma, :key2) @@ -2006,6 +2008,7 @@ test "POST /api/pleroma/admin/config error" do Application.delete_env(:pleroma, :keyaa2) Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) + Application.put_env(:pleroma, :http, http) Application.put_env(:tesla, :adapter, Tesla.Mock) :ok = File.rm("config/test.exported_from_db.secret.exs") end) @@ -2656,17 +2659,102 @@ test "delete part of settings by atom subkeys", %{conn: conn} do ] }) - assert( - json_response(conn, 200) == %{ - "configs" => [ + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":keyaa1", + "value" => [%{"tuple" => [":subkey2", "val2"]}] + } + ] + } + end + + test "proxy tuple localhost", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ %{ - "group" => ":pleroma", - "key" => ":keyaa1", - "value" => [%{"tuple" => [":subkey2", "val2"]}] + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]}, + %{"tuple" => [":send_user_agent", false]} + ] } ] - } - ) + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]}, + %{"tuple" => [":send_user_agent", false]} + ] + } + ] + } + end + + test "proxy tuple domain", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]}, + %{"tuple" => [":send_user_agent", false]} + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]}, + %{"tuple" => [":send_user_agent", false]} + ] + } + ] + } + end + + test "proxy tuple ip", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]}, + %{"tuple" => [":send_user_agent", false]} + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]}, + %{"tuple" => [":send_user_agent", false]} + ] + } + ] + } end end diff --git a/test/web/admin_api/config_test.exs b/test/web/admin_api/config_test.exs index c37eff092..b8b1b0130 100644 --- a/test/web/admin_api/config_test.exs +++ b/test/web/admin_api/config_test.exs @@ -217,6 +217,36 @@ test "2 child tuple" do assert Config.from_binary(binary) == {"v1", :v2} end + test "proxy tuple with localhost" do + binary = + Config.transform(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}] + }) + + assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, :localhost, 1234}}) + assert Config.from_binary(binary) == {:proxy_url, {:socks5, :localhost, 1234}} + end + + test "proxy tuple with domain" do + binary = + Config.transform(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}] + }) + + assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, 'domain.com', 1234}}) + assert Config.from_binary(binary) == {:proxy_url, {:socks5, 'domain.com', 1234}} + end + + test "proxy tuple with ip" do + binary = + Config.transform(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}] + }) + + assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}}) + assert Config.from_binary(binary) == {:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}} + end + test "tuple with n childs" do binary = Config.transform(%{ From 063ab6d9115ec17bd1907d42a998f335a0cd69c9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 19 Dec 2019 10:19:56 +0300 Subject: [PATCH 11/46] logger backends fix --- lib/pleroma/web/admin_api/config.ex | 3 +- .../admin_api/admin_api_controller_test.exs | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index acc5a5183..a8a698c89 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -52,7 +52,8 @@ def update(%Config{} = config, %{value: value}) do {:mime, :types}, {:cors_plug, [:max_age, :methods, :expose, :headers]}, {:auto_linker, :opts}, - {:swarm, :node_blacklist} + {:swarm, :node_blacklist}, + {:logger, :backends} ] defp only_full_update?(%Config{} = config) do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 06b3266c1..56a3a3a97 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2204,6 +2204,42 @@ test "saving config with partial update", %{conn: conn} do } end + test "saving full setting if value is in full_key_update list", %{conn: conn} do + backends = Application.get_env(:logger, :backends) + on_exit(fn -> Application.put_env(:logger, :backends, backends) end) + + config = + insert(:config, + group: ":logger", + key: ":backends", + value: :erlang.term_to_binary([]) + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: [":console"]} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":logger", + "key" => ":backends", + "value" => [":console"] + } + ] + } + + assert Application.get_env(:logger, :backends) == [:console] + + ExUnit.CaptureLog.capture_log(fn -> + require Logger + Logger.warn("Ooops...") + end) =~ "Ooops..." + end + test "saving full setting if value is not keyword", %{conn: conn} do config = insert(:config, From cda2c1fc630e455b3d419f5c3b22c366dc883ce1 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 20 Dec 2019 10:22:53 +0300 Subject: [PATCH 12/46] fix for subgroup tuple added settings for swoosh adapters local --- config/description.exs | 23 ++++++++++++++++++++++- lib/pleroma/docs/generator.ex | 8 ++++++++ test/docs/generator_test.exs | 7 +++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 3abcc6266..52f7d2017 100644 --- a/config/description.exs +++ b/config/description.exs @@ -238,7 +238,8 @@ Swoosh.Adapters.AmazonSES, Swoosh.Adapters.Dyn, Swoosh.Adapters.SocketLabs, - Swoosh.Adapters.Gmail + Swoosh.Adapters.Gmail, + Swoosh.Adapters.Local ] }, %{ @@ -449,6 +450,26 @@ } ] }, + %{ + group: :swoosh, + type: :group, + description: "`Swoosh.Adapters.Local` adapter specific settings", + children: [ + %{ + group: {:subgroup, Swoosh.Adapters.Local}, + key: :serve_mailbox, + type: :boolean, + description: "Run the preview server together as part of your app" + }, + %{ + group: {:subgroup, Swoosh.Adapters.Local}, + key: :preview_port, + type: :integer, + description: "The preview server port", + suggestions: [4001] + } + ] + }, %{ group: :pleroma, key: :uri_schemes, diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex index b57e47e8b..6b12dcdd9 100644 --- a/lib/pleroma/docs/generator.ex +++ b/lib/pleroma/docs/generator.ex @@ -58,15 +58,23 @@ defp format_child(%{suggestions: suggestions} = entity) do entity |> Map.put(:suggestions, format_suggestions(suggestions)) |> format_key() + |> format_group() |> format_children() end defp format_child(entity) do entity |> format_key() + |> format_group() |> format_children() end + defp format_group(%{group: group} = entity) do + Map.put(entity, :group, format_suggestion(group)) + end + + defp format_group(entity), do: entity + defp atom_to_string(entity) when is_binary(entity), do: entity defp atom_to_string(entity) when is_atom(entity), do: inspect(entity) diff --git a/test/docs/generator_test.exs b/test/docs/generator_test.exs index 42e7c32c8..0106809c2 100644 --- a/test/docs/generator_test.exs +++ b/test/docs/generator_test.exs @@ -207,5 +207,12 @@ test "key as string subchild" do child = Enum.at(children, 7) assert child[:key] == "application/xml" end + + test "subgroup with module name" do + [%{children: children} | _] = Generator.convert_to_strings(@descriptions) + + %{group: subgroup} = Enum.at(children, 6) + assert subgroup == {":subgroup", "Swoosh.Adapters.SMTP"} + end end end From 9c1f3bfeffa7e40d319d931c975b948f33800c40 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 21 Dec 2019 13:54:22 +0300 Subject: [PATCH 13/46] fixes for logger backends --- lib/pleroma/web/admin_api/config.ex | 3 ++- test/web/admin_api/admin_api_controller_test.exs | 16 +++++++++++++--- test/web/admin_api/config_test.exs | 12 ++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index a8a698c89..ef658e079 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -295,7 +295,8 @@ defp do_transform_string(value) do @spec is_module_name?(String.t()) :: boolean() def is_module_name?(string) do - Regex.match?(~r/^(Pleroma|Phoenix|Tesla)\./, string) or string in ["Oban", "Ueberauth"] + Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack)\./, string) or + string in ["Oban", "Ueberauth", "ExSyslogger"] end defp do_eval(entity) do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 56a3a3a97..ea3c43158 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2218,7 +2218,11 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/config", %{ configs: [ - %{group: config.group, key: config.key, value: [":console"]} + %{ + group: config.group, + key: config.key, + value: [":console", %{"tuple" => ["ExSyslogger", ":ex_syslogger"]}] + } ] }) @@ -2227,12 +2231,18 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do %{ "group" => ":logger", "key" => ":backends", - "value" => [":console"] + "value" => [ + ":console", + %{"tuple" => ["ExSyslogger", ":ex_syslogger"]} + ] } ] } - assert Application.get_env(:logger, :backends) == [:console] + assert Application.get_env(:logger, :backends) == [ + :console, + {ExSyslogger, :ex_syslogger} + ] ExUnit.CaptureLog.capture_log(fn -> require Logger diff --git a/test/web/admin_api/config_test.exs b/test/web/admin_api/config_test.exs index b8b1b0130..4f96322af 100644 --- a/test/web/admin_api/config_test.exs +++ b/test/web/admin_api/config_test.exs @@ -175,6 +175,18 @@ test "tesla module" do assert Config.from_binary(binary) == Tesla.Adapter.Hackney end + test "ExSyslogger module" do + binary = Config.transform("ExSyslogger") + assert binary == :erlang.term_to_binary(ExSyslogger) + assert Config.from_binary(binary) == ExSyslogger + end + + test "Quack.Logger module" do + binary = Config.transform("Quack.Logger") + assert binary == :erlang.term_to_binary(Quack.Logger) + assert Config.from_binary(binary) == Quack.Logger + end + test "sigil" do binary = Config.transform("~r[comp[lL][aA][iI][nN]er]") assert binary == :erlang.term_to_binary(~r/comp[lL][aA][iI][nN]er/) From 0b020403276519da84dce51053240ac6637eb1b3 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 25 Dec 2019 15:31:51 +0300 Subject: [PATCH 14/46] little fixes and typos fix --- config/description.exs | 12 +++--- lib/pleroma/web/admin_api/config.ex | 5 +++ test/docs/generator_test.exs | 12 ++++++ .../admin_api/admin_api_controller_test.exs | 41 +++++++++++++++++++ test/web/admin_api/config_test.exs | 6 +++ 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/config/description.exs b/config/description.exs index 52f7d2017..a9b2efec5 100644 --- a/config/description.exs +++ b/config/description.exs @@ -101,8 +101,8 @@ %{ key: :versions, type: {:list, :atom}, - descriptions: "List of TLS version to use", - suggestions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + description: "List of TLS version to use", + suggestions: [:tlsv1, ":tlsv1.1", ":tlsv1.2"] } ] } @@ -1509,8 +1509,8 @@ %{ key: :versions, type: {:list, :atom}, - descriptions: "List of TLS version to use", - suggestions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + description: "List of TLS version to use", + suggestions: [:tlsv1, ":tlsv1.1", ":tlsv1.2"] } ] } @@ -2820,8 +2820,8 @@ %{ key: :versions, type: {:list, :atom}, - descriptions: "List of TLS version to use", - suggestions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + description: "List of TLS version to use", + suggestions: [:tlsv1, ":tlsv1.1", ":tlsv1.2"] } ] } diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index ef658e079..b55851602 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -183,6 +183,11 @@ defp do_convert(entity) when is_tuple(entity), defp do_convert(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity), do: entity + defp do_convert(entity) + when is_atom(entity) and entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do + ":#{to_string(entity)}" + end + defp do_convert(entity) when is_atom(entity), do: inspect(entity) defp do_convert(entity) when is_binary(entity), do: entity diff --git a/test/docs/generator_test.exs b/test/docs/generator_test.exs index 0106809c2..9c9f4357b 100644 --- a/test/docs/generator_test.exs +++ b/test/docs/generator_test.exs @@ -85,6 +85,12 @@ defmodule Pleroma.Docs.GeneratorTest do key: "application/xml", type: {:list, :string}, suggestions: ["xml"] + }, + %{ + key: :versions, + type: {:list, :atom}, + description: "List of TLS version to use", + suggestions: [:tlsv1, ":tlsv1.1", ":tlsv1.2"] } ] }, @@ -208,6 +214,12 @@ test "key as string subchild" do assert child[:key] == "application/xml" end + test "suggestion for tls versions" do + [%{children: children} | _] = Generator.convert_to_strings(@descriptions) + child = Enum.at(children, 8) + assert child[:suggestions] == [":tlsv1", ":tlsv1.1", ":tlsv1.2"] + end + test "subgroup with module name" do [%{children: children} | _] = Generator.convert_to_strings(@descriptions) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index ea3c43158..d83a95aae 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2204,6 +2204,47 @@ test "saving config with partial update", %{conn: conn} do } end + test "saving special atoms", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ] + } + ] + } + + assert Application.get_env(:pleroma, :key1) == [ + ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]] + ] + end + test "saving full setting if value is in full_key_update list", %{conn: conn} do backends = Application.get_env(:logger, :backends) on_exit(fn -> Application.put_env(:logger, :backends, backends) end) diff --git a/test/web/admin_api/config_test.exs b/test/web/admin_api/config_test.exs index 4f96322af..cc4c903bf 100644 --- a/test/web/admin_api/config_test.exs +++ b/test/web/admin_api/config_test.exs @@ -151,6 +151,12 @@ test "atom" do assert Config.from_binary(binary) == :atom end + test "ssl options" do + binary = Config.transform([":tlsv1", ":tlsv1.1", ":tlsv1.2"]) + assert binary == :erlang.term_to_binary([:tlsv1, :"tlsv1.1", :"tlsv1.2"]) + assert Config.from_binary(binary) == [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + end + test "pleroma module" do binary = Config.transform("Pleroma.Bookmark") assert binary == :erlang.term_to_binary(Pleroma.Bookmark) From c841174de820c891929b206e3eb2604cb6368ae6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 26 Dec 2019 10:05:30 +0300 Subject: [PATCH 15/46] flag for delete fix --- lib/pleroma/web/admin_api/admin_api_controller.ex | 2 +- test/web/admin_api/admin_api_controller_test.exs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 23dcbedba..d12ed459d 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -815,7 +815,7 @@ def config_update(conn, %{"configs" => configs}) do with :ok <- check_dynamic_configuration(conn) do updated = Enum.map(configs, fn - %{"group" => group, "key" => key, "delete" => "true"} = params -> + %{"group" => group, "key" => key, "delete" => true} = params -> with {:ok, config} <- Config.delete(%{group: group, key: key, subkeys: params["subkeys"]}) do config diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index d83a95aae..55a4055a7 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2330,11 +2330,11 @@ test "update config setting & delete", %{conn: conn} do post(conn, "/api/pleroma/admin/config", %{ configs: [ %{group: config1.group, key: config1.key, value: "another_value"}, - %{group: config2.group, key: config2.key, delete: "true"}, + %{group: config2.group, key: config2.key, delete: true}, %{ group: "ueberauth", key: "Ueberauth.Strategy.Microsoft.OAuth", - delete: "true" + delete: true } ] }) @@ -2741,7 +2741,7 @@ test "delete part of settings by atom subkeys", %{conn: conn} do group: config.group, key: config.key, subkeys: [":subkey1", ":subkey3"], - delete: "true" + delete: true } ] }) From 88a16bb9fcd1f80b8a2634e815cb855d3a8346ee Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 6 Jan 2020 14:05:32 +0300 Subject: [PATCH 16/46] deep merge in config update --- lib/pleroma/web/admin_api/config.ex | 2 +- .../admin_api/admin_api_controller_test.exs | 50 +++++++++++++++++++ test/web/admin_api/config_test.exs | 20 ++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/config.ex b/lib/pleroma/web/admin_api/config.ex index b55851602..a01c28716 100644 --- a/lib/pleroma/web/admin_api/config.ex +++ b/lib/pleroma/web/admin_api/config.ex @@ -81,7 +81,7 @@ def update_or_create(params) do old_value <- from_binary(config.value), transformed_value <- do_transform(params[:value]), {:can_be_merged, true, config} <- {:can_be_merged, is_list(transformed_value), config}, - new_value <- Keyword.merge(old_value, transformed_value) do + new_value <- DeepMerge.deep_merge(old_value, transformed_value) do Config.update(config, %{value: new_value, transformed?: true}) else {reason, false, config} when reason in [:partial_update, :can_be_merged] -> diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 55a4055a7..ebd9054e3 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2204,6 +2204,56 @@ test "saving config with partial update", %{conn: conn} do } end + test "saving config with nested merge", %{conn: conn} do + config = + insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + value: [ + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k1", 1]}, + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ] + } + ] + } + end + test "saving special atoms", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/config", %{ diff --git a/test/web/admin_api/config_test.exs b/test/web/admin_api/config_test.exs index cc4c903bf..2c0601b56 100644 --- a/test/web/admin_api/config_test.exs +++ b/test/web/admin_api/config_test.exs @@ -68,6 +68,26 @@ test "partial update" do assert value[:key3] == :val3 end + test "deep merge" do + config = insert(:config, value: Config.to_binary(key1: "val1", key2: [k1: :v1, k2: "v2"])) + + {:ok, config} = + Config.update_or_create(%{ + group: config.group, + key: config.key, + value: [key1: :val1, key2: [k2: :v2, k3: :v3], key3: :val3] + }) + + updated = Config.get_by_params(%{group: config.group, key: config.key}) + + assert config.value == updated.value + + value = Config.from_binary(updated.value) + assert value[:key1] == :val1 + assert value[:key2] == [k1: :v1, k2: :v2, k3: :v3] + assert value[:key3] == :val3 + end + test "only full update for some keys" do config1 = insert(:config, key: ":ecto_repos", value: Config.to_binary(repo: Pleroma.Repo)) config2 = insert(:config, group: ":cors_plug", key: ":max_age", value: Config.to_binary(18)) From 6395ad942c7087e97a8dcf2e9002005ed7b4c383 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 10 Jan 2020 15:38:53 +0300 Subject: [PATCH 17/46] Update AdminFE build --- .../{app.fdd73ce4.css => app.818593ed.css} | Bin ...a.2246593e.css => chunk-15fa.0cd632f8.css} | Bin ...1.ed715f8d.css => chunk-18e1.14655819.css} | Bin ...1.62c86eea.css => chunk-38cb.c8318e6f.css} | Bin ...6.f36071a4.css => chunk-76b7.7170768c.css} | Bin ...e.d508c376.css => chunk-7f8e.6018dc5c.css} | Bin priv/static/adminfe/chunk-8827.5199a38e.css | Bin 0 -> 4676 bytes ...4.571d0025.css => chunk-ad4a.1ff19a9e.css} | Bin ...9.889d1da1.css => chunk-dbb9.ed82feaf.css} | Bin ...0.2a82c722.css => chunk-e666.ead12949.css} | Bin .../adminfe/chunk-elementUI.50005a1c.css | Bin 0 -> 232660 bytes .../adminfe/chunk-elementUI.a842fb0a.css | Bin 224642 -> 0 bytes priv/static/adminfe/chunk-f3c9.155bfc51.css | Bin 3758 -> 0 bytes ...a.03fe0a3f.css => chunk-f6af.56111241.css} | Bin ...s.57fe98a3.css => chunk-libs.286d86db.css} | Bin priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.19b7049e.js | Bin 180590 -> 0 bytes .../adminfe/static/js/app.19b7049e.js.map | Bin 396651 -> 0 bytes priv/static/adminfe/static/js/app.55e2fc23.js | Bin 0 -> 176239 bytes .../adminfe/static/js/app.55e2fc23.js.map | Bin 0 -> 391364 bytes ...5fa.10871dbf.js => chunk-15fa.6292d111.js} | Bin 7919 -> 7919 bytes ...1dbf.js.map => chunk-15fa.6292d111.js.map} | Bin 17438 -> 17438 bytes ...8e1.9f7c9b0f.js => chunk-18e1.3370aa84.js} | Bin 2080 -> 2080 bytes ...9b0f.js.map => chunk-18e1.3370aa84.js.map} | Bin 9090 -> 9090 bytes .../adminfe/static/js/chunk-2561.93a05adb.js | Bin 0 -> 408902 bytes .../static/js/chunk-2561.93a05adb.js.map | Bin 0 -> 1243820 bytes ...601.cc880efe.js => chunk-38cb.79662d56.js} | Bin 15418 -> 15418 bytes ...0efe.js.map => chunk-38cb.79662d56.js.map} | Bin 53763 -> 53763 bytes ...c46.b92c7c1b.js => chunk-76b7.530904ca.js} | Bin 7737 -> 7737 bytes ...7c1b.js.map => chunk-76b7.530904ca.js.map} | Bin 26010 -> 26010 bytes ...f8e.2c3e63e9.js => chunk-7f8e.a58e8380.js} | Bin 9618 -> 9618 bytes ...63e9.js.map => chunk-7f8e.a58e8380.js.map} | Bin 39890 -> 39890 bytes .../adminfe/static/js/chunk-7fe2.458f9da5.js | Bin 408449 -> 0 bytes .../static/js/chunk-7fe2.458f9da5.js.map | Bin 1242154 -> 0 bytes .../adminfe/static/js/chunk-8827.edc6ef0a.js | Bin 0 -> 106984 bytes .../static/js/chunk-8827.edc6ef0a.js.map | Bin 0 -> 385894 bytes ...cc4.35b47d0a.js => chunk-ad4a.b89af9aa.js} | Bin 26736 -> 26736 bytes ...7d0a.js.map => chunk-ad4a.b89af9aa.js.map} | Bin 81998 -> 81998 bytes ...de9.7b8cda50.js => chunk-dbb9.06b878b4.js} | Bin 29969 -> 29969 bytes ...da50.js.map => chunk-dbb9.06b878b4.js.map} | Bin 117302 -> 117302 bytes ...bb0.9c56835f.js => chunk-e666.4cc38eac.js} | Bin 5112 -> 5112 bytes ...835f.js.map => chunk-e666.4cc38eac.js.map} | Bin 19744 -> 19744 bytes .../static/js/chunk-elementUI.c97eb8b4.js | Bin 0 -> 663883 bytes .../static/js/chunk-elementUI.c97eb8b4.js.map | Bin 0 -> 2404935 bytes .../static/js/chunk-elementUI.fa319e7b.js | Bin 638936 -> 0 bytes .../static/js/chunk-elementUI.fa319e7b.js.map | Bin 2312798 -> 0 bytes .../adminfe/static/js/chunk-f3c9.b3de53e2.js | Bin 242980 -> 0 bytes .../static/js/chunk-f3c9.b3de53e2.js.map | Bin 745354 -> 0 bytes ...01a.970cf312.js => chunk-f6af.e577dbbe.js} | Bin 32170 -> 32170 bytes ...f312.js.map => chunk-f6af.e577dbbe.js.map} | Bin 110610 -> 110610 bytes ...ibs.35c18287.js => chunk-libs.18610c36.js} | Bin 275816 -> 275473 bytes .../static/js/chunk-libs.18610c36.js.map | Bin 0 -> 1640125 bytes .../static/js/chunk-libs.35c18287.js.map | Bin 1641569 -> 0 bytes .../adminfe/static/js/runtime.57c5c4de.js | Bin 0 -> 3906 bytes ...6d1aaab.js.map => runtime.57c5c4de.js.map} | Bin 16640 -> 16640 bytes .../adminfe/static/js/runtime.d6d1aaab.js | Bin 3906 -> 0 bytes 56 files changed, 1 insertion(+), 1 deletion(-) rename priv/static/adminfe/{app.fdd73ce4.css => app.818593ed.css} (100%) rename priv/static/adminfe/{chunk-15fa.2246593e.css => chunk-15fa.0cd632f8.css} (100%) rename priv/static/adminfe/{chunk-18e1.ed715f8d.css => chunk-18e1.14655819.css} (100%) rename priv/static/adminfe/{chunk-a601.62c86eea.css => chunk-38cb.c8318e6f.css} (100%) rename priv/static/adminfe/{chunk-1c46.f36071a4.css => chunk-76b7.7170768c.css} (100%) rename priv/static/adminfe/{chunk-7f8e.d508c376.css => chunk-7f8e.6018dc5c.css} (100%) create mode 100644 priv/static/adminfe/chunk-8827.5199a38e.css rename priv/static/adminfe/{chunk-0cc4.571d0025.css => chunk-ad4a.1ff19a9e.css} (100%) rename priv/static/adminfe/{chunk-7de9.889d1da1.css => chunk-dbb9.ed82feaf.css} (100%) rename priv/static/adminfe/{chunk-9bb0.2a82c722.css => chunk-e666.ead12949.css} (100%) create mode 100644 priv/static/adminfe/chunk-elementUI.50005a1c.css delete mode 100644 priv/static/adminfe/chunk-elementUI.a842fb0a.css delete mode 100644 priv/static/adminfe/chunk-f3c9.155bfc51.css rename priv/static/adminfe/{chunk-d01a.03fe0a3f.css => chunk-f6af.56111241.css} (100%) rename priv/static/adminfe/{chunk-libs.57fe98a3.css => chunk-libs.286d86db.css} (100%) delete mode 100644 priv/static/adminfe/static/js/app.19b7049e.js delete mode 100644 priv/static/adminfe/static/js/app.19b7049e.js.map create mode 100644 priv/static/adminfe/static/js/app.55e2fc23.js create mode 100644 priv/static/adminfe/static/js/app.55e2fc23.js.map rename priv/static/adminfe/static/js/{chunk-15fa.10871dbf.js => chunk-15fa.6292d111.js} (99%) rename priv/static/adminfe/static/js/{chunk-15fa.10871dbf.js.map => chunk-15fa.6292d111.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-18e1.9f7c9b0f.js => chunk-18e1.3370aa84.js} (97%) rename priv/static/adminfe/static/js/{chunk-18e1.9f7c9b0f.js.map => chunk-18e1.3370aa84.js.map} (98%) create mode 100644 priv/static/adminfe/static/js/chunk-2561.93a05adb.js create mode 100644 priv/static/adminfe/static/js/chunk-2561.93a05adb.js.map rename priv/static/adminfe/static/js/{chunk-a601.cc880efe.js => chunk-38cb.79662d56.js} (99%) rename priv/static/adminfe/static/js/{chunk-a601.cc880efe.js.map => chunk-38cb.79662d56.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-1c46.b92c7c1b.js => chunk-76b7.530904ca.js} (98%) rename priv/static/adminfe/static/js/{chunk-1c46.b92c7c1b.js.map => chunk-76b7.530904ca.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-7f8e.2c3e63e9.js => chunk-7f8e.a58e8380.js} (99%) rename priv/static/adminfe/static/js/{chunk-7f8e.2c3e63e9.js.map => chunk-7f8e.a58e8380.js.map} (99%) delete mode 100644 priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js delete mode 100644 priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js.map create mode 100644 priv/static/adminfe/static/js/chunk-8827.edc6ef0a.js create mode 100644 priv/static/adminfe/static/js/chunk-8827.edc6ef0a.js.map rename priv/static/adminfe/static/js/{chunk-0cc4.35b47d0a.js => chunk-ad4a.b89af9aa.js} (99%) rename priv/static/adminfe/static/js/{chunk-0cc4.35b47d0a.js.map => chunk-ad4a.b89af9aa.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-7de9.7b8cda50.js => chunk-dbb9.06b878b4.js} (99%) rename priv/static/adminfe/static/js/{chunk-7de9.7b8cda50.js.map => chunk-dbb9.06b878b4.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-9bb0.9c56835f.js => chunk-e666.4cc38eac.js} (97%) rename priv/static/adminfe/static/js/{chunk-9bb0.9c56835f.js.map => chunk-e666.4cc38eac.js.map} (99%) create mode 100644 priv/static/adminfe/static/js/chunk-elementUI.c97eb8b4.js create mode 100644 priv/static/adminfe/static/js/chunk-elementUI.c97eb8b4.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-elementUI.fa319e7b.js delete mode 100644 priv/static/adminfe/static/js/chunk-elementUI.fa319e7b.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-f3c9.b3de53e2.js delete mode 100644 priv/static/adminfe/static/js/chunk-f3c9.b3de53e2.js.map rename priv/static/adminfe/static/js/{chunk-d01a.970cf312.js => chunk-f6af.e577dbbe.js} (99%) rename priv/static/adminfe/static/js/{chunk-d01a.970cf312.js.map => chunk-f6af.e577dbbe.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-libs.35c18287.js => chunk-libs.18610c36.js} (85%) create mode 100644 priv/static/adminfe/static/js/chunk-libs.18610c36.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-libs.35c18287.js.map create mode 100644 priv/static/adminfe/static/js/runtime.57c5c4de.js rename priv/static/adminfe/static/js/{runtime.d6d1aaab.js.map => runtime.57c5c4de.js.map} (90%) delete mode 100644 priv/static/adminfe/static/js/runtime.d6d1aaab.js diff --git a/priv/static/adminfe/app.fdd73ce4.css b/priv/static/adminfe/app.818593ed.css similarity index 100% rename from priv/static/adminfe/app.fdd73ce4.css rename to priv/static/adminfe/app.818593ed.css diff --git a/priv/static/adminfe/chunk-15fa.2246593e.css b/priv/static/adminfe/chunk-15fa.0cd632f8.css similarity index 100% rename from priv/static/adminfe/chunk-15fa.2246593e.css rename to priv/static/adminfe/chunk-15fa.0cd632f8.css diff --git a/priv/static/adminfe/chunk-18e1.ed715f8d.css b/priv/static/adminfe/chunk-18e1.14655819.css similarity index 100% rename from priv/static/adminfe/chunk-18e1.ed715f8d.css rename to priv/static/adminfe/chunk-18e1.14655819.css diff --git a/priv/static/adminfe/chunk-a601.62c86eea.css b/priv/static/adminfe/chunk-38cb.c8318e6f.css similarity index 100% rename from priv/static/adminfe/chunk-a601.62c86eea.css rename to priv/static/adminfe/chunk-38cb.c8318e6f.css diff --git a/priv/static/adminfe/chunk-1c46.f36071a4.css b/priv/static/adminfe/chunk-76b7.7170768c.css similarity index 100% rename from priv/static/adminfe/chunk-1c46.f36071a4.css rename to priv/static/adminfe/chunk-76b7.7170768c.css diff --git a/priv/static/adminfe/chunk-7f8e.d508c376.css b/priv/static/adminfe/chunk-7f8e.6018dc5c.css similarity index 100% rename from priv/static/adminfe/chunk-7f8e.d508c376.css rename to priv/static/adminfe/chunk-7f8e.6018dc5c.css diff --git a/priv/static/adminfe/chunk-8827.5199a38e.css b/priv/static/adminfe/chunk-8827.5199a38e.css new file mode 100644 index 0000000000000000000000000000000000000000..8627bfb13cf85b52c0ff6616e6f906123e0ec082 GIT binary patch literal 4676 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-elementUI.a842fb0a.css b/priv/static/adminfe/chunk-elementUI.a842fb0a.css deleted file mode 100644 index 3fef5e5fdb26a8762d4ca7ef8be2410ec15c1654..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224642 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 rY}3kDkPzH8JxDaf5|H9;_9$s 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