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