diff --git a/Installer.iss b/Installer.iss index 9ef1c7d06..7c61fc808 100644 --- a/Installer.iss +++ b/Installer.iss @@ -23,8 +23,8 @@ DefaultDirName={pf}\{#MyAppName} DefaultGroupName={#MyAppName} DisableProgramGroupPage=yes OutputBaseFilename={#MyOutputFilename} -SetupIconFile=src\Jackett.Console\jackett.ico -UninstallDisplayIcon={commonappdata}\Jackett\JackettConsole.exe +SetupIconFile=src\Jackett.Tray\jackett.ico +UninstallDisplayIcon={commonappdata}\Jackett\{#MyAppExeName} VersionInfoVersion={#MyAppVersion} UninstallDisplayName={#MyAppName} Compression=lzma @@ -47,16 +47,16 @@ Name: "{group}\{#MyAppName}"; Filename: "{commonappdata}\Jackett\{#MyAppExeName} Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" Name: "{commondesktop}\{#MyAppName}"; Filename: "{commonappdata}\Jackett\{#MyAppExeName}"; Tasks: desktopicon -[Run] -Filename: "{commonappdata}\Jackett\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent - [Run] Filename: "{commonappdata}\Jackett\JackettConsole.exe"; Parameters: "--Uninstall"; Flags: waituntilterminated runhidden; Filename: "{commonappdata}\Jackett\JackettConsole.exe"; Parameters: "--ReserveUrls"; Flags: waituntilterminated runhidden; Filename: "{commonappdata}\Jackett\JackettConsole.exe"; Parameters: "--Install"; Flags: waituntilterminated runhidden; Tasks: windowsService Filename: "{commonappdata}\Jackett\JackettConsole.exe"; Parameters: "--Start"; Flags: waituntilterminated runhidden; Tasks: windowsService +Filename: "{commonappdata}\Jackett\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent [UninstallRun] Filename: "{commonappdata}\Jackett\JackettConsole.exe"; Parameters: "--Uninstall"; Flags: waituntilterminated skipifdoesntexist runhidden +Filename: "{sys}\taskkill.exe"; Parameters: "/f /im {#MyAppExeName}"; Flags: waituntilterminated skipifdoesntexist runhidden +Filename: "{sys}\taskkill.exe"; Parameters: "/f /im JackettConsole.exe"; Flags: waituntilterminated skipifdoesntexist runhidden diff --git a/build.cake b/build.cake index 275fc06e6..8e882f994 100644 --- a/build.cake +++ b/build.cake @@ -6,7 +6,7 @@ ////////////////////////////////////////////////////////////////////// var target = Argument("target", "Default"); -var configuration = Argument("configuration", "Release"); +var configuration = Argument("configuration", "Debug"); ////////////////////////////////////////////////////////////////////// // PREPARATION @@ -36,8 +36,8 @@ Task("Clean") .IsDependentOn("Info") .Does(() => { - CleanDirectories("./src/**/obj" + configuration); - CleanDirectories("./src/**/bin" + configuration); + CleanDirectories("./src/**/obj"); + CleanDirectories("./src/**/bin"); CleanDirectories("./BuildOutput"); CleanDirectories("./" + artifactsDirName); CleanDirectories("./" + testResultsDirName); @@ -149,15 +149,8 @@ Task("Package-Files-Full-Framework-Mono") .IsDependentOn("Check-Packaging-Platform") .Does(() => { - var cygMonoBuildPath = RelativeWinPathToCygPath(monoBuildFullFramework); - var tarFileName = "Jackett.Binaries.Mono.tar"; - var tarArguments = @"-cvf " + cygMonoBuildPath + "/" + tarFileName + " -C " + cygMonoBuildPath + " Jackett --mode ='755'"; - var gzipArguments = @"-k " + cygMonoBuildPath + "/" + tarFileName; - - RunCygwinCommand("Tar", tarArguments); - RunCygwinCommand("Gzip", gzipArguments); - - MoveFile($"{monoBuildFullFramework}/{tarFileName}.gz", $"./{artifactsDirName}/{tarFileName}.gz"); + Gzip(monoBuildFullFramework, $"./{artifactsDirName}", "Jackett", "Jackett.Binaries.Mono.tar.gz"); + Information(@"Full Framework Mono Binaries Gzip Completed"); }); Task("Package-Full-Framework") @@ -169,8 +162,117 @@ Task("Package-Full-Framework") Information("Full Framework Packaging Completed"); }); +Task("Kestrel-Full-Framework") + .IsDependentOn("Package-Full-Framework") + .Does(() => + { + CleanDirectories("./src/**/obj"); + CleanDirectories("./src/**/bin"); + + //Patch csproj to net461 until off Owin (net452 can't handle a nestandard library) + var trayCsproj = File("./src/Jackett.Tray/Jackett.Tray.csproj"); + XmlPoke(trayCsproj, "*[name()='Project']/*[name()='PropertyGroup']/*[name()='TargetFrameworkVersion']", "v4.6.1"); + + var serviceCsproj = File("./src/Jackett.Service/Jackett.Service.csproj"); + XmlPoke(serviceCsproj, "*[name()='Project']/*[name()='PropertyGroup']/*[name()='TargetFrameworkVersion']", "v4.6.1"); + + NuGetRestore("./src/Jackett.sln"); + + var buildSettings = new MSBuildSettings() + .SetConfiguration(configuration) + .UseToolVersion(MSBuildToolVersion.VS2017); + + MSBuild("./src/Jackett.sln", buildSettings); + }); + +Task("Experimental-Kestrel-Windows-Full-Framework") + .IsDependentOn("Kestrel-Full-Framework") + .Does(() => + { + string serverProjectPath = "./src/Jackett.Server/Jackett.Server.csproj"; + string buildOutputPath = "./BuildOutput/Experimental/net461/win7-x86/Jackett"; + + DotNetCorePublish(serverProjectPath, "net461", "win7-x86"); + + CopyFiles("./src/Jackett.Service/bin/" + configuration + "/JackettService.*", buildOutputPath); + CopyFiles("./src/Jackett.Tray/bin/" + configuration + "/JackettTray.*", buildOutputPath); + CopyFiles("./src/Jackett.Updater/bin/" + configuration + "/net461" + "/JackettUpdater.*", buildOutputPath); //builds against multiple frameworks + + Zip("./BuildOutput/Experimental/net461/win7-x86", $"./{artifactsDirName}/Experimental.Jackett.Binaries.Windows.zip"); + + //InnoSetup + string sourceFolder = MakeAbsolute(Directory(buildOutputPath)).ToString(); + + InnoSetupSettings settings = new InnoSetupSettings(); + settings.OutputDirectory = workingDir + "/" + artifactsDirName; + settings.Defines = new Dictionary + { + { "MyFileForVersion", sourceFolder + "/Jackett.Common.dll" }, + { "MySourceFolder", sourceFolder }, + { "MyOutputFilename", "Experimental.Jackett.Installer.Windows" }, + }; + + InnoSetup("./Installer.iss", settings); + }); + +Task("Experimental-Kestrel-Mono-Full-Framework") + .IsDependentOn("Kestrel-Full-Framework") + .Does(() => + { + string serverProjectPath = "./src/Jackett.Server/Jackett.Server.csproj"; + string buildOutputPath = "./BuildOutput/Experimental/net461/linux-x64/Jackett"; + + DotNetCorePublish(serverProjectPath, "net461", "linux-x64"); + + CopyFiles("./src/Jackett.Updater/bin/" + configuration + "/net461" + "/JackettUpdater.*", buildOutputPath); //builds against multiple frameworks + + //There is an issue with Mono 5.8 (fixed in Mono 5.12) where its expecting to use its own patched version of System.Net.Http.dll, instead of the version supplied in folder + //https://github.com/dotnet/corefx/issues/19914 + //https://bugzilla.xamarin.com/show_bug.cgi?id=60315 + //The workaround is to delete System.Net.Http.dll and patch the .exe.config file + + DeleteFile(buildOutputPath + "/System.Net.Http.dll"); + + var configFile = File(buildOutputPath + "/JackettConsole.exe.config"); + XmlPoke(configFile, "configuration/runtime/*[name()='assemblyBinding']/*[name()='dependentAssembly']/*[name()='assemblyIdentity'][@name='System.Net.Http']/../*[name()='bindingRedirect']/@newVersion", "4.0.0.0"); + + Gzip("./BuildOutput/Experimental/net461/linux-x64", $"./{artifactsDirName}", "Jackett", "Experimental.Jackett.Binaries.Mono.tar.gz"); + }); + +Task("Experimental-DotNetCore") + .IsDependentOn("Kestrel-Full-Framework") + .Does(() => + { + string serverProjectPath = "./src/Jackett.Server/Jackett.Server.csproj"; + + DotNetCorePublish(serverProjectPath, "netcoreapp2.1", "win-x86"); + DotNetCorePublish(serverProjectPath, "netcoreapp2.1", "linux-x64"); + DotNetCorePublish(serverProjectPath, "netcoreapp2.1", "osx-x64"); + + Zip("./BuildOutput/Experimental/netcoreapp2.1/win-x86", $"./{artifactsDirName}/Experimental.netcoreapp.win-x86.zip"); + Zip("./BuildOutput/Experimental/netcoreapp2.1/osx-x64", $"./{artifactsDirName}/Experimental.netcoreapp.osx-x64.zip"); + Gzip("./BuildOutput/Experimental/netcoreapp2.1/linux-x64", $"./{artifactsDirName}", "Jackett", "Experimental.netcoreapp.linux-x64.tar.gz"); + }); + +Task("Experimental") + .IsDependentOn("Experimental-Kestrel-Windows-Full-Framework") + .IsDependentOn("Experimental-Kestrel-Mono-Full-Framework") + //.IsDependentOn("Experimental-DotNetCore") + .Does(() => + { + //Unpatch csproj because it's annoying in source control (remove once off Owin) + var trayCsproj = File("./src/Jackett.Tray/Jackett.Tray.csproj"); + XmlPoke(trayCsproj, "*[name()='Project']/*[name()='PropertyGroup']/*[name()='TargetFrameworkVersion']", "v4.5.2"); + + var serviceCsproj = File("./src/Jackett.Service/Jackett.Service.csproj"); + XmlPoke(serviceCsproj, "*[name()='Project']/*[name()='PropertyGroup']/*[name()='TargetFrameworkVersion']", "v4.5.2"); + + Information("Experimental builds completed"); + }); + Task("Appveyor-Push-Artifacts") .IsDependentOn("Package-Full-Framework") + .IsDependentOn("Experimental") .Does(() => { if (AppVeyor.IsRunningOnAppVeyor) @@ -276,10 +378,35 @@ private void RunCygwinCommand(string utility, string utilityArguments) private string RelativeWinPathToCygPath(string relativePath) { var cygdriveBase = "/cygdrive/" + workingDir.ToString().Replace(":", "").Replace("\\", "/"); - var cygPath = cygdriveBase + relativePath.Replace(".", ""); + var cygPath = cygdriveBase + relativePath.TrimStart('.'); return cygPath; } +private void Gzip(string sourceFolder, string outputDirectory, string tarCdirectoryOption, string outputFileName) +{ + var cygSourcePath = RelativeWinPathToCygPath(sourceFolder); + var tarFileName = outputFileName.Remove(outputFileName.Length - 3, 3); + var tarArguments = @"-cvf " + cygSourcePath + "/" + tarFileName + " -C " + cygSourcePath + $" {tarCdirectoryOption} --mode ='755'"; + var gzipArguments = @"-k " + cygSourcePath + "/" + tarFileName; + + RunCygwinCommand("Tar", tarArguments); + RunCygwinCommand("Gzip", gzipArguments); + + MoveFile($"{sourceFolder}/{tarFileName}.gz", $"{outputDirectory}/{tarFileName}.gz"); +} + +private void DotNetCorePublish(string projectPath, string framework, string runtime) +{ + var settings = new DotNetCorePublishSettings + { + Framework = framework, + Runtime = runtime, + OutputDirectory = $"./BuildOutput/Experimental/{framework}/{runtime}/Jackett" + }; + + DotNetCorePublish(projectPath, settings); +} + ////////////////////////////////////////////////////////////////////// // TASK TARGETS ////////////////////////////////////////////////////////////////////// diff --git a/src/Jackett.Common/Engine.cs b/src/Jackett.Common/Engine.cs index 892a7ce8d..e7c960f12 100644 --- a/src/Jackett.Common/Engine.cs +++ b/src/Jackett.Common/Engine.cs @@ -13,7 +13,6 @@ using Jackett.Common.Services.Interfaces; using Jackett.Common.Utils.Clients; using NLog; using NLog.Config; -using NLog.LayoutRenderers; using NLog.Targets; namespace Jackett.Common @@ -179,7 +178,7 @@ namespace Jackett.Common var logFileName = settings.CustomLogFileName ?? "log.txt"; var logLevel = settings.TracingEnabled ? LogLevel.Debug : LogLevel.Info; // Add custom date time format renderer as the default is too long - ConfigurationItemFactory.Default.LayoutRenderers.RegisterDefinition("simpledatetime", typeof(SimpleDateTimeRenderer)); + ConfigurationItemFactory.Default.LayoutRenderers.RegisterDefinition("simpledatetime", typeof(Utils.LoggingSetup.SimpleDateTimeRenderer)); var logConfig = new LoggingConfiguration(); var logFile = new FileTarget(); @@ -265,13 +264,4 @@ namespace Jackett.Common ConfigService.SaveConfig(ServerConfig); } } - - [LayoutRenderer("simpledatetime")] - public class SimpleDateTimeRenderer : LayoutRenderer - { - protected override void Append(StringBuilder builder, LogEventInfo logEvent) - { - builder.Append(DateTime.Now.ToString("MM-dd HH:mm:ss")); - } - } } \ No newline at end of file diff --git a/src/Jackett.Common/Indexers/Newpct.cs b/src/Jackett.Common/Indexers/Newpct.cs index cad054d80..a355f626e 100644 --- a/src/Jackett.Common/Indexers/Newpct.cs +++ b/src/Jackett.Common/Indexers/Newpct.cs @@ -28,6 +28,7 @@ namespace Jackett.Common.Indexers class NewpctRelease : ReleaseInfo { + public string SerieName; public int? Season; public int? Episode; public int? EpisodeTo; @@ -364,7 +365,7 @@ namespace Jackett.Common.Indexers Match match = _titleListRegex.Match(title); if (match.Success) { - string name = match.Groups[1].Value.Trim(' ', '-'); + result.SerieName = match.Groups[1].Value.Trim(' ', '-'); result.Season = int.Parse(match.Groups[4].Success ? match.Groups[4].Value.Trim() : "1"); result.Episode = int.Parse(match.Groups[7].Value.Trim().PadLeft(2, '0')); result.EpisodeTo = match.Groups[10].Success ? (int?)int.Parse(match.Groups[10].Value.Trim()) : null; @@ -376,7 +377,7 @@ namespace Jackett.Common.Indexers string episodeToText = result.EpisodeTo.HasValue ? "_" + seasonText + result.EpisodeTo.ToString().PadLeft(2, '0') : ""; result.Title = string.Format("{0} - Temporada {1} [{2}][Cap.{3}{4}][{5}]", - name, seasonText, quality, episodeText, episodeToText, audioQuality); + result.SerieName, seasonText, quality, episodeText, episodeToText, audioQuality); } else { @@ -412,7 +413,22 @@ namespace Jackett.Common.Indexers result.Seeders = 1; result.Peers = 1; + result.Title = FixedTitle(result, quality); + return result; } + + private string FixedTitle(NewpctRelease release, string quality) + { + var titleParts = new List(); + titleParts.Add(release.SerieName); + titleParts.Add("S" + release.Season.ToString().PadLeft(2, '0') + "E" + release.Episode.ToString().PadLeft(2, '0')); + titleParts.Add(quality); + if (release.Title.ToLower().Contains("esp") || release.Title.ToLower().Contains("cast")) + { + titleParts.Add("Spanish"); + } + return String.Join(".", titleParts); + } } } diff --git a/src/Jackett.Common/Plumbing/JackettModule.cs b/src/Jackett.Common/Plumbing/JackettModule.cs index bebd1713c..2a405182c 100644 --- a/src/Jackett.Common/Plumbing/JackettModule.cs +++ b/src/Jackett.Common/Plumbing/JackettModule.cs @@ -53,6 +53,9 @@ namespace Jackett.Common.Plumbing // Register the best web client for the platform or the override switch (_runtimeSettings.ClientOverride) { + case "httpclientnetcore": + // do nothing, registered by the netcore app + break; case "httpclient": RegisterWebClient(builder); break; diff --git a/src/Jackett.Common/Services/UpdateService.cs b/src/Jackett.Common/Services/UpdateService.cs index 2bde084e1..065ff2a24 100644 --- a/src/Jackett.Common/Services/UpdateService.cs +++ b/src/Jackett.Common/Services/UpdateService.cs @@ -324,20 +324,21 @@ namespace Jackett.Common.Services startInfo.Arguments += " --StartTray"; } - if (isWindows) - { - lockService.Signal(); - logger.Info("Signal sent to lock service"); - Thread.Sleep(2000); - } - logger.Info($"Starting updater: {startInfo.FileName} {startInfo.Arguments}"); var procInfo = Process.Start(startInfo); logger.Info($"Updater started process id: {procInfo.Id}"); if (!NoRestart) { + if (isWindows) + { + logger.Info("Signal sent to lock service"); + lockService.Signal(); + Thread.Sleep(2000); + } + logger.Info("Exiting Jackett.."); + //TODO: Remove once off Owin if (EnvironmentUtil.IsRunningLegacyOwin) { diff --git a/src/Jackett.Common/Utils/LoggingSetup.cs b/src/Jackett.Common/Utils/LoggingSetup.cs index e73a5ecf8..c052684e7 100644 --- a/src/Jackett.Common/Utils/LoggingSetup.cs +++ b/src/Jackett.Common/Utils/LoggingSetup.cs @@ -1,8 +1,12 @@ using Jackett.Common.Models.Config; using Jackett.Common.Services; +using NLog; using NLog.Config; +using NLog.LayoutRenderers; using NLog.Targets; +using System; using System.IO; +using System.Text; namespace Jackett.Common.Utils { @@ -45,5 +49,14 @@ namespace Jackett.Common.Utils return logConfig; } + + [LayoutRenderer("simpledatetime")] + public class SimpleDateTimeRenderer : LayoutRenderer + { + protected override void Append(StringBuilder builder, LogEventInfo logEvent) + { + builder.Append(DateTime.Now.ToString("MM-dd HH:mm:ss")); + } + } } } diff --git a/src/Jackett.Server/Controllers/BlackholeController.cs b/src/Jackett.Server/Controllers/BlackholeController.cs new file mode 100644 index 000000000..e71b3b741 --- /dev/null +++ b/src/Jackett.Server/Controllers/BlackholeController.cs @@ -0,0 +1,98 @@ +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using NLog; +using System; +using System.IO; +using System.Net; +using System.Threading.Tasks; + +namespace Jackett.Server.Controllers +{ + [AllowAnonymous] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + [Route("bh/{indexerID}")] + public class BlackholeController : Controller + { + private Logger logger; + private IIndexerManagerService indexerService; + private readonly ServerConfig serverConfig; + private IProtectionService protectionService; + + public BlackholeController(IIndexerManagerService i, Logger l, ServerConfig config, IProtectionService ps) + { + logger = l; + indexerService = i; + serverConfig = config; + protectionService = ps; + } + + [HttpGet] + public async Task Blackhole(string indexerID, string path, string jackett_apikey, string file) + { + var jsonReply = new JObject(); + try + { + var indexer = indexerService.GetWebIndexer(indexerID); + if (!indexer.IsConfigured) + { + logger.Warn(string.Format("Rejected a request to {0} which is unconfigured.", indexer.DisplayName)); + throw new Exception("This indexer is not configured."); + } + + if (serverConfig.APIKey != jackett_apikey) + throw new Exception("Incorrect API key"); + + path = WebUtility.UrlDecode(path); + path = protectionService.UnProtect(path); + var remoteFile = new Uri(path, UriKind.RelativeOrAbsolute); + var fileExtension = ".torrent"; + var downloadBytes = await indexer.Download(remoteFile); + + // handle magnet URLs + if (downloadBytes.Length >= 7 + && downloadBytes[0] == 0x6d // m + && downloadBytes[1] == 0x61 // a + && downloadBytes[2] == 0x67 // g + && downloadBytes[3] == 0x6e // n + && downloadBytes[4] == 0x65 // e + && downloadBytes[5] == 0x74 // t + && downloadBytes[6] == 0x3a // : + ) + { + fileExtension = ".magnet"; + } + + if (string.IsNullOrWhiteSpace(serverConfig.BlackholeDir)) + { + throw new Exception("Blackhole directory not set!"); + } + + if (!Directory.Exists(serverConfig.BlackholeDir)) + { + throw new Exception("Blackhole directory does not exist: " + serverConfig.BlackholeDir); + } + + var fileName = DateTime.Now.Ticks.ToString() + "-" + StringUtil.MakeValidFileName(indexer.DisplayName, '_', false); + if (string.IsNullOrWhiteSpace(file)) + fileName += fileExtension; + else + fileName += "-" + StringUtil.MakeValidFileName(file + fileExtension, '_', false); // call MakeValidFileName() again to avoid any possibility of path traversal attacks + + System.IO.File.WriteAllBytes(Path.Combine(serverConfig.BlackholeDir, fileName), downloadBytes); + jsonReply["result"] = "success"; + } + catch (Exception ex) + { + logger.Error(ex, "Error downloading to blackhole " + indexerID + " " + path); + jsonReply["result"] = "error"; + jsonReply["error"] = ex.Message; + } + + return Json(jsonReply); + } + } +} diff --git a/src/Jackett.Server/Controllers/DownloadController.cs b/src/Jackett.Server/Controllers/DownloadController.cs new file mode 100644 index 000000000..99b8a5fc9 --- /dev/null +++ b/src/Jackett.Server/Controllers/DownloadController.cs @@ -0,0 +1,96 @@ +using BencodeNET.Parsing; +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; +using NLog; +using System; +using System.Text; +using System.Threading.Tasks; + +namespace Jackett.Server.Controllers +{ + [AllowAnonymous] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + [Route("dl/{indexerID}")] + public class DownloadController : Controller + { + private ServerConfig config; + private Logger logger; + private IIndexerManagerService indexerService; + private IProtectionService protectionService; + + public DownloadController(IIndexerManagerService i, Logger l, IProtectionService ps, ServerConfig serverConfig) + { + config = serverConfig; + logger = l; + indexerService = i; + protectionService = ps; + } + + [HttpGet] + public async Task Download(string indexerID, string path, string jackett_apikey, string file) + { + try + { + var indexer = indexerService.GetWebIndexer(indexerID); + + if (!indexer.IsConfigured) + { + logger.Warn(string.Format("Rejected a request to {0} which is unconfigured.", indexer.DisplayName)); + return Forbid("This indexer is not configured."); + } + + path = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(path)); + path = protectionService.UnProtect(path); + + if (config.APIKey != jackett_apikey) + return Unauthorized(); + + var target = new Uri(path, UriKind.RelativeOrAbsolute); + var downloadBytes = await indexer.Download(target); + + // handle magnet URLs + if (downloadBytes.Length >= 7 + && downloadBytes[0] == 0x6d // m + && downloadBytes[1] == 0x61 // a + && downloadBytes[2] == 0x67 // g + && downloadBytes[3] == 0x6e // n + && downloadBytes[4] == 0x65 // e + && downloadBytes[5] == 0x74 // t + && downloadBytes[6] == 0x3a // : + ) + { + var magneturi = Encoding.UTF8.GetString(downloadBytes); + return Redirect(new Uri(magneturi).ToString()); + } + + // This will fix torrents where the keys are not sorted, and thereby not supported by Sonarr. + byte[] sortedDownloadBytes = null; + try + { + var parser = new BencodeParser(); + var torrentDictionary = parser.Parse(downloadBytes); + sortedDownloadBytes = torrentDictionary.EncodeAsBytes(); + } + catch (Exception e) + { + var content = indexer.Encoding.GetString(downloadBytes); + logger.Error(content); + throw new Exception("BencodeParser failed", e); + } + + string fileName = StringUtil.MakeValidFileName(file, '_', false) + ".torrent"; // call MakeValidFileName again to avoid any kind of injection attack + + return File(sortedDownloadBytes, "application/x-bittorrent", fileName); + } + catch (Exception e) + { + logger.Error(e, "Error downloading " + indexerID + " " + path); + return NotFound(); + } + } + } +} diff --git a/src/Jackett.Server/Controllers/IndexerApiController.cs b/src/Jackett.Server/Controllers/IndexerApiController.cs new file mode 100644 index 000000000..8e740266a --- /dev/null +++ b/src/Jackett.Server/Controllers/IndexerApiController.cs @@ -0,0 +1,178 @@ +using Jackett.Common.Indexers; +using Jackett.Common.Models; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Jackett.Server.Controllers +{ + public interface IIndexerController + { + IIndexerManagerService IndexerService { get; } + IIndexer CurrentIndexer { get; set; } + } + + public class RequiresIndexer : IActionFilter + { + public void OnActionExecuting(ActionExecutingContext context) + { + var controller = context.Controller; + if (!(controller is IIndexerController)) + return; + + var indexerController = controller as IIndexerController; + + var parameters = context.RouteData.Values; + + if (!parameters.ContainsKey("indexerId")) + { + indexerController.CurrentIndexer = null; + return; + } + + var indexerId = parameters["indexerId"] as string; + if (indexerId.IsNullOrEmptyOrWhitespace()) + return; + + var indexerService = indexerController.IndexerService; + var indexer = indexerService.GetIndexer(indexerId); + indexerController.CurrentIndexer = indexer; + } + + public void OnActionExecuted(ActionExecutedContext context) + { + // do something after the action executes + } + } + + [Route("api/v2.0/indexers")] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + public class IndexerApiController : Controller, IIndexerController + { + public IIndexerManagerService IndexerService { get; private set; } + public IIndexer CurrentIndexer { get; set; } + private Logger logger; + private IServerService serverService; + private ICacheService cacheService; + + public IndexerApiController(IIndexerManagerService indexerManagerService, IServerService ss, ICacheService c, Logger logger) + { + IndexerService = indexerManagerService; + serverService = ss; + cacheService = c; + this.logger = logger; + } + + [HttpGet] + [TypeFilter(typeof(RequiresIndexer))] + [Route("{indexerId?}/Config")] + public async Task Config() + { + var config = await CurrentIndexer.GetConfigurationForSetup(); + return Ok(config.ToJson(null)); + } + + [HttpPost] + [Route("{indexerId?}/Config")] + [TypeFilter(typeof(RequiresIndexer))] + public async Task UpdateConfig([FromBody]Common.Models.DTO.ConfigItem[] config) + { + try + { + // HACK + var jsonString = JsonConvert.SerializeObject(config); + var json = JToken.Parse(jsonString); + + var configurationResult = await CurrentIndexer.ApplyConfiguration(json); + + if (configurationResult == IndexerConfigurationStatus.RequiresTesting) + { + await IndexerService.TestIndexer(CurrentIndexer.ID); + } + + return new NoContentResult(); + } + catch + { + var baseIndexer = CurrentIndexer as BaseIndexer; + if (null != baseIndexer) + baseIndexer.ResetBaseConfig(); + throw; + } + } + + [HttpGet] + [Route("")] + public IEnumerable Indexers() + { + var dto = IndexerService.GetAllIndexers().Select(i => new Common.Models.DTO.Indexer(i)); + return dto; + } + + [HttpPost] + [Route("{indexerid}/[action]")] + [TypeFilter(typeof(RequiresIndexer))] + public async Task Test() + { + JToken jsonReply = new JObject(); + try + { + await IndexerService.TestIndexer(CurrentIndexer.ID); + CurrentIndexer.LastError = null; + return NoContent(); + } + catch (Exception ex) + { + var msg = ex.Message; + if (ex.InnerException != null) + msg += ": " + ex.InnerException.Message; + + if (CurrentIndexer != null) + CurrentIndexer.LastError = msg; + + throw; + } + } + + [HttpDelete] + [TypeFilter(typeof(RequiresIndexer))] + [Route("{indexerid}")] + public void Delete() + { + IndexerService.DeleteIndexer(CurrentIndexer.ID); + } + + // TODO + // This should go to ServerConfigurationController + [Route("Cache")] + [HttpGet] + public List Cache() + { + var results = cacheService.GetCachedResults(); + ConfigureCacheResults(results); + return results; + } + + private void ConfigureCacheResults(IEnumerable results) + { + var serverUrl = serverService.GetServerUrl(Request); + foreach (var result in results) + { + var link = result.Link; + var file = StringUtil.MakeValidFileName(result.Title, '_', false); + result.Link = serverService.ConvertToProxyLink(link, serverUrl, result.TrackerId, "dl", file); + if (result.Link != null && result.Link.Scheme != "magnet" && !string.IsNullOrWhiteSpace(serverService.GetBlackholeDirectory())) + result.BlackholeLink = serverService.ConvertToProxyLink(link, serverUrl, result.TrackerId, "bh", file); + } + } + + } +} diff --git a/src/Jackett.Server/Controllers/ResultsController.cs b/src/Jackett.Server/Controllers/ResultsController.cs new file mode 100644 index 000000000..8eed2f51f --- /dev/null +++ b/src/Jackett.Server/Controllers/ResultsController.cs @@ -0,0 +1,544 @@ +using Jackett.Common; +using Jackett.Common.Indexers; +using Jackett.Common.Indexers.Meta; +using Jackett.Common.Models; +using Jackett.Common.Models.DTO; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using NLog; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; + +namespace Jackett.Server.Controllers +{ + public class RequiresApiKey : IActionFilter + { + public IServerService serverService; + + public RequiresApiKey(IServerService ss) + { + serverService = ss; + } + + public void OnActionExecuting(ActionExecutingContext context) + { + var validApiKey = serverService.GetApiKey(); + var queryParams = context.HttpContext.Request.Query; + var queryApiKey = queryParams.Where(x => x.Key == "apikey" || x.Key == "passkey").Select(x => x.Value).FirstOrDefault(); + +#if DEBUG + if (Debugger.IsAttached) + { + return; + } +#endif + if (queryApiKey != validApiKey) + { + context.Result = ResultsController.GetErrorActionResult(context.RouteData, HttpStatusCode.Unauthorized, 100, "Invalid API Key"); + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { + // do something after the action executes + } + } + + public class RequiresConfiguredIndexer : IActionFilter + { + public void OnActionExecuting(ActionExecutingContext context) + { + var controller = context.Controller; + if (!(controller is IIndexerController)) + return; + + var indexerController = controller as IIndexerController; + + var parameters = context.RouteData.Values; + + if (!parameters.ContainsKey("indexerId")) + { + indexerController.CurrentIndexer = null; + context.Result = ResultsController.GetErrorActionResult(context.RouteData, HttpStatusCode.NotFound, 200, "Indexer is not specified"); + return; + } + + var indexerId = parameters["indexerId"] as string; + if (indexerId.IsNullOrEmptyOrWhitespace()) + { + indexerController.CurrentIndexer = null; + context.Result = ResultsController.GetErrorActionResult(context.RouteData, HttpStatusCode.NotFound, 201, "Indexer is not specified (empty value)"); + return; + } + + var indexerService = indexerController.IndexerService; + var indexer = indexerService.GetIndexer(indexerId); + + if (indexer == null) + { + indexerController.CurrentIndexer = null; + context.Result = ResultsController.GetErrorActionResult(context.RouteData, HttpStatusCode.NotFound, 201, "Indexer is not supported"); + return; + } + + if (!indexer.IsConfigured) + { + indexerController.CurrentIndexer = null; + context.Result = ResultsController.GetErrorActionResult(context.RouteData, HttpStatusCode.NotFound, 201, "Indexer is not configured"); + return; + } + + indexerController.CurrentIndexer = indexer; + } + + public void OnActionExecuted(ActionExecutedContext context) + { + // do something after the action executes + } + } + + public class RequiresValidQuery : IActionFilter + { + public void OnActionExecuting(ActionExecutingContext context) + { + //TODO: Not sure what this is meant to do + //if (context.HttpContext.Response != null) + // return; + + var controller = context.Controller; + if (!(controller is IResultController)) + { + return; + } + + var resultController = controller as IResultController; + + var query = context.ActionArguments.First().Value; + var queryType = query.GetType(); + var converter = queryType.GetMethod("ToTorznabQuery", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + if (converter == null) + { + context.Result = ResultsController.GetErrorActionResult(context.RouteData, HttpStatusCode.BadRequest, 900, "ToTorznabQuery() not found"); + } + + var converted = converter.Invoke(null, new object[] { query }); + var torznabQuery = converted as TorznabQuery; + resultController.CurrentQuery = torznabQuery; + + if (queryType == typeof(ApiSearch)) // Skip CanHandleQuery() check for manual search (CurrentIndexer isn't used during manul search) + { + return; + } + + if (!resultController.CurrentIndexer.CanHandleQuery(resultController.CurrentQuery)) + { + context.Result = ResultsController.GetErrorActionResult(context.RouteData, HttpStatusCode.BadRequest, 201, $"{resultController.CurrentIndexer.ID} " + + $"does not support the requested query. Please check the capabilities (t=caps) and make sure the search mode and categories are supported."); + + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { + // do something after the action executes + } + } + + public interface IResultController : IIndexerController + { + TorznabQuery CurrentQuery { get; set; } + } + + [AllowAnonymous] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + [Route("api/v2.0/indexers/{indexerId}/results")] + [TypeFilter(typeof(RequiresApiKey))] + [TypeFilter(typeof(RequiresConfiguredIndexer))] + [TypeFilter(typeof(RequiresValidQuery))] + public class ResultsController : Controller, IResultController + { + public IIndexerManagerService IndexerService { get; private set; } + public IIndexer CurrentIndexer { get; set; } + public TorznabQuery CurrentQuery { get; set; } + private Logger logger; + private IServerService serverService; + private ICacheService cacheService; + private Common.Models.Config.ServerConfig serverConfig; + + public ResultsController(IIndexerManagerService indexerManagerService, IServerService ss, ICacheService c, Logger logger, Common.Models.Config.ServerConfig sConfig) + { + IndexerService = indexerManagerService; + serverService = ss; + cacheService = c; + this.logger = logger; + serverConfig = sConfig; + } + + [Route("")] + [HttpGet] + public async Task Results([FromQuery] ApiSearch requestt) + { + //TODO: Better way to parse querystring + + ApiSearch request = new ApiSearch(); + + foreach (var t in Request.Query) + { + if (t.Key == "Tracker[]") + { + request.Tracker = t.Value.ToString().Split(','); + } + + if (t.Key == "Category[]") + { + request.Category = t.Value.ToString().Split(',').Select(Int32.Parse).ToArray(); + } + + if (t.Key == "query") + { + request.Query = t.Value.ToString(); + } + } + + var manualResult = new ManualSearchResult(); + var trackers = IndexerService.GetAllIndexers().ToList().Where(t => t.IsConfigured); + if (request.Tracker != null) + { + trackers = trackers.Where(t => request.Tracker.Contains(t.ID)); + } + + trackers = trackers.Where(t => t.CanHandleQuery(CurrentQuery)); + + var tasks = trackers.ToList().Select(t => t.ResultsForQuery(CurrentQuery)).ToList(); + try + { + var aggregateTask = Task.WhenAll(tasks); + await aggregateTask; + } + catch (AggregateException aex) + { + foreach (var ex in aex.InnerExceptions) + { + logger.Error(ex); + } + } + catch (Exception ex) + { + logger.Error(ex); + } + + manualResult.Indexers = tasks.Select(t => + { + var resultIndexer = new ManualSearchResultIndexer(); + IIndexer indexer = null; + if (t.Status == TaskStatus.RanToCompletion) + { + resultIndexer.Status = ManualSearchResultIndexerStatus.OK; + resultIndexer.Results = t.Result.Releases.Count(); + resultIndexer.Error = null; + indexer = t.Result.Indexer; + } + else if (t.Exception.InnerException is IndexerException) + { + resultIndexer.Status = ManualSearchResultIndexerStatus.Error; + resultIndexer.Results = 0; + resultIndexer.Error = ((IndexerException)t.Exception.InnerException).ToString(); + indexer = ((IndexerException)t.Exception.InnerException).Indexer; + } + else + { + resultIndexer.Status = ManualSearchResultIndexerStatus.Unknown; + resultIndexer.Results = 0; + resultIndexer.Error = null; + } + + if (indexer != null) + { + resultIndexer.ID = indexer.ID; + resultIndexer.Name = indexer.DisplayName; + } + return resultIndexer; + }).ToList(); + + manualResult.Results = tasks.Where(t => t.Status == TaskStatus.RanToCompletion).Where(t => t.Result.Releases.Any()).SelectMany(t => + { + var searchResults = t.Result.Releases; + var indexer = t.Result.Indexer; + cacheService.CacheRssResults(indexer, searchResults); + + return searchResults.Select(result => + { + var item = AutoMapper.Mapper.Map(result); + item.Tracker = indexer.DisplayName; + item.TrackerId = indexer.ID; + item.Peers = item.Peers - item.Seeders; // Use peers as leechers + + return item; + }); + }).OrderByDescending(d => d.PublishDate).ToList(); + + ConfigureCacheResults(manualResult.Results); + + logger.Info(string.Format("Manual search for \"{0}\" on {1} with {2} results.", CurrentQuery.SanitizedSearchTerm, string.Join(", ", manualResult.Indexers.Select(i => i.ID)), manualResult.Results.Count())); + return Json(manualResult); + } + + [Route("[action]/{ignored?}")] + [HttpGet] + public async Task Torznab([FromQuery]TorznabRequest request) + { + if (string.Equals(CurrentQuery.QueryType, "caps", StringComparison.InvariantCultureIgnoreCase)) + { + return Content(CurrentIndexer.TorznabCaps.ToXml(), "application/rss+xml", Encoding.UTF8); + } + + // indexers - returns a list of all included indexers (meta indexers only) + if (string.Equals(CurrentQuery.QueryType, "indexers", StringComparison.InvariantCultureIgnoreCase)) + { + if (!(CurrentIndexer is BaseMetaIndexer)) // shouldn't be needed because CanHandleQuery should return false + { + logger.Warn($"A search request with t=indexers from {Request.HttpContext.Connection.RemoteIpAddress} was made but the indexer {CurrentIndexer.DisplayName} isn't a meta indexer."); + return GetErrorXML(203, "Function Not Available: this isn't a meta indexer"); + } + var CurrentBaseMetaIndexer = (BaseMetaIndexer)CurrentIndexer; + var indexers = CurrentBaseMetaIndexer.Indexers; + if (string.Equals(request.configured, "true", StringComparison.InvariantCultureIgnoreCase)) + indexers = indexers.Where(i => i.IsConfigured); + else if (string.Equals(request.configured, "false", StringComparison.InvariantCultureIgnoreCase)) + indexers = indexers.Where(i => !i.IsConfigured); + + var xdoc = new XDocument( + new XDeclaration("1.0", "UTF-8", null), + new XElement("indexers", + from i in indexers + select new XElement("indexer", + new XAttribute("id", i.ID), + new XAttribute("configured", i.IsConfigured), + new XElement("title", i.DisplayName), + new XElement("description", i.DisplayDescription), + new XElement("link", i.SiteLink), + new XElement("language", i.Language), + new XElement("type", i.Type), + i.TorznabCaps.GetXDocument().FirstNode + ) + ) + ); + + return Content(xdoc.Declaration.ToString() + Environment.NewLine + xdoc.ToString(), "application/xml", Encoding.UTF8); + } + + if (CurrentQuery.ImdbID != null) + { + if (!string.IsNullOrEmpty(CurrentQuery.SearchTerm)) + { + logger.Warn($"A search request from {Request.HttpContext.Connection.RemoteIpAddress} was made containing q and imdbid."); + return GetErrorXML(201, "Incorrect parameter: please specify either imdbid or q"); + } + + CurrentQuery.ImdbID = ParseUtil.GetFullImdbID(CurrentQuery.ImdbID); // normalize ImdbID + if (CurrentQuery.ImdbID == null) + { + logger.Warn($"A search request from {Request.HttpContext.Connection.RemoteIpAddress} was made with an invalid imdbid."); + return GetErrorXML(201, "Incorrect parameter: invalid imdbid format"); + } + + if (!CurrentIndexer.TorznabCaps.SupportsImdbSearch) + { + logger.Warn($"A search request with imdbid from {Request.HttpContext.Connection.RemoteIpAddress} was made but the indexer {CurrentIndexer.DisplayName} doesn't support it."); + return GetErrorXML(203, "Function Not Available: imdbid is not supported by this indexer"); + } + } + + try + { + var result = await CurrentIndexer.ResultsForQuery(CurrentQuery); + + // Some trackers do not support multiple category filtering so filter the releases that match manually. + int? newItemCount = null; + + // Cache non query results + if (string.IsNullOrEmpty(CurrentQuery.SanitizedSearchTerm)) + { + newItemCount = cacheService.GetNewItemCount(CurrentIndexer, result.Releases); + cacheService.CacheRssResults(CurrentIndexer, result.Releases); + } + + // Log info + var logBuilder = new StringBuilder(); + if (newItemCount != null) + { + logBuilder.AppendFormat("Found {0} ({1} new) releases from {2}", result.Releases.Count(), newItemCount, CurrentIndexer.DisplayName); + } + else + { + logBuilder.AppendFormat("Found {0} releases from {1}", result.Releases.Count(), CurrentIndexer.DisplayName); + } + + if (!string.IsNullOrWhiteSpace(CurrentQuery.SanitizedSearchTerm)) + { + logBuilder.AppendFormat(" for: {0}", CurrentQuery.GetQueryString()); + } + + logger.Info(logBuilder.ToString()); + + var serverUrl = serverService.GetServerUrl(Request); + var resultPage = new ResultPage(new ChannelInfo + { + Title = CurrentIndexer.DisplayName, + Description = CurrentIndexer.DisplayDescription, + Link = new Uri(CurrentIndexer.SiteLink), + ImageUrl = new Uri(serverUrl + "logos/" + CurrentIndexer.ID + ".png"), + ImageTitle = CurrentIndexer.DisplayName, + ImageLink = new Uri(CurrentIndexer.SiteLink), + ImageDescription = CurrentIndexer.DisplayName + }); + + var proxiedReleases = result.Releases.Select(r => AutoMapper.Mapper.Map(r)).Select(r => + { + r.Link = serverService.ConvertToProxyLink(r.Link, serverUrl, r.Origin.ID, "dl", r.Title); + return r; + }); + + resultPage.Releases = proxiedReleases.ToList(); + + var xml = resultPage.ToXml(new Uri(serverUrl)); + // Force the return as XML + + return Content(xml, "application/rss+xml", Encoding.UTF8); + } + catch (Exception ex) + { + logger.Error(ex); + return GetErrorXML(900, ex.ToString()); + } + } + + [Route("[action]/{ignored?}")] + public IActionResult GetErrorXML(int code, string description) + { + return Content(CreateErrorXML(code, description), "application/xml", Encoding.UTF8); + } + + public static string CreateErrorXML(int code, string description) + { + var xdoc = new XDocument( + new XDeclaration("1.0", "UTF-8", null), + new XElement("error", + new XAttribute("code", code.ToString()), + new XAttribute("description", description) + ) + ); + return xdoc.Declaration + Environment.NewLine + xdoc; + } + + public static IActionResult GetErrorActionResult(RouteData routeData, HttpStatusCode status, int torznabCode, string description) + { + bool isTorznab = routeData.Values["action"].ToString().Equals("torznab", StringComparison.OrdinalIgnoreCase); + + if (isTorznab) + { + ContentResult contentResult = new ContentResult + { + Content = CreateErrorXML(torznabCode, description), + ContentType = "application/xml", + StatusCode = 200 + }; + return contentResult; + } + else + { + switch (status) + { + case HttpStatusCode.Unauthorized: + return new UnauthorizedResult(); + case HttpStatusCode.NotFound: + return new NotFoundObjectResult(description); + case HttpStatusCode.BadRequest: + return new BadRequestObjectResult(description); + default: + return new ContentResult + { + Content = description, + StatusCode = (int)status + }; + } + } + } + + [Route("[action]/{ignored?}")] + [HttpGet] + public async Task Potato([FromQuery]TorrentPotatoRequest request) + { + var result = await CurrentIndexer.ResultsForQuery(CurrentQuery); + + // Cache non query results + if (string.IsNullOrEmpty(CurrentQuery.SanitizedSearchTerm)) + cacheService.CacheRssResults(CurrentIndexer, result.Releases); + + // Log info + if (string.IsNullOrWhiteSpace(CurrentQuery.SanitizedSearchTerm)) + logger.Info($"Found {result.Releases.Count()} torrentpotato releases from {CurrentIndexer.DisplayName}"); + else + logger.Info($"Found {result.Releases.Count()} torrentpotato releases from {CurrentIndexer.DisplayName} for: {CurrentQuery.GetQueryString()}"); + + var serverUrl = serverService.GetServerUrl(Request); + var potatoReleases = result.Releases.Where(r => r.Link != null || r.MagnetUri != null).Select(r => + { + var release = AutoMapper.Mapper.Map(r); + release.Link = serverService.ConvertToProxyLink(release.Link, serverUrl, CurrentIndexer.ID, "dl", release.Title); + var item = new TorrentPotatoResponseItem() + { + release_name = release.Title + "[" + CurrentIndexer.DisplayName + "]", // Suffix the indexer so we can see which tracker we are using in CPS as it just says torrentpotato >.> + torrent_id = release.Guid.ToString(), + details_url = release.Comments.ToString(), + download_url = (release.Link != null ? release.Link.ToString() : release.MagnetUri.ToString()), + imdb_id = release.Imdb.HasValue ? "tt" + release.Imdb : null, + freeleech = (release.DownloadVolumeFactor == 0 ? true : false), + type = "movie", + size = (long)release.Size / (1024 * 1024), // This is in MB + leechers = (release.Peers ?? -1) - (release.Seeders ?? 0), + seeders = release.Seeders ?? -1, + publish_date = r.PublishDate == DateTime.MinValue ? null : release.PublishDate.ToUniversalTime().ToString("s") + }; + return item; + }); + + var potatoResponse = new TorrentPotatoResponse() + { + results = potatoReleases.ToList() + }; + + return potatoResponse; + } + + [Route("[action]/{ignored?}")] + private void ConfigureCacheResults(IEnumerable results) + { + var serverUrl = serverService.GetServerUrl(Request); + foreach (var result in results) + { + var link = result.Link; + var file = StringUtil.MakeValidFileName(result.Title, '_', false); + result.Link = serverService.ConvertToProxyLink(link, serverUrl, result.TrackerId, "dl", file); + if (!string.IsNullOrWhiteSpace(serverConfig.BlackholeDir)) + { + if (result.Link != null) + result.BlackholeLink = serverService.ConvertToProxyLink(link, serverUrl, result.TrackerId, "bh", file); + else if (result.MagnetUri != null) + result.BlackholeLink = serverService.ConvertToProxyLink(result.MagnetUri, serverUrl, result.TrackerId, "bh", file); + } + } + } + + } +} diff --git a/src/Jackett.Server/Controllers/ServerConfigurationController.cs b/src/Jackett.Server/Controllers/ServerConfigurationController.cs new file mode 100644 index 000000000..bd32be677 --- /dev/null +++ b/src/Jackett.Server/Controllers/ServerConfigurationController.cs @@ -0,0 +1,194 @@ +using Jackett.Common.Models; +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Microsoft.AspNetCore.Mvc; +using NLog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace Jackett.Server.Controllers +{ + [Route("api/v2.0/server/[action]")] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + public class ServerConfigurationController : Controller + { + private readonly IConfigurationService configService; + private ServerConfig serverConfig; + private IServerService serverService; + private IProcessService processService; + private IIndexerManagerService indexerService; + private ISecuityService securityService; + private IUpdateService updater; + private ILogCacheService logCache; + private Logger logger; + + public ServerConfigurationController(IConfigurationService c, IServerService s, IProcessService p, IIndexerManagerService i, ISecuityService ss, IUpdateService u, ILogCacheService lc, Logger l, ServerConfig sc) + { + configService = c; + serverConfig = sc; + serverService = s; + processService = p; + indexerService = i; + securityService = ss; + updater = u; + logCache = lc; + logger = l; + } + + [HttpPost] + public IActionResult AdminPassword([FromBody]string password) + { + var oldPassword = serverConfig.AdminPassword; + if (string.IsNullOrEmpty(password)) + password = null; + + if (oldPassword != password) + { + serverConfig.AdminPassword = securityService.HashPassword(password); + configService.SaveConfig(serverConfig); + } + + return new NoContentResult(); + } + + [HttpPost] + public void Update() + { + updater.CheckForUpdatesNow(); + } + + [HttpGet] + public Common.Models.DTO.ServerConfig Config() + { + var dto = new Common.Models.DTO.ServerConfig(serverService.notices, serverConfig, configService.GetVersion()); + return dto; + } + + [ActionName("Config")] + [HttpPost] + public IActionResult UpdateConfig([FromBody]Common.Models.DTO.ServerConfig config) + { + var originalPort = serverConfig.Port; + var originalAllowExternal = serverConfig.AllowExternal; + int port = config.port; + bool external = config.external; + string saveDir = config.blackholedir; + bool updateDisabled = config.updatedisabled; + bool preRelease = config.prerelease; + bool logging = config.logging; + string basePathOverride = config.basepathoverride; + if (basePathOverride != null) + { + basePathOverride = basePathOverride.TrimEnd('/'); + if (!string.IsNullOrWhiteSpace(basePathOverride) && !basePathOverride.StartsWith("/")) + throw new Exception("The Base Path Override must start with a /"); + } + + string omdbApiKey = config.omdbkey; + + serverConfig.UpdateDisabled = updateDisabled; + serverConfig.UpdatePrerelease = preRelease; + serverConfig.BasePathOverride = basePathOverride; + serverConfig.RuntimeSettings.BasePath = serverService.BasePath(); + configService.SaveConfig(serverConfig); + + Helper.SetLogLevel(logging ? LogLevel.Debug : LogLevel.Info); + serverConfig.RuntimeSettings.TracingEnabled = logging; + + if (omdbApiKey != serverConfig.OmdbApiKey) + { + serverConfig.OmdbApiKey = omdbApiKey; + configService.SaveConfig(serverConfig); + // HACK + indexerService.InitAggregateIndexer(); + } + + if (config.proxy_type != serverConfig.ProxyType || + config.proxy_url != serverConfig.ProxyUrl || + config.proxy_port != serverConfig.ProxyPort || + config.proxy_username != serverConfig.ProxyUsername || + config.proxy_password != serverConfig.ProxyPassword) + { + if (config.proxy_port < 1 || config.proxy_port > 65535) + throw new Exception("The port you have selected is invalid, it must be below 65535."); + + serverConfig.ProxyUrl = config.proxy_url; + serverConfig.ProxyType = config.proxy_type; + serverConfig.ProxyPort = config.proxy_port; + serverConfig.ProxyUsername = config.proxy_username; + serverConfig.ProxyPassword = config.proxy_password; + configService.SaveConfig(serverConfig); + } + + if (port != serverConfig.Port || external != serverConfig.AllowExternal) + { + if (ServerUtil.RestrictedPorts.Contains(port)) + throw new Exception("The port you have selected is restricted, try a different one."); + + if (port < 1 || port > 65535) + throw new Exception("The port you have selected is invalid, it must be below 65535."); + + // Save port to the config so it can be picked up by the if needed when running as admin below. + serverConfig.AllowExternal = external; + serverConfig.Port = port; + configService.SaveConfig(serverConfig); + + // On Windows change the url reservations + if (System.Environment.OSVersion.Platform != PlatformID.Unix) + { + if (!ServerUtil.IsUserAdministrator()) + { + try + { + var consoleExePath = System.Reflection.Assembly.GetExecutingAssembly().CodeBase.Replace(".dll", ".exe"); + processService.StartProcessAndLog(consoleExePath, "--ReserveUrls", true); + } + catch + { + serverConfig.Port = originalPort; + serverConfig.AllowExternal = originalAllowExternal; + configService.SaveConfig(serverConfig); + + throw new Exception("Failed to acquire admin permissions to reserve the new port."); + } + } + else + { + serverService.ReserveUrls(true); + } + } + + Thread.Sleep(500); + Helper.RestartWebHost(); + } + + if (saveDir != serverConfig.BlackholeDir) + { + if (!string.IsNullOrEmpty(saveDir)) + { + if (!Directory.Exists(saveDir)) + { + throw new Exception("Blackhole directory does not exist"); + } + } + + serverConfig.BlackholeDir = saveDir; + configService.SaveConfig(serverConfig); + } + + serverConfig.ConfigChanged(); + + return Json(serverConfig); + } + + [HttpGet] + public List Logs() + { + return logCache.Logs; + } + } +} diff --git a/src/Jackett.Server/Controllers/UIController.cs b/src/Jackett.Server/Controllers/UIController.cs new file mode 100644 index 000000000..43b49c215 --- /dev/null +++ b/src/Jackett.Server/Controllers/UIController.cs @@ -0,0 +1,105 @@ +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Jackett.Server.Controllers +{ + [Route("UI/[action]")] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + public class WebUIController : Controller + { + private IConfigurationService config; + private ServerConfig serverConfig; + private ISecuityService securityService; + private Logger logger; + + public WebUIController(IConfigurationService config, ISecuityService ss, ServerConfig s, Logger l) + { + this.config = config; + serverConfig = s; + securityService = ss; + logger = l; + } + + [HttpGet] + [AllowAnonymous] + public async Task Login() + { + if (string.IsNullOrEmpty(serverConfig.AdminPassword)) + { + await MakeUserAuthenticated(); + } + + if (User.Identity.IsAuthenticated) + { + return Redirect("Dashboard"); + } + + return new PhysicalFileResult(config.GetContentFolder() + "/login.html", "text/html"); ; + } + + [HttpGet] + [AllowAnonymous] + public async Task Logout() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Redirect("Login"); + } + + [HttpPost] + [AllowAnonymous] + public async Task Dashboard([FromForm] string password) + { + if (password != null && securityService.HashPassword(password) == serverConfig.AdminPassword) + { + await MakeUserAuthenticated(); + } + + return Redirect("Dashboard"); + } + + [HttpGet] + public IActionResult Dashboard() + { + bool logout = HttpContext.Request.Query.Where(x => String.Equals(x.Key, "logout", StringComparison.OrdinalIgnoreCase) + && String.Equals(x.Value, "true", StringComparison.OrdinalIgnoreCase)).Any(); + + if (logout) + { + return Redirect("Logout"); + } + + return new PhysicalFileResult(config.GetContentFolder() + "/index.html", "text/html"); + } + + //TODO: Move this to security service once off Mono + private async Task MakeUserAuthenticated() + { + var claims = new List + { + new Claim(ClaimTypes.Name, "Jackett", ClaimValueTypes.String) + }; + + var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(claimsIdentity), + new AuthenticationProperties + { + ExpiresUtc = DateTime.UtcNow.AddMinutes(20), + IsPersistent = false, + AllowRefresh = true + }); + } + } +} diff --git a/src/Jackett.Server/Helper.cs b/src/Jackett.Server/Helper.cs new file mode 100644 index 000000000..594c8a6d0 --- /dev/null +++ b/src/Jackett.Server/Helper.cs @@ -0,0 +1,160 @@ +using Autofac; +using AutoMapper; +using Jackett.Common.Models; +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils.Clients; +using Microsoft.AspNetCore.Hosting; +using NLog; +using System.Linq; +using System.Text; + +namespace Jackett.Server +{ + public static class Helper + { + public static IContainer ApplicationContainer { get; set; } + public static IApplicationLifetime applicationLifetime; + private static bool _automapperInitialised = false; + + public static void Initialize() + { + if (_automapperInitialised == false) + { + //Automapper only likes being initialized once per app domain. + //Since we can restart Jackett from the command line it's possible that we'll build the container more than once. (tests do this too) + InitAutomapper(); + _automapperInitialised = true; + } + + //Load the indexers + ServerService.Initalize(); + + //Kicks off the update checker + ServerService.Start(); + } + + public static void RestartWebHost() + { + Logger.Info("Restart of the web application host (not process) initiated"); + Program.isWebHostRestart = true; + applicationLifetime.StopApplication(); + } + + public static void StopWebHost() + { + Logger.Info("Jackett is being stopped"); + applicationLifetime.StopApplication(); + } + + public static IConfigurationService ConfigService + { + get + { + return ApplicationContainer.Resolve(); + } + } + + public static IServerService ServerService + { + get + { + return ApplicationContainer.Resolve(); + } + } + + public static IServiceConfigService ServiceConfigService + { + get + { + return ApplicationContainer.Resolve(); + } + } + + public static ServerConfig ServerConfiguration + { + get + { + return ApplicationContainer.Resolve(); + } + } + + public static Logger Logger + { + get + { + return ApplicationContainer.Resolve(); + } + } + + private static void InitAutomapper() + { + Mapper.Initialize(cfg => + { + cfg.CreateMap().ForMember(x => x.Content, opt => opt.Ignore()).AfterMap((be, str) => + { + var encoding = be.Request.Encoding ?? Encoding.UTF8; + str.Content = encoding.GetString(be.Content); + }); + + cfg.CreateMap().ForMember(x => x.Content, opt => opt.Ignore()).AfterMap((str, be) => + { + if (!string.IsNullOrEmpty(str.Content)) + { + var encoding = str.Request.Encoding ?? Encoding.UTF8; + be.Content = encoding.GetBytes(str.Content); + } + }); + + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + + cfg.CreateMap().AfterMap((r, t) => + { + if (r.Category != null) + { + t.CategoryDesc = string.Join(", ", r.Category.Select(x => TorznabCatType.GetCatDesc(x)).Where(x => !string.IsNullOrEmpty(x))); + } + else + { + t.CategoryDesc = ""; + } + }); + }); + } + + public static void SetupLogging(ContainerBuilder builder) + { + Logger logger = LogManager.GetCurrentClassLogger(); + + if (builder != null) + { + builder.RegisterInstance(logger).SingleInstance(); + } + } + + public static void SetLogLevel(LogLevel level) + { + foreach (var rule in LogManager.Configuration.LoggingRules) + { + if (level == LogLevel.Debug) + { + if (!rule.Levels.Contains(LogLevel.Debug)) + { + rule.EnableLoggingForLevel(LogLevel.Debug); + } + } + else + { + if (rule.Levels.Contains(LogLevel.Debug)) + { + rule.DisableLoggingForLevel(LogLevel.Debug); + } + } + } + + LogManager.ReconfigExistingLoggers(); + } + } +} diff --git a/src/Jackett.Server/HttpWebClientNetCore.cs b/src/Jackett.Server/HttpWebClientNetCore.cs new file mode 100644 index 000000000..6f8245c34 --- /dev/null +++ b/src/Jackett.Server/HttpWebClientNetCore.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using com.LandonKey.SocksWebProxy; +using com.LandonKey.SocksWebProxy.Proxy; +using CloudFlareUtilities; +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using NLog; +using Jackett.Common.Helpers; + +namespace Jackett.Common.Utils.Clients +{ + // custom HttpWebClient based WebClient for netcore (due to changed custom certificate validation API) + public class HttpWebClientNetCore : WebClient + { + static protected Dictionary> trustedCertificates = new Dictionary>(); + static protected string webProxyUrl; + static protected IWebProxy webProxy; + + static public void InitProxy(ServerConfig serverConfig) + { + // dispose old SocksWebProxy + if (webProxy != null && webProxy is SocksWebProxy) + { + ((SocksWebProxy)webProxy).Dispose(); + webProxy = null; + } + + webProxyUrl = serverConfig.GetProxyUrl(); + if (!string.IsNullOrWhiteSpace(webProxyUrl)) + { + if (serverConfig.ProxyType != ProxyType.Http) + { + var addresses = Dns.GetHostAddressesAsync(serverConfig.ProxyUrl).Result; + var socksConfig = new ProxyConfig + { + SocksAddress = addresses.FirstOrDefault(), + Username = serverConfig.ProxyUsername, + Password = serverConfig.ProxyPassword, + Version = serverConfig.ProxyType == ProxyType.Socks4 ? + ProxyConfig.SocksVersion.Four : + ProxyConfig.SocksVersion.Five + }; + if (serverConfig.ProxyPort.HasValue) + { + socksConfig.SocksPort = serverConfig.ProxyPort.Value; + } + webProxy = new SocksWebProxy(socksConfig, false); + } + else + { + NetworkCredential creds = null; + if (!serverConfig.ProxyIsAnonymous) + { + var username = serverConfig.ProxyUsername; + var password = serverConfig.ProxyPassword; + creds = new NetworkCredential(username, password); + } + webProxy = new WebProxy(webProxyUrl) + { + BypassProxyOnLocal = false, + Credentials = creds + }; + } + } + } + + public HttpWebClientNetCore(IProcessService p, Logger l, IConfigurationService c, ServerConfig sc) + : base(p: p, + l: l, + c: c, + sc: sc) + { + if (webProxyUrl == null) + InitProxy(sc); + } + + // Called everytime the ServerConfig changes + public override void OnNext(ServerConfig value) + { + var newProxyUrl = serverConfig.GetProxyUrl(); + if (webProxyUrl != newProxyUrl) // if proxy URL changed + InitProxy(serverConfig); + } + + override public void Init() + { + ServicePointManager.DefaultConnectionLimit = 1000; + + if (serverConfig.RuntimeSettings.IgnoreSslErrors == true) + { + logger.Info(string.Format("HttpWebClient: Disabling certificate validation")); + ServicePointManager.ServerCertificateValidationCallback += (sender, certificate, chain, sslPolicyErrors) => { return true; }; + } + } + + override protected async Task Run(WebRequest webRequest) + { + ServicePointManager.SecurityProtocol = (SecurityProtocolType)192 | (SecurityProtocolType)768 | (SecurityProtocolType)3072; + + var cookies = new CookieContainer(); + if (!string.IsNullOrEmpty(webRequest.Cookies)) + { + var uri = new Uri(webRequest.Url); + var cookieUrl = new Uri(uri.Scheme + "://" + uri.Host); // don't include the path, Scheme is needed for mono compatibility + foreach (var c in webRequest.Cookies.Split(';')) + { + try + { + cookies.SetCookies(cookieUrl, c.Trim()); + } + catch (CookieException ex) + { + logger.Info("(Non-critical) Problem loading cookie {0}, {1}, {2}", uri, c, ex.Message); + } + } + } + + using (ClearanceHandler clearanceHandlr = new ClearanceHandler()) + { + clearanceHandlr.MaxRetries = 30; + using (HttpClientHandler clientHandlr = new HttpClientHandler + { + CookieContainer = cookies, + AllowAutoRedirect = false, // Do not use this - Bugs ahoy! Lost cookies and more. + UseCookies = true, + Proxy = webProxy, + UseProxy = (webProxy != null), + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }) + { + // custom certificate validation handler (netcore version) + clientHandlr.ServerCertificateCustomValidationCallback = (request, certificate, chain, sslPolicyErrors) => + { + var hash = certificate.GetCertHashString(); + + ICollection hosts; + + trustedCertificates.TryGetValue(hash, out hosts); + if (hosts != null) + { + if (hosts.Contains(request.RequestUri.Host)) + return true; + } + return sslPolicyErrors == SslPolicyErrors.None; + }; + + clearanceHandlr.InnerHandler = clientHandlr; + using (var client = new HttpClient(clearanceHandlr)) + { + if (webRequest.EmulateBrowser == true) + client.DefaultRequestHeaders.Add("User-Agent", BrowserUtil.ChromeUserAgent); + else + client.DefaultRequestHeaders.Add("User-Agent", "Jackett/" + configService.GetVersion()); + + HttpResponseMessage response = null; + using (var request = new HttpRequestMessage()) + { + request.Headers.ExpectContinue = false; + request.RequestUri = new Uri(webRequest.Url); + + if (webRequest.Headers != null) + { + foreach (var header in webRequest.Headers) + { + if (header.Key != "Content-Type") + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + if (!string.IsNullOrEmpty(webRequest.Referer)) + request.Headers.Referrer = new Uri(webRequest.Referer); + + if (!string.IsNullOrEmpty(webRequest.RawBody)) + { + var type = webRequest.Headers.Where(h => h.Key == "Content-Type").Cast?>().FirstOrDefault(); + if (type.HasValue) + { + var str = new StringContent(webRequest.RawBody); + str.Headers.Remove("Content-Type"); + str.Headers.Add("Content-Type", type.Value.Value); + request.Content = str; + } + else + request.Content = new StringContent(webRequest.RawBody); + request.Method = HttpMethod.Post; + } + else if (webRequest.Type == RequestType.POST) + { + if (webRequest.PostData != null) + request.Content = new FormUrlEncodedContent(webRequest.PostData); + request.Method = HttpMethod.Post; + } + else + { + request.Method = HttpMethod.Get; + } + + using (response = await client.SendAsync(request)) + { + var result = new WebClientByteResult + { + Content = await response.Content.ReadAsByteArrayAsync() + }; + + foreach (var header in response.Headers) + { + IEnumerable value = header.Value; + result.Headers[header.Key.ToLowerInvariant()] = value.ToArray(); + } + + // some cloudflare clients are using a refresh header + // Pull it out manually + if (response.StatusCode == HttpStatusCode.ServiceUnavailable && response.Headers.Contains("Refresh")) + { + var refreshHeaders = response.Headers.GetValues("Refresh"); + var redirval = ""; + var redirtime = 0; + if (refreshHeaders != null) + { + foreach (var value in refreshHeaders) + { + var start = value.IndexOf("="); + var end = value.IndexOf(";"); + var len = value.Length; + if (start > -1) + { + redirval = value.Substring(start + 1); + result.RedirectingTo = redirval; + // normally we don't want a serviceunavailable (503) to be a redirect, but that's the nature + // of this cloudflare approach..don't want to alter BaseWebResult.IsRedirect because normally + // it shoudln't include service unavailable..only if we have this redirect header. + response.StatusCode = System.Net.HttpStatusCode.Redirect; + redirtime = Int32.Parse(value.Substring(0, end)); + System.Threading.Thread.Sleep(redirtime * 1000); + } + } + } + } + if (response.Headers.Location != null) + { + result.RedirectingTo = response.Headers.Location.ToString(); + } + // Mono won't add the baseurl to relative redirects. + // e.g. a "Location: /index.php" header will result in the Uri "file:///index.php" + // See issue #1200 + if (result.RedirectingTo != null && result.RedirectingTo.StartsWith("file://")) + { + // URL decoding apparently is needed to, without it e.g. Demonoid download is broken + // TODO: is it always needed (not just for relative redirects)? + var newRedirectingTo = WebUtilityHelpers.UrlDecode(result.RedirectingTo, webRequest.Encoding); + newRedirectingTo = newRedirectingTo.Replace("file://", request.RequestUri.Scheme + "://" + request.RequestUri.Host); + logger.Debug("[MONO relative redirect bug] Rewriting relative redirect URL from " + result.RedirectingTo + " to " + newRedirectingTo); + result.RedirectingTo = newRedirectingTo; + } + result.Status = response.StatusCode; + + // Compatiblity issue between the cookie format and httpclient + // Pull it out manually ignoring the expiry date then set it manually + // http://stackoverflow.com/questions/14681144/httpclient-not-storing-cookies-in-cookiecontainer + IEnumerable cookieHeaders; + var responseCookies = new List>(); + + if (response.Headers.TryGetValues("set-cookie", out cookieHeaders)) + { + foreach (var value in cookieHeaders) + { + var nameSplit = value.IndexOf('='); + if (nameSplit > -1) + { + responseCookies.Add(new Tuple(value.Substring(0, nameSplit), value.Substring(0, value.IndexOf(';') == -1 ? value.Length : (value.IndexOf(';'))) + ";")); + } + } + + var cookieBuilder = new StringBuilder(); + foreach (var cookieGroup in responseCookies.GroupBy(c => c.Item1)) + { + cookieBuilder.AppendFormat("{0} ", cookieGroup.Last().Item2); + } + result.Cookies = cookieBuilder.ToString().Trim(); + } + ServerUtil.ResureRedirectIsFullyQualified(webRequest, result); + return result; + } + } + } + } + } + } + + override public void AddTrustedCertificate(string host, string hash) + { + hash = hash.ToUpper(); + ICollection hosts; + trustedCertificates.TryGetValue(hash.ToUpper(), out hosts); + if (hosts == null) + { + hosts = new HashSet(); + trustedCertificates[hash] = hosts; + } + hosts.Add(host); + } + } +} diff --git a/src/Jackett.Server/Initialisation.cs b/src/Jackett.Server/Initialisation.cs new file mode 100644 index 000000000..809e6b7be --- /dev/null +++ b/src/Jackett.Server/Initialisation.cs @@ -0,0 +1,163 @@ +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Jackett.Server.Services; +using NLog; +using System; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Jackett.Server +{ + public static class Initialisation + { + public static void ProcessSettings(RuntimeSettings runtimeSettings, Logger logger) + { + if (runtimeSettings.ClientOverride != "httpclient" && runtimeSettings.ClientOverride != "httpclient2" && runtimeSettings.ClientOverride != "httpclientnetcore") + { + logger.Error($"Client override ({runtimeSettings.ClientOverride}) has been deprecated, please remove it from your start arguments"); + Environment.Exit(1); + } + + if (runtimeSettings.DoSSLFix != null) + { + logger.Error("SSLFix has been deprecated, please remove it from your start arguments"); + Environment.Exit(1); + } + + if (runtimeSettings.LogRequests) + { + logger.Info("Logging enabled."); + } + + if (runtimeSettings.TracingEnabled) + { + logger.Info("Tracing enabled."); + } + + if (runtimeSettings.IgnoreSslErrors == true) + { + logger.Error($"The IgnoreSslErrors option has been deprecated, please remove it from your start arguments"); + } + + if (!string.IsNullOrWhiteSpace(runtimeSettings.CustomDataFolder)) + { + logger.Info("Jackett Data will be stored in: " + runtimeSettings.CustomDataFolder); + } + + if (runtimeSettings.ProxyConnection != null) + { + logger.Info("Proxy enabled. " + runtimeSettings.ProxyConnection); + } + } + + public static void ProcessWindowsSpecificArgs(ConsoleOptions consoleOptions, IProcessService processService, ServerConfig serverConfig, Logger logger) + { + IServiceConfigService serviceConfigService = new ServiceConfigService(); + IServerService serverService = new ServerService(null, processService, null, null, logger, null, null, null, serverConfig); + + /* ====== Actions ===== */ + + // Install service + if (consoleOptions.Install) + { + logger.Info("Initiating Jackett service install"); + serviceConfigService.Install(); + Environment.Exit(1); + } + + // Uninstall service + if (consoleOptions.Uninstall) + { + logger.Info("Initiating Jackett service uninstall"); + serverService.ReserveUrls(false); + serviceConfigService.Uninstall(); + Environment.Exit(1); + } + + // Start Service + if (consoleOptions.StartService) + { + if (!serviceConfigService.ServiceRunning()) + { + logger.Info("Initiating Jackett service start"); + serviceConfigService.Start(); + } + Environment.Exit(1); + } + + // Stop Service + if (consoleOptions.StopService) + { + if (serviceConfigService.ServiceRunning()) + { + logger.Info("Initiating Jackett service stop"); + serviceConfigService.Stop(); + } + Environment.Exit(1); + } + + // Reserve urls + if (consoleOptions.ReserveUrls) + { + logger.Info("Initiating ReserveUrls"); + serverService.ReserveUrls(true); + Environment.Exit(1); + } + } + + public static void ProcessConsoleOverrides(ConsoleOptions consoleOptions, IProcessService processService, ServerConfig serverConfig, IConfigurationService configurationService, Logger logger) + { + IServerService serverService = new ServerService(null, processService, null, null, logger, null, null, null, serverConfig); + + // Override port + if (consoleOptions.Port != 0) + { + Int32.TryParse(serverConfig.Port.ToString(), out Int32 configPort); + + if (configPort != consoleOptions.Port) + { + logger.Info("Overriding port to " + consoleOptions.Port); + serverConfig.Port = consoleOptions.Port; + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + if (ServerUtil.IsUserAdministrator()) + { + serverService.ReserveUrls(true); + } + else + { + logger.Error("Unable to switch ports when not running as administrator"); + Environment.Exit(1); + } + } + configurationService.SaveConfig(serverConfig); + } + } + + // Override listen public + if (consoleOptions.ListenPublic || consoleOptions.ListenPrivate) + { + if (serverConfig.AllowExternal != consoleOptions.ListenPublic) + { + logger.Info("Overriding external access to " + consoleOptions.ListenPublic); + serverConfig.AllowExternal = consoleOptions.ListenPublic; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (ServerUtil.IsUserAdministrator()) + { + serverService.ReserveUrls(true); + } + else + { + logger.Error("Unable to switch to public listening without admin rights."); + Environment.Exit(1); + } + } + configurationService.SaveConfig(serverConfig); + } + } + } + } +} diff --git a/src/Jackett.Server/Jackett.Server.csproj b/src/Jackett.Server/Jackett.Server.csproj new file mode 100644 index 000000000..204a85226 --- /dev/null +++ b/src/Jackett.Server/Jackett.Server.csproj @@ -0,0 +1,59 @@ + + + + netcoreapp2.1;net461 + jackett.ico + JackettConsole + Exe + + + + win-x86;linux-x64;osx-x64 + + + + win7-x86;linux-x64 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/Jackett.Server/Middleware/CustomExceptionHandler.cs b/src/Jackett.Server/Middleware/CustomExceptionHandler.cs new file mode 100644 index 000000000..1081985a1 --- /dev/null +++ b/src/Jackett.Server/Middleware/CustomExceptionHandler.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Jackett.Common; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NLog; + +namespace Jackett.Server.Middleware +{ + public class CustomExceptionHandler + { + private readonly RequestDelegate _next; + private Logger logger; + + public CustomExceptionHandler(RequestDelegate next, Logger l) + { + _next = next; + logger = l; + } + + public async Task Invoke(HttpContext httpContext) + { + try + { + await _next(httpContext); + } + catch (Exception ex) + { + try + { + string msg = ""; + var json = new JObject(); + + logger.Error(ex); + + var message = ex.Message; + if (ex.InnerException != null) + { + message += ": " + ex.InnerException.Message; + } + + msg = message; + + if (ex is ExceptionWithConfigData) + { + json["config"] = ((ExceptionWithConfigData)ex).ConfigData.ToJson(null, false); + } + + json["result"] = "error"; + json["error"] = msg; + json["stacktrace"] = ex.StackTrace; + if (ex.InnerException != null) + { + json["innerstacktrace"] = ex.InnerException.StackTrace; + } + + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + httpContext.Response.ContentType = "application/json"; + await httpContext.Response.WriteAsync(json.ToString()); + return; + } + catch (Exception ex2) + { + logger.Error(ex2, "An exception was thrown attempting to execute the custom exception error handler."); + } + + await _next(httpContext); + } + } + } + + // Extension method used to add the middleware to the HTTP request pipeline. + public static class CustomExceptionHandlerExtensions + { + public static IApplicationBuilder UseCustomExceptionHandler(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} diff --git a/src/Jackett.Server/Middleware/RedirectRules.cs b/src/Jackett.Server/Middleware/RedirectRules.cs new file mode 100644 index 000000000..f93a7a050 --- /dev/null +++ b/src/Jackett.Server/Middleware/RedirectRules.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Net.Http.Headers; +using System; + +namespace Jackett.Server.Middleware +{ + public class RedirectRules + { + public static void RedirectToDashboard(RewriteContext context) + { + HttpRequest request = context.HttpContext.Request; + + if (request.Path == null || string.IsNullOrWhiteSpace(request.Path.ToString()) || request.Path.ToString() == "/" + || request.Path.ToString().Equals("/index.html", StringComparison.OrdinalIgnoreCase)) + { + // 301 is the status code of permanent redirect + var redir = Helper.ServerService.BasePath() + "/UI/Dashboard"; + var response = context.HttpContext.Response; + response.StatusCode = StatusCodes.Status301MovedPermanently; + context.Result = RuleResult.EndResponse; + response.Headers[HeaderNames.Location] = redir; + } + } + } +} diff --git a/src/Jackett.Server/Middleware/RewriteRules.cs b/src/Jackett.Server/Middleware/RewriteRules.cs new file mode 100644 index 000000000..2ff2fc2fd --- /dev/null +++ b/src/Jackett.Server/Middleware/RewriteRules.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Rewrite; +using System; + +namespace Jackett.Server.Middleware +{ + public class RewriteRules + { + public static void RewriteBasePath(RewriteContext context) + { + var request = context.HttpContext.Request; + + string serverBasePath = Helper.ServerService.BasePath() ?? string.Empty; + + if (request.Path != null && request.Path.HasValue && serverBasePath.Length > 0 + && (request.Path.Value.StartsWith(serverBasePath + "/", StringComparison.Ordinal) + || request.Path.Value.Equals(serverBasePath, StringComparison.Ordinal))) + { + string path = request.Path.Value.Substring(serverBasePath.Length); + path = string.IsNullOrEmpty(path) ? "/" : path; + request.Path = new PathString(path); + } + } + } +} diff --git a/src/Jackett.Server/Program.cs b/src/Jackett.Server/Program.cs new file mode 100644 index 000000000..73178ba42 --- /dev/null +++ b/src/Jackett.Server/Program.cs @@ -0,0 +1,184 @@ +using CommandLine; +using CommandLine.Text; +using Jackett.Common.Models.Config; +using Jackett.Common.Services; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using NLog; +using NLog.Web; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Jackett.Server +{ + public static class Program + { + public static IConfiguration Configuration { get; set; } + private static RuntimeSettings Settings { get; set; } + public static bool isWebHostRestart = false; + + public static void Main(string[] args) + { + AppDomain.CurrentDomain.ProcessExit += CurrentDomain_ProcessExit; + + var commandLineParser = new Parser(settings => settings.CaseSensitive = false); + var optionsResult = commandLineParser.ParseArguments(args); + var runtimeDictionary = new Dictionary(); + ConsoleOptions consoleOptions = new ConsoleOptions(); + + optionsResult.WithNotParsed(errors => + { + var text = HelpText.AutoBuild(optionsResult); + text.Copyright = " "; + text.Heading = "Jackett v" + EnvironmentUtil.JackettVersion; + Console.WriteLine(text); + Environment.Exit(1); + return; + }); + + optionsResult.WithParsed(options => + { + if (string.IsNullOrEmpty(options.Client)) + { + //TODO: Remove libcurl once off owin + bool runningOnDotNetCore = RuntimeInformation.FrameworkDescription.IndexOf("Core", StringComparison.OrdinalIgnoreCase) >= 0; + + if (runningOnDotNetCore) + { + options.Client = "httpclientnetcore"; + } + else + { + options.Client = "httpclient"; + } + } + + Settings = options.ToRunTimeSettings(); + consoleOptions = options; + runtimeDictionary = GetValues(Settings); + }); + + LogManager.Configuration = LoggingSetup.GetLoggingConfiguration(Settings); + Logger logger = LogManager.GetCurrentClassLogger(); + logger.Info("Starting Jackett v" + EnvironmentUtil.JackettVersion); + + // create PID file early + if (!string.IsNullOrWhiteSpace(Settings.PIDFile)) + { + try + { + var proc = Process.GetCurrentProcess(); + File.WriteAllText(Settings.PIDFile, proc.Id.ToString()); + } + catch (Exception e) + { + logger.Error(e, "Error while creating the PID file"); + } + } + + Initialisation.ProcessSettings(Settings, logger); + + ISerializeService serializeService = new SerializeService(); + IProcessService processService = new ProcessService(logger); + IConfigurationService configurationService = new ConfigurationService(serializeService, processService, logger, Settings); + + if (consoleOptions.Install || consoleOptions.Uninstall || consoleOptions.StartService || consoleOptions.StopService || consoleOptions.ReserveUrls) + { + bool isWindows = Environment.OSVersion.Platform == PlatformID.Win32NT; + + if (isWindows) + { + ServerConfig serverConfig = configurationService.BuildServerConfig(Settings); + Initialisation.ProcessWindowsSpecificArgs(consoleOptions, processService, serverConfig, logger); + } + else + { + logger.Error($"ReserveUrls and service arguments only apply to Windows, please remove them from your start arguments"); + Environment.Exit(1); + } + } + + var builder = new ConfigurationBuilder(); + builder.AddInMemoryCollection(runtimeDictionary); + + Configuration = builder.Build(); + + do + { + if (!isWebHostRestart) + { + if (consoleOptions.Port != 0 || consoleOptions.ListenPublic || consoleOptions.ListenPrivate) + { + ServerConfig serverConfiguration = configurationService.BuildServerConfig(Settings); + Initialisation.ProcessConsoleOverrides(consoleOptions, processService, serverConfiguration, configurationService, logger); + } + } + + ServerConfig serverConfig = configurationService.BuildServerConfig(Settings); + Int32.TryParse(serverConfig.Port.ToString(), out Int32 configPort); + string[] url = serverConfig.GetListenAddresses(serverConfig.AllowExternal).Take(1).ToArray(); //Kestrel doesn't need 127.0.0.1 and localhost to be registered, remove once off OWIN + + isWebHostRestart = false; + + try + { + CreateWebHostBuilder(args, url).Build().Run(); + } + catch (Exception ex) + { + if (ex.InnerException is Microsoft.AspNetCore.Connections.AddressInUseException) + { + logger.Error("Address already in use: Most likely Jackett is already running. " + ex.Message); + Environment.Exit(1); + } + logger.Error(ex); + throw; + } + } while (isWebHostRestart); + } + + public static Dictionary GetValues(object obj) + { + return obj + .GetType() + .GetProperties() + .ToDictionary(p => "RuntimeSettings:" + p.Name, p => p.GetValue(obj) == null ? null : p.GetValue(obj).ToString()); + } + + private static void CurrentDomain_ProcessExit(object sender, EventArgs e) + { + try + { + if (Settings != null && !string.IsNullOrWhiteSpace(Settings.PIDFile)) + { + var PIDFile = Settings.PIDFile; + if (File.Exists(PIDFile)) + { + Console.WriteLine("Deleting PID file " + PIDFile); + File.Delete(PIDFile); + } + LogManager.Shutdown(); + } + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.ToString(), "Error while deleting the PID file"); + } + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args, string[] urls) => + WebHost.CreateDefaultBuilder(args) + .UseConfiguration(Configuration) + .UseUrls(urls) + .PreferHostingUrls(true) + .UseStartup() + .UseNLog(); + } +} diff --git a/src/Jackett.Server/Services/ProtectionService.cs b/src/Jackett.Server/Services/ProtectionService.cs new file mode 100644 index 000000000..ecd00fbbe --- /dev/null +++ b/src/Jackett.Server/Services/ProtectionService.cs @@ -0,0 +1,211 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Jackett.Common; +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Microsoft.AspNetCore.DataProtection; + +namespace Jackett.Server.Services +{ + + public class ProtectionService : IProtectionService + { + DataProtectionScope PROTECTION_SCOPE = DataProtectionScope.LocalMachine; + private const string JACKETT_KEY = "JACKETT_KEY"; + const string APPLICATION_KEY = "Dvz66r3n8vhTGip2/quiw5ISyM37f7L2iOdupzdKmzkvXGhAgQiWK+6F+4qpxjPVNks1qO7LdWuVqRlzgLzeW8mChC6JnBMUS1Fin4N2nS9lh4XPuCZ1che75xO92Nk2vyXUo9KSFG1hvEszAuLfG2Mcg1r0sVyVXd2gQDU/TbY="; + private byte[] _instanceKey; + IDataProtector _protector = null; + + public ProtectionService(ServerConfig config, IDataProtectionProvider provider = null) + { + if (Environment.OSVersion.Platform == PlatformID.Unix) + { + // We should not be running as root and will only have access to the local store. + PROTECTION_SCOPE = DataProtectionScope.CurrentUser; + } + _instanceKey = Encoding.UTF8.GetBytes(config.InstanceId); + + if (provider != null) + { + var jackettKey = Environment.GetEnvironmentVariable(JACKETT_KEY); + string purpose = string.IsNullOrEmpty(jackettKey) ? APPLICATION_KEY : jackettKey.ToString(); + + _protector = provider.CreateProtector(purpose); + } + + } + + public string Protect(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + return string.Empty; + + return _protector.Protect(plainText); + } + + public string UnProtect(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + return string.Empty; + + return _protector.Unprotect(plainText); + } + + public string LegacyProtect(string plainText) + { + var jackettKey = Environment.GetEnvironmentVariable(JACKETT_KEY); + + if (jackettKey == null) + { + return ProtectDefaultMethod(plainText); + } + else + { + return ProtectUsingKey(plainText, jackettKey); + } + } + + public string LegacyUnProtect(string plainText) + { + var jackettKey = Environment.GetEnvironmentVariable(JACKETT_KEY); + + if (jackettKey == null) + { + return UnProtectDefaultMethod(plainText); + } + else + { + return UnProtectUsingKey(plainText, jackettKey); + } + } + + private string ProtectDefaultMethod(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + return string.Empty; + + var plainBytes = Encoding.UTF8.GetBytes(plainText); + var appKey = Convert.FromBase64String(APPLICATION_KEY); + var instanceKey = _instanceKey; + var entropy = new byte[appKey.Length + instanceKey.Length]; + Buffer.BlockCopy(instanceKey, 0, entropy, 0, instanceKey.Length); + Buffer.BlockCopy(appKey, 0, entropy, instanceKey.Length, appKey.Length); + + var protectedBytes = ProtectedData.Protect(plainBytes, entropy, PROTECTION_SCOPE); + + using (MemoryStream ms = new MemoryStream()) + { + using (RijndaelManaged AES = new RijndaelManaged()) + { + AES.KeySize = 256; + AES.BlockSize = 128; + + var key = new Rfc2898DeriveBytes(instanceKey, instanceKey.Reverse().ToArray(), 64); + AES.Key = key.GetBytes(AES.KeySize / 8); + AES.IV = key.GetBytes(AES.BlockSize / 8); + + AES.Mode = CipherMode.CBC; + + using (var cs = new CryptoStream(ms, AES.CreateEncryptor(), CryptoStreamMode.Write)) + { + cs.Write(protectedBytes, 0, protectedBytes.Length); + cs.Close(); + } + protectedBytes = ms.ToArray(); + } + } + + return Convert.ToBase64String(protectedBytes); + } + + private string UnProtectDefaultMethod(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + return string.Empty; + + var protectedBytes = Convert.FromBase64String(plainText); + var instanceKey = _instanceKey; + + using (MemoryStream ms = new MemoryStream()) + { + using (RijndaelManaged AES = new RijndaelManaged()) + { + AES.KeySize = 256; + AES.BlockSize = 128; + + var key = new Rfc2898DeriveBytes(instanceKey, instanceKey.Reverse().ToArray(), 64); + AES.Key = key.GetBytes(AES.KeySize / 8); + AES.IV = key.GetBytes(AES.BlockSize / 8); + + AES.Mode = CipherMode.CBC; + + using (var cs = new CryptoStream(ms, AES.CreateDecryptor(), CryptoStreamMode.Write)) + { + cs.Write(protectedBytes, 0, protectedBytes.Length); + cs.Close(); + } + protectedBytes = ms.ToArray(); + } + } + + var appKey = Convert.FromBase64String(APPLICATION_KEY); + var entropy = new byte[appKey.Length + instanceKey.Length]; + Buffer.BlockCopy(instanceKey, 0, entropy, 0, instanceKey.Length); + Buffer.BlockCopy(appKey, 0, entropy, instanceKey.Length, appKey.Length); + + var unprotectedBytes = ProtectedData.Unprotect(protectedBytes, entropy, PROTECTION_SCOPE); + return Encoding.UTF8.GetString(unprotectedBytes); + } + + private string ProtectUsingKey(string plainText, string key) + { + return StringCipher.Encrypt(plainText, key); + } + + private string UnProtectUsingKey(string plainText, string key) + { + return StringCipher.Decrypt(plainText, key); + } + + public void Protect(T obj) + { + var type = obj.GetType(); + + foreach (var property in type.GetProperties(BindingFlags.SetProperty | BindingFlags.GetProperty | BindingFlags.Public)) + { + if (property.GetCustomAttributes(typeof(JackettProtectedAttribute), false).Count() > 0) + { + var value = property.GetValue(obj); + if (value is string) + { + var protectedString = Protect(value as string); + property.SetValue(obj, protectedString); + } + } + } + } + + public void UnProtect(T obj) + { + var type = obj.GetType(); + + foreach (var property in type.GetProperties(BindingFlags.SetProperty | BindingFlags.GetProperty | BindingFlags.Public)) + { + if (property.GetCustomAttributes(typeof(JackettProtectedAttribute), false).Count() > 0) + { + var value = property.GetValue(obj); + if (value is string) + { + var unprotectedString = UnProtect(value as string); + property.SetValue(obj, unprotectedString); + } + } + } + } + } +} diff --git a/src/Jackett.Server/Services/SecuityService.cs b/src/Jackett.Server/Services/SecuityService.cs new file mode 100644 index 000000000..ff3d81f61 --- /dev/null +++ b/src/Jackett.Server/Services/SecuityService.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; + +namespace Jackett.Server.Services +{ + + class SecuityService : ISecuityService + { + private const string COOKIENAME = "JACKETT"; + private ServerConfig _serverConfig; + + public SecuityService(ServerConfig sc) + { + _serverConfig = sc; + } + + public string HashPassword(string input) + { + if (input == null) + return null; + // Append key as salt + input += _serverConfig.APIKey; + + UnicodeEncoding UE = new UnicodeEncoding(); + byte[] hashValue; + byte[] message = UE.GetBytes(input); + + SHA512Managed hashString = new SHA512Managed(); + string hex = ""; + + hashValue = hashString.ComputeHash(message); + foreach (byte x in hashValue) + { + hex += String.Format("{0:x2}", x); + } + return hex; + } + + public void Login(HttpResponseMessage response) + { + // Login + response.Headers.Add("Set-Cookie", COOKIENAME + "=" + _serverConfig.AdminPassword + "; path=/"); + } + + public void Logout(HttpResponseMessage response) + { + // Logout + response.Headers.Add("Set-Cookie", COOKIENAME + "=; path=/"); + } + + public bool CheckAuthorised(HttpRequestMessage request) + { + if (string.IsNullOrEmpty(_serverConfig.AdminPassword)) + return true; + + try + { + var cookie = request.Headers.GetValues(COOKIENAME).FirstOrDefault(); + if (cookie != null) + { + return cookie == _serverConfig.AdminPassword; + } + } + catch { } + + return false; + } + } +} diff --git a/src/Jackett.Server/Services/ServerService.cs b/src/Jackett.Server/Services/ServerService.cs new file mode 100644 index 000000000..96f2f7872 --- /dev/null +++ b/src/Jackett.Server/Services/ServerService.cs @@ -0,0 +1,341 @@ +using Jackett.Common.Models.Config; +using Jackett.Common.Services.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using NLog; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace Jackett.Server.Services +{ + public class ServerService : IServerService + { + private IIndexerManagerService indexerService; + private IProcessService processService; + private ISerializeService serializeService; + private IConfigurationService configService; + private Logger logger; + private Common.Utils.Clients.WebClient client; + private IUpdateService updater; + private List _notices = new List(); + private ServerConfig config; + private IProtectionService _protectionService; + + public ServerService(IIndexerManagerService i, IProcessService p, ISerializeService s, IConfigurationService c, Logger l, Common.Utils.Clients.WebClient w, IUpdateService u, IProtectionService protectionService, ServerConfig serverConfig) + { + indexerService = i; + processService = p; + serializeService = s; + configService = c; + logger = l; + client = w; + updater = u; + config = serverConfig; + _protectionService = protectionService; + } + + public List notices + { + get + { + return _notices; + } + } + + public Uri ConvertToProxyLink(Uri link, string serverUrl, string indexerId, string action = "dl", string file = "t") + { + if (link == null || (link.IsAbsoluteUri && link.Scheme == "magnet" && action != "bh")) // no need to convert a magnet link to a proxy link unless it's a blackhole link + return link; + + var encryptedLink = _protectionService.Protect(link.ToString()); + var encodedLink = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(encryptedLink)); + string urlEncodedFile = WebUtility.UrlEncode(file); + var proxyLink = string.Format("{0}{1}/{2}/?jackett_apikey={3}&path={4}&file={5}", serverUrl, action, indexerId, config.APIKey, encodedLink, urlEncodedFile); + return new Uri(proxyLink); + } + + public string BasePath() + { + if (config.BasePathOverride == null || config.BasePathOverride == "") + { + return ""; + } + var path = config.BasePathOverride; + if (path.EndsWith("/")) + { + path = path.TrimEnd('/'); + } + if (!path.StartsWith("/")) + { + path = "/" + path; + } + return path; + } + + public void Initalize() + { + try + { + var x = Environment.OSVersion; + var runtimedir = RuntimeEnvironment.GetRuntimeDirectory(); + logger.Info("Environment version: " + Environment.Version.ToString() + " (" + runtimedir + ")"); + logger.Info("OS version: " + Environment.OSVersion.ToString() + (Environment.Is64BitOperatingSystem ? " (64bit OS)" : "") + (Environment.Is64BitProcess ? " (64bit process)" : "")); + + try + { + ThreadPool.GetMaxThreads(out int workerThreads, out int completionPortThreads); + logger.Info("ThreadPool MaxThreads: " + workerThreads + " workerThreads, " + completionPortThreads + " completionPortThreads"); + } + catch (Exception e) + { + logger.Error("Error while getting MaxThreads details: " + e); + } + + logger.Info("App config/log directory: " + configService.GetAppDataFolder()); + + try + { + var issuefile = "/etc/issue"; + if (File.Exists(issuefile)) + { + using (StreamReader reader = new StreamReader(issuefile)) + { + string firstLine = reader.ReadLine(); + if (firstLine != null) + logger.Info("issue: " + firstLine); + } + } + } + catch (Exception e) + { + logger.Error(e, "Error while reading the issue file"); + } + + bool runningOnDotNetCore = RuntimeInformation.FrameworkDescription.IndexOf("Core", StringComparison.OrdinalIgnoreCase) >= 0; + + Type monotype = Type.GetType("Mono.Runtime"); + if (monotype != null && !runningOnDotNetCore) + { + MethodInfo displayName = monotype.GetMethod("GetDisplayName", BindingFlags.NonPublic | BindingFlags.Static); + var monoVersion = "unknown"; + if (displayName != null) + monoVersion = displayName.Invoke(null, null).ToString(); + logger.Info("mono version: " + monoVersion); + + var monoVersionO = new Version(monoVersion.Split(' ')[0]); + + if (monoVersionO.Major < 5 || (monoVersionO.Major == 5 && monoVersionO.Minor < 8)) + { + //Hard minimum of 5.8 + //5.4 throws a SIGABRT, looks related to this which was fixed in 5.8 https://bugzilla.xamarin.com/show_bug.cgi?id=60625 + + logger.Error("Your mono version is too old. Please update to the latest version from http://www.mono-project.com/download/"); + Environment.Exit(2); + } + + if (monoVersionO.Major < 5 || (monoVersionO.Major == 5 && monoVersionO.Minor < 8)) + { + string notice = "A minimum Mono version of 5.8 is required. Please update to the latest version from http://www.mono-project.com/download/"; + _notices.Add(notice); + logger.Error(notice); + } + + try + { + // Check for mono-devel + // Is there any better way which doesn't involve a hard cashes? + var mono_devel_file = Path.Combine(runtimedir, "mono-api-info.exe"); + if (!File.Exists(mono_devel_file)) + { + var notice = "It looks like the mono-devel package is not installed, please make sure it's installed to avoid crashes."; + _notices.Add(notice); + logger.Error(notice); + } + } + catch (Exception e) + { + logger.Error(e, "Error while checking for mono-devel"); + } + + try + { + // Check for ca-certificates-mono + var mono_cert_file = Path.Combine(runtimedir, "cert-sync.exe"); + if (!File.Exists(mono_cert_file)) + { + var notice = "The ca-certificates-mono package is not installed, HTTPS trackers won't work. Please install it."; + _notices.Add(notice); + logger.Error(notice); + } + } + catch (Exception e) + { + logger.Error(e, "Error while checking for ca-certificates-mono"); + } + + try + { + Encoding.GetEncoding("windows-1255"); + } + catch (NotSupportedException e) + { + logger.Debug(e); + logger.Error(e.Message + " Most likely the mono-locale-extras package is not installed."); + Environment.Exit(2); + } + + // check if the certificate store was initialized using Mono.Security.X509.X509StoreManager.TrustedRootCertificates.Count + try + { + var monoSecurity = Assembly.Load("Mono.Security"); + Type monoX509StoreManager = monoSecurity.GetType("Mono.Security.X509.X509StoreManager"); + if (monoX509StoreManager != null) + { + var TrustedRootCertificatesProperty = monoX509StoreManager.GetProperty("TrustedRootCertificates"); + var TrustedRootCertificates = (ICollection)TrustedRootCertificatesProperty.GetValue(null); + + logger.Info("TrustedRootCertificates count: " + TrustedRootCertificates.Count); + + if (TrustedRootCertificates.Count == 0) + { + var CACertificatesFiles = new string[] { + "/etc/ssl/certs/ca-certificates.crt", // Debian based + "/etc/pki/tls/certs/ca-bundle.c", // RedHat based + "/etc/ssl/ca-bundle.pem", // SUSE + }; + + var notice = "The mono certificate store is not initialized.
\n"; + var logSpacer = " "; + var CACertificatesFile = CACertificatesFiles.Where(f => File.Exists(f)).FirstOrDefault(); + var CommandRoot = "curl -sS https://curl.haxx.se/ca/cacert.pem | cert-sync /dev/stdin"; + var CommandUser = "curl -sS https://curl.haxx.se/ca/cacert.pem | cert-sync --user /dev/stdin"; + if (CACertificatesFile != null) + { + CommandRoot = "cert-sync " + CACertificatesFile; + CommandUser = "cert-sync --user " + CACertificatesFile; + } + notice += logSpacer + "Please run the following command as root:
\n"; + notice += logSpacer + "
" + CommandRoot + "

\n"; + notice += logSpacer + "If you don't have root access or you're running MacOS, please run the following command as the jackett user (" + Environment.UserName + "):
\n"; + notice += logSpacer + "
" + CommandUser + "
"; + _notices.Add(notice); + logger.Error(Regex.Replace(notice, "<.*?>", String.Empty)); + } + } + } + catch (Exception e) + { + logger.Error(e, "Error while chekcing the mono certificate store"); + } + } + + } + catch (Exception e) + { + logger.Error("Error while getting environment details: " + e); + } + + try + { + if (Environment.UserName == "root") + { + var notice = "Jackett is running with root privileges. You should run Jackett as an unprivileged user."; + _notices.Add(notice); + logger.Error(notice); + } + } + catch (Exception e) + { + logger.Error(e, "Error while checking the username"); + } + + CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); + // Load indexers + indexerService.InitIndexers(configService.GetCardigannDefinitionsFolders()); + client.Init(); + updater.CleanupTempDir(); + } + + public void Start() + { + updater.StartUpdateChecker(); + } + + public void ReserveUrls(bool doInstall = true) + { + logger.Debug("Unreserving Urls"); + config.GetListenAddresses(false).ToList().ForEach(u => RunNetSh(string.Format("http delete urlacl {0}", u))); + config.GetListenAddresses(true).ToList().ForEach(u => RunNetSh(string.Format("http delete urlacl {0}", u))); + if (doInstall) + { + logger.Debug("Reserving Urls"); + config.GetListenAddresses(true).ToList().ForEach(u => RunNetSh(string.Format("http add urlacl {0} sddl=D:(A;;GX;;;S-1-1-0)", u))); + logger.Debug("Urls reserved"); + } + } + + private void RunNetSh(string args) + { + processService.StartProcessAndLog("netsh.exe", args); + } + + public void Stop() + { + // Only needed for Owin + } + + public string GetServerUrl(Object obj) + { + string serverUrl = ""; + + if (obj is HttpRequest request) + { + var scheme = request.Scheme; + var port = request.HttpContext.Request.Host.Port; + + // Check for protocol headers added by reverse proxys + // X-Forwarded-Proto: A de facto standard for identifying the originating protocol of an HTTP request + var X_Forwarded_Proto = request.Headers.Where(x => x.Key == "X-Forwarded-Proto").Select(x => x.Value).FirstOrDefault(); + if (X_Forwarded_Proto.Count > 0) + { + scheme = X_Forwarded_Proto.First(); + } + // Front-End-Https: Non-standard header field used by Microsoft applications and load-balancers + else if (request.Headers.Where(x => x.Key == "Front-End-Https" && x.Value.FirstOrDefault() == "on").Any()) + { + scheme = "https"; + } + + //default to 443 if the Host header doesn't contain the port (needed for reverse proxy setups) + if (scheme == "https" && !request.HttpContext.Request.Host.Value.Contains(":")) + { + port = 443; + } + + serverUrl = string.Format("{0}://{1}:{2}{3}/", scheme, request.HttpContext.Request.Host.Host, port, BasePath()); + } + + return serverUrl; + } + + public string GetBlackholeDirectory() + { + return config.BlackholeDir; + } + + public string GetApiKey() + { + return config.APIKey; + } + } +} diff --git a/src/Jackett.Server/Services/ServiceConfigService.cs b/src/Jackett.Server/Services/ServiceConfigService.cs new file mode 100644 index 000000000..a0ff5ac4d --- /dev/null +++ b/src/Jackett.Server/Services/ServiceConfigService.cs @@ -0,0 +1,121 @@ +using NLog; +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.ServiceProcess; +using Jackett.Common.Services.Interfaces; +using System.Reflection; +using Jackett.Common.Services; + +namespace Jackett.Server.Services +{ + public class ServiceConfigService : IServiceConfigService + { + private const string NAME = "Jackett"; + private const string DESCRIPTION = "API Support for your favorite torrent trackers"; + private const string SERVICEEXE = "JackettService.exe"; + + private IProcessService processService; + private Logger logger; + + public ServiceConfigService() + { + logger = LogManager.GetCurrentClassLogger(); + processService = new ProcessService(logger); + } + + public bool ServiceExists() + { + return GetService(NAME) != null; + } + + public bool ServiceRunning() + { + var service = GetService(NAME); + if (service == null) + return false; + return service.Status == ServiceControllerStatus.Running; + } + + public void Start() + { + + var service = GetService(NAME); + service.Start(); + } + + public void Stop() + { + var service = GetService(NAME); + service.Stop(); + } + + public ServiceController GetService(string serviceName) + { + return ServiceController.GetServices().FirstOrDefault(c => String.Equals(c.ServiceName, serviceName, StringComparison.InvariantCultureIgnoreCase)); + } + + public void Install() + { + if (ServiceExists()) + { + logger.Warn("The service is already installed!"); + } + else + { + string applicationFolder = Path.GetDirectoryName(new Uri(Assembly.GetExecutingAssembly().CodeBase).LocalPath); + + var exePath = Path.Combine(applicationFolder, SERVICEEXE); + if (!File.Exists(exePath) && Debugger.IsAttached) + { + exePath = Path.Combine(applicationFolder, "..\\..\\..\\Jackett.Service\\bin\\Debug", SERVICEEXE); + } + + string arg = $"create {NAME} start= auto binpath= \"{exePath}\" DisplayName= {NAME}"; + + processService.StartProcessAndLog("sc.exe", arg, true); + + processService.StartProcessAndLog("sc.exe", $"description {NAME} \"{DESCRIPTION}\"", true); + } + } + + public void Uninstall() + { + RemoveService(); + + processService.StartProcessAndLog("sc.exe", $"delete {NAME}", true); + + logger.Info("The service was uninstalled."); + } + + public void RemoveService() + { + var service = GetService(NAME); + if(service == null) + { + logger.Warn("The service is already uninstalled"); + return; + } + if (service.Status != ServiceControllerStatus.Stopped) + { + service.Stop(); + service.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(60)); + + service.Refresh(); + if (service.Status == ServiceControllerStatus.Stopped) + { + logger.Info("Service stopped."); + } + else + { + logger.Error("Failed to stop the service"); + } + } + else + { + logger.Warn("The service was already stopped"); + } + } + } +} diff --git a/src/Jackett.Server/Startup.cs b/src/Jackett.Server/Startup.cs new file mode 100644 index 000000000..729e84587 --- /dev/null +++ b/src/Jackett.Server/Startup.cs @@ -0,0 +1,134 @@ +using Autofac; +using Autofac.Extensions.DependencyInjection; +using Jackett.Common.Models.Config; +using Jackett.Common.Plumbing; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils.Clients; +using Jackett.Server.Middleware; +using Jackett.Server.Services; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Newtonsoft.Json.Serialization; +using System; +using System.IO; +using System.Text; + +namespace Jackett.Server +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public IServiceProvider ConfigureServices(IServiceCollection services) + { + services.AddResponseCompression(); + + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, + options => + { + options.LoginPath = new PathString("/UI/Login"); + options.AccessDeniedPath = new PathString("/UI/Login"); + options.LogoutPath = new PathString("/UI/Logout"); + options.Cookie.Name = "Jackett"; + }); + + services.AddMvc(config => + { + var policy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + config.Filters.Add(new AuthorizeFilter(policy)); + }) + .AddJsonOptions(options => + { + options.SerializerSettings.ContractResolver = new DefaultContractResolver(); //Web app uses Pascal Case JSON + }) + .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + + RuntimeSettings runtimeSettings = new RuntimeSettings(); + Configuration.GetSection("RuntimeSettings").Bind(runtimeSettings); + + DirectoryInfo dataProtectionFolder = new DirectoryInfo(Path.Combine(runtimeSettings.DataFolder, "DataProtection")); + + services.AddDataProtection() + .PersistKeysToFileSystem(dataProtectionFolder) + .SetApplicationName("Jackett"); + + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + var builder = new ContainerBuilder(); + + Helper.SetupLogging(builder); + + builder.Populate(services); + builder.RegisterModule(new JackettModule(runtimeSettings)); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + if (runtimeSettings.ClientOverride == "httpclientnetcore") + builder.RegisterType().As(); + + IContainer container = builder.Build(); + Helper.ApplicationContainer = container; + + Helper.Initialize(); + + return new AutofacServiceProvider(container); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApplicationLifetime applicationLifetime) + { + applicationLifetime.ApplicationStopping.Register(OnShutdown); + Helper.applicationLifetime = applicationLifetime; + app.UseResponseCompression(); + + app.UseDeveloperExceptionPage(); + + app.UseCustomExceptionHandler(); + + var rewriteOptions = new RewriteOptions() + .Add(RewriteRules.RewriteBasePath) + .AddRewrite(@"^torznab\/([\w-]*)", "api/v2.0/indexers/$1/results/torznab", skipRemainingRules: true) //legacy torznab route + .AddRewrite(@"^potato\/([\w-]*)", "api/v2.0/indexers/$1/results/potato", skipRemainingRules: true) //legacy potato route + .Add(RedirectRules.RedirectToDashboard); + + app.UseRewriter(rewriteOptions); + + app.UseFileServer(new FileServerOptions + { + FileProvider = new PhysicalFileProvider(Helper.ConfigService.GetContentFolder()), + RequestPath = "", + EnableDefaultFiles = true, + EnableDirectoryBrowsing = false + }); + + app.UseAuthentication(); + + app.UseMvc(); + } + + private void OnShutdown() + { + //this code is called when the application stops + } + } +} diff --git a/src/Jackett.Server/appsettings.Development.json b/src/Jackett.Server/appsettings.Development.json new file mode 100644 index 000000000..fa8ce71a9 --- /dev/null +++ b/src/Jackett.Server/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/Jackett.Server/appsettings.json b/src/Jackett.Server/appsettings.json new file mode 100644 index 000000000..26bb0ac7a --- /dev/null +++ b/src/Jackett.Server/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "IncludeScopes": false, + "Debug": { + "LogLevel": { + "Default": "Warning" + } + }, + "Console": { + "LogLevel": { + "Default": "Warning" + } + } + } +} diff --git a/src/Jackett.Server/jackett.ico b/src/Jackett.Server/jackett.ico new file mode 100644 index 000000000..fb7b8efe5 Binary files /dev/null and b/src/Jackett.Server/jackett.ico differ diff --git a/src/Jackett.Test/Jackett.Test.csproj b/src/Jackett.Test/Jackett.Test.csproj index 6b239fbb5..e8e9e0a2b 100644 --- a/src/Jackett.Test/Jackett.Test.csproj +++ b/src/Jackett.Test/Jackett.Test.csproj @@ -1,37 +1,46 @@  + - net452 + net461 false + + + + - - - - + + + + + + + + \ No newline at end of file diff --git a/src/Jackett.Test/TestUtil.cs b/src/Jackett.Test/TestUtil.cs index 4717cf392..89677c4de 100644 --- a/src/Jackett.Test/TestUtil.cs +++ b/src/Jackett.Test/TestUtil.cs @@ -18,7 +18,7 @@ namespace Jackett.Test { var builder = new ContainerBuilder(); builder.RegisterModule(new JackettModule(new RuntimeSettings())); - builder.RegisterModule(); + builder.RegisterType().As(); builder.RegisterType().As().SingleInstance(); builder.RegisterInstance(LogManager.GetCurrentClassLogger()).SingleInstance(); builder.RegisterType().As().SingleInstance(); diff --git a/src/Jackett.Updater/Program.cs b/src/Jackett.Updater/Program.cs index 50a55527a..2b8283c89 100644 --- a/src/Jackett.Updater/Program.cs +++ b/src/Jackett.Updater/Program.cs @@ -306,7 +306,15 @@ namespace Jackett.Updater UseShellExecute = true }; - if (!isWindows) + if (isWindows) + { + //User didn't initiate the update from Windows service and wasn't running Jackett via the tray, must have started from the console + startInfo.Arguments = $"/K {startInfo.FileName} {startInfo.Arguments}"; + startInfo.FileName = "cmd.exe"; + startInfo.CreateNoWindow = false; + startInfo.WindowStyle = ProcessWindowStyle.Normal; + } + else { startInfo.Arguments = startInfo.FileName + " " + startInfo.Arguments; startInfo.FileName = "mono"; diff --git a/src/Jackett.sln b/src/Jackett.sln index 6a7f40ab4..a3c7a1a0e 100644 --- a/src/Jackett.sln +++ b/src/Jackett.sln @@ -21,7 +21,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jackett.Service", "Jackett. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jackett.Tray", "Jackett.Tray\Jackett.Tray.csproj", "{FF9025B1-EC14-4AA9-8081-9F69C5E35B63}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jackett.Updater", "Jackett.Updater\Jackett.Updater.csproj", "{A61E311A-6F8B-4497-B5E4-2EA8994C7BD8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jackett.Updater", "Jackett.Updater\Jackett.Updater.csproj", "{A61E311A-6F8B-4497-B5E4-2EA8994C7BD8}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jackett.Test", "Jackett.Test\Jackett.Test.csproj", "{FA22C904-9F5D-4D3C-9122-3E33652E7373}" EndProject @@ -39,6 +39,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".NET Core", ".NET Core", "{ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Executables", "Executables", "{AA50F785-12B8-4669-8D4F-EAFB49258E60}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jackett.Server", "Jackett.Server\Jackett.Server.csproj", "{84182782-EDBC-4342-ADA6-72B7694D0862}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +83,10 @@ Global {6B854A1B-9A90-49C0-BC37-9A35C75BCA73}.Debug|Any CPU.Build.0 = Debug|Any CPU {6B854A1B-9A90-49C0-BC37-9A35C75BCA73}.Release|Any CPU.ActiveCfg = Release|Any CPU {6B854A1B-9A90-49C0-BC37-9A35C75BCA73}.Release|Any CPU.Build.0 = Release|Any CPU + {84182782-EDBC-4342-ADA6-72B7694D0862}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84182782-EDBC-4342-ADA6-72B7694D0862}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84182782-EDBC-4342-ADA6-72B7694D0862}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84182782-EDBC-4342-ADA6-72B7694D0862}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -98,6 +104,7 @@ Global {6B854A1B-9A90-49C0-BC37-9A35C75BCA73} = {2FA9B879-5882-4B39-8D34-9EBCB82B4F2B} {FF8B9A1B-AE7E-4F14-9C37-DA861D034738} = {AA50F785-12B8-4669-8D4F-EAFB49258E60} {6A06EC9B-AF21-4DE8-9B50-BC7E3C2C78B9} = {AA50F785-12B8-4669-8D4F-EAFB49258E60} + {84182782-EDBC-4342-ADA6-72B7694D0862} = {6A06EC9B-AF21-4DE8-9B50-BC7E3C2C78B9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {54BC4102-8B85-49C1-BA12-257D941D1B97}