The Battle for Wesnoth  1.19.10+dev
server.cpp
Go to the documentation of this file.
1 /*
2  Copyright (C) 2015 - 2025
3  by Iris Morelle <shadowm2006@gmail.com>
4  Copyright (C) 2003 - 2018 by David White <dave@whitevine.net>
5  Part of the Battle for Wesnoth Project https://www.wesnoth.org/
6 
7  This program is free software; you can redistribute it and/or modify
8  it under the terms of the GNU General Public License as published by
9  the Free Software Foundation; either version 2 of the License, or
10  (at your option) any later version.
11  This program is distributed in the hope that it will be useful,
12  but WITHOUT ANY WARRANTY.
13 
14  See the COPYING file for more details.
15 */
16 
17 /**
18  * @file
19  * Wesnoth addon server.
20  * Expects a "server.cfg" config file in the current directory
21  * and saves addons under data/.
22  */
23 
25 
26 #include "filesystem.hpp"
27 #include "lexical_cast.hpp"
28 #include "log.hpp"
29 #include "serialization/base64.hpp"
31 #include "serialization/chrono.hpp"
32 #include "serialization/parser.hpp"
35 #include "addon/validation.hpp"
42 #include "game_version.hpp"
43 #include "hash.hpp"
44 #include "utils/optimer.hpp"
45 
46 #ifdef HAVE_MYSQLPP
48 #endif
49 
50 #include <csignal>
51 #include <iomanip>
52 #include <iostream>
53 #include <utility>
54 
55 // the fork execute is unix specific only tested on Linux quite sure it won't
56 // work on Windows not sure which other platforms have a problem with it.
57 #if !(defined(_WIN32))
58 #include <errno.h>
59 #endif
60 
61 static lg::log_domain log_campaignd("campaignd");
62 #define DBG_CS LOG_STREAM(debug, log_campaignd)
63 #define LOG_CS LOG_STREAM(info, log_campaignd)
64 #define WRN_CS LOG_STREAM(warn, log_campaignd)
65 #define ERR_CS LOG_STREAM(err, log_campaignd)
66 
67 static lg::log_domain log_config("config");
68 #define ERR_CONFIG LOG_STREAM(err, log_config)
69 #define WRN_CONFIG LOG_STREAM(warn, log_config)
70 
71 static lg::log_domain log_server("server");
72 #define ERR_SERVER LOG_STREAM(err, log_server)
73 
74 namespace campaignd {
75 
76 namespace {
77 
78 /**
79  * campaignd capabilities supported by this version of the server.
80  *
81  * These are advertised to clients using the @a [server_id] command. They may
82  * be disabled or re-enabled at runtime.
83  */
84 const std::set<std::string> cap_defaults = {
85  // Legacy item and passphrase-based authentication
86  "auth:legacy",
87  // Delta WML packs
88  "delta",
89 };
90 
91 /**
92  * Default URL to the add-ons server web index.
93  */
94 const std::string default_web_url = "https://add-ons.wesnoth.org/";
95 
96 /**
97  * Default license terms for content uploaded to the server.
98  *
99  * This used by both the @a [server_id] command and @a [request_terms] in
100  * their responses.
101  *
102  * The text is intended for display on the client with Pango markup enabled and
103  * sent by the server as-is, so it ought to be formatted accordingly.
104  */
105 const std::string default_license_notice = R"(<span size='x-large'>General Rules</span>
106 
107 The current version of the server rules can be found at: https://r.wesnoth.org/t51347
108 
109 <span color='#f88'>Any content that does not conform to the rules listed at the link above, as well as the licensing terms below, may be removed at any time without prior notice.</span>
110 
111 <span size='x-large'>Licensing</span>
112 
113 All content within add-ons uploaded to this server must be licensed under the terms of the GNU General Public License (GPL), version 2 or later, with the sole exception of graphics and audio explicitly denoted as released under a Creative Commons license either in:
114 
115  a) a combined toplevel file, e.g. “<span font_family='monospace'>My_Addon/ART_LICENSE</span>”; <b>or</b>
116  b) a file with the same path as the asset with “<span font_family='monospace'>.license</span>” appended, e.g. “<span font_family='monospace'>My_Addon/images/units/axeman.png.license</span>”.
117 
118 <b>By uploading content to this server, you certify that you have the right to:</b>
119 
120  a) release all included art and audio explicitly denoted with a Creative Commons license in the prescribed manner under that license; <b>and</b>
121  b) release all other included content under the terms of the chosen versions of the GNU GPL.)";
122 
123 bool timing_reports_enabled = false;
124 
125 void timing_report_function(const utils::ms_optimer& tim, const campaignd::server::request& req, const std::string& label = {})
126 {
127  if(timing_reports_enabled) {
128  if(label.empty()) {
129  LOG_CS << req << "Time elapsed: " << tim << " ms";
130  } else {
131  LOG_CS << req << "Time elapsed [" << label << "]: " << tim << " ms";
132  }
133  }
134 }
135 
136 inline utils::ms_optimer service_timer(const campaignd::server::request& req, const std::string& label = {})
137 {
138  return utils::ms_optimer{std::bind(timing_report_function, std::placeholders::_1, req, label)};
139 }
140 
141 //
142 // Auxiliary shortcut functions
143 //
144 
145 /**
146  * WML version of campaignd::auth::verify_passphrase().
147  * The salt and hash are retrieved from the @a passsalt and @a passhash attributes, respectively, if not using forum_auth.
148  *
149  * @param addon The config of the addon being authenticated for upload.
150  * @param passphrase The password being validated.
151  * @return Whether the password is correct for the addon.
152  */
153 inline bool authenticate(config& addon, const config::attribute_value& passphrase)
154 {
155  if(!addon["forum_auth"].to_bool()) {
156  return auth::verify_passphrase(passphrase, addon["passsalt"], addon["passhash"]);
157  }
158  return false;
159 }
160 
161 /**
162  * WML version of campaignd::auth::generate_hash().
163  * The salt and hash are written into the @a passsalt and @a passhash attributes, respectively, if not using forum_auth.
164  *
165  * @param addon The addon whose password is being set.
166  * @param passphrase The password being hashed.
167  */
168 inline void set_passphrase(config& addon, const std::string& passphrase)
169 {
170  // don't do anything if using forum authorization
171  if(!addon["forum_auth"].to_bool()) {
172  std::tie(addon["passsalt"], addon["passhash"]) = auth::generate_hash(passphrase);
173  }
174 }
175 
176 /**
177  * Returns the update pack filename for the specified old/new version pair.
178  *
179  * The filename is in the form @p "update_pack_<VERSION_MD5>.gz".
180  */
181 inline std::string make_update_pack_filename(const std::string& old_version, const std::string& new_version)
182 {
183  return "update_pack_" + utils::md5(old_version + new_version).hex_digest() + ".gz";
184 }
185 
186 /**
187  * Returns the full pack filename for the specified version.
188  *
189  * The filename is in the form @p "full_pack_<VERSION_MD5>.gz".
190  */
191 inline std::string make_full_pack_filename(const std::string& version)
192 {
193  return "full_pack_" + utils::md5(version).hex_digest() + ".gz";
194 }
195 
196 /**
197  * Returns the index filename for the specified version.
198  *
199  * The filename is in the form @p "full_pack_<VERSION_MD5>.hash.gz".
200  */
201 inline std::string make_index_filename(const std::string& version)
202 {
203  return "full_pack_" + utils::md5(version).hex_digest() + ".hash.gz";
204 }
205 
206 /**
207  * Returns the index counterpart for the specified full pack file.
208  *
209  * The result is in the same form as make_index_filename().
210  */
211 inline std::string index_from_full_pack_filename(std::string pack_fn)
212 {
213  auto dot_pos = pack_fn.find_last_of('.');
214  if(dot_pos != std::string::npos) {
215  pack_fn.replace(dot_pos, std::string::npos, ".hash.gz");
216  }
217  return pack_fn;
218 }
219 
220 /**
221  * Returns @a false if @a cfg is null or empty.
222  */
223 bool have_wml(const utils::optional_reference<const config>& cfg)
224 {
225  return cfg && !cfg->empty();
226 }
227 
228 /**
229  * Scans multiple WML pack-like trees for illegal names.
230  *
231  * Null WML objects are skipped.
232  */
233 std::vector<std::string> multi_find_illegal_names(const std::initializer_list<optional_const_config>& cfgs)
234 {
235  std::vector<std::string> names;
236  for(auto cfg : cfgs) {
237  if(cfg) {
238  check_names_legal(*cfg, &names);
239  }
240  }
241  return names;
242 }
243 
244 /**
245  * Scans multiple WML pack-like trees for case conflicts.
246  *
247  * Null WML objects are skipped.
248  */
249 std::vector<std::string> multi_find_case_conflicts(const std::initializer_list<optional_const_config>& cfgs)
250 {
251  std::vector<std::string> names;
252  for(auto cfg : cfgs) {
253  if(cfg) {
255  }
256  }
257  return names;
258 }
259 
260 /**
261  * Escapes double quotes intended to be passed into simple_wml.
262  *
263  * Just why does simple_wml have to be so broken to force us to use this, though?
264  */
265 std::string simple_wml_escape(const std::string& text)
266 {
267  std::string res;
268  auto it = text.begin();
269 
270  while(it != text.end()) {
271  res.append(*it == '"' ? 2 : 1, *it);
272  ++it;
273  }
274 
275  return res;
276 }
277 
278 } // end anonymous namespace
279 
280 server::server(const std::string& cfg_file, unsigned short port)
282  , user_handler_(nullptr)
283  , capabilities_(cap_defaults)
284  , addons_()
285  , dirty_addons_()
286  , cfg_()
287  , cfg_file_(cfg_file)
288  , read_only_(false)
289  , compress_level_(0)
290  , update_pack_lifespan_(0)
291  , strict_versions_(true)
292  , hooks_()
293  , handlers_()
294  , server_id_()
295  , feedback_url_format_()
296  , web_url_()
297  , license_notice_()
298  , blacklist_()
299  , blacklist_file_()
300  , stats_exempt_ips_()
301  , flush_timer_(io_service_)
302 {
303 
304 #ifndef _WIN32
305  struct sigaction sa;
306  std::memset( &sa, 0, sizeof(sa) );
307  #pragma GCC diagnostic ignored "-Wold-style-cast"
308  sa.sa_handler = SIG_IGN;
309  int res = sigaction( SIGPIPE, &sa, nullptr);
310  assert( res == 0 );
311 #endif
312  load_config();
313 
314  // Command line config override. This won't get saved back to disk since we
315  // leave the WML intentionally untouched.
316  if(port != 0) {
317  port_ = port;
318  }
319 
320  LOG_CS << "Port: " << port_;
321  LOG_CS << "Server directory: " << game_config::path << " (" << addons_.size() << " add-ons)";
322 
324 
325  start_server();
326  flush_cfg();
327 }
328 
330 {
331  write_config();
332 }
333 
335 {
336  LOG_CS << "Reading configuration from " << cfg_file_ << "...";
337 
339 
340  read_only_ = cfg_["read_only"].to_bool(false);
341 
342  if(read_only_) {
343  LOG_CS << "READ-ONLY MODE ACTIVE";
344  }
345 
346  strict_versions_ = cfg_["strict_versions"].to_bool(true);
347 
348  // Seems like compression level above 6 is a waste of CPU cycles.
349  compress_level_ = cfg_["compress_level"].to_int(6);
350  // One month probably will be fine (#TODO: testing needed)
351  constexpr std::chrono::seconds seconds_in_a_month{30 * 24 * 60 * 60};
352  update_pack_lifespan_ = chrono::parse_duration(cfg_["update_pack_lifespan"], seconds_in_a_month);
353 
354  const auto& svinfo_cfg = server_info();
355 
356  server_id_ = svinfo_cfg["id"].str();
357  feedback_url_format_ = svinfo_cfg["feedback_url_format"].str();
358  web_url_ = svinfo_cfg["web_url"].str(default_web_url);
359  license_notice_ = svinfo_cfg["license_notice"].str(default_license_notice);
360 
361  blacklist_file_ = cfg_["blacklist_file"].str();
362  load_blacklist();
363 
364  stats_exempt_ips_ = utils::split(cfg_["stats_exempt_ips"].str());
365 
366  // Load any configured hooks.
367  hooks_.emplace(std::string("hook_post_upload"), cfg_["hook_post_upload"]);
368  hooks_.emplace(std::string("hook_post_erase"), cfg_["hook_post_erase"]);
369 
370 #ifndef _WIN32
371  // Open the control socket if enabled.
372  if(!cfg_["control_socket"].empty()) {
373  const std::string& path = cfg_["control_socket"].str();
374 
375  if(path != fifo_path_) {
376  const int res = mkfifo(path.c_str(),0660);
377  if(res != 0 && errno != EEXIST) {
378  ERR_CS << "could not make fifo at '" << path << "' (" << strerror(errno) << ")";
379  } else {
380  input_.close();
381  int fifo = open(path.c_str(), O_RDWR|O_NONBLOCK);
382  input_.assign(fifo);
383  LOG_CS << "opened fifo at '" << path << "'. Server commands may be written to this file.";
384  read_from_fifo();
385  fifo_path_ = path;
386  }
387  }
388  }
389 #endif
390 
391  // Certain config values are saved to WML again so that a given server
392  // instance's parameters remain constant even if the code defaults change
393  // at some later point.
394  cfg_["compress_level"] = compress_level_;
395 
396  // But not the listening port number.
397  port_ = cfg_["port"].to_int(default_campaignd_port);
398 
399  // Limit the max size of WML documents received from the net to prevent the
400  // possible excessive use of resources due to malformed packets received.
401  // Since an addon is sent in a single WML document this essentially limits
402  // the maximum size of an addon that can be uploaded.
404 
405  //Loading addons
406  addons_.clear();
407  std::vector<std::string> legacy_addons, dirs;
408  filesystem::get_files_in_dir("data", &legacy_addons, &dirs);
409 
410  for(const std::string& addon_dir : dirs) {
411  auto addon_path = filesystem::normalize_path("data/" + addon_dir + "/addon.cfg");
412  config meta = io::read(*filesystem::istream_file(addon_path));
413 
414  if(!meta.empty()) {
415  addons_.emplace(meta["name"].str(), meta);
416  } else {
417  throw filesystem::io_exception("Failed to load addon from dir '" + addon_dir + "'\n");
418  }
419  }
420 
421  // Convert all legacy addons to the new format on load
422  if(cfg_.has_child("campaigns")) {
423  config& campaigns = cfg_.mandatory_child("campaigns");
424  WRN_CS << "Old format addons have been detected in the config! They will be converted to the new file format! "
425  << campaigns.child_count("campaign") << " entries to be processed.";
426  for(config& campaign : campaigns.child_range("campaign")) {
427  const std::string& addon_id = campaign["name"].str();
428  const std::string& addon_file = campaign["filename"].str();
429  if(get_addon(addon_id)) {
430  throw filesystem::io_exception("The addon '" + addon_id
431  + "' already exists in the new form! Possible code or filesystem interference!\n");
432  }
433  if(std::find(legacy_addons.begin(), legacy_addons.end(), addon_id) == legacy_addons.end()) {
434  throw filesystem::io_exception("No file has been found for the legacy addon '" + addon_id
435  + "'. Check the file structure!\n");
436  }
437 
439  if (data.empty()) {
440  throw filesystem::io_exception("Couldn't read the content file for the legacy addon '" + addon_id + "'!\n");
441  }
442  config version_cfg = config("version", campaign["version"].str());
443  version_cfg["filename"] = make_full_pack_filename(campaign["version"]);
444  campaign.add_child("version", version_cfg);
445 
446  data.remove_attributes("title", "campaign_name", "author", "description", "version", "timestamp", "original_timestamp", "icon", "type", "tags");
448  {
449  filesystem::atomic_commit campaign_file(addon_file + "/" + version_cfg["filename"].str());
450  config_writer writer(*campaign_file.ostream(), true, compress_level_);
451  writer.write(data);
452  campaign_file.commit();
453  }
454  {
455  filesystem::atomic_commit campaign_hash_file(addon_file + "/" + make_index_filename(campaign["version"]));
456  config_writer writer(*campaign_hash_file.ostream(), true, compress_level_);
457  config data_hash = config("name", "");
458  write_hashlist(data_hash, data);
459  writer.write(data_hash);
460  campaign_hash_file.commit();
461  }
462 
463  addons_.emplace(addon_id, campaign);
464  mark_dirty(addon_id);
465  }
466  cfg_.clear_children("campaigns");
467  LOG_CS << "Legacy addons processing finished.";
468  write_config();
469  }
470 
471  LOG_CS << "Loaded addons metadata. " << addons_.size() << " addons found.";
472 
473 #ifdef HAVE_MYSQLPP
474  if(auto user_handler = cfg_.optional_child("user_handler")) {
475  if(server_id_ == "") {
476  ERR_CS << "The server id must be set when database support is used.";
477  exit(1);
478  }
479  user_handler_.reset(new fuh(*user_handler));
480  LOG_CS << "User handler initialized.";
481  }
482 #endif
483 
485 }
486 
487 std::ostream& operator<<(std::ostream& o, const server::request& r)
488 {
489  o << '[' << (utils::holds_alternative<tls_socket_ptr>(r.sock) ? "+" : "") << r.addr << ' ' << r.cmd << "] ";
490  return o;
491 }
492 
494 {
495  boost::asio::spawn(
496  io_service_, [this, socket](boost::asio::yield_context yield) { serve_requests(socket, std::move(yield)); }
497 #if BOOST_VERSION >= 108000
498  , [](const std::exception_ptr& e) { if (e) std::rethrow_exception(e); }
499 #endif
500  );
501 }
502 
504 {
505  boost::asio::spawn(
506  io_service_, [this, socket](boost::asio::yield_context yield) { serve_requests(socket, std::move(yield)); }
507 #if BOOST_VERSION >= 108000
508  , [](const std::exception_ptr& e) { if (e) std::rethrow_exception(e); }
509 #endif
510  );
511 }
512 
513 template<class Socket>
514 void server::serve_requests(Socket socket, boost::asio::yield_context yield)
515 {
516  while(true) {
517  auto doc { coro_receive_doc(socket, yield) };
518  if(!doc) {
519  socket->lowest_layer().close();
520  return;
521  }
522 
523  config data = io::read(doc->output());
524  auto i = data.ordered_begin();
525 
526  if(i != data.ordered_end()) {
527  // We only handle the first child.
528  const config::any_child& c = *i;
529 
530  request_handlers_table::const_iterator j
531  = handlers_.find(c.key);
532 
533  if(j != handlers_.end()) {
534  // Call the handler.
535  request req{c.key, c.cfg, socket, yield};
536  auto st = service_timer(req);
537  j->second(this, req);
538  } else {
539  send_error("Unrecognized [" + c.key + "] request.",socket);
540  }
541  }
542  }
543 }
544 
545 #ifndef _WIN32
546 
547 void server::handle_read_from_fifo(const boost::system::error_code& error, std::size_t)
548 {
549  if(error) {
550  if(error == boost::asio::error::operation_aborted)
551  // This means fifo was closed by load_config() to open another fifo
552  return;
553  ERR_CS << "Error reading from fifo: " << error.message();
554  return;
555  }
556 
557  std::istream is(&admin_cmd_);
558  std::string cmd;
559  std::getline(is, cmd);
560 
561  const control_line ctl = cmd;
562 
563  if(ctl == "shut_down") {
564  LOG_CS << "Shut down requested by admin, shutting down...";
565  BOOST_THROW_EXCEPTION(server_shutdown("Shut down via fifo command"));
566  } else if(ctl == "readonly") {
567  if(ctl.args_count()) {
568  cfg_["read_only"] = read_only_ = utils::string_bool(ctl[1], true);
569  }
570 
571  LOG_CS << "Read only mode: " << (read_only_ ? "enabled" : "disabled");
572  } else if(ctl == "flush") {
573  LOG_CS << "Flushing config to disk...";
574  write_config();
575  } else if(ctl == "reload") {
576  if(ctl.args_count()) {
577  if(ctl[1] == "blacklist") {
578  LOG_CS << "Reloading blacklist...";
579  load_blacklist();
580  } else {
581  ERR_CS << "Unrecognized admin reload argument: " << ctl[1];
582  }
583  } else {
584  LOG_CS << "Reloading all configuration...";
585  load_config();
586  LOG_CS << "Reloaded configuration";
587  }
588  } else if(ctl == "delete") {
589  if(ctl.args_count() != 1) {
590  ERR_CS << "Incorrect number of arguments for 'delete'";
591  } else {
592  const std::string& addon_id = ctl[1];
593 
594  LOG_CS << "deleting add-on '" << addon_id << "' requested from control FIFO";
595  delete_addon(addon_id);
596  }
597  } else if(ctl == "hide" || ctl == "unhide") {
598  // there are also hides/unhides handler methods
599  if(ctl.args_count() != 1) {
600  ERR_CS << "Incorrect number of arguments for '" << ctl.cmd() << "'";
601  } else {
602  const std::string& addon_id = ctl[1];
603  auto addon = get_addon(addon_id);
604 
605  if(!addon) {
606  ERR_CS << "Add-on '" << addon_id << "' not found, cannot " << ctl.cmd();
607  } else {
608  addon["hidden"] = (ctl.cmd() == "hide");
609  mark_dirty(addon_id);
610  write_config();
611  LOG_CS << "Add-on '" << addon_id << "' is now " << (ctl.cmd() == "hide" ? "hidden" : "unhidden");
612  }
613  }
614  } else if(ctl == "setpass") {
615  if(ctl.args_count() != 2) {
616  ERR_CS << "Incorrect number of arguments for 'setpass'";
617  } else {
618  const std::string& addon_id = ctl[1];
619  const std::string& newpass = ctl[2];
620  auto addon = get_addon(addon_id);
621 
622  if(!addon) {
623  ERR_CS << "Add-on '" << addon_id << "' not found, cannot set passphrase";
624  } else if(newpass.empty()) {
625  // Shouldn't happen!
626  ERR_CS << "Add-on passphrases may not be empty!";
627  } else if(addon["forum_auth"].to_bool()) {
628  ERR_CS << "Can't set passphrase for add-on using forum_auth.";
629  } else {
630  set_passphrase(*addon, newpass);
631  mark_dirty(addon_id);
632  write_config();
633  LOG_CS << "New passphrase set for '" << addon_id << "'";
634  }
635  }
636  } else if(ctl == "setattr") {
637  if(ctl.args_count() < 3) {
638  ERR_CS << "Incorrect number of arguments for 'setattr'";
639  } else {
640  const std::string& addon_id = ctl[1];
641  const std::string& key = ctl[2];
642  std::string value;
643  for (std::size_t i = 3; i <= ctl.args_count(); ++i) {
644  if(i > 3) {
645  value += ' ';
646  }
647  value += ctl[i];
648  }
649 
650  auto addon = get_addon(addon_id);
651 
652  if(!addon) {
653  ERR_CS << "Add-on '" << addon_id << "' not found, cannot set attribute";
654  } else if(key == "name" || key == "version") {
655  ERR_CS << "setattr cannot be used to rename add-ons or change their version";
656  } else if(key == "passhash"|| key == "passsalt") {
657  ERR_CS << "setattr cannot be used to set auth data -- use setpass instead";
658  } else if(!addon->has_attribute(key)) {
659  // NOTE: This is a very naive approach for validating setattr's
660  // input, but it should generally work since add-on
661  // uploads explicitly set all recognized attributes to
662  // the values provided by the .pbl data or the empty
663  // string if absent, and this is normally preserved by
664  // the config serialization.
665  ERR_CS << "Attribute '" << key << "' is not a recognized add-on attribute";
666  } else {
667  addon[key] = value;
668  mark_dirty(addon_id);
669  write_config();
670  LOG_CS << "Set attribute on add-on '" << addon_id << "':\n"
671  << key << "=\"" << value << "\"";
672  }
673  }
674  } else if(ctl == "log") {
675  static const std::map<std::string, lg::severity> log_levels = {
676  { "error", lg::err().get_severity() },
677  { "warning", lg::warn().get_severity() },
678  { "info", lg::info().get_severity() },
679  { "debug", lg::debug().get_severity() },
680  { "none", lg::severity::LG_NONE }
681  };
682 
683  if(ctl.args_count() != 2) {
684  ERR_CS << "Incorrect number of arguments for 'log'";
685  } else if(ctl[1] == "precise") {
686  if(ctl[2] == "on") {
688  LOG_CS << "Precise timestamps enabled";
689  } else if(ctl[2] == "off") {
690  lg::precise_timestamps(false);
691  LOG_CS << "Precise timestamps disabled";
692  } else {
693  ERR_CS << "Invalid argument for 'log precise': " << ctl[2];
694  }
695  } else if(log_levels.find(ctl[1]) == log_levels.end()) {
696  ERR_CS << "Invalid log level '" << ctl[1] << "'";
697  } else {
698  auto sev = log_levels.find(ctl[1])->second;
699  for(const auto& domain : utils::split(ctl[2])) {
700  if(!lg::set_log_domain_severity(domain, sev)) {
701  ERR_CS << "Unknown log domain '" << domain << "'";
702  } else {
703  LOG_CS << "Set log level for domain '" << domain << "' to " << ctl[1];
704  }
705  }
706  }
707  } else if(ctl == "timings") {
708  if(ctl.args_count() != 1) {
709  ERR_CS << "Incorrect number of arguments for 'timings'";
710  } else if(ctl[1] == "on") {
711  campaignd::timing_reports_enabled = true;
712  LOG_CS << "Request servicing timing reports enabled";
713  } else if(ctl[1] == "off") {
714  campaignd::timing_reports_enabled = false;
715  LOG_CS << "Request servicing timing reports disabled";
716  } else {
717  ERR_CS << "Invalid argument for 'timings': " << ctl[1];
718  }
719  } else {
720  ERR_CS << "Unrecognized admin command: " << ctl.full();
721  }
722 
723  read_from_fifo();
724 }
725 
726 void server::handle_sighup(const boost::system::error_code&, int)
727 {
728  LOG_CS << "SIGHUP caught, reloading config.";
729 
730  load_config(); // TODO: handle port number config changes
731 
732  LOG_CS << "Reloaded configuration";
733 
734  sighup_.async_wait(std::bind(&server::handle_sighup, this, std::placeholders::_1, std::placeholders::_2));
735 }
736 
737 #endif
738 
740 {
741  flush_timer_.expires_after(std::chrono::minutes(10));
742  flush_timer_.async_wait(std::bind(&server::handle_flush, this, std::placeholders::_1));
743 }
744 
745 void server::handle_flush(const boost::system::error_code& error)
746 {
747  if(error) {
748  ERR_CS << "Error from reload timer: " << error.message();
749  throw boost::system::system_error(error);
750  }
751  write_config();
752  flush_cfg();
753 }
754 
756 {
757  // We *always* want to clear the blacklist first, especially if we are
758  // reloading the configuration and the blacklist is no longer enabled.
759  blacklist_.clear();
760 
761  if(blacklist_file_.empty()) {
762  return;
763  }
764 
765  try {
767  LOG_CS << "using blacklist from " << blacklist_file_;
768  } catch(const config::error&) {
769  ERR_CS << "failed to read blacklist from " << blacklist_file_ << ", blacklist disabled";
770  }
771 }
772 
774 {
775  DBG_CS << "writing configuration and add-ons list to disk...";
777  io::write(*out.ostream(), cfg_);
778  out.commit();
779 
780  for(const std::string& name : dirty_addons_) {
781  auto addon = get_addon(name);
782  if(addon && !addon["filename"].empty()) {
783  filesystem::atomic_commit addon_out(filesystem::normalize_path(addon["filename"].str() + "/addon.cfg"));
784  io::write(*addon_out.ostream(), *addon);
785  addon_out.commit();
786  }
787  }
788 
789  dirty_addons_.clear();
790  DBG_CS << "... done";
791 }
792 
793 void server::fire(const std::string& hook, [[maybe_unused]] const std::string& addon)
794 {
795  const std::map<std::string, std::string>::const_iterator itor = hooks_.find(hook);
796  if(itor == hooks_.end()) {
797  return;
798  }
799 
800  const std::string& script = itor->second;
801  if(script.empty()) {
802  return;
803  }
804 
805 #if defined(_WIN32)
806  ERR_CS << "Tried to execute a script on an unsupported platform";
807  return;
808 #else
809  pid_t childpid;
810 
811  if((childpid = fork()) == -1) {
812  ERR_CS << "fork failed while updating add-on " << addon;
813  return;
814  }
815 
816  if(childpid == 0) {
817  // We are the child process. Execute the script. We run as a
818  // separate thread sharing stdout/stderr, which will make the
819  // log look ugly.
820  execlp(script.c_str(), script.c_str(), addon.c_str(), static_cast<char *>(nullptr));
821 
822  // exec() and family never return; if they do, we have a problem
823  PLAIN_LOG << "ERROR: exec failed with errno " << errno << " for addon " << addon;
824  exit(errno);
825 
826  } else {
827  return;
828  }
829 #endif
830 }
831 
832 bool server::ignore_address_stats(const std::string& addr) const
833 {
834  for(const auto& mask : stats_exempt_ips_) {
835  // TODO: we want CIDR subnet mask matching here, not glob matching!
836  if(utils::wildcard_string_match(addr, mask)) {
837  return true;
838  }
839  }
840 
841  return false;
842 }
843 
844 void server::send_message(const std::string& msg, const any_socket_ptr& sock)
845 {
846  const auto& escaped_msg = simple_wml_escape(msg);
848  doc.root().add_child("message").set_attr_dup("message", escaped_msg.c_str());
849  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, sock);
850 }
851 
852 inline std::string client_address(const any_socket_ptr& sock) {
853  return utils::visit([](auto&& sock) { return ::client_address(sock); }, sock);
854 }
855 
856 void server::send_error(const std::string& msg, const any_socket_ptr& sock)
857 {
858  ERR_CS << "[" << client_address(sock) << "] " << msg;
859  const auto& escaped_msg = simple_wml_escape(msg);
861  doc.root().add_child("error").set_attr_dup("message", escaped_msg.c_str());
862  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, sock);
863 }
864 
865 void server::send_error(const std::string& msg, const std::string& extra_data, unsigned int status_code, const any_socket_ptr& sock)
866 {
867  const std::string& status_hex = formatter()
868  << "0x" << std::setfill('0') << std::setw(2*sizeof(unsigned int)) << std::hex
869  << std::uppercase << status_code;
870  ERR_CS << "[" << client_address(sock) << "]: (" << status_hex << ") " << msg;
871 
872  const auto& escaped_status_str = simple_wml_escape(std::to_string(status_code));
873  const auto& escaped_msg = simple_wml_escape(msg);
874  const auto& escaped_extra_data = simple_wml_escape(extra_data);
875 
877  simple_wml::node& err_cfg = doc.root().add_child("error");
878 
879  err_cfg.set_attr_dup("message", escaped_msg.c_str());
880  err_cfg.set_attr_dup("extra_data", escaped_extra_data.c_str());
881  err_cfg.set_attr_dup("status_code", escaped_status_str.c_str());
882 
883  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, sock);
884 }
885 
886 optional_config server::get_addon(const std::string& id)
887 {
888  auto addon = addons_.find(id);
889  if(addon != addons_.end()) {
890  return addon->second;
891  } else {
892  return optional_config();
893  }
894 }
895 
896 void server::delete_addon(const std::string& id)
897 {
899 
900  if(!cfg) {
901  ERR_CS << "Cannot delete unrecognized add-on '" << id << "'";
902  return;
903  }
904 
905  if(cfg["forum_auth"].to_bool()) {
906  user_handler_->db_delete_addon_authors(server_id_, cfg["name"].str());
907  }
908 
909  std::string fn = cfg["filename"].str();
910 
911  if(fn.empty()) {
912  ERR_CS << "Add-on '" << id << "' does not have an associated filename, cannot delete";
913  }
914 
916  ERR_CS << "Could not delete the directory for addon '" << id
917  << "' (" << fn << "): " << strerror(errno);
918  }
919 
920  addons_.erase(id);
921  write_config();
922 
923  fire("hook_post_erase", id);
924 
925  LOG_CS << "Deleted add-on '" << id << "'";
926 }
927 
928 #define REGISTER_CAMPAIGND_HANDLER(req_id) \
929  handlers_[#req_id] = std::bind(&server::handle_##req_id, \
930  std::placeholders::_1, std::placeholders::_2)
931 
933 {
934  REGISTER_CAMPAIGND_HANDLER(server_id);
935  REGISTER_CAMPAIGND_HANDLER(request_campaign_list);
936  REGISTER_CAMPAIGND_HANDLER(request_campaign);
937  REGISTER_CAMPAIGND_HANDLER(request_campaign_hash);
938  REGISTER_CAMPAIGND_HANDLER(request_terms);
941  REGISTER_CAMPAIGND_HANDLER(change_passphrase);
942  REGISTER_CAMPAIGND_HANDLER(hide_addon);
943  REGISTER_CAMPAIGND_HANDLER(unhide_addon);
944  REGISTER_CAMPAIGND_HANDLER(list_hidden);
945  REGISTER_CAMPAIGND_HANDLER(addon_downloads_by_version);
946  REGISTER_CAMPAIGND_HANDLER(forum_auth_usage);
947  REGISTER_CAMPAIGND_HANDLER(admins_list);
948 }
949 
951 {
952  DBG_CS << req << "Sending server identification";
953 
954  std::ostringstream ostr;
955  io::write(ostr, config{"server_id", config{
956  "id", server_id_,
957  "cap", utils::join(capabilities_),
958  "version", game_config::revision,
959  "url", web_url_,
960  "license_notice", license_notice_,
961  }});
962 
963  const auto& wml = ostr.str();
965  doc.compress();
966 
967  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, req.sock);
968 }
969 
971 {
972  LOG_CS << req << "Sending add-ons list";
973 
974  auto now = std::chrono::system_clock::now();
975  const bool relative_to_now = req.cfg["times_relative_to"] == "now";
976 
978  addons_list["timestamp"] = chrono::serialize_timestamp(now);
979 
980  bool before_flag = !req.cfg["before"].empty();
981  std::chrono::system_clock::time_point before;
982  if(before_flag) {
983  if(relative_to_now) {
984  auto time_delta = chrono::parse_duration<std::chrono::seconds>(req.cfg["before"]);
985  before = now + time_delta; // delta may be negative
986  } else {
987  before = chrono::parse_timestamp(req.cfg["before"]);
988  }
989  }
990 
991  bool after_flag = !req.cfg["after"].empty();
992  std::chrono::system_clock::time_point after;
993  if(after_flag) {
994  if(relative_to_now) {
995  auto time_delta = chrono::parse_duration<std::chrono::seconds>(req.cfg["after"]);
996  after = now + time_delta; // delta may be negative
997  } else {
998  after = chrono::parse_timestamp(req.cfg["after"]);
999  }
1000  }
1001 
1002  const std::string& name = req.cfg["name"];
1003  const std::string& lang = req.cfg["language"];
1004 
1005  for(const auto& addon : addons_)
1006  {
1007  if(!name.empty() && name != addon.first) {
1008  continue;
1009  }
1010 
1011  config i = addon.second;
1012 
1013  if(i["hidden"].to_bool()) {
1014  continue;
1015  }
1016 
1017  const auto& tm = i["timestamp"];
1018 
1019  if(before_flag && (tm.empty() || chrono::parse_timestamp(tm) >= before)) {
1020  continue;
1021  }
1022  if(after_flag && (tm.empty() || chrono::parse_timestamp(tm) <= after)) {
1023  continue;
1024  }
1025 
1026  if(!lang.empty()) {
1027  bool found = false;
1028 
1029  for(const config& j : i.child_range("translation"))
1030  {
1031  if(j["language"] == lang && j["supported"].to_bool(true)) {//for old addons
1032  found = true;
1033  break;
1034  }
1035  }
1036 
1037  if(!found) {
1038  continue;
1039  }
1040  }
1041 
1042  addons_list.add_child("campaign", i);
1043  }
1044 
1045  for(config& j : addons_list.child_range("campaign"))
1046  {
1047  // Remove attributes containing information that's considered sensitive
1048  // or irrelevant to clients
1049  j.remove_attributes("passphrase", "passhash", "passsalt", "upload_ip", "email");
1050 
1051  // don't include icons if requested
1052  if(!req.cfg["send_icons"].to_bool(true)) {
1053  j.remove_attribute("icon");
1054  }
1055 
1056  // Build a feedback_url string attribute from the internal [feedback]
1057  // data or deliver an empty value, in case clients decide to assume its
1058  // presence.
1059  const config& url_params = j.child_or_empty("feedback");
1060  j["feedback_url"] = !url_params.empty() && !feedback_url_format_.empty()
1061  ? format_addon_feedback_url(feedback_url_format_, url_params) : "";
1062 
1063  // Clients don't need to see the original data, so discard it.
1064  j.clear_children("feedback");
1065 
1066  // Update packs info is internal stuff
1067  j.clear_children("update_pack");
1068  }
1069 
1070  config response;
1071  response.add_child("campaigns", std::move(addons_list));
1072 
1073  std::ostringstream ostr;
1074  io::write(ostr, response);
1075  std::string wml = ostr.str();
1077  doc.compress();
1078 
1079  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, req.sock);
1080 }
1081 
1083 {
1084  auto addon = get_addon(req.cfg["name"]);
1085 
1086  if(!addon || addon["hidden"].to_bool()) {
1087  send_error("Add-on '" + req.cfg["name"].str() + "' not found.", req.sock);
1088  return;
1089  }
1090 
1091  const auto& name = req.cfg["name"].str();
1092  auto version_map = get_version_map(*addon);
1093 
1094  if(version_map.empty()) {
1095  send_error("No versions of the add-on '" + name + "' are available on the server.", req.sock);
1096  return;
1097  }
1098 
1099  // Base the payload against the latest version if no particular version is being requested
1100  const auto& from = req.cfg["from_version"].str();
1101  const auto& to = req.cfg["version"].str(version_map.rbegin()->first);
1102 
1103  const version_info from_parsed{from};
1104  const version_info to_parsed{to};
1105 
1106  auto to_version_iter = version_map.find(to_parsed);
1107  if(to_version_iter == version_map.end()) {
1108  send_error("Could not find requested version " + to + " of the addon '" + name +
1109  "'.", req.sock);
1110  return;
1111  }
1112 
1113  auto full_pack_path = addon["filename"].str() + '/' + to_version_iter->second["filename"].str();
1114  const int full_pack_size = filesystem::file_size(full_pack_path);
1115 
1116  // Assert `From < To` before attempting to do build an update sequence, since std::distance(A, B)
1117  // requires A <= B to avoid UB with std::map (which doesn't support random access iterators) and
1118  // we're going to be using that a lot next. We also can't do anything fancy with downgrades anyway,
1119  // and same-version downloads can be regarded as though no From version was specified in order to
1120  // keep things simple.
1121 
1122  if(!from.empty() && from_parsed < to_parsed && version_map.count(from_parsed) != 0) {
1123  // Build a sequence of updates beginning from the client's old version to the
1124  // requested version. Every pair of incrementing versions on the server should
1125  // have an update pack written to disk during the original upload(s).
1126  //
1127  // TODO: consider merging update packs instead of building a linear
1128  // and possibly redundant sequence out of them.
1129 
1130  config delta;
1131  int delivery_size = 0;
1132  bool force_use_full = false;
1133 
1134  auto start_point = version_map.find(from_parsed); // Already known to exist
1135  auto end_point = std::next(to_version_iter, 1); // May be end()
1136 
1137  if(std::distance(start_point, end_point) <= 1) {
1138  // This should not happen, skip the sequence build entirely
1139  ERR_CS << "Bad update sequence bounds in version " << from << " -> " << to << " update sequence for the add-on '" << name << "', sending a full pack instead";
1140  force_use_full = true;
1141  }
1142 
1143  for(auto iter = start_point; !force_use_full && std::distance(iter, end_point) > 1;) {
1144  const auto& prev_version_cfg = iter->second;
1145  const auto& next_version_cfg = (++iter)->second;
1146 
1147  for(const config& pack : addon->child_range("update_pack")) {
1148  if(pack["from"].str() != prev_version_cfg["version"].str() ||
1149  pack["to"].str() != next_version_cfg["version"].str()) {
1150  continue;
1151  }
1152 
1153  const auto& update_pack_path = addon["filename"].str() + '/' + pack["filename"].str();
1154  auto in = filesystem::istream_file(update_pack_path);
1155 
1156  config step_delta = io::read_gz(*in);
1157 
1158  if(!step_delta.empty()) {
1159  // Don't copy arbitrarily large data around
1160  delta.append(std::move(step_delta));
1161  delivery_size += filesystem::file_size(update_pack_path);
1162  } else {
1163  ERR_CS << "Broken update sequence from version " << from << " to "
1164  << to << " for the add-on '" << name << "', sending a full pack instead";
1165  force_use_full = true;
1166  break;
1167  }
1168 
1169  // No point in sending an overlarge delta update.
1170  // FIXME: This doesn't take into account over-the-wire compression
1171  // from async_send_doc() though, maybe some heuristics based on
1172  // individual update pack size would be useful?
1173  if(delivery_size > full_pack_size && full_pack_size > 0) {
1174  force_use_full = true;
1175  break;
1176  }
1177  }
1178  }
1179 
1180  if(!force_use_full && !delta.empty()) {
1181  std::ostringstream ostr;
1182  io::write(ostr, delta);
1183  const auto& wml_text = ostr.str();
1184 
1185  simple_wml::document doc(wml_text.c_str(), simple_wml::INIT_STATIC);
1186  doc.compress();
1187 
1188  LOG_CS << req << "Sending add-on '" << name << "' version: " << from << " -> " << to << " (delta)";
1189 
1190  utils::visit([this, &req, &doc](auto && sock) {
1191  coro_send_doc(sock, doc, req.yield);
1192  }, req.sock);
1193 
1194  full_pack_path.clear();
1195  }
1196  }
1197 
1198  // Send a full pack if the client's previous version was not specified, is
1199  // not known by the server, or if any other condition above caused us to
1200  // give up on the update pack option.
1201  if(!full_pack_path.empty()) {
1202  if(full_pack_size < 0) {
1203  send_error("Add-on '" + name + "' could not be read by the server.", req.sock);
1204  return;
1205  }
1206 
1207  LOG_CS << req << "Sending add-on '" << name << "' version: " << to << " size: " << full_pack_size / 1024 << " KiB";
1208  utils::visit([this, &req, &full_pack_path](auto&& socket) {
1209  coro_send_file(socket, full_pack_path, req.yield);
1210  }, req.sock);
1211  }
1212 
1213  // Clients doing upgrades or some other specific thing shouldn't bump
1214  // the downloads count. Default to true for compatibility with old
1215  // clients that won't tell us what they are trying to do.
1216  if(req.cfg["increase_downloads"].to_bool(true) && !ignore_address_stats(req.addr)) {
1217  addon["downloads"] = 1 + addon["downloads"].to_int();
1218  mark_dirty(name);
1219  if(user_handler_) {
1220  user_handler_->db_update_addon_download_count(server_id_, name, to);
1221  }
1222  }
1223 }
1224 
1226 {
1227  auto addon = get_addon(req.cfg["name"]);
1228 
1229  if(!addon || addon["hidden"].to_bool()) {
1230  send_error("Add-on '" + req.cfg["name"].str() + "' not found.", req.sock);
1231  return;
1232  }
1233 
1234  std::string path = addon["filename"].str() + '/';
1235 
1236  auto version_map = get_version_map(*addon);
1237 
1238  if(version_map.empty()) {
1239  send_error("No versions of the add-on '" + req.cfg["name"].str() + "' are available on the server.", req.sock);
1240  return;
1241  } else {
1242  const auto& version_str = addon["version"].str();
1243  version_info version_parsed{version_str};
1244  auto version = version_map.find(version_parsed);
1245  if(version != version_map.end()) {
1246  path += version->second["filename"].str();
1247  } else {
1248  // Selecting the latest version before the selected version or the overall latest version if unspecified
1249  if(version_str.empty()) {
1250  path += version_map.rbegin()->second["filename"].str();
1251  } else {
1252  path += (--version_map.upper_bound(version_parsed))->second["filename"].str();
1253  }
1254  }
1255 
1256  path = index_from_full_pack_filename(path);
1257  const int file_size = filesystem::file_size(path);
1258 
1259  if(file_size < 0) {
1260  send_error("Missing index file for the add-on '" + req.cfg["name"].str() + "'.", req.sock);
1261  return;
1262  }
1263 
1264  LOG_CS << req << "Sending add-on hash index for '" << req.cfg["name"] << "' size: " << file_size / 1024 << " KiB";
1265  utils::visit([this, &path, &req](auto&& socket) {
1266  coro_send_file(socket, path, req.yield);
1267  }, req.sock);
1268  }
1269 }
1270 
1272 {
1273  // This usually means the client wants to upload content, so tell it
1274  // to give up when we're in read-only mode.
1275  if(read_only_) {
1276  LOG_CS << "in read-only mode, request for upload terms denied";
1277  send_error("The server is currently in read-only mode, add-on uploads are disabled.", req.sock);
1278  return;
1279  }
1280 
1281  LOG_CS << req << "Sending license terms";
1283 }
1284 
1285 ADDON_CHECK_STATUS server::validate_addon(const server::request& req, config*& existing_addon, std::string& error_data)
1286 {
1287  if(read_only_) {
1288  LOG_CS << "Validation error: uploads not permitted in read-only mode.";
1290  }
1291 
1292  const config& upload = req.cfg;
1293 
1294  const auto data = upload.optional_child("data");
1295  const auto removelist = upload.optional_child("removelist");
1296  const auto addlist = upload.optional_child("addlist");
1297 
1298  const bool is_upload_pack = have_wml(removelist) || have_wml(addlist);
1299 
1300  const std::string& name = upload["name"].str();
1301 
1302  existing_addon = nullptr;
1303  error_data.clear();
1304 
1305  bool passed_name_utf8_check = false;
1306 
1307  try {
1308  const std::string& lc_name = utf8::lowercase(name);
1309  passed_name_utf8_check = true;
1310 
1311  for(auto& c : addons_) {
1312  if(utf8::lowercase(c.first) == lc_name) {
1313  existing_addon = &c.second;
1314  break;
1315  }
1316  }
1317  } catch(const utf8::invalid_utf8_exception&) {
1318  if(!passed_name_utf8_check) {
1319  LOG_CS << "Validation error: bad UTF-8 in add-on name";
1321  } else {
1322  ERR_CS << "Validation error: add-ons list has bad UTF-8 somehow, this is a server side issue, it's bad, and you should probably fix it ASAP";
1324  }
1325  }
1326 
1327  // Auth and block-list based checks go first
1328 
1329  if(upload["passphrase"].empty()) {
1330  LOG_CS << "Validation error: no passphrase specified";
1332  }
1333 
1334  if(existing_addon && upload["forum_auth"].to_bool() != (*existing_addon)["forum_auth"].to_bool()) {
1335  LOG_CS << "Validation error: forum_auth is " << upload["forum_auth"].to_bool() << " but was previously uploaded set to " << (*existing_addon)["forum_auth"].to_bool();
1337  } else if(upload["forum_auth"].to_bool()) {
1338  if(!user_handler_) {
1339  LOG_CS << "Validation error: client requested forum authentication but server does not support it";
1341  } else {
1342  if(!user_handler_->user_exists(upload["uploader"].str())) {
1343  LOG_CS << "Validation error: forum auth requested for an author who doesn't exist";
1345  }
1346 
1347  for(const std::string& primary_author : utils::split(upload["primary_authors"].str(), ',')) {
1348  if(!user_handler_->user_exists(primary_author)) {
1349  LOG_CS << "Validation error: forum auth requested for a primary author who doesn't exist";
1351  }
1352  }
1353 
1354  for(const std::string& secondary_author : utils::split(upload["secondary_authors"].str(), ',')) {
1355  if(!user_handler_->user_exists(secondary_author)) {
1356  LOG_CS << "Validation error: forum auth requested for a secondary author who doesn't exist";
1358  }
1359  }
1360 
1361  if(!authenticate_forum(upload, upload["passphrase"].str(), false)) {
1362  LOG_CS << "Validation error: forum passphrase does not match";
1364  }
1365  }
1366  } else if(existing_addon && !authenticate(*existing_addon, upload["passphrase"])) {
1367  LOG_CS << "Validation error: campaignd passphrase does not match";
1369  }
1370 
1371  if(existing_addon && (*existing_addon)["hidden"].to_bool()) {
1372  LOG_CS << "Validation error: add-on is hidden";
1374  }
1375 
1376  try {
1377  if(blacklist_.is_blacklisted(name,
1378  upload["title"].str(),
1379  upload["description"].str(),
1380  upload["author"].str(),
1381  req.addr,
1382  upload["email"].str()))
1383  {
1384  LOG_CS << "Validation error: blacklisted uploader or publish information";
1386  }
1387  } catch(const utf8::invalid_utf8_exception&) {
1388  LOG_CS << "Validation error: invalid UTF-8 sequence in publish information while checking against the blacklist";
1390  }
1391 
1392  // Structure and syntax checks follow
1393 
1394  if(!is_upload_pack && !have_wml(data)) {
1395  LOG_CS << "Validation error: no add-on data.";
1397  }
1398 
1399  if(is_upload_pack && !have_wml(removelist) && !have_wml(addlist)) {
1400  LOG_CS << "Validation error: no add-on data.";
1402  }
1403 
1404  if(!addon_name_legal(name)) {
1405  LOG_CS << "Validation error: invalid add-on name.";
1407  }
1408 
1409  if(is_text_markup_char(name[0])) {
1410  LOG_CS << "Validation error: add-on name starts with an illegal formatting character.";
1412  }
1413 
1414  if(upload["title"].empty()) {
1415  LOG_CS << "Validation error: no add-on title specified";
1417  }
1418 
1419  if(addon_icon_too_large(upload["icon"].str())) {
1420  LOG_CS << "Validation error: icon too large";
1422  }
1423 
1424  if(is_text_markup_char(upload["title"].str()[0])) {
1425  LOG_CS << "Validation error: add-on title starts with an illegal formatting character.";
1427  }
1428 
1429  if(get_addon_type(upload["type"]) == ADDON_UNKNOWN) {
1430  LOG_CS << "Validation error: unknown add-on type specified";
1432  }
1433 
1434  if(upload["author"].empty()) {
1435  LOG_CS << "Validation error: no add-on author specified";
1437  }
1438 
1439  if(upload["version"].empty()) {
1440  LOG_CS << "Validation error: no add-on version specified";
1442  }
1443 
1444  if(existing_addon) {
1445  version_info new_version{upload["version"].str()};
1446  version_info old_version{(*existing_addon)["version"].str()};
1447 
1448  if(strict_versions_ ? new_version <= old_version : new_version < old_version) {
1449  LOG_CS << "Validation error: add-on version not incremented";
1451  }
1452  }
1453 
1454  if(upload["description"].empty()) {
1455  LOG_CS << "Validation error: no add-on description specified";
1457  }
1458 
1459  // if using forum_auth, email will be pulled from the forum database later
1460  if(upload["email"].empty() && !upload["forum_auth"].to_bool()) {
1461  LOG_CS << "Validation error: no add-on email specified";
1463  }
1464 
1465  if(std::vector<std::string> badnames = multi_find_illegal_names({data, addlist, removelist}); !badnames.empty()) {
1466  error_data = utils::join(badnames, "\n");
1467  LOG_CS << "Validation error: invalid filenames in add-on pack (" << badnames.size() << " entries)";
1469  }
1470 
1471  if(std::vector<std::string> badnames = multi_find_case_conflicts({data, addlist, removelist}); !badnames.empty()) {
1472  error_data = utils::join(badnames, "\n");
1473  LOG_CS << "Validation error: case conflicts in add-on pack (" << badnames.size() << " entries)";
1475  }
1476 
1477  if(is_upload_pack && !existing_addon) {
1478  LOG_CS << "Validation error: attempted to send an update pack for a non-existent add-on";
1480  }
1481 
1482  if(auto url_params = upload.optional_child("feedback")) {
1483  try {
1484  int topic_id = std::stoi(url_params["topic_id"].str("0"));
1485  if(user_handler_ && topic_id != 0) {
1486  if(!user_handler_->db_topic_id_exists(topic_id)) {
1487  LOG_CS << "Validation error: feedback topic ID does not exist in forum database";
1489  }
1490  }
1491  } catch(...) {
1492  LOG_CS << "Validation error: feedback topic ID is not a valid number";
1494  }
1495  }
1496 
1498 }
1499 
1501 {
1502  const auto upload_ts = std::chrono::system_clock::now();
1503  const config& upload = req.cfg;
1504  const auto& name = upload["name"].str();
1505 
1506  LOG_CS << req << "Validating add-on '" << name << "'...";
1507 
1508  config* addon_ptr = nullptr;
1509  std::string val_error_data;
1510  const auto val_status = validate_addon(req, addon_ptr, val_error_data);
1511 
1512  if(val_status != ADDON_CHECK_STATUS::SUCCESS) {
1513  LOG_CS << "Upload of '" << name << "' aborted due to a failed validation check";
1514  const auto msg = std::string("Add-on rejected: ") + addon_check_status_desc(val_status);
1515  send_error(msg, val_error_data, static_cast<unsigned int>(val_status), req.sock);
1516  return;
1517  }
1518 
1519  LOG_CS << req << "Processing add-on '" << name << "'...";
1520 
1521  const auto full_pack = upload.optional_child("data");
1522  const auto delta_remove = upload.optional_child("removelist");
1523  const auto delta_add = upload.optional_child("addlist");
1524 
1525  const bool is_delta_upload = have_wml(delta_remove) || have_wml(delta_add);
1526  const bool is_existing_upload = addon_ptr != nullptr;
1527 
1528  if(!is_existing_upload) {
1529  // Create a new add-ons list entry and work with that from now on
1530  auto entry = addons_.emplace(name, config("original_timestamp", chrono::serialize_timestamp(upload_ts)));
1531  addon_ptr = &(*entry.first).second;
1532  }
1533 
1534  config& addon = *addon_ptr;
1535 
1536  LOG_CS << req << "Upload type: "
1537  << (is_delta_upload ? "delta" : "full") << ", "
1538  << (is_existing_upload ? "update" : "new");
1539 
1540  // Write general metadata attributes
1541 
1542  addon.copy_or_remove_attributes(upload,
1543  "title", "name", "uploader", "author", "primary_authors", "secondary_authors", "description", "version", "icon",
1544  "translate", "dependencies", "core", "type", "tags", "email", "forum_auth"
1545  );
1546 
1547  const std::string& pathstem = "data/" + name;
1548  addon["filename"] = pathstem;
1549  addon["upload_ip"] = req.addr;
1550 
1551  if(!is_existing_upload && !addon["forum_auth"].to_bool()) {
1552  set_passphrase(addon, upload["passphrase"]);
1553  }
1554 
1555  if(addon["downloads"].empty()) {
1556  addon["downloads"] = 0;
1557  }
1558 
1559  addon["timestamp"] = chrono::serialize_timestamp(upload_ts);
1560  addon["uploads"] = 1 + addon["uploads"].to_int();
1561 
1562  addon.clear_children("feedback");
1563  int topic_id = 0;
1564  if(auto url_params = upload.optional_child("feedback")) {
1565  addon.add_child("feedback", *url_params);
1566  // already validated that this can be converted to an int in validate_addon()
1567  topic_id = url_params["topic_id"].to_int();
1568  }
1569 
1570  if(user_handler_) {
1571  if(addon["forum_auth"].to_bool()) {
1572  addon["email"] = user_handler_->get_user_email(upload["uploader"].str());
1573 
1574  // if no author information exists, insert data since that of course means no primary author can be found
1575  // or if the author is the primary uploader, replace the author information
1576  bool do_authors_exist = user_handler_->db_do_any_authors_exist(server_id_, name);
1577  bool is_primary = user_handler_->db_is_user_primary_author(server_id_, name, upload["uploader"].str());
1578  if(!do_authors_exist || is_primary) {
1579  user_handler_->db_delete_addon_authors(server_id_, name);
1580  // author instead of uploader here is intentional, since this allows changing the primary author
1581  // if p1 is primary, p2 is secondary, and p1 uploads, then uploader and author are p1 while p2 is a secondary author
1582  // if p1 is primary, p2 is secondary, and p2 uploads, then this is skipped because the uploader is not the primary author
1583  // if next time p2 is primary, p1 is secondary, and p1 uploads, then p1 is both uploader and secondary author
1584  // therefore p2's author information would not be reinserted if the uploader attribute were used instead
1585  user_handler_->db_insert_addon_authors(server_id_, name, utils::split(addon["primary_authors"].str(), ','), utils::split(addon["secondary_authors"].str(), ','));
1586  }
1587  }
1588  user_handler_->db_insert_addon_info(server_id_, name, addon["title"].str(), addon["type"].str(), addon["version"].str(), addon["forum_auth"].to_bool(), topic_id, upload["uploader"].str());
1589  }
1590 
1591  // Copy in any metadata translations provided directly in the .pbl.
1592  // Catalogue detection is done later -- in the meantime we just mark
1593  // translations with valid metadata as not supported until we find out
1594  // whether the add-on ships translation catalogues for them or not.
1595 
1596  addon.clear_children("translation");
1597 
1598  for(const config& locale_params : upload.child_range("translation")) {
1599  if(!locale_params["language"].empty()) {
1600  config& locale = addon.add_child("translation");
1601  locale["language"] = locale_params["language"].str();
1602  locale["supported"] = false;
1603 
1604  if(!locale_params["title"].empty()) {
1605  locale["title"] = locale_params["title"].str();
1606  }
1607  if(!locale_params["description"].empty()) {
1608  locale["description"] = locale_params["description"].str();
1609  }
1610  }
1611  }
1612 
1613  // We need to alter the WML pack slightly, but we don't want to do a deep
1614  // copy of data that's larger than 5 MB in the average case (and as large
1615  // as 100 MB in the worst case). On the other hand, if the upload is a
1616  // delta then need to leave this empty and fill it in later instead.
1617 
1618  config rw_full_pack;
1619  if(have_wml(full_pack)) {
1620  // Void the warranty
1621  rw_full_pack = std::move(const_cast<config&>(*full_pack));
1622  }
1623 
1624  // Versioning support
1625 
1626  const auto& new_version = addon["version"].str();
1627  auto version_map = get_version_map(addon);
1628 
1629  if(is_delta_upload) {
1630  // Create the full pack by grabbing the one for the requested 'from'
1631  // version (or latest available) and applying the delta on it. We
1632  // proceed from there by fill in rw_full_pack with the result.
1633 
1634  if(version_map.empty()) {
1635  // This should NEVER happen
1636  ERR_CS << "Add-on '" << name << "' has an empty version table, this should not happen";
1637  send_error("Server error: Cannot process update pack with an empty version table.", "", static_cast<unsigned int>(ADDON_CHECK_STATUS::SERVER_DELTA_NO_VERSIONS), req.sock);
1638  return;
1639  }
1640 
1641  auto prev_version = upload["from"].str();
1642 
1643  if(prev_version.empty()) {
1644  prev_version = version_map.rbegin()->first;
1645  } else {
1646  // If the requested 'from' version doesn't exist, select the newest
1647  // older version available.
1648  version_info prev_version_parsed{prev_version};
1649  auto vm_entry = version_map.find(prev_version_parsed);
1650  if(vm_entry == version_map.end()) {
1651  prev_version = (--version_map.upper_bound(prev_version_parsed))->first;
1652  }
1653  }
1654 
1655  // Remove any existing update packs targeting the new version. This is
1656  // really only needed if the server allows multiple uploads of an
1657  // add-on with the same version number.
1658 
1659  std::set<std::string> delete_packs;
1660  for(const auto& pack : addon.child_range("update_pack")) {
1661  if(pack["to"].str() == new_version) {
1662  const auto& pack_filename = pack["filename"].str();
1663  filesystem::delete_file(pathstem + '/' + pack_filename);
1664  delete_packs.insert(pack_filename);
1665  }
1666  }
1667 
1668  if(!delete_packs.empty()) {
1669  addon.remove_children("update_pack", [&delete_packs](const config& p) {
1670  return delete_packs.find(p["filename"].str()) != delete_packs.end();
1671  });
1672  }
1673 
1674  const auto& update_pack_fn = make_update_pack_filename(prev_version, new_version);
1675 
1676  config& pack_info = addon.add_child("update_pack");
1677 
1678  pack_info["from"] = prev_version;
1679  pack_info["to"] = new_version;
1680  pack_info["expire"] = chrono::serialize_timestamp(upload_ts + update_pack_lifespan_);
1681  pack_info["filename"] = update_pack_fn;
1682 
1683  // Write the update pack to disk
1684 
1685  {
1686  LOG_CS << "Saving provided update pack for " << prev_version << " -> " << new_version << "...";
1687 
1688  filesystem::atomic_commit pack_file{pathstem + '/' + update_pack_fn};
1689  config_writer writer{*pack_file.ostream(), true, compress_level_};
1690  static const config empty_config;
1691 
1692  writer.open_child("removelist");
1693  writer.write(have_wml(delta_remove) ? *delta_remove : empty_config);
1694  writer.close_child("removelist");
1695 
1696  writer.open_child("addlist");
1697  writer.write(have_wml(delta_add) ? *delta_add : empty_config);
1698  writer.close_child("addlist");
1699 
1700  pack_file.commit();
1701  }
1702 
1703  // Apply it to the addon data from the previous version to generate a
1704  // new full pack, which will be written later near the end of this
1705  // request servicing routine.
1706 
1707  version_info prev_version_parsed{prev_version};
1708  auto it = version_map.find(prev_version_parsed);
1709  if(it == version_map.end()) {
1710  // This REALLY should never happen
1711  ERR_CS << "Previous version dropped off the version map?";
1712  send_error("Server error: Previous version disappeared.", "", static_cast<unsigned int>(ADDON_CHECK_STATUS::SERVER_UNSPECIFIED), req.sock);
1713  return;
1714  }
1715 
1716  auto in = filesystem::istream_file(pathstem + '/' + it->second["filename"].str());
1717  rw_full_pack = io::read_gz(*in);
1718 
1719  if(have_wml(delta_remove)) {
1720  data_apply_removelist(rw_full_pack, *delta_remove);
1721  }
1722 
1723  if(have_wml(delta_add)) {
1724  data_apply_addlist(rw_full_pack, *delta_add);
1725  }
1726  }
1727 
1728  // Detect translation catalogues and toggle their supported status accordingly
1729 
1730  find_translations(rw_full_pack, addon);
1731 
1732  // Add default license information if needed
1733 
1734  add_license(rw_full_pack);
1735 
1736  // Update version map, first removing any identical existing versions
1737 
1738  version_info new_version_parsed{new_version};
1739  config version_cfg{"version", new_version};
1740  version_cfg["filename"] = make_full_pack_filename(new_version);
1741 
1742  version_map.erase(new_version_parsed);
1743  addon.remove_children("version", [&new_version](const config& old_cfg)
1744  {
1745  return old_cfg["version"].str() == new_version;
1746  }
1747  );
1748 
1749  version_map.emplace(new_version_parsed, version_cfg);
1750  addon.add_child("version", version_cfg);
1751 
1752  // Clean-up
1753 
1754  rw_full_pack["name"] = ""; // [dir] syntax expects this to be present and empty
1755 
1756  // Write the full pack and its index file
1757 
1758  const auto& full_pack_path = pathstem + '/' + version_cfg["filename"].str();
1759  const auto& index_path = pathstem + '/' + make_index_filename(new_version);
1760 
1761  {
1762  config pack_index{"name", ""}; // [dir] syntax expects this to be present and empty
1763  write_hashlist(pack_index, rw_full_pack);
1764 
1765  filesystem::atomic_commit addon_pack_file{full_pack_path};
1766  config_writer{*addon_pack_file.ostream(), true, compress_level_}.write(rw_full_pack);
1767  addon_pack_file.commit();
1768 
1769  filesystem::atomic_commit addon_index_file{index_path};
1770  config_writer{*addon_index_file.ostream(), true, compress_level_}.write(pack_index);
1771  addon_index_file.commit();
1772  }
1773 
1774  addon["size"] = filesystem::file_size(full_pack_path);
1775 
1776  // Expire old update packs and delete them
1777 
1778  std::set<std::string> expire_packs;
1779 
1780  for(const config& pack : addon.child_range("update_pack")) {
1781  if(upload_ts > chrono::parse_timestamp(pack["expire"]) || pack["from"].str() == new_version || (!is_delta_upload && pack["to"].str() == new_version)) {
1782  LOG_CS << "Expiring upate pack for " << pack["from"].str() << " -> " << pack["to"].str();
1783  const auto& pack_filename = pack["filename"].str();
1784  filesystem::delete_file(pathstem + '/' + pack_filename);
1785  expire_packs.insert(pack_filename);
1786  }
1787  }
1788 
1789  if(!expire_packs.empty()) {
1790  addon.remove_children("update_pack", [&expire_packs](const config& p) {
1791  return expire_packs.find(p["filename"].str()) != expire_packs.end();
1792  });
1793  }
1794 
1795  // Create any missing update packs between consecutive versions. This covers
1796  // cases where clients were not able to upload those update packs themselves.
1797 
1798  for(auto iter = version_map.begin(); std::distance(iter, version_map.end()) > 1;) {
1799  const config& prev_version = iter->second;
1800  const config& next_version = (++iter)->second;
1801 
1802  const auto& prev_version_name = prev_version["version"].str();
1803  const auto& next_version_name = next_version["version"].str();
1804 
1805  bool found = false;
1806 
1807  for(const auto& pack : addon.child_range("update_pack")) {
1808  if(pack["from"].str() == prev_version_name && pack["to"].str() == next_version_name) {
1809  found = true;
1810  break;
1811  }
1812  }
1813 
1814  if(found) {
1815  // Nothing to do
1816  continue;
1817  }
1818 
1819  LOG_CS << "Automatically generating update pack for " << prev_version_name << " -> " << next_version_name << "...";
1820 
1821  const auto& prev_path = pathstem + '/' + prev_version["filename"].str();
1822  const auto& next_path = pathstem + '/' + next_version["filename"].str();
1823 
1824  if(filesystem::file_size(prev_path) <= 0 || filesystem::file_size(next_path) <= 0) {
1825  ERR_CS << "Unable to automatically generate an update pack for '" << name
1826  << "' for version " << prev_version_name << " to " << next_version_name
1827  << "!";
1828  continue;
1829  }
1830 
1831  const auto& update_pack_fn = make_update_pack_filename(prev_version_name, next_version_name);
1832 
1833  config& pack_info = addon.add_child("update_pack");
1834  pack_info["from"] = prev_version_name;
1835  pack_info["to"] = next_version_name;
1836  pack_info["expire"] = chrono::serialize_timestamp(upload_ts + update_pack_lifespan_);
1837  pack_info["filename"] = update_pack_fn;
1838 
1839  // Generate the update pack from both full packs
1840 
1842  config from = io::read_gz(*in);
1843  in = filesystem::istream_file(next_path);
1844  config to = io::read_gz(*in);
1845 
1846  config pack;
1847  make_updatepack(pack, from, to);
1848 
1849  {
1850  filesystem::atomic_commit pack_file{pathstem + '/' + update_pack_fn};
1851  config_writer{*pack_file.ostream(), true, compress_level_}.write(pack);
1852  pack_file.commit();
1853  }
1854  }
1855 
1856  mark_dirty(name);
1857  write_config();
1858 
1859  LOG_CS << req << "Finished uploading add-on '" << upload["name"] << "'";
1860 
1861  send_message("Add-on accepted.", req.sock);
1862 
1863  fire("hook_post_upload", name);
1864 }
1865 
1867 {
1868  const config& erase = req.cfg;
1869  const std::string& id = erase["name"].str();
1870 
1871  if(read_only_) {
1872  LOG_CS << req << "in read-only mode, request to delete '" << id << "' denied";
1873  send_error("Cannot delete add-on: The server is currently in read-only mode.", req.sock);
1874  return;
1875  }
1876 
1877  LOG_CS << req << "Deleting add-on '" << id << "'";
1878 
1879  auto addon = get_addon(id);
1880  if(!addon) {
1881  send_error("The add-on does not exist.", req.sock);
1882  return;
1883  }
1884 
1885  const config::attribute_value& pass = erase["passphrase"];
1886 
1887  if(pass.empty()) {
1888  send_error("No passphrase was specified.", req.sock);
1889  return;
1890  }
1891 
1892  if(erase["admin"].to_bool()) {
1893  if(!authenticate_admin(erase["uploader"].str(), pass.str())) {
1894  send_error("The passphrase is incorrect.", req.sock);
1895  return;
1896  }
1897  } else {
1898  if(addon["forum_auth"].to_bool()) {
1899  if(!authenticate_forum(erase, pass, true)) {
1900  send_error("The passphrase is incorrect.", req.sock);
1901  return;
1902  }
1903  } else {
1904  if(!authenticate(*addon, pass)) {
1905  send_error("The passphrase is incorrect.", req.sock);
1906  return;
1907  }
1908  }
1909  }
1910 
1911  if(addon["hidden"].to_bool()) {
1912  LOG_CS << "Add-on removal denied - hidden add-on.";
1913  send_error("Add-on deletion denied. Please contact the server administration for assistance.", req.sock);
1914  return;
1915  }
1916 
1917  delete_addon(id);
1918 
1919  send_message("Add-on deleted.", req.sock);
1920 }
1921 
1923 {
1924  const config& cpass = req.cfg;
1925 
1926  if(read_only_) {
1927  LOG_CS << "in read-only mode, request to change passphrase denied";
1928  send_error("Cannot change passphrase: The server is currently in read-only mode.", req.sock);
1929  return;
1930  }
1931 
1932  auto addon = get_addon(cpass["name"]);
1933 
1934  if(!addon) {
1935  send_error("No add-on with that name exists.", req.sock);
1936  } else if(addon["forum_auth"].to_bool()) {
1937  send_error("Changing the password for add-ons using forum_auth is not supported.", req.sock);
1938  } else if(!authenticate(*addon, cpass["passphrase"])) {
1939  send_error("Your old passphrase was incorrect.", req.sock);
1940  } else if(addon["hidden"].to_bool()) {
1941  LOG_CS << "Passphrase change denied - hidden add-on.";
1942  send_error("Add-on passphrase change denied. Please contact the server administration for assistance.", req.sock);
1943  } else if(cpass["new_passphrase"].empty()) {
1944  send_error("No new passphrase was supplied.", req.sock);
1945  } else {
1946  set_passphrase(*addon, cpass["new_passphrase"]);
1947  dirty_addons_.emplace(addon["name"]);
1948  write_config();
1949  send_message("Passphrase changed.", req.sock);
1950  }
1951 }
1952 
1953 // the fifo handler also hides add-ons
1955 {
1956  std::string addon_id = req.cfg["addon"].str();
1957  std::string username = req.cfg["username"].str();
1958  std::string passphrase = req.cfg["passphrase"].str();
1959 
1960  auto addon = get_addon(addon_id);
1961 
1962  if(!addon) {
1963  ERR_CS << "Add-on '" << addon_id << "' not found, cannot hide";
1964  send_error("The add-on was not found.", req.sock);
1965  return;
1966  } else {
1967  if(!authenticate_admin(username, passphrase)) {
1968  send_error("The passphrase is incorrect.", req.sock);
1969  return;
1970  }
1971 
1972  addon["hidden"] = true;
1973  mark_dirty(addon_id);
1974  write_config();
1975  LOG_CS << "Add-on '" << addon_id << "' is now hidden";
1976  }
1977 
1978  send_message("Add-on hidden.", req.sock);
1979 }
1980 
1981 // the fifo handler also unhides add-ons
1983 {
1984  std::string addon_id = req.cfg["addon"].str();
1985  std::string username = req.cfg["username"].str();
1986  std::string passphrase = req.cfg["passphrase"].str();
1987 
1988  auto addon = get_addon(addon_id);
1989 
1990  if(!addon) {
1991  ERR_CS << "Add-on '" << addon_id << "' not found, cannot unhide";
1992  send_error("The add-on was not found.", req.sock);
1993  return;
1994  } else {
1995  if(!authenticate_admin(username, passphrase)) {
1996  send_error("The passphrase is incorrect.", req.sock);
1997  return;
1998  }
1999 
2000  addon["hidden"] = false;
2001  mark_dirty(addon_id);
2002  write_config();
2003  LOG_CS << "Add-on '" << addon_id << "' is now unhidden";
2004  }
2005 
2006  send_message("Add-on unhidden.", req.sock);
2007 }
2008 
2010 {
2011  config response;
2012  std::string username = req.cfg["username"].str();
2013  std::string passphrase = req.cfg["passphrase"].str();
2014 
2015  if(!authenticate_admin(username, passphrase)) {
2016  send_error("The passphrase is incorrect.", req.sock);
2017  return;
2018  }
2019 
2020  for(const auto& addon : addons_) {
2021  if(addon.second["hidden"].to_bool()) {
2022  config& child = response.add_child("hidden");
2023  child["addon"] = addon.second["name"].str();
2024  }
2025  }
2026 
2027  std::ostringstream ostr;
2028  io::write(ostr, response);
2029 
2030  const auto& wml = ostr.str();
2032  doc.compress();
2033 
2034  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, req.sock);
2035 }
2036 
2038 {
2039  config response;
2040 
2041  if(user_handler_) {
2042  response = user_handler_->db_get_addon_downloads_info(server_id_, req.cfg["addon"].str());
2043  }
2044 
2045  std::ostringstream ostr;
2046  io::write(ostr, response);
2047 
2048  const auto& wml = ostr.str();
2050  doc.compress();
2051 
2052  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, req.sock);
2053 }
2054 
2056 {
2057  config response;
2058 
2059  if(user_handler_) {
2060  response = user_handler_->db_get_forum_auth_usage(server_id_);
2061  }
2062 
2063  std::ostringstream ostr;
2064  io::write(ostr, response);
2065 
2066  const auto& wml = ostr.str();
2068  doc.compress();
2069 
2070  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, req.sock);
2071 }
2072 
2074 {
2075  config response;
2076 
2077  if(user_handler_) {
2078  response = user_handler_->db_get_addon_admins();
2079  }
2080 
2081  std::ostringstream ostr;
2082  io::write(ostr, response);
2083 
2084  const auto& wml = ostr.str();
2086  doc.compress();
2087 
2088  utils::visit([this, &doc](auto&& sock) { async_send_doc_queued(sock, doc); }, req.sock);
2089 }
2090 
2091 bool server::authenticate_forum(const config& addon, const std::string& passphrase, bool is_delete) {
2092  if(!user_handler_) {
2093  return false;
2094  }
2095 
2096  std::string uploader = addon["uploader"].str();
2097  std::string id = addon["name"].str();
2098  bool do_authors_exist = user_handler_->db_do_any_authors_exist(server_id_, id);
2099  bool is_primary = user_handler_->db_is_user_primary_author(server_id_, id, uploader);
2100  bool is_secondary = user_handler_->db_is_user_secondary_author(server_id_, id, uploader);
2101 
2102  // allow if there is no author information - this is a new upload
2103  // don't allow other people to upload if author information does exist
2104  // don't allow secondary authors to remove the add-on from the server
2105  if((do_authors_exist && !is_primary && !is_secondary) || (is_secondary && is_delete)) {
2106  return false;
2107  }
2108 
2109  std::string author = addon["uploader"].str();
2110  std::string salt = user_handler_->extract_salt(author);
2111  std::string hashed_password = hash_password(passphrase, salt, author);
2112 
2113  return user_handler_->login(author, hashed_password);
2114 }
2115 
2116 bool server::authenticate_admin(const std::string& username, const std::string& passphrase)
2117 {
2118  if(!user_handler_ || !user_handler_->user_is_addon_admin(username)) {
2119  return false;
2120  }
2121 
2122  std::string salt = user_handler_->extract_salt(username);
2123  std::string hashed_password = hash_password(passphrase, salt, username);
2124 
2125  return user_handler_->login(username, hashed_password);
2126 }
2127 
2128 } // end namespace campaignd
2129 
2130 static int run_campaignd(int argc, char** argv)
2131 {
2132  campaignd::command_line cmdline{argc, argv};
2133  std::string server_path = filesystem::get_cwd();
2134  std::string config_file = "server.cfg";
2135  unsigned short port = 0;
2136 
2137  //
2138  // Log defaults
2139  //
2140 
2141  for(auto domain : { "campaignd", "campaignd/blacklist", "server" }) {
2143  }
2144 
2145  lg::timestamps(true);
2146 
2147  //
2148  // Process command line
2149  //
2150 
2151  if(cmdline.help) {
2152  std::cout << cmdline.help_text();
2153  return 0;
2154  }
2155 
2156  if(cmdline.version) {
2157  std::cout << "Wesnoth campaignd v" << game_config::revision << '\n';
2158  return 0;
2159  }
2160 
2161  if(cmdline.config_file) {
2162  // Don't fully resolve the path, so that filesystem::ostream_file() can
2163  // create path components as needed (dumb legacy behavior).
2164  config_file = filesystem::normalize_path(*cmdline.config_file, true, false);
2165  }
2166 
2167  if(cmdline.server_dir) {
2168  server_path = filesystem::normalize_path(*cmdline.server_dir, true, true);
2169  }
2170 
2171  if(cmdline.port) {
2172  port = *cmdline.port;
2173  // We use 0 as a placeholder for the default port for this version
2174  // otherwise, hence this check must only exists in this code path. It's
2175  // only meant to protect against user mistakes.
2176  if(!port) {
2177  PLAIN_LOG << "Invalid network port: " << port;
2178  return 2;
2179  }
2180  }
2181 
2182  if(cmdline.show_log_domains) {
2183  std::cout << lg::list_log_domains("");
2184  return 0;
2185  }
2186 
2187  for(const auto& ldl : cmdline.log_domain_levels) {
2188  if(!lg::set_log_domain_severity(ldl.first, static_cast<lg::severity>(ldl.second))) {
2189  PLAIN_LOG << "Unknown log domain: " << ldl.first;
2190  return 2;
2191  }
2192  }
2193 
2194  if(cmdline.log_precise_timestamps) {
2195  lg::precise_timestamps(true);
2196  }
2197 
2198  if(cmdline.report_timings) {
2199  campaignd::timing_reports_enabled = true;
2200  }
2201 
2202  PLAIN_LOG << "Wesnoth campaignd v" << game_config::revision << " starting...";
2203 
2204  if(server_path.empty() || !filesystem::is_directory(server_path)) {
2205  PLAIN_LOG << "Server directory '" << *cmdline.server_dir << "' does not exist or is not a directory.";
2206  return 1;
2207  }
2208 
2209  if(filesystem::is_directory(config_file)) {
2210  PLAIN_LOG << "Server configuration file '" << config_file << "' is not a file.";
2211  return 1;
2212  }
2213 
2214  // Everything does file I/O with pwd as the implicit starting point, so we
2215  // need to change it accordingly. We don't do this before because paths in
2216  // the command line need to remain relative to the original pwd.
2217  if(cmdline.server_dir && !filesystem::set_cwd(server_path)) {
2218  PLAIN_LOG << "Bad server directory '" << server_path << "'.";
2219  return 1;
2220  }
2221 
2222  game_config::path = server_path;
2223 
2224  //
2225  // Run the server
2226  //
2227  return campaignd::server(config_file, port).run();
2228 }
2229 
2230 int main(int argc, char** argv)
2231 {
2232  try {
2233  run_campaignd(argc, argv);
2234  } catch(const boost::program_options::error& e) {
2235  PLAIN_LOG << "Error in command line: " << e.what();
2236  return 10;
2237  } catch(const config::error& e) {
2238  PLAIN_LOG << "Could not parse config file: " << e.message;
2239  return 1;
2240  } catch(const filesystem::io_exception& e) {
2241  PLAIN_LOG << "File I/O error: " << e.what();
2242  return 2;
2243  } catch(const std::bad_function_call& /*e*/) {
2244  PLAIN_LOG << "Bad request handler function call";
2245  return 4;
2246  }
2247 
2248  return 0;
2249 }
campaignd authentication API.
std::vector< std::string > names
Definition: build_info.cpp:67
#define ERR_CS
Definition: server.cpp:65
#define REGISTER_CAMPAIGND_HANDLER(req_id)
Definition: server.cpp:928
int main(int argc, char **argv)
Definition: server.cpp:2230
#define WRN_CS
Definition: server.cpp:64
#define LOG_CS
Definition: server.cpp:63
static lg::log_domain log_campaignd("campaignd")
#define DBG_CS
Definition: server.cpp:62
static int run_campaignd(int argc, char **argv)
Definition: server.cpp:2130
static lg::log_domain log_server("server")
static lg::log_domain log_config("config")
void read(const config &cfg)
Initializes the blacklist from WML.
Definition: blacklist.cpp:60
bool is_blacklisted(const std::string &name, const std::string &title, const std::string &description, const std::string &author, const std::string &ip, const std::string &email) const
Whether an add-on described by these fields is blacklisted.
Definition: blacklist.cpp:76
Represents a server control line written to a communication socket.
Definition: control.hpp:34
const std::string & cmd() const
Returns the control command.
Definition: control.hpp:67
std::string full() const
Return the full command line string.
Definition: control.hpp:93
std::size_t args_count() const
Returns the total number of arguments, not including the command itself.
Definition: control.hpp:75
Legacy add-ons server.
Definition: server.hpp:40
request_handlers_table handlers_
Definition: server.hpp:126
void handle_upload(const request &req)
Definition: server.cpp:1500
ADDON_CHECK_STATUS validate_addon(const server::request &req, config *&existing_addon, std::string &error_data)
Performs validation on an incoming add-on.
Definition: server.cpp:1285
std::string web_url_
Definition: server.hpp:132
void delete_addon(const std::string &id)
Definition: server.cpp:896
std::set< std::string > capabilities_
Definition: server.hpp:105
config cfg_
Server config.
Definition: server.hpp:113
void handle_unhide_addon(const request &req)
Definition: server.cpp:1982
void handle_change_passphrase(const request &req)
Definition: server.cpp:1922
void send_error(const std::string &msg, const any_socket_ptr &sock)
Send a client an error message.
Definition: server.cpp:856
std::string license_notice_
Definition: server.hpp:133
void load_blacklist()
Reads the add-ons upload blacklist from WML.
Definition: server.cpp:755
std::map< std::string, std::string > hooks_
Definition: server.hpp:125
void handle_new_client(socket_ptr socket)
Definition: server.cpp:493
const config & server_info() const
Retrieves the contents of the [server_info] WML node.
Definition: server.hpp:210
std::string blacklist_file_
Definition: server.hpp:136
bool authenticate_admin(const std::string &username, const std::string &passphrase)
Definition: server.cpp:2116
std::unique_ptr< user_handler > user_handler_
Definition: server.hpp:101
void handle_request_campaign_hash(const request &req)
Definition: server.cpp:1225
void write_config()
Writes the server configuration WML back to disk.
Definition: server.cpp:773
void mark_dirty(const std::string &addon)
Definition: server.hpp:185
void handle_request_terms(const request &req)
Definition: server.cpp:1271
void send_message(const std::string &msg, const any_socket_ptr &sock)
Send a client an informational message.
Definition: server.cpp:844
optional_config get_addon(const std::string &id)
Retrieves an addon by id if found, or a null config otherwise.
Definition: server.cpp:886
void handle_flush(const boost::system::error_code &error)
Definition: server.cpp:745
void handle_list_hidden(const server::request &req)
Definition: server.cpp:2009
std::string feedback_url_format_
Definition: server.hpp:130
void flush_cfg()
Starts timer to write config to disk every ten minutes.
Definition: server.cpp:739
void handle_hide_addon(const request &req)
Definition: server.cpp:1954
std::unordered_map< std::string, config > addons_
The hash map of addons metadata.
Definition: server.hpp:108
void handle_addon_downloads_by_version(const request &req)
Definition: server.cpp:2037
std::unordered_set< std::string > dirty_addons_
The set of unique addon names with pending metadata updates.
Definition: server.hpp:110
bool ignore_address_stats(const std::string &addr) const
Checks if the specified address should never bump download counts.
Definition: server.cpp:832
blacklist blacklist_
Definition: server.hpp:135
static const std::size_t default_document_size_limit
Default upload size limit in bytes.
Definition: server.hpp:123
int compress_level_
Used for add-on archives.
Definition: server.hpp:117
boost::asio::basic_waitable_timer< std::chrono::steady_clock > flush_timer_
Definition: server.hpp:140
void handle_server_id(const request &req)
Definition: server.cpp:950
void handle_read_from_fifo(const boost::system::error_code &error, std::size_t bytes_transferred)
Definition: server.cpp:547
const std::string cfg_file_
Definition: server.hpp:114
void serve_requests(Socket socket, boost::asio::yield_context yield)
Definition: server.cpp:514
void handle_request_campaign(const request &req)
Definition: server.cpp:1082
void fire(const std::string &hook, const std::string &addon)
Fires a hook script.
Definition: server.cpp:793
std::string server_id_
Definition: server.hpp:128
bool authenticate_forum(const config &addon, const std::string &passphrase, bool is_delete)
Check whether the provided passphrase matches the add-on and its author by checked against the forum ...
Definition: server.cpp:2091
void handle_forum_auth_usage(const request &req)
Definition: server.cpp:2055
std::vector< std::string > stats_exempt_ips_
Definition: server.hpp:138
void handle_delete(const request &req)
Definition: server.cpp:1866
void handle_request_campaign_list(const request &req)
Definition: server.cpp:970
void load_config()
Reads the server configuration from WML.
Definition: server.cpp:334
server(const std::string &cfg_file, unsigned short port=0)
Definition: server.cpp:280
void handle_admins_list(const request &req)
Definition: server.cpp:2073
bool strict_versions_
Definition: server.hpp:120
std::chrono::seconds update_pack_lifespan_
Definition: server.hpp:118
void handle_sighup(const boost::system::error_code &error, int signal_number)
Definition: server.cpp:726
void register_handlers()
Registers client request handlers.
Definition: server.cpp:932
Variant for storing WML attributes.
std::string str(const std::string &fallback="") const
bool empty() const
Tests for an attribute that either was never set or was set to "".
Class for writing a config out to a file in pieces.
void write(const config &cfg)
A config object defines a single node in a WML file, with access to child nodes.
Definition: config.hpp:158
void copy_or_remove_attributes(const config &from, T... keys)
Copies or deletes attributes to match the source config.
Definition: config.hpp:566
void append(const config &cfg)
Append data from another config object to this one.
Definition: config.cpp:188
const config & child_or_empty(config_key_type key) const
Returns the first child with the given key, or an empty config if there is none.
Definition: config.cpp:390
config & mandatory_child(config_key_type key, int n=0)
Returns the nth child with the given key, or throws an error if there is none.
Definition: config.cpp:362
std::size_t child_count(config_key_type key) const
Definition: config.cpp:292
void clear_children(T... keys)
Definition: config.hpp:602
bool has_child(config_key_type key) const
Determine whether a config has a child or not.
Definition: config.cpp:312
void remove_children(config_key_type key, const std::function< bool(const config &)> &p={})
Removes all children with tag key for which p returns true.
Definition: config.cpp:650
child_itors child_range(config_key_type key)
Definition: config.cpp:268
bool empty() const
Definition: config.cpp:845
void clear()
Definition: config.cpp:824
optional_config_impl< config > optional_child(config_key_type key, int n=0)
Equivalent to mandatory_child, but returns an empty optional if the nth child was not found.
Definition: config.cpp:380
config & add_child(config_key_type key)
Definition: config.cpp:436
Wrapper class that guarantees that file commit atomicity.
Definition: fs_commit.hpp:53
void commit()
Commits the new file contents to disk atomically.
Definition: fs_commit.cpp:206
scoped_ostream & ostream()
Returns the write stream associated with the file.
Definition: fs_commit.hpp:73
std::ostringstream wrapper.
Definition: formatter.hpp:40
A class to handle the non-SQL logic for connecting to the phpbb forum database.
severity get_severity() const
Definition: log.hpp:219
Base class for implementing servers that use gzipped-WML network protocol.
Definition: server_base.hpp:81
std::string hash_password(const std::string &pw, const std::string &salt, const std::string &username)
Handles hashing the password provided by the player before comparing it to the hashed password in the...
boost::asio::signal_set sighup_
boost::asio::streambuf admin_cmd_
void async_send_doc_queued(SocketPtr socket, simple_wml::document &doc)
High level wrapper for sending a WML document.
void coro_send_doc(SocketPtr socket, simple_wml::document &doc, const boost::asio::yield_context &yield)
Send a WML document from within a coroutine.
boost::asio::io_context io_service_
unsigned short port_
void read_from_fifo()
void start_server()
Definition: server_base.cpp:78
std::unique_ptr< simple_wml::document > coro_receive_doc(SocketPtr socket, const boost::asio::yield_context &yield)
Receive WML document from a coroutine.
boost::asio::posix::stream_descriptor input_
void coro_send_file(const socket_ptr &socket, const std::string &filename, const boost::asio::yield_context &yield)
Send contents of entire file directly to socket from within a coroutine.
void load_tls_config(const config &cfg)
std::string fifo_path_
static std::size_t document_size_limit
Definition: simple_wml.hpp:292
node & add_child(const char *name)
Definition: simple_wml.cpp:466
node & set_attr_dup(const char *key, const char *value)
Definition: simple_wml.cpp:429
bool empty() const
Definition: tstring.hpp:195
const char * c_str() const
Definition: tstring.hpp:201
An interface class to handle nick registration To activate it put a [user_handler] section into the s...
Thrown by operations encountering invalid UTF-8 data.
virtual std::string hex_digest() const override
Definition: hash.cpp:119
A simple wrapper class for optional reference types.
Represents version numbers.
std::string str() const
Serializes the version number into string form.
optional_config_impl< config > optional_config
Definition: config.hpp:925
Declarations for File-IO.
t_string script
Definition: font_config.cpp:67
Atomic filesystem commit functions.
std::size_t i
Definition: function.cpp:1030
unsigned in
If equal to search_counter, the node is off the list.
Interfaces for manipulating version numbers of engine, add-ons, etc.
std::string label
What to show in the filter's drop-down list.
Definition: manager.cpp:201
std::map< std::string, addon_info > addons_list
Definition: info.hpp:27
New lexcical_cast header.
Standard logging facilities (interface).
#define PLAIN_LOG
Definition: log.hpp:296
std::pair< std::string, std::string > generate_hash(const std::string &passphrase)
Generates a salted hash from the specified passphrase.
Definition: auth.cpp:55
bool verify_passphrase(const std::string &passphrase, const std::string &salt, const std::string &hash)
Verifies the specified plain text passphrase against a salted hash.
Definition: auth.cpp:50
std::ostream & operator<<(std::ostream &o, const server::request &r)
Definition: server.cpp:487
std::string format_addon_feedback_url(const std::string &format, const config &params)
Format a feedback URL for an add-on.
Definition: addon_utils.cpp:64
void data_apply_addlist(config &data, const config &addlist)
void find_translations(const config &base_dir, config &addon)
Scans an add-on archive directory for translations.
bool data_apply_removelist(config &data, const config &removelist)
bool is_text_markup_char(char c)
Definition: addon_utils.hpp:34
std::map< version_info, config > get_version_map(config &addon)
std::string client_address(const any_socket_ptr &sock)
Definition: server.cpp:852
void add_license(config &cfg)
Adds a COPYING.txt file with the full text of the GNU GPL to an add-on.
auto serialize_timestamp(const std::chrono::system_clock::time_point &time)
Definition: chrono.hpp:57
auto parse_timestamp(long long val)
Definition: chrono.hpp:47
auto parse_duration(const config_attribute_value &val, const Duration &def=Duration{0})
Definition: chrono.hpp:71
filesystem::scoped_istream istream_file(const std::string &fname, bool treat_failure_as_error)
void get_files_in_dir(const std::string &dir, std::vector< std::string > *files, std::vector< std::string > *dirs, name_mode mode, filter_mode filter, reorder_mode reorder, file_tree_checksum *checksum)
Get a list of all files and/or directories in a given directory.
Definition: filesystem.cpp:450
bool delete_file(const std::string &filename)
bool is_directory(const std::string &fname)
Returns true if the given file is a directory.
bool delete_directory(const std::string &dirname, const bool keep_pbl)
int file_size(const std::string &fname)
Returns the size of a file, or -1 if the file doesn't exist.
std::unique_ptr< std::istream > scoped_istream
Definition: filesystem.hpp:53
bool set_cwd(const std::string &dir)
Definition: filesystem.cpp:961
std::string get_cwd()
Definition: filesystem.cpp:948
std::string normalize_path(const std::string &fpath, bool normalize_separators, bool resolve_dot_entries)
Returns the absolute path of a file.
std::string path
Definition: filesystem.cpp:93
const std::string revision
config read(std::istream &in, abstract_validator *validator)
Definition: parser.cpp:627
void write(std::ostream &out, const configr_of &cfg, unsigned int level)
Definition: parser.cpp:764
config read_gz(std::istream &file, abstract_validator *validator)
Might throw a std::ios_base::failure especially a gzip_error.
Definition: parser.cpp:683
logger & err()
Definition: log.cpp:306
severity
Definition: log.hpp:82
std::string list_log_domains(const std::string &filter)
Definition: log.cpp:377
logger & debug()
Definition: log.cpp:324
logger & warn()
Definition: log.cpp:312
void timestamps(bool t)
Definition: log.cpp:303
void precise_timestamps(bool pt)
Definition: log.cpp:304
logger & info()
Definition: log.cpp:318
bool set_log_domain_severity(const std::string &name, severity severity)
Definition: log.cpp:346
std::string lowercase(std::string_view s)
Returns a lowercased version of the string.
Definition: unicode.cpp:50
std::string & erase(std::string &str, const std::size_t start, const std::size_t len)
Erases a portion of a UTF-8 string.
Definition: unicode.cpp:103
int stoi(std::string_view str)
Same interface as std::stoi and meant as a drop in replacement, except:
Definition: charconv.hpp:154
bool wildcard_string_match(const std::string &str, const std::string &match)
Match using '*' as any number of characters (including none), '+' as one or more characters,...
bool string_bool(const std::string &str, bool def)
Convert no, false, off, 0, 0.0 to false, empty to def, and others to true.
std::string join(const T &v, const std::string &s=",")
Generates a new string joining container items in a list.
std::vector< std::string > split(const config_attribute_value &val)
auto * find(Container &container, const Value &value)
Convenience wrapper for using find on a container without needing to comare to end()
Definition: general.hpp:140
static void msg(const char *act, debug_info &i, const char *to="", const char *result="")
Definition: debugger.cpp:109
campaignd command line options parsing.
std::string_view data
Definition: picture.cpp:178
std::shared_ptr< boost::asio::ssl::stream< socket_ptr::element_type > > tls_socket_ptr
Definition: server_base.hpp:52
utils::variant< socket_ptr, tls_socket_ptr > any_socket_ptr
Definition: server_base.hpp:53
std::shared_ptr< boost::asio::ip::tcp::socket > socket_ptr
Definition: server_base.hpp:49
Client request information object.
Definition: server.hpp:57
const config & cfg
Definition: server.hpp:59
const std::string & cmd
Definition: server.hpp:58
const any_socket_ptr sock
Definition: server.hpp:61
boost::asio::yield_context yield
context of the coroutine the request is executed in async operations on sock can use it instead of a ...
Definition: server.hpp:68
const std::string addr
Definition: server.hpp:62
An exception object used when an IO error occurs.
Definition: filesystem.hpp:67
Reports time elapsed at the end of an object scope.
Definition: optimer.hpp:37
mock_char c
mock_party p
bool addon_name_legal(const std::string &name)
Checks whether an add-on id/name is legal or not.
Definition: validation.cpp:56
bool addon_icon_too_large(const std::string &icon)
Checks whether an add-on icon is too large.
Definition: validation.cpp:74
void make_updatepack(config &pack, const config &from, const config &to)
&from, &to are the top directories of their structures; addlist/removelist tag is treated as [dir]
Definition: validation.cpp:374
ADDON_TYPE get_addon_type(const std::string &str)
Definition: validation.cpp:184
bool check_names_legal(const config &dir, std::vector< std::string > *badlist)
Scans an add-on archive for illegal names.
Definition: validation.cpp:170
void write_hashlist(config &hashlist, const config &data)
Definition: validation.cpp:276
const unsigned short default_campaignd_port
Default port number for the addon server.
Definition: validation.cpp:27
std::string addon_check_status_desc(unsigned int code)
Definition: validation.cpp:382
bool check_case_insensitive_duplicates(const config &dir, std::vector< std::string > *badlist)
Scans an add-on archive for case-conflicts.
Definition: validation.cpp:179
ADDON_CHECK_STATUS
Definition: validation.hpp:33
@ SERVER_ADDONS_LIST
Corrupted server add-ons list.
@ UNAUTHORIZED
Authentication failed.
@ BAD_FEEDBACK_TOPIC_ID
The provided topic ID for the addon's feedback forum thread is invalid.
@ SERVER_DELTA_NO_VERSIONS
No versions to deltify against.
@ NO_TITLE
No title specified.
@ AUTH_TYPE_MISMATCH
The addon's forum_auth value does not match its previously set value.
@ ILLEGAL_FILENAME
Bad filename.
@ ICON_TOO_LARGE
The add-on's icon is too large (presumably a DataURI)
@ NO_PASSPHRASE
No passphrase specified.
@ UNEXPECTED_DELTA
Delta for a non-existent add-on.
@ VERSION_NOT_INCREMENTED
Version number is not an increment.
@ FILENAME_CASE_CONFLICT
Filename case conflict.
@ TITLE_HAS_MARKUP
Markup in add-on title.
@ SERVER_UNSPECIFIED
Unspecified server error.
@ DENIED
Upload denied.
@ BAD_TYPE
Bad add-on type.
@ NO_AUTHOR
No author specified.
@ EMPTY_PACK
Empty pack.
@ NO_DESCRIPTION
No description specified.
@ NO_EMAIL
No email specified.
@ FEEDBACK_TOPIC_ID_NOT_FOUND
The provided topic ID for the addon's feedback forum thread wasn't found in the forum database.
@ INVALID_UTF8_NAME
Invalid UTF-8 sequence in add-on name.
@ USER_DOES_NOT_EXIST
Requested forum authentication for a user that doesn't exist on the forums.
@ INVALID_UTF8_ATTRIBUTE
Invalid UTF-8 sequence in add-on metadata.
@ SUCCESS
No error.
@ BAD_NAME
Bad add-on name.
@ NAME_HAS_MARKUP
Markup in add-on name.
@ SERVER_FORUM_AUTH_DISABLED
The remote add-ons server does not support forum authorization.
@ SERVER_READ_ONLY
Server read-only mode on.
@ NO_VERSION
No version specified.
@ ADDON_UNKNOWN
a.k.a.
Definition: validation.hpp:103
#define e