├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── db └── .gitkeep ├── inc ├── config.php ├── core.php ├── db.php └── feedwriter.php ├── nginx.conf.example ├── packagefiles └── .gitkeep └── public ├── count.php ├── delete.php ├── download.php ├── findByID.php ├── index.php ├── metadata.xml ├── push.php ├── search.php └── updates.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = tab 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | db/*.sqlite3 2 | nginx.conf 3 | *.nupkg 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Daniel Lo Nigro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Simple NuGet Server 2 | =================== 3 | 4 | A very simple NuGet server for my personal use, similar to 5 | [NuGet.Server](https://www.nuget.org/packages/NuGet.Server) but in PHP. Designed 6 | for scenarios where a single user (ie. a person or a build server) pushes 7 | packages. 8 | 9 | Features 10 | ======== 11 | - Basic searching and listing of packages 12 | - Pushing via NuGet command line (NuGet.exe) 13 | - Simple set up 14 | - Stores data in SQLite or MySQL database 15 | - Uses a single API key (so not suitable for scenarios where multiple users 16 | need to be able to push packages) 17 | 18 | Setup 19 | ===== 20 | 21 | For a Debian-based distro (including Ubuntu), installation is something along the lines of what I've written below. Installation using other Linux distributions will vary but it should be pretty similar. 22 | 23 | 1 - Ensure all dependencies are installed: 24 | 25 | - A web server (Nginx, Apache, Cherokee, etc.) 26 | - PHP 5.4+ or HHVM 27 | - SQLite, XML, and Zip extensions (bundled with HHVM, or `apt-get install php5-sqlite` for PHP 5, or `apt-get install php7.0-sqlite php7.0-xml php7.0-zip` for PHP 7) 28 | 29 | Note: If using Nginx, please make sure `ngx_http_dav_module` is installed. This is required to enable HTTP `PUT` support. 30 | 31 | 2 - Copy app to your server, you could do a Git clone on the server or add it as a Git submodule if adding to a site that's already managed via Git: 32 | ```bash 33 | cd /var/www 34 | git clone https://github.com/Daniel15/simple-nuget-server.git 35 | # Make db and packages directories writable 36 | chown www-data:www-data db packagefiles 37 | chmod 0770 db packagefiles 38 | ``` 39 | 40 | 3 - Ensure you have a PHP 5.4+ or HHVM upstream configured. For example, in `/etc/nginx/conf.d/php.conf`: 41 | ``` 42 | upstream php { 43 | server unix:/var/run/php7.2-fpm.sock; 44 | } 45 | ``` 46 | 47 | 4 - Copy nginx.conf.example to `/etc/nginx/sites-available/nuget` and modify it for your environment: 48 | - Change `example.com` to your domain name 49 | - Change `/var/www/simple-nuget-server/` to the checkout directory 50 | - Change "php" to the name of a PHP upstream in your Nginx config, configured in step 3. This can be regular PHP 5.4+ or HHVM. 51 | - If hosting as part of another site, prefix everything with a subdirectory and combine the config with your existing site's config (see ReactJS.NET config at the end of this comment) 52 | 53 | 5 - Edit `inc/config.php` and change `ChangeThisKey` to a [randomly-generated string](https://www.random.org/strings/) 54 | 55 | 6 - Enable the site (if creating a new site) 56 | ```bash 57 | ln -s /etc/nginx/sites-available/nuget /etc/nginx/sites-enabled/nuget 58 | /etc/init.d/nginx reload 59 | ``` 60 | 61 | 7 - Set the API key and test it out. I test using [nuget.exe](https://docs.nuget.org/consume/command-line-reference) (which is required for pushing) 62 | ``` 63 | nuget.exe setApiKey -Source http://example.com/ ChangeThisKey 64 | nuget.exe push Foo.nupkg -Source http://example.com/ 65 | ``` 66 | (if using Mono, run `mono nuget.exe` instead) 67 | 68 | Licence 69 | ======= 70 | (The MIT licence) 71 | 72 | Copyright (C) 2014 Daniel Lo Nigro (Daniel15) 73 | 74 | Permission is hereby granted, free of charge, to any person obtaining a copy of 75 | this software and associated documentation files (the "Software"), to deal in 76 | the Software without restriction, including without limitation the rights to 77 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 78 | of the Software, and to permit persons to whom the Software is furnished to do 79 | so, subject to the following conditions: 80 | 81 | The above copyright notice and this permission notice shall be included in all 82 | copies or substantial portions of the Software. 83 | 84 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 85 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 86 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 87 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 88 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 89 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 90 | SOFTWARE. 91 | -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel15/simple-nuget-server/6a3f9b9ae3e9942e425c0a20430953f566314120/db/.gitkeep -------------------------------------------------------------------------------- /inc/config.php: -------------------------------------------------------------------------------- 1 | getMessage()); 18 | }); 19 | 20 | // Make $_GET keys lower-case for improved NuGet client compatibility. 21 | $_GET = array_change_key_case($_GET, CASE_LOWER); 22 | 23 | /** 24 | * Ensures that the API key is valid. 25 | */ 26 | function require_auth() { 27 | if (empty($_SERVER['HTTP_X_NUGET_APIKEY']) || $_SERVER['HTTP_X_NUGET_APIKEY'] != Config::$apiKey) { 28 | api_error('403', 'Invalid API key'); 29 | } 30 | } 31 | 32 | /** 33 | * Gets the HTTP method used for the current request. 34 | */ 35 | function request_method() { 36 | return !empty($_SERVER['HTTP_X_METHOD_OVERRIDE']) 37 | ? $_SERVER['HTTP_X_METHOD_OVERRIDE'] 38 | : $_SERVER['REQUEST_METHOD']; 39 | } 40 | 41 | /** 42 | * Gets the file path for the specified package version. Throws an exception if 43 | * the package version does not exist. 44 | */ 45 | function get_package_path($id, $version) { 46 | if ( 47 | !DB::validateIdAndVersion($id, $version) 48 | // These should be caught by validateIdAndVersion, but better to be safe. 49 | || strpos($id, '/') !== false 50 | || strpos($version, '/') !== false 51 | ) { 52 | api_error('404', 'Package version not found'); 53 | } 54 | 55 | // This is safe - These values have been validated via validateIdAndVersion above 56 | return '/packagefiles/' . $id . '/' . $version . '.nupkg'; 57 | } 58 | 59 | /* Used to construct URIs */ 60 | function url_scheme() { 61 | if ( (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $_SERVER['SERVER_PORT'] == 443 || isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') { 62 | return 'https://'; 63 | } else { 64 | return 'http://'; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /inc/db.php: -------------------------------------------------------------------------------- 1 | setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 13 | static::$isMysql = static::$conn->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql'; 14 | static::createTables(); 15 | } 16 | 17 | private static function createTables() { 18 | static::$conn->exec(' 19 | CREATE TABLE IF NOT EXISTS packages ( 20 | PackageId VARCHAR (256) PRIMARY KEY, 21 | Title VARCHAR (256), 22 | DownloadCount INTEGER NOT NULL DEFAULT 0, 23 | LatestVersion TEXT' . (static::$isMysql ? ', 24 | INDEX packages_DownloadCount (DownloadCount), 25 | INDEX packages_Title(Title) 26 | ' : '') . ' 27 | ); 28 | ' . (static::$isMysql ? '' : ' 29 | CREATE INDEX IF NOT EXISTS packages_DownloadCount ON packages (DownloadCount); 30 | CREATE INDEX IF NOT EXISTS packages_Title ON packages (Title); 31 | ') . ' 32 | CREATE TABLE IF NOT EXISTS versions ( 33 | VersionId INTEGER AUTO_INCREMENT PRIMARY KEY, 34 | PackageId TEXT, 35 | Title TEXT, 36 | Description TEXT, 37 | Created INTEGER, 38 | Version VARCHAR (32), 39 | PackageHash TEXT, 40 | PackageHashAlgorithm TEXT, 41 | Dependencies TEXT, 42 | PackageSize INTEGER, 43 | ReleaseNotes TEXT, 44 | VersionDownloadCount INTEGER NOT NULL DEFAULT 0, 45 | Tags TEXT, 46 | LicenseUrl TEXT, 47 | ProjectUrl TEXT, 48 | IconUrl TEXT, 49 | Authors TEXT, 50 | Owners TEXT, 51 | RequireLicenseAcceptance BOOLEAN, 52 | Copyright TEXT, 53 | IsPrerelease BOOLEAN' . (static::$isMysql ? ', 54 | INDEX versions_Version (Version) 55 | ' : '') . ' 56 | ); 57 | ' . (static::$isMysql ? '' : ' 58 | CREATE INDEX IF NOT EXISTS versions_Version ON versions (Version); 59 | ') . ' 60 | '); 61 | } 62 | 63 | public static function countPackages() { 64 | return static::$conn->query(' 65 | SELECT COUNT(PackageId) AS count FROM packages 66 | ')->fetchColumn(); 67 | } 68 | 69 | public static function searchPackages($params) { 70 | $query_params = []; 71 | $where = '1=1'; 72 | $pagination = ''; 73 | 74 | // Defaults 75 | if (empty($params['orderBy'])) { 76 | $params['orderBy'] = Config::$defaultSortOrder; 77 | } 78 | 79 | if (!$params['includePrerelease']) { 80 | $where .= ' AND IsPrerelease = 0'; 81 | } 82 | if (!empty($params['searchQuery'])) { 83 | $where .= ' AND 84 | (packages.Title LIKE :searchQuery 85 | OR packages.PackageId LIKE :searchQuery)'; 86 | $query_params['searchQuery'] = '%' . $params['searchQuery'] . '%'; 87 | } 88 | if (!empty($params['top'])) { 89 | // Have some attempt at checking valid values 90 | $int = intval($params['top']); 91 | if ($int > 0) { 92 | $pagination .= ' LIMIT ' . $int; 93 | } 94 | } 95 | if (!empty($params['offset'])) { 96 | $int = intval($params['offset']); 97 | if ($int > 0) { 98 | $pagination .= ' OFFSET ' . $int; 99 | } 100 | } 101 | 102 | switch ($params['filter']) { 103 | case '': 104 | break; 105 | 106 | case 'IsAbsoluteLatestVersion': 107 | case 'IsLatestVersion': 108 | $where .= ' AND versions.Version = packages.LatestVersion'; 109 | break; 110 | 111 | default: 112 | throw new Exception('Unknown filter "' . $params['filter'] . '"'); 113 | } 114 | 115 | $order = static::parseOrderBy($params['orderBy']); 116 | 117 | return static::doSearch($where, $order, $query_params, $pagination); 118 | } 119 | 120 | public static function packageUpdates($params) { 121 | $VERSION_SEPARATOR = '~~'; 122 | $packages = []; 123 | $package_versions = []; 124 | foreach ($params['packages'] as $package => $version) { 125 | $packages[] = $package; 126 | $package_versions[] = $package . $VERSION_SEPARATOR . $version; 127 | } 128 | 129 | $query_params = []; 130 | // Where: 131 | // 1. Package is the latest version 132 | // 2. Package is in list of packages we want 133 | // 3. Package version is NOT the version we already have 134 | $where = 135 | 'versions.Version = packages.LatestVersion AND ' . 136 | static::buildInClause( 137 | 'packages.PackageId', 138 | $packages, 139 | $query_params 140 | ) . ' AND ' . 141 | static::buildInClause( 142 | 'packages.PackageId || "' . $VERSION_SEPARATOR . '" || versions.Version', 143 | $package_versions, 144 | $query_params, 145 | true 146 | ); 147 | 148 | return static::doSearch( 149 | $where, 150 | 'packages.PackageId ASC', 151 | $query_params, 152 | '' 153 | ); 154 | } 155 | 156 | /** 157 | * Finds a package by ID and version. If no version is passed, returns 158 | * all versions. 159 | */ 160 | public static function findByID($id, $version = null) { 161 | $where = 'packages.PackageId = :id COLLATE NOCASE'; 162 | $params = ['id' => $id]; 163 | if (!empty($version)) { 164 | $where .= ' AND versions.Version = :version'; 165 | $params['version'] = $version; 166 | } 167 | return static::doSearch($where, 'versions.Version DESC', $params, ''); 168 | } 169 | 170 | private static function parseOrderBy($order_by) { 171 | $valid_sort_columns = [ 172 | 'downloadcount' => 'packages.DownloadCount', 173 | 'id' => 'LOWER(packages.PackageId)', 174 | 'published' => 'versions.Created', 175 | ]; 176 | $columns = explode(',', $order_by); 177 | $output = []; 178 | 179 | foreach ($columns as $column) { 180 | $direction = 'asc'; 181 | $column = trim(strtolower($column)); 182 | if (strpos($column, ' ') !== false) { 183 | $pieces = explode(' ', $column); 184 | $column = $pieces[0]; 185 | $direction = $pieces[1]; 186 | } 187 | if (!isset($valid_sort_columns[$column])) { 188 | throw new Exception('Unknown sort column "' . $column . '"'); 189 | } 190 | if ($direction !== 'asc' && $direction !== 'desc') { 191 | throw new Exception('Unknown sort order "' . $direction .'"'); 192 | } 193 | $output[] = $valid_sort_columns[$column] . ' ' . $direction; 194 | } 195 | return implode(', ', $output); 196 | } 197 | 198 | // *Assumes* $where and $order are sanitised!! This should be done at the 199 | // callsites! 200 | private static function doSearch($where, $order, $params, $pagination) { 201 | // TODO: Move this to a view 202 | $stmt = static::$conn->prepare(' 203 | SELECT 204 | packages.PackageId, packages.DownloadCount, 205 | packages.LatestVersion, 206 | versions.Title, versions.Description, 207 | versions.Tags, versions.LicenseUrl, versions.ProjectUrl, 208 | versions.IconUrl, versions.Authors, versions.Owners, 209 | versions.RequireLicenseAcceptance, versions.Copyright, 210 | versions.Created, versions.Version, versions.PackageHash, 211 | versions.PackageHashAlgorithm, versions.PackageSize, 212 | versions.Dependencies, versions.ReleaseNotes, 213 | versions.VersionDownloadCount, versions.IsPrerelease 214 | FROM packages 215 | INNER JOIN versions ON packages.PackageId = versions.PackageId 216 | WHERE ' . $where . ' 217 | ORDER BY ' . $order . ' 218 | ' . $pagination . ' 219 | '); 220 | $stmt->execute($params); 221 | return $stmt->fetchAll(); 222 | } 223 | 224 | public static function validateIdAndVersion($id, $version) { 225 | $stmt = static::$conn->prepare(' 226 | SELECT COUNT(1) 227 | FROM versions 228 | WHERE PackageId = :id AND Version = :version 229 | '); 230 | $stmt->execute([ 231 | ':id' => $id, 232 | ':version' => $version, 233 | ]); 234 | return $stmt->fetchColumn() == 1; 235 | } 236 | 237 | public static function incrementDownloadCount($id, $version) { 238 | $stmt = static::$conn->prepare(' 239 | UPDATE versions 240 | SET VersionDownloadCount = VersionDownloadCount + 1 241 | WHERE PackageId = :id AND Version = :version 242 | '); 243 | $stmt->execute([ 244 | ':id' => $id, 245 | ':version' => $version, 246 | ]); 247 | 248 | // Denormalised since this isn't much of a perf issue and improves 249 | // query performance 250 | $stmt = static::$conn->prepare(' 251 | UPDATE packages 252 | SET DownloadCount = DownloadCount + 1 253 | WHERE PackageId = :id 254 | '); 255 | $stmt->execute([ 256 | ':id' => $id, 257 | ]); 258 | } 259 | 260 | public static function insertOrUpdatePackage($params) { 261 | // Upserts aren't standardised across DBMSes :( 262 | // Easiest thing here is to just do an insert followed by an update. 263 | $stmt = static::$conn->prepare(' 264 | INSERT ' . (static::$isMysql ? '' : 'OR') . ' IGNORE INTO packages 265 | (PackageId, Title, LatestVersion) 266 | VALUES 267 | (:id, :title, :version) 268 | '); 269 | $stmt->execute($params); 270 | 271 | $stmt = static::$conn->prepare(' 272 | UPDATE packages 273 | SET Title = :title, LatestVersion = :version 274 | WHERE PackageId = :id' 275 | ); 276 | $stmt->execute($params); 277 | } 278 | 279 | public static function insertVersion($params) { 280 | $params[':Created'] = time(); 281 | $params[':Dependencies'] = json_encode($params[':Dependencies']); 282 | $params[':IsPrerelease'] = empty($params[':IsPrerelease']) ? '0' : '1'; 283 | $params[':RequireLicenseAcceptance'] = 284 | empty($params[':RequireLicenseAcceptance']) ? '0' : '1'; 285 | 286 | $stmt = static::$conn->prepare(' 287 | INSERT INTO versions ( 288 | Authors, Copyright, Created, Dependencies, Description, 289 | IconUrl, IsPrerelease, LicenseUrl, Owners, PackageId, 290 | PackageHash, PackageHashAlgorithm, PackageSize, ProjectUrl, 291 | ReleaseNotes, RequireLicenseAcceptance, Tags, Title, Version 292 | ) 293 | VALUES ( 294 | :Authors, :Copyright, :Created, :Dependencies, :Description, 295 | :IconUrl, :IsPrerelease, :LicenseUrl, :Owners, :PackageId, 296 | :PackageHash, :PackageHashAlgorithm, :PackageSize, :ProjectUrl, 297 | :ReleaseNotes, :RequireLicenseAcceptance, :Tags, :Title, :Version 298 | ) 299 | '); 300 | $stmt->execute($params); 301 | } 302 | 303 | /** 304 | * Deletes a particular package version. If there are no remaining versions 305 | * for this package, also deletes the package itself. 306 | */ 307 | public static function deleteVersion($id, $version) { 308 | $stmt = static::$conn->prepare(' 309 | DELETE FROM versions 310 | WHERE PackageId = :id AND Version = :version 311 | '); 312 | $stmt->execute([ 313 | 'id' => $id, 314 | 'version' => $version, 315 | ]); 316 | 317 | // Check if any other version exist for this package 318 | $stmt = static::$conn->prepare(' 319 | SELECT Version 320 | FROM versions 321 | WHERE PackageId = :id 322 | ORDER BY Created DESC 323 | LIMIT 1 324 | '); 325 | $stmt->execute([ 326 | 'id' => $id, 327 | ]); 328 | $latestVersion = $stmt->fetchColumn(); 329 | 330 | if (empty($latestVersion)) { 331 | // No other versions remaining, also delete the package 332 | $stmt = static::$conn->prepare(' 333 | DELETE FROM packages 334 | WHERE PackageId = :id 335 | '); 336 | $stmt->execute([ 337 | 'id' => $id, 338 | ]); 339 | } else { 340 | // There's still at least one version remaining, update the LatestVersion. 341 | $stmt = static::$conn->prepare(' 342 | UPDATE packages 343 | SET LatestVersion = :latestVersion 344 | WHERE PackageId = :id 345 | '); 346 | $stmt->execute([ 347 | 'id' => $id, 348 | 'latestVersion' => $latestVersion, 349 | ]); 350 | } 351 | } 352 | 353 | /** 354 | * Builds a parameterised IN(...) WHERE clause. 355 | * 356 | * @param $field Name of the field 357 | * @param $values Array of values to use in the IN clause 358 | * @param $params Hash to add query parameters to 359 | * @param $invert If true, do a "NOT IN" clause rather than an "IN" clause 360 | * @return There WHERE clause segment 361 | */ 362 | private static function buildInClause($field, $values, &$params, $invert = false) { 363 | if (count($values) === 0) { 364 | return '1=1'; 365 | } 366 | 367 | $i = 0; 368 | $placeholders = []; 369 | // Generate a unique prefix to use for all the query parameters 370 | $prefix = ':value' . mt_rand(0, 9999999); 371 | foreach ($values as $value) { 372 | $param_name = $prefix . $i; 373 | $params[$param_name] = $value; 374 | $placeholders[] = $param_name; 375 | $i++; 376 | } 377 | $clause = $field; 378 | if (!empty($invert)) { 379 | $clause = $clause . ' NOT'; 380 | } 381 | $clause = $clause . ' IN (' . implode(', ', $placeholders) . ')'; 382 | return $clause; 383 | } 384 | } 385 | 386 | DB::init(); 387 | -------------------------------------------------------------------------------- /inc/feedwriter.php: -------------------------------------------------------------------------------- 1 | feedID = $id; 8 | $this->baseURL = 9 | url_scheme() . 10 | $_SERVER['SERVER_NAME'] . ':' . $_SERVER['SERVER_PORT'] . 11 | rtrim(dirname($_SERVER['REQUEST_URI']), '/') . '/'; 12 | } 13 | 14 | public function write(array $results) { 15 | $this->beginFeed(); 16 | foreach ($results as $result) { 17 | $this->addEntry($result); 18 | } 19 | return $this->feed->asXML(); 20 | } 21 | 22 | public function writeToOutput(array $results) { 23 | header('Content-Type: application/atom+xml; type=feed; charset=UTF-8'); 24 | echo $this->write($results); 25 | } 26 | 27 | private function beginFeed() { 28 | $this->feed = simplexml_load_string( 29 | ' 30 | 36 | 37 | '); 38 | 39 | $this->feed->addChild('id', $this->baseURL . $this->feedID); 40 | $this->addWithAttribs($this->feed, 'title', $this->feedID, ['type' => 'text']); 41 | $this->feed->addChild('updated', static::formatDate(time())); 42 | $this->addWithAttribs($this->feed, 'link', null, [ 43 | 'rel' => 'self', 44 | 'title' => $this->feedID, 45 | 'href' => $this->feedID 46 | ]); 47 | } 48 | 49 | private function addEntry($row) { 50 | $entry_id = 'Packages(Id=\'' . $row['PackageId'] . '\',Version=\'' . $row['Version'] . '\')'; 51 | $entry = $this->feed->addChild('entry'); 52 | $entry->addChild('id', 'https://www.nuget.org/api/v2/' . $entry_id); 53 | $this->addWithAttribs($entry, 'category', null, [ 54 | 'term' => 'NuGetGallery.V2FeedPackage', 55 | 'scheme' => 'http://schemas.microsoft.com/ado/2007/08/dataservices/scheme', 56 | ]); 57 | $this->addWithAttribs($entry, 'link', null, [ 58 | 'rel' => 'edit', 59 | 'title' => 'V2FeedPackage', 60 | 'href' => $entry_id, 61 | ]); 62 | // Yes, this is correct. This "title" is actually the package ID 63 | // The actual title is in the metadata section. lolwat. 64 | $this->addWithAttribs($entry, 'title', $row['PackageId'], ['type' => 'text']); 65 | $this->addWithAttribs($entry, 'summary', null, ['type' => 'text']); 66 | $entry->addChild('updated', static::formatDate($row['Created'])); 67 | 68 | $authors = $entry->addChild('author'); 69 | $authors->addChild('name', $row['Authors']); 70 | 71 | $this->addWithAttribs($entry, 'link', null, [ 72 | 'rel' => 'edit-media', 73 | 'title' => 'V2FeedPackage', 74 | 'href' => $entry_id . '/$value', 75 | ]); 76 | $this->addWithAttribs($entry, 'content', null, [ 77 | 'type' => 'application/zip', 78 | 'src' => $this->baseURL . 'download/' . $row['PackageId'] . '/' . $row['Version'], 79 | ]); 80 | $this->addEntryMeta($entry, $row); 81 | } 82 | 83 | private function addEntryMeta($entry, $row) { 84 | $properties = $entry->addChild( 85 | 'properties', 86 | null, 87 | 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata' 88 | ); 89 | 90 | $meta = [ 91 | 'Version' => $row['Version'], 92 | 'NormalizedVersion' => $row['Version'], 93 | 'Copyright' => $row['Copyright'], 94 | 'Created' => static::renderMetaDate($row['Created']), 95 | 'Dependencies' => $this->renderDependencies($row['Dependencies']), 96 | 'Description' => htmlspecialchars($row['Description']), 97 | 'DownloadCount' => ['value' => $row['DownloadCount'], 'type' => 'Edm.Int32'], 98 | 'GalleryDetailsUrl' => $this->baseURL . 'details/' . $row['PackageId'] . '/' . $row['Version'], 99 | 'IconUrl' => htmlspecialchars($row['IconUrl']), 100 | 'IsLatestVersion' => static::renderMetaBoolean($row['LatestVersion'] === $row['Version']), 101 | 'IsAbsoluteLatestVersion' => static::renderMetaBoolean($row['LatestVersion'] === $row['Version']), 102 | 'IsPrerelease' => static::renderMetaBoolean($row['IsPrerelease']), 103 | 'Language' => null, 104 | 'Published' => static::renderMetaDate($row['Created']), 105 | 'PackageHash' => $row['PackageHash'], 106 | 'PackageHashAlgorithm' => $row['PackageHashAlgorithm'], 107 | 'PackageSize' => ['value' => $row['PackageSize'], 'type' => 'Edm.Int64'], 108 | 'ProjectUrl' => $row['ProjectUrl'], 109 | 'ReportAbuseUrl' => '', 110 | 'ReleaseNotes' => htmlspecialchars($row['ReleaseNotes']), 111 | 'RequireLicenseAcceptance' => static::renderMetaBoolean($row['RequireLicenseAcceptance']), 112 | 'Summary' => null, 113 | 'Tags' => $row['Tags'], 114 | 'Title' => $row['Title'], 115 | 'VersionDownloadCount' => ['value' => $row['VersionDownloadCount'], 'type' => 'Edm.Int32'], 116 | 'MinClientVersion' => '', 117 | 'LastEdited' => ['value' => null, 'type' => 'Edm.DateTime'], 118 | 'LicenseUrl' => $row['LicenseUrl'], 119 | 'LicenseNames' => '', 120 | 'LicenseReportUrl' => '', 121 | ]; 122 | 123 | foreach ($meta as $name => $data) { 124 | if (is_array($data)) { 125 | $value = $data['value']; 126 | $type = $data['type']; 127 | } else { 128 | $value = $data; 129 | $type = null; 130 | } 131 | 132 | $this->addMeta($properties, $name, $value, $type); 133 | } 134 | 135 | } 136 | 137 | private static function renderMetaDate($date) { 138 | return [ 139 | 'value' => static::formatDate($date), 140 | 'type' => 'Edm.DateTime' 141 | ]; 142 | } 143 | 144 | private static function renderMetaBoolean($value) { 145 | return [ 146 | 'value' => $value ? 'true' : 'false', 147 | 'type' => 'Edm.Boolean' 148 | ]; 149 | } 150 | 151 | private static function formatDate($date) { 152 | return gmdate('Y-m-d\TH:i:s\Z', $date); 153 | } 154 | 155 | private function renderDependencies($raw) { 156 | if (!$raw) { 157 | return ''; 158 | } 159 | 160 | $data = json_decode($raw); 161 | if (!$data) { 162 | return ''; 163 | } 164 | 165 | $output = []; 166 | // Hax: Previous versions used an associative array of id => version for the 167 | // dependencies, but newer versions use a 'real' array. Determine if we're 168 | // using the old format or the new format. 169 | if (is_array($data)) { 170 | foreach ($data as $dependency) { 171 | $formatted_dependency = 172 | $dependency->id . ':' . 173 | $dependency->version . ':'; 174 | if (!empty($dependency->framework)) { 175 | $formatted_dependency .= $this->formatTargetFramework($dependency->framework); 176 | } 177 | $output[] = $formatted_dependency; 178 | } 179 | } else { 180 | // Legacy format 181 | foreach ($data as $id => $version) { 182 | $output[] = $id . ':' . $version . ':'; 183 | } 184 | } 185 | return implode('|', $output); 186 | } 187 | 188 | /** 189 | * Formats a raw target framework from a NuSpec into the format used in the 190 | * packages feed (eg. "DNX4.5.1" -> "dnx451", "DNXCore5.0" -> "dnxcore50"). 191 | */ 192 | private function formatTargetFramework($framework) { 193 | return strtolower(preg_replace('/[^A-Z0-9]/i', '', $framework)); 194 | } 195 | 196 | private function addWithAttribs($entry, $name, $value, $attributes) { 197 | $node = $entry->addChild($name, $value); 198 | foreach ($attributes as $attrib_name => $attrib_value) { 199 | $node->addAttribute($attrib_name, $attrib_value); 200 | } 201 | } 202 | 203 | private function addMeta($entry, $name, $value, $type = null) { 204 | $node = $entry->addChild( 205 | $name, 206 | $value, 207 | 'http://schemas.microsoft.com/ado/2007/08/dataservices' 208 | ); 209 | if ($type) { 210 | $node->addAttribute( 211 | 'm:type', 212 | $type, 213 | 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata' 214 | ); 215 | } 216 | if ($value === null) { 217 | $node->addAttribute( 218 | 'm:null', 219 | 'true', 220 | 'http://schemas.microsoft.com/ado/2007/08/dataservices/metadata' 221 | ); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /nginx.conf.example: -------------------------------------------------------------------------------- 1 | # Example of an Nginx configuration for Simple NuGet Server 2 | 3 | server { 4 | server_name example.com; 5 | root /var/www/simple-nuget-server/public/; 6 | 7 | rewrite ^/$ /index.php; 8 | rewrite ^/\$metadata$ /metadata.xml; 9 | rewrite ^/Search\(\)/\$count$ /count.php; 10 | rewrite ^/Search\(\)$ /search.php; 11 | rewrite ^/Packages\(\)$ /search.php; 12 | rewrite ^/Packages\(Id='([^']+)',Version='([^']+)'\)$ /findByID.php?id=$1&version=$2; 13 | rewrite ^/GetUpdates\(\)$ /updates.php; 14 | rewrite ^/FindPackagesById\(\)$ /findByID.php; 15 | # NuGet.exe sometimes uses two slashes (//download/blah) 16 | rewrite ^//?download/([^/]+)/([^/]+)$ /download.php?id=$1&version=$2; 17 | rewrite ^/([^/]+)/([^/]+)$ /delete.php?id=$1&version=$2; 18 | 19 | # NuGet.exe adds /api/v2/ to URL when the server is at the root 20 | rewrite ^/api/v2/package/$ /index.php; 21 | rewrite ^/api/v2/package/([^/]+)/([^/]+)$ /delete.php?id=$1&version=$2; 22 | 23 | location ~ \.php$ { 24 | include fastcgi_params; 25 | fastcgi_pass php; 26 | } 27 | 28 | location = /index.php { 29 | dav_methods PUT DELETE; 30 | include fastcgi_params; 31 | fastcgi_pass php; 32 | 33 | # PHP doesn't parse request body for PUT requests, so fake a POST. 34 | fastcgi_param REQUEST_METHOD POST; 35 | fastcgi_param HTTP_X_METHOD_OVERRIDE $request_method; 36 | } 37 | 38 | # Used with X-Accel-Redirect 39 | location /packagefiles { 40 | internal; 41 | root /var/www/simple-nuget-server/; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packagefiles/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel15/simple-nuget-server/6a3f9b9ae3e9942e425c0a20430953f566314120/packagefiles/.gitkeep -------------------------------------------------------------------------------- /public/count.php: -------------------------------------------------------------------------------- 1 | writeToOutput($results); 14 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | "; 13 | ?> 14 | 18 | 19 | Default 20 | 21 | Packages 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /public/push.php: -------------------------------------------------------------------------------- 1 | open($upload_filename); 14 | $nuspec_index = false; 15 | for ($i = 0; $i < $package_zip->numFiles; $i++) { 16 | if (substr($package_zip->getNameIndex($i), -7) === '.nuspec') { 17 | $nuspec_index = $i; 18 | break; 19 | } 20 | } 21 | if ($nuspec_index === false) { 22 | api_error('400', 'NuSpec file not found in package'); 23 | } 24 | $nuspec_string = $package_zip->getFromIndex($nuspec_index); 25 | $nuspec = simplexml_load_string($nuspec_string); 26 | 27 | if (!$nuspec->metadata->id || !$nuspec->metadata->version) { 28 | api_error('400', 'ID or version is missing'); 29 | } 30 | 31 | $id = (string)$nuspec->metadata->id; 32 | $version = (string)$nuspec->metadata->version; 33 | $valid_id = '/^[A-Z0-9\.\~\+\_\-]+$/i'; 34 | if (!preg_match($valid_id, $id) || !preg_match($valid_id, $version)) { 35 | api_error('400', 'Invalid ID or version'); 36 | } 37 | 38 | if (DB::validateIdAndVersion($id, $version)) { 39 | api_error('409', 'This package version already exists'); 40 | } 41 | 42 | $hash = base64_encode(hash_file('sha512', $upload_filename, true)); 43 | $filesize = filesize($_FILES['package']['tmp_name']); 44 | $dependencies = []; 45 | 46 | 47 | if ($nuspec->metadata->dependencies) { 48 | if ($nuspec->metadata->dependencies->dependency) { 49 | // Dependencies that are not specific to any framework 50 | foreach ($nuspec->metadata->dependencies->dependency as $dependency) { 51 | $dependencies[] = [ 52 | 'framework' => null, 53 | 'id' => (string)$dependency['id'], 54 | 'version' => (string)$dependency['version'] 55 | ]; 56 | } 57 | } 58 | 59 | if ($nuspec->metadata->dependencies->group) { 60 | // Dependencies that are specific to a particular framework 61 | foreach ($nuspec->metadata->dependencies->group as $group) { 62 | if ($group->dependency) { 63 | foreach ($group->dependency as $dependency) { 64 | $dependencies[] = [ 65 | 'framework' => (string)$group['targetFramework'], 66 | 'id' => (string)$dependency['id'], 67 | 'version' => (string)$dependency['version'] 68 | ]; 69 | } 70 | } else { 71 | // Group doesn't have any dependencies but we still need to save the framework 72 | $dependencies[] = [ 73 | 'framework' => (string)$group['targetFramework'], 74 | 'id' => '', 75 | 'version' => '' 76 | ]; 77 | } 78 | } 79 | } 80 | } 81 | 82 | // Move package into place. 83 | $dir = Config::$packageDir . $id . DIRECTORY_SEPARATOR; 84 | $path = $dir . $version . '.nupkg'; 85 | 86 | if (!file_exists($dir)) { 87 | mkdir($dir, /* mode */ 0755, /* recursive */ true); 88 | } 89 | if (!move_uploaded_file($upload_filename, $path)) { 90 | api_error('500', 'Could not save file'); 91 | } 92 | 93 | // Update database 94 | DB::insertOrUpdatePackage([ 95 | ':id' => $id, 96 | ':title' => $nuspec->metadata->title, 97 | ':version' => $version 98 | ]); 99 | DB::insertVersion([ 100 | ':Authors' => $nuspec->metadata->authors, 101 | ':Copyright' => $nuspec->metadata->copyright, 102 | ':Dependencies' => $dependencies, 103 | ':Description' => $nuspec->metadata->description, 104 | ':PackageHash' => $hash, 105 | ':PackageHashAlgorithm' => 'SHA512', 106 | ':PackageSize' => $filesize, 107 | ':IconUrl' => $nuspec->metadata->iconUrl, 108 | ':IsPrerelease' => strpos($version, '-') !== false, 109 | ':LicenseUrl' => $nuspec->metadata->licenseUrl, 110 | ':Owners' => $nuspec->metadata->owners, 111 | ':PackageId' => $id, 112 | ':ProjectUrl' => $nuspec->metadata->projectUrl, 113 | ':ReleaseNotes' => $nuspec->metadata->releaseNotes, 114 | ':RequireLicenseAcceptance' => $nuspec->metadata->requireLicenseAcceptance === 'true', 115 | ':Tags' => $nuspec->metadata->tags, 116 | ':Title' => $nuspec->metadata->title, 117 | ':Version' => $version, 118 | ]); 119 | 120 | // All done! 121 | header('HTTP/1.1 201 Created'); 122 | -------------------------------------------------------------------------------- /public/search.php: -------------------------------------------------------------------------------- 1 | !empty($_GET['includeprerelease']), 7 | 'orderBy' => isset($_GET['$orderby']) ? $_GET['$orderby'] : '', 8 | 'filter' => isset($_GET['$filter']) ? $_GET['$filter'] : '', 9 | 'searchQuery' => isset($_GET['searchterm']) ? trim($_GET['searchterm'], '\'') : '', 10 | 'top' => isset($_GET['$top']) ? $_GET['$top'] : '', 11 | 'offset' => isset($_GET['$skip']) ? $_GET['$skip'] : '' 12 | ]); 13 | $feed = new FeedWriter('Search'); 14 | $feed->writeToOutput($results); 15 | -------------------------------------------------------------------------------- /public/updates.php: -------------------------------------------------------------------------------- 1 | !empty($_GET['includeprerelease']), 11 | 'packages' => $package_to_version, 12 | ]); 13 | $feed = new FeedWriter('GetUpdates'); 14 | $feed->writeToOutput($results); 15 | --------------------------------------------------------------------------------