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