diff --git a/.env.example b/.env.example new file mode 100644 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +MEDIAWIKI_ENTRY_POINT="/srv/mediawiki/index.php" +DB_HOST="localhost" +DB_USER="root" +DB_PASS="" diff --git a/.gitignore b/.gitignore new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Composer +/vendor/ +composer.lock + +# DotEnv +.env diff --git a/LocalSettings.php b/LocalSettings.php new file mode 100644 --- /dev/null +++ b/LocalSettings.php @@ -0,0 +1,22 @@ +<?php + +$service->run(); +$serviceConfiguration = $service->getConfiguration(); + +$localDatabases = $serviceConfiguration->getLocalDatabases(); + +$wgConf->wikis = $localDatabases; +$wgConf->localVHosts = [ 'localhost' ]; +$wgConf->settings = $serviceConfiguration->getSettings(); +$wgConf->suffixes = $localDatabases; +$wgConf->siteParamsCallback = 'Nasqueron\SAAS\MediaWiki\Hooks::onSitePameters'; + +$wgDBname = $serviceConfiguration->getSelectedDatabase(); +$wgDBserver = $_ENV['DB_HOST']; +$wgDBuser = $_ENV['DB_USER']; +$wgDBpassword = $_ENV['DB_PASS']; + +$wgConf->extractAllGlobals( $wgDBname ); + +wfLoadExtensions($serviceConfiguration->getResources('Extension')); +wfLoadSkins($serviceConfiguration->getResources('Skin')); diff --git a/composer.json b/composer.json new file mode 100644 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "nasqueron/saas-mediawiki", + "description": "SaaS configuration entry point for MediaWiki", + "keywords": [ + "nasqueron", + "SAAS", + "mediawiki", + "farm" + ], + "type": "project", + "license": "BSD-2-Clause", + "authors": [ + { + "name": "Sébastien Santoro", + "email": "dereckson@espace-win.org" + } + ], + "require": { + "vlucas/phpdotenv": "^2.4" + }, + "autoload": { + "psr-4": { + "Nasqueron\\SAAS\\MediaWiki\\": "src/", + "Nasqueron\\SAAS\\MediaWiki\\Configuration\\": "config/", + "Nasqueron\\SAAS\\MediaWiki\\Tests\\": "tests/" + } + } +} diff --git a/config/CommonSettings.php b/config/CommonSettings.php new file mode 100644 --- /dev/null +++ b/config/CommonSettings.php @@ -0,0 +1,37 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki\Configuration; + +class CommonSettings { + + public static function mapSettings (array &$settings) : void { + $settings += self::getMappedSettings($settings); + } + + public static function getMappedSettings (array $settings) : array { + $mappedSettings = []; + $mappedSettings += self::getRightsSettings($settings['saasLicense']); + return $mappedSettings; + } + + /// + /// Individual set of settings + /// + + private static function getRightsSettings (array $licenses) : array { + $settings = []; + foreach ($licenses as $key => $license) { + switch ($license) { + case 'CC-BY 4.0': + $settings['wgRightsUrl'][$key] = 'http://creativecommons.org/licenses/by/4.0/'; + $settings['wgRightsText'][$key] = 'Creative Commons Attribution 4.0 International License'; + $settings['wgRightsIcon'][$key] = 'https://i.creativecommons.org/l/by/4.0/88x31.png'; + break; + + default: + throw new ConfigurationException("License unknown: $license"); + } + } + return $settings; + } +} diff --git a/config/Instances.php b/config/Instances.php new file mode 100644 --- /dev/null +++ b/config/Instances.php @@ -0,0 +1,32 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki\Configuration; + +use Nasqueron\SAAS\MediaWiki\InstancesRepository; + +class Instances extends InstancesRepository { + + static public function getList () : array { + return [ + // Format: => database name + + "agora.nasqueron.org" => "nasqueron_wiki", + "arsmagica.espace-win.org" => "arsmagica", + "utopia.espace-win.org" => "utopia", + "www.wolfplex.be" => "wolfplexdb", + ]; + } + + static public function getAliases () : array { + return [ + // Format: Database => [ hosts ] + + "wolfplexdb" => [ + "www.wolfplex.org", + "wiki.wolfplex.org", + "wiki.wolfplex.be", + ] + ]; + } + +} diff --git a/config/MappableSettings.php b/config/MappableSettings.php new file mode 100644 --- /dev/null +++ b/config/MappableSettings.php @@ -0,0 +1,43 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki\Configuration; + +/// +/// Temporary hack to get clean configuration. +/// +/// Plan is to deploy the MediaWiki SaaS on a dedicated node. +/// +/// Meanwhile, as we share the main Nasqueron MySQL database, +/// with an history of databases going back to 2001, we need +/// to map nicely named site key to reml databases. + +class MappableSettings { + + static public function getSettings () : array { + $settings = []; + foreach (static::getMappedSettings() as $setting => $values) { + $settings[$setting] = self::mapDatabases($values); + } + return $settings; + } + + static private function mapDatabases ($items) { + $setting = []; + foreach ($items as $key => $value) { + $mappedKey = self::mapDatabase($key); + $setting[$mappedKey] = $value; + } + return $setting; + } + + static private function mapDatabase ($key) { + foreach (static::getDatabaseMap() as $canonical => $actual) { + if ($key === $canonical) { + return $actual; + } + } + + return $key; + } + +} diff --git a/config/Settings.php b/config/Settings.php new file mode 100644 --- /dev/null +++ b/config/Settings.php @@ -0,0 +1,98 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki\Configuration; + +class Settings extends MappableSettings { + + static public function getDatabaseMap () : array { + return [ + 'agora' => 'nasqueron_wiki', + 'wolfplex' => 'wolfplexdb', + ]; + } + + static protected function getMappedSettings () : array { + return [ + 'wgDBprefix' => [ + 'default' => '', + + // Legacy installations + 'arsmagica' => 'arsm_', + 'utopia' => 'wiki_', + 'wolfplex' => 'mw_', // shared database + ], + + 'wgSitename' => [ + 'agora' => 'Nasqueron Agora', + 'arsmagica' => 'Ars Magica', + 'utopia' => 'Utopia', + 'wolfplex' => 'Wolfplex', + ], + + 'wgLanguageCode' => [ + 'default' => 'en', + 'arsmagica' => 'fr', + 'utopia' => 'fr', + ], + + 'wgArticlePath' => [ + 'default' => '/wiki/$1', + 'arsmagica' => '/$1', + ], + + 'wgUploadDirectory' => [ + 'agora' => '', + 'arsmagica' => '', + 'utopia' => '', + 'wolfplex' => '', + ], + + 'wgLogo' => [ + // Do we serve /w/images for each wiki? + 'default' => '/w/images/b/bc/Wiki.png', + ], + + 'wgEnableUploads' => [ + 'default' => false, + ], + + 'wgNamespacesWithSubpages' => [ + 'wolfplex' => [ + NS_MAIN => true, + ], + 'nasqueron' => [ + NS_MAIN => true, + ], + ], + + 'saasLicense' => [ + 'default' => 'CC-BY 4.0', + ], + + 'wgEnableCreativeCommonsRdf' => [ + 'default' => true, + ], + + 'wgEnableDublinCoreRdf' => [ + 'default' => true, + ], + + 'saasUseExtensionCite' => [ + 'default' => true, + ], + + 'saasUseSkinMonoBook' => [ + 'default' => true, + ], + + 'saasUseSkinVector' => [ + 'default' => true, + ], + + 'saasUseSkinTimeless' => [ + 'default' => true, + ], + ]; + } + +} diff --git a/index.php b/index.php new file mode 100644 --- /dev/null +++ b/index.php @@ -0,0 +1,14 @@ +<?php + +use Nasqueron\SAAS\MediaWiki\Service; + +// Composer PSR-4 autoloading and .env → environment +require __DIR__ . '/vendor/autoload.php'; +(new Dotenv\Dotenv(__DIR__))->load(); + +$service = new Service(); +$service + ->handleNotExistingSite() + ->handleAliveRequest(); + +require $service->getEntryPoint(); diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,62 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki; + +use Nasqueron\SAAS\MediaWiki\Configuration\CommonSettings; +use Nasqueron\SAAS\MediaWiki\Configuration\Instances; +use Nasqueron\SAAS\MediaWiki\Configuration\Settings; + +class Configuration { + + /** + * @var string + */ + private $host; + + public function __construct (string $host) { + $this->host = $host; + } + + public function getLocalDatabases () : array { + return array_values(Instances::getList()); + } + + public function getSettings () : array { + // wg… keys + $settings = Settings::getSettings(); + + // saas… → wg… keys + CommonSettings::mapSettings($settings); + + return $settings; + } + + public function getResources (string $type) : array { + // saasUse<type><resource name> + // e.g. saasUseExtensionCite or saasUseSkinTimeless + + $resources = []; + + $prefix = "saasUse" . $type; + $len = strlen($prefix); + foreach ($GLOBALS as $key => $value) { + if (substr($key, 0, $len) === $prefix) { + $resources[] = substr($key, $len); + } + } + + return $resources; + } + + public function getSelectedDatabase () { + return Instances::getDatabaseFromHost($this->host); + } + + /// + /// Helper methods + /// + + public static function isSelectedWiki (string $wiki, string $suffix) : bool { + return substr($wiki, -strlen($suffix)) == $suffix; + } +} diff --git a/src/Hooks.php b/src/Hooks.php new file mode 100644 --- /dev/null +++ b/src/Hooks.php @@ -0,0 +1,31 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki; + +class Hooks { + + public static function onSiteParameters ($conf, $wiki) { + $site = null; + $lang = null; + + foreach ($conf->suffixes as $suffix) { + if (Configuration::isSelectedWiki($wiki, $suffix)) { + $site = $suffix; + $lang = substr( $wiki, 0, -strlen( $suffix ) ); + break; + } + } + + return [ + 'suffix' => $site, + 'lang' => $lang, + 'params' => [ + 'lang' => $lang, + 'site' => $site, + 'wiki' => $wiki, + ], + 'tags' => [], + ]; + } + +} diff --git a/src/InstancesRepository.php b/src/InstancesRepository.php new file mode 100644 --- /dev/null +++ b/src/InstancesRepository.php @@ -0,0 +1,35 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki; + +abstract class InstancesRepository { + + /// + /// Repository data methods + /// + + abstract static public function getList () : array; + abstract static public function getAliases () : array; + + /// + /// Helper methods + /// + + public static function getDatabaseFromHost (string $host) { + // Case 1 - the host is canonical + $canonicalList = static::getList(); + if (isset($canonicalList[$host])) { + return $canonicalList[$host]; + } + + // Case 2 - the host is an alias + foreach (static::getAliases() as $database => $vhost) { + if ($host === $vhost) { + return $database; + } + } + + throw new InstanceNotFoundException($host); + } + +} diff --git a/src/Service.php b/src/Service.php new file mode 100644 --- /dev/null +++ b/src/Service.php @@ -0,0 +1,139 @@ +<?php + +namespace Nasqueron\SAAS\MediaWiki; + +class Service extends BaseService { + + /** + * @var string + */ + private $host; + + /** + * @var Configuration + */ + private $configuration = null; + + public function __construct (string $host = '') { + $this->host = $host ?: self::getServerHost(); + } + + public function getHost () : string { + return $this->host; + } + + public function run () : void { + $this->decorateHeaders(); + } + + private function decorateHeaders () : void { + header("SaaS-Host: " . $this->host); + header("SaaS-App: MediaWiki"); + } + + public function serveNotExistingResponse() : void { + header("HTTP/1.0 404 Not Found"); + require 'views/404.php'; + die; + } + + public function getEntryPoint() : string { + return $_ENV['MEDIAWIKI_ENTRY_POINT']; + } + + public function getConfiguration() : Configuration { + if ($this->configuration === null) { + $this->configuration = new Configuration($this->host); + } + + return $this->configuration; + } + +} + +abstract class BaseService { + + /// + /// Request methods + /// + + public function handleNotExistingSite() : BaseService { + if (!$this->isExisting()) { + $this->serveNotExistingResponse(); + } + + return $this; + } + + public function handleAliveRequest() : BaseService { + // Handles /status requests + if ($this->isAliveRequest()) { + $this->serveAliveResponse(); + } + + return $this; + } + + public abstract function run(); + + /// + /// Default implementation + /// + + public function isExisting () : bool { + return true; + } + + public function serveAliveResponse() : void { + die("ALIVE"); + } + + public function serveNotExistingResponse(): void { + header("HTTP/1.0 404 Not Found"); + die("This site doesn't exist."); + } + + /// + /// Helper methods + /// + + public function getServerHost() : string { + return self::extractHost($_SERVER['HTTP_HOST']); + } + + /** + * Extracts hosts from a host:port expression + */ + private static function extractHost ($host) : string { + $pos = strpos($host, ':'); + + if ($pos === false) { + return $host; + } + + return substr ($host, 0, $pos); + } + + public function getUri() : string { + $sources = [ + 'DOCUMENT_URI', + 'REQUEST_URI', + ]; + + foreach ($sources as $source) { + if (isset($_SERVER[$source])) { + return $_SERVER[$source]; + } + } + + throw new \Exception("Can't get URI."); + } + + private function isAliveRequest() : bool { + return + $_SERVER['REQUEST_METHOD'] === 'GET' + && + $this->getUri() === '/status'; + } + +}