User authentication. Jellyfin configuration incomplete.
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2024-12-06 09:53:21 +01:00
parent a5099f60ce
commit 1da58e2e0e
17 changed files with 571 additions and 169 deletions

View File

@@ -1,3 +1,5 @@
using System.Linq.Expressions;
using CinemaLib.Webshare;
using Jellyfin.Plugin.Cinema.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
@@ -20,8 +22,11 @@ public sealed class CinemaHost : IHostedService
private static ILibraryManager _libraryManager;
private static IServerConfigurationManager _config;
private static IFileSystem _fileSystem;
private static Session? _webshare;
#pragma warning restore CS8618
private readonly ILogger<CinemaHost> _logger;
private string? _lastWebsharePasswordHash;
private string? _lastWebshareToken;
/// <summary>
/// Initializes a the Stream Cinema plugin.
@@ -44,12 +49,16 @@ public sealed class CinemaHost : IHostedService
public static string FallbackCulture => "en";
public static Session? Webshare => _webshare;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
CinemaPluginConfiguration config = CinemaPlugin.Instance!.Configuration;
CinemaPlugin.Instance!.ConfigurationChanged += ReloadConfig;
EnsureRootFolders(CinemaPlugin.Instance!.Configuration);
EnsureWebshareSession(config);
EnsureRootFolders(config);
return Task.CompletedTask;
}
@@ -58,10 +67,13 @@ public sealed class CinemaHost : IHostedService
private void ReloadConfig(object? sender, BasePluginConfiguration e)
{
EnsureRootFolders((CinemaPluginConfiguration)e);
CinemaPluginConfiguration config = (CinemaPluginConfiguration)e;
EnsureWebshareSession(config);
EnsureRootFolders(config);
}
private void EnsureRootFolders(CinemaPluginConfiguration config) {
private void EnsureRootFolders(CinemaPluginConfiguration config)
{
CinemaFilterFolder.CreateRootFilterFolder<CinemaMoviesFolder>(string.IsNullOrWhiteSpace(config.MoviesFolderName) ? "Movies" : config.MoviesFolderName)
.Hide = config.HideMoviesFolder;
CinemaFilterFolder.CreateRootFilterFolder<CinemaTvShowsFolder>(string.IsNullOrWhiteSpace(config.SeriesFolderName) ? "TV Shows" : config.SeriesFolderName)
@@ -71,4 +83,21 @@ public sealed class CinemaHost : IHostedService
CinemaFilterFolder.CreateRootFilterFolder<CinemaConcertFolder>(string.IsNullOrWhiteSpace(config.MusicFolderName) ? "Music" : config.MusicFolderName)
.Hide = config.HideMusicFolder;
}
private void EnsureWebshareSession(CinemaPluginConfiguration config)
{
if (config.WebshareUser != _webshare?.UserName
|| config.WebsharePasswordHash != _lastWebsharePasswordHash
|| config.WebshareToken != _lastWebshareToken)
{
// Re-create the Webshare session as the credentials have changed
if (config.WebshareUser != null && config.WebsharePasswordHash != null)
_webshare = new Session(config.WebshareUser, config.WebsharePasswordHash, config.WebshareToken);
else
_webshare = null;
_lastWebsharePasswordHash = config.WebsharePasswordHash;
_lastWebshareToken = config.WebshareToken;
}
}
}

View File

@@ -772,7 +772,7 @@ public sealed class CinemaMediaSourceManager : IMediaSourceManager
Uri link;
switch (provider)
{
case "webshare": link = await LinkGenerator.GenerateDownloadLinkAsync(ident, name, cancel); break;
case "webshare": link = await LinkGenerator.GenerateDownloadLinkAsync(ident, name, CinemaHost.Webshare, cancel); break;
default: throw new InvalidOperationException();
}

View File

@@ -24,5 +24,6 @@ public class CinemaPluginConfiguration : BasePluginConfiguration
public bool HideMusicFolder { get; set; }
public string WebshareUser { get; set; }
public string WebsharePassword { get; set; }
public string WebsharePasswordHash { get; set; }
public string WebshareToken { get; set; }
}

View File

@@ -56,6 +56,7 @@
<div class="inputContainer">
<input is="emby-input" type="password" id="websharePassword" label="Webshare password" />
<input type="hidden" id="websharePasswordHash" />
</div>
</div>
<br />
@@ -117,10 +118,10 @@
bubbles: true,
cancelable: false
}));
var websharePassword = document.querySelector('#websharePassword');
if (config.WebsharePassword)
websharePassword.value = config.WebsharePassword;
websharePassword.dispatchEvent(new Event('change', {
var websharePasswordHash = document.querySelector('#websharePasswordHash');
if (config.WebsharePasswordHash && config.WebsharePasswordHash.length != 0)
websharePasswordHash.value = "";
websharePasswordHash.dispatchEvent(new Event('change', {
bubbles: true,
cancelable: false
}));
@@ -144,7 +145,7 @@
config.HideMusicFolder = document.querySelector('#hideMusicFolder').checked;
config.WebshareUser = document.querySelector('#webshareUser').value;
config.WebsharePassword = document.querySelector('#websharePassword').value;
config.WebsharePasswordHash = document.querySelector('#websharePasswordHash').value;
ApiClient.updatePluginConfiguration(CinemaPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});

View File

@@ -9,7 +9,7 @@ class Program
{
CancellationToken cancel = default;
//string a = LinkGenerator.CalculatePassword(fileId: "2kRBqAYQS2", fileName: "bIaFGdkvKiiTBo2_S3E1", salt: "UX8y8Fpa");
Uri a = await LinkGenerator.GenerateDownloadLinkAsync(fileId: "2kRBqAYQS2", fileName: "bIaFGdkvKiiTBo2_S3E1", cancel);
Uri a = await LinkGenerator.GenerateDownloadLinkAsync(fileId: "2kRBqAYQS2", fileName: "bIaFGdkvKiiTBo2_S3E1", null, cancel);
Console.WriteLine(a);
}
}

View File

@@ -0,0 +1,25 @@
using System.Xml;
namespace Cinema.Webshare;
static class ApiExtensions {
public static Exception? GetExceptionIfStatusNotOK(this XmlReader r, out string? code)
{
string status = r.ReadElementContentAsString("status", "");
if (status == "OK") {
code = null;
return null;
}
code = r.ReadElementContentAsString("code", "");
string message = r.ReadElementContentAsString("message", "");
return new IOException(code + ": " + message);
}
public static void ThrowIfStatusNotOK(this XmlReader r)
{
Exception? e = GetExceptionIfStatusNotOK(r, out _);
if (e != null)
throw e;
}
}

View File

@@ -0,0 +1,24 @@
using System;
namespace CinemaLib.Webshare;
/// <summary>
/// Context for checking user name and password step-by-step.
/// </summary>
public struct CredentialCheck
{
private readonly string _userName;
private readonly string _salt;
internal CredentialCheck(string userName, string salt)
{
this._userName = userName;
this._salt = salt;
}
public bool IsNull => _userName == null;
public string UserName => _userName;
internal string Salt => _salt;
}

View File

@@ -1,24 +1,24 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using System.Xml;
using CinemaLib.Webshare;
namespace Cinema.Webshare;
public class LinkGenerator
public static class LinkGenerator
{
private static readonly BaseEncoding UnixMD5 = new BaseEncoding("./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", false);
private static readonly Uri WebshareApiUri = new Uri("https://webshare.cz/api/");
internal static readonly Uri WebshareApiUri = new Uri("https://webshare.cz/api/");
/// <summary>
/// Generates a download link for the given Webshare download.
/// </summary>
/// <param name="fileId">Webshare file identifier.</param>
/// <param name="fileName">Webshare file name.</param>
/// <param name="session">Optional session to get a VIP link.</param>
/// <param name="cancel">Asynchronous cancellation.</param>
/// <returns>Time-limited download link.</returns>
public async static Task<Uri> GenerateDownloadLinkAsync(string fileId, string fileName, CancellationToken cancel)
public async static Task<Uri> GenerateDownloadLinkAsync(string fileId, string fileName, Session? session, CancellationToken cancel)
{
// Obtain the download password salt
// Example response: <?xml version="1.0" encoding="UTF-8"?><response><status>OK</status><salt>UX8y8Fpa</salt><app_version>30</app_version></response>
@@ -29,7 +29,7 @@ public class LinkGenerator
XmlReader saltR = XmlReader.Create(saltRes.Content.ReadAsStream());
saltR.ReadStartElement("response");
Exception? e = GetExceptionIfStatusNotOK(saltR, out string? code);
Exception? e = saltR.GetExceptionIfStatusNotOK(out string? code);
string password;
if (e != null)
{
@@ -61,6 +61,7 @@ public class LinkGenerator
new KeyValuePair<string, string>("device_res_x", "3840"),
new KeyValuePair<string, string>("device_res_y", "2160"),
new KeyValuePair<string, string>("password", password),
new KeyValuePair<string, string>(Session.TokenKeyName, (session != null ? await session.GetTokenAsync(cancel) : null) ?? ""),
}), cancel
);
if (!linkRes.IsSuccessStatusCode)
@@ -68,7 +69,7 @@ public class LinkGenerator
XmlReader linkR = XmlReader.Create(linkRes.Content.ReadAsStream());
linkR.ReadStartElement("response");
ThrowIfStatusNotOK(linkR);
linkR.ThrowIfStatusNotOK();
string link = linkR.ReadElementContentAsString("link", "");
linkR.ReadElementContentAsString("app_version", "");
linkR.ReadEndElement(); // response
@@ -76,26 +77,6 @@ public class LinkGenerator
return new Uri(link);
}
private static Exception? GetExceptionIfStatusNotOK(XmlReader r, out string? code)
{
string status = r.ReadElementContentAsString("status", "");
if (status == "OK") {
code = null;
return null;
}
code = r.ReadElementContentAsString("code", "");
string message = r.ReadElementContentAsString("message", "");
return new IOException(code + ": " + message);
}
private static void ThrowIfStatusNotOK(XmlReader r)
{
Exception? e = GetExceptionIfStatusNotOK(r, out _);
if (e != null)
throw e;
}
/// <summary>
/// Calculates the Webshare download password from file identifier, name and salt.
/// </summary>
@@ -117,110 +98,8 @@ public class LinkGenerator
string toHash = fileName + fileId;
byte[] password = SHA256.HashData(Encoding.UTF8.GetBytes(toHash));
string passwordS = Convert.ToHexString(password).ToLowerInvariant();
string crypt = MD5Crypt(passwordS, salt);
string crypt = Session.MD5Crypt(passwordS, salt);
byte[] result = SHA1.HashData(Encoding.ASCII.GetBytes(crypt));
return Convert.ToHexString(result).ToLowerInvariant();
}
private static string MD5Crypt(string password, string salt)
{
string prefixString = "$1$";
byte[] prefixBytes = Encoding.ASCII.GetBytes(prefixString);
byte[] saltBytes = Encoding.ASCII.GetBytes(salt);
byte[] key = Encoding.ASCII.GetBytes(password);
byte[] truncatedSalt = TruncateAndCopy(saltBytes, 8);
byte[] crypt = Crypt(key, truncatedSalt, prefixBytes, MD5.Create());
string result = prefixString
+ salt + '$'
+ UnixMD5.GetString(crypt);
return result.TrimEnd('=');
}
private static byte[] Crypt(byte[] key, byte[] salt, byte[] prefix, HashAlgorithm A)
{
byte[] H = null, I = null;
A.Initialize();
AddToDigest(A, key);
AddToDigest(A, salt);
AddToDigest(A, key);
FinishDigest(A);
I = (byte[])A.Hash.Clone();
A.Initialize();
AddToDigest(A, key);
AddToDigest(A, prefix);
AddToDigest(A, salt);
AddToDigestRolling(A, I, 0, I.Length, key.Length);
int length = key.Length;
for (int i = 0; i < 31 && length != 0; i++)
{
AddToDigest(A, new[] { (length & (1 << i)) != 0 ? (byte)0 : key[0] });
length &= ~(1 << i);
}
FinishDigest(A);
H = (byte[])A.Hash.Clone();
for (int i = 0; i < 1000; i++)
{
A.Initialize();
if ((i & 1) != 0) { AddToDigest(A, key); }
if ((i & 1) == 0) { AddToDigest(A, H); }
if ((i % 3) != 0) { AddToDigest(A, salt); }
if ((i % 7) != 0) { AddToDigest(A, key); }
if ((i & 1) != 0) { AddToDigest(A, H); }
if ((i & 1) == 0) { AddToDigest(A, key); }
FinishDigest(A);
Array.Copy(A.Hash, H, H.Length);
}
byte[] crypt = new byte[H.Length];
int[] permutation = new[] { 11, 4, 10, 5, 3, 9, 15, 2, 8, 14, 1, 7, 13, 0, 6, 12 };
Array.Reverse(permutation);
for (int i = 0; i < crypt.Length; i++)
{
crypt[i] = H[permutation[i]];
}
return crypt;
}
private static void AddToDigest(HashAlgorithm algorithm, byte[] buffer)
{
AddToDigest(algorithm, buffer, 0, buffer.Length);
}
private static void AddToDigest(HashAlgorithm algorithm, byte[] buffer, int offset, int count)
{
algorithm.TransformBlock(buffer, offset, count, buffer, offset);
}
private static void AddToDigestRolling(HashAlgorithm algorithm, byte[] buffer, int offset, int inputCount, int outputCount)
{
int count;
for (count = 0; count < outputCount; count += inputCount)
{
AddToDigest(algorithm, buffer, offset, Math.Min(outputCount - count, inputCount));
}
}
private static void FinishDigest(HashAlgorithm algorithm)
{
algorithm.TransformFinalBlock(new byte[0], 0, 0);
}
private static byte[] TruncateAndCopy(byte[] buffer, int maxLength)
{
byte[] truncatedBuffer = new byte[Math.Min(buffer.Length, maxLength)];
Array.Copy(buffer, truncatedBuffer, truncatedBuffer.Length);
return truncatedBuffer;
}
}

View File

@@ -0,0 +1,343 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Xml;
using Cinema.Webshare;
namespace CinemaLib.Webshare;
/// <summary>
/// Webshare user authentication context.
/// </summary>
public sealed class Session
{
internal const string TokenKeyName = "wst";
private static readonly BaseEncoding UnixMD5 = new BaseEncoding("./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", false);
private static readonly Uri WebshareApiUri = LinkGenerator.WebshareApiUri;
private static readonly TimeSpan InitialLoginBackoff = new TimeSpan(4 * TimeSpan.TicksPerSecond);
private static readonly TimeSpan MaxLoginBackoff = new TimeSpan(8 * TimeSpan.TicksPerMinute);
private readonly string _userName;
private readonly string _passwordHash;
private string? _token;
private bool? _isVip;
private int? _vipDaysLeft;
private DateTime _nextLoginTrial;
private TimeSpan _nextLoginBackoff;
/// <summary>
/// Constructs a session from credentials and optionally a possibly live token.
/// </summary>
public Session(string userName, string passwordHash, string? token = null)
{
if (userName == null || passwordHash == null)
throw new ArgumentNullException();
this._userName = userName;
this._passwordHash = passwordHash;
this._token = token;
this._nextLoginTrial = default;
this._nextLoginBackoff = InitialLoginBackoff;
}
/// <summary>
/// Gets the user name under which the session is authenticated.
/// </summary>
public string UserName => _userName;
/// <summary>
/// Gets an existing VIP session token or creates a new one.
/// </summary>
/// <param name="cancel">Asynchronous cancellation.</param>
/// <returns>Valid VIP token. -or- Null if account credentials are invalid or not with VIP active.</returns>
public async Task<string?> GetTokenAsync(CancellationToken cancel)
{
return (await EnsureValidAsync(cancel)) && (_isVip ?? false) ? _token : null;
}
/// <summary>
/// Gets the number of days left in the VIP account.
/// </summary>
/// <param name="cancel">Asynchronous cancellation.</param>
/// <returns>Days left. -or- Null if account credentials are invalid or not with VIP active.</returns>
public async Task<int?> GetVipDaysLeftAsync(CancellationToken cancel)
{
return await EnsureValidAsync(cancel) ? _vipDaysLeft : null;
}
/// <summary>
/// Makes sure the cached instance data are valid. The account still may not be VIP.
/// </summary>
/// <param name="cancel"></param>
/// <returns>True if the local instance data are valid. -or- False otherwise.</returns>
private async Task<bool> EnsureValidAsync(CancellationToken cancel)
{
if (!await RefreshUserDataAsync(cancel))
{
DateTime now = DateTime.Now;
if (now > _nextLoginTrial)
{
// Try to login again
string? token;
try
{
string? salt = await GetSaltAsync(_userName, cancel);
token = salt != null ? await GetTokenAsync(new CredentialCheck(_userName, salt), _passwordHash, cancel) : null;
}
catch
{
token = null;
}
if (token != null) {
_nextLoginBackoff = InitialLoginBackoff;
_token = token;
if (await RefreshUserDataAsync(cancel))
return true;
} else {
TimeSpan nextBackoff = new TimeSpan(2 * _nextLoginBackoff.Ticks);
if (nextBackoff > MaxLoginBackoff)
nextBackoff = MaxLoginBackoff;
_nextLoginTrial = now + nextBackoff;
}
}
_token = null;
_isVip = null;
_vipDaysLeft = null;
return false;
}
else
return true;
}
/// <summary>
/// Checks whether user name is valid.
/// </summary>
/// <param name="userName">User name to check.</param>
/// <param name="cancel">Asynchronous cancellation.</param>
/// <returns>Intermedeiate result usable for password check. -or- <see cref="CredentialCheck.IsNull"/> for invalid user name.</returns>
public static async Task<CredentialCheck> CheckUserNameAsync(string userName, CancellationToken cancel)
{
if (userName == null || userName.Length == 0)
throw new ArgumentNullException();
string? salt = await GetSaltAsync(userName, cancel);
return salt != null ? new CredentialCheck(userName, salt) : default;
}
/// <summary>
/// Checks if password is valid for the given user name.
/// </summary>
/// <param name="userName">User name or email.</param>
/// <param name="password">Password in plain-text.</param>
/// <returns>True if password is valid. -or- False otherwise.</returns>
public static async Task<bool> CheckPasswordAsync(CredentialCheck userName, string password, CancellationToken cancel)
{
if (userName.IsNull || password == null)
throw new ArgumentNullException();
string passwordHash = MD5Crypt(password, userName.Salt);
return null != await GetTokenAsync(userName, passwordHash, cancel);
}
/// <summary>
/// Gets a user name salt.
/// </summary>
/// <returns>Non-null if user name is valid. -or- Null otherwise.</returns>
private static async Task<string?> GetSaltAsync(string userName, CancellationToken cancel)
{
// Example response: <?xml version="1.0" encoding="UTF-8"?><response><status>OK</status><salt>somesaltvalue</salt><app_version>30</app_version></response>
Uri saltUri = new Uri(WebshareApiUri, "salt/");
HttpResponseMessage saltRes = await Program._http.PostAsync(saltUri, new FormUrlEncodedContent(new[] {
new KeyValuePair<string, string>("username_or_email", userName),
}), cancel
);
if (!saltRes.IsSuccessStatusCode)
return null;
XmlReader saltR = XmlReader.Create(saltRes.Content.ReadAsStream());
saltR.ReadStartElement("response");
Exception? e = saltR.GetExceptionIfStatusNotOK(out string? code);
if (e != null)
return null;
// Calculate the download password
string salt = saltR.ReadElementContentAsString("salt", "");
saltR.ReadElementContentAsString("app_version", "");
saltR.ReadEndElement(); // response
return salt;
}
/// <summary>
/// Gets the session token.
/// </summary>
/// <returns>Non-null if user name is valid. -or- Null otherwise.</returns>
private static async Task<string?> GetTokenAsync(CredentialCheck userName, string passwordHash, CancellationToken cancel)
{
// Example response: <?xml version="1.0" encoding="UTF-8"?><response><status>OK</status><token>sometokenvalue</token><app_version>30</app_version></response>
Uri tokenUri = new Uri(WebshareApiUri, "login/");
HttpResponseMessage tokenRes = await Program._http.PostAsync(tokenUri, new FormUrlEncodedContent(new[] {
new KeyValuePair<string, string>("username_or_email", userName.UserName),
new KeyValuePair<string, string>("password", passwordHash),
new KeyValuePair<string, string>("keep_logged_in", "1"),
}), cancel
);
if (!tokenRes.IsSuccessStatusCode)
return null;
XmlReader tokenR = XmlReader.Create(tokenRes.Content.ReadAsStream());
tokenR.ReadStartElement("response");
Exception? e = tokenR.GetExceptionIfStatusNotOK(out string? code);
if (e != null)
return null;
// Calculate the download password
string token = tokenR.ReadElementContentAsString("token", "");
tokenR.ReadElementContentAsString("app_version", "");
tokenR.ReadEndElement(); // response
return token;
}
/// <summary>
/// Gets user data and indirectly validates the session token.
/// </summary>
/// <returns>True if refresh has been successful and session token is valid. -or- False otherwise.</returns>
private async Task<bool> RefreshUserDataAsync(CancellationToken cancel)
{
_isVip = null;
_vipDaysLeft = null;
// Example response: <?xml version="1.0" encoding="UTF-8"?><response><status>OK</status>...??...<app_version>30</app_version></response>
Uri dataUri = new Uri(WebshareApiUri, "user_data/");
HttpResponseMessage dataRes = await Program._http.PostAsync(dataUri, new FormUrlEncodedContent(new[] {
new KeyValuePair<string, string>(TokenKeyName, _token ?? "")
}), cancel
);
if (!dataRes.IsSuccessStatusCode)
return false;
XmlReader tokenR = XmlReader.Create(dataRes.Content.ReadAsStream());
tokenR.ReadStartElement("response");
Exception? e = tokenR.GetExceptionIfStatusNotOK(out string? code);
if (e != null)
return false;
// Calculate the download password
_isVip = tokenR.ReadElementContentAsString("vip", "") == "1";
if (int.TryParse(tokenR.ReadElementContentAsString("vip_days", ""), CultureInfo.InvariantCulture, out int vipDays))
_vipDaysLeft = vipDays;
tokenR.ReadElementContentAsString("app_version", "");
tokenR.ReadEndElement(); // response
return true;
}
internal static string MD5Crypt(string password, string salt)
{
string prefixString = "$1$";
byte[] prefixBytes = Encoding.ASCII.GetBytes(prefixString);
byte[] saltBytes = Encoding.ASCII.GetBytes(salt);
byte[] key = Encoding.ASCII.GetBytes(password);
byte[] truncatedSalt = TruncateAndCopy(saltBytes, 8);
byte[] crypt = Crypt(key, truncatedSalt, prefixBytes, MD5.Create());
string result = prefixString
+ salt + '$'
+ UnixMD5.GetString(crypt);
return result.TrimEnd('=');
}
private static byte[] Crypt(byte[] key, byte[] salt, byte[] prefix, HashAlgorithm A)
{
byte[] H = null, I = null;
A.Initialize();
AddToDigest(A, key);
AddToDigest(A, salt);
AddToDigest(A, key);
FinishDigest(A);
I = (byte[])A.Hash.Clone();
A.Initialize();
AddToDigest(A, key);
AddToDigest(A, prefix);
AddToDigest(A, salt);
AddToDigestRolling(A, I, 0, I.Length, key.Length);
int length = key.Length;
for (int i = 0; i < 31 && length != 0; i++)
{
AddToDigest(A, new[] { (length & (1 << i)) != 0 ? (byte)0 : key[0] });
length &= ~(1 << i);
}
FinishDigest(A);
H = (byte[])A.Hash.Clone();
for (int i = 0; i < 1000; i++)
{
A.Initialize();
if ((i & 1) != 0) { AddToDigest(A, key); }
if ((i & 1) == 0) { AddToDigest(A, H); }
if ((i % 3) != 0) { AddToDigest(A, salt); }
if ((i % 7) != 0) { AddToDigest(A, key); }
if ((i & 1) != 0) { AddToDigest(A, H); }
if ((i & 1) == 0) { AddToDigest(A, key); }
FinishDigest(A);
Array.Copy(A.Hash, H, H.Length);
}
byte[] crypt = new byte[H.Length];
int[] permutation = new[] { 11, 4, 10, 5, 3, 9, 15, 2, 8, 14, 1, 7, 13, 0, 6, 12 };
Array.Reverse(permutation);
for (int i = 0; i < crypt.Length; i++)
{
crypt[i] = H[permutation[i]];
}
return crypt;
}
private static void AddToDigest(HashAlgorithm algorithm, byte[] buffer)
{
AddToDigest(algorithm, buffer, 0, buffer.Length);
}
private static void AddToDigest(HashAlgorithm algorithm, byte[] buffer, int offset, int count)
{
algorithm.TransformBlock(buffer, offset, count, buffer, offset);
}
private static void AddToDigestRolling(HashAlgorithm algorithm, byte[] buffer, int offset, int inputCount, int outputCount)
{
int count;
for (count = 0; count < outputCount; count += inputCount)
{
AddToDigest(algorithm, buffer, offset, Math.Min(outputCount - count, inputCount));
}
}
private static void FinishDigest(HashAlgorithm algorithm)
{
algorithm.TransformFinalBlock(new byte[0], 0, 0);
}
private static byte[] TruncateAndCopy(byte[] buffer, int maxLength)
{
byte[] truncatedBuffer = new byte[Math.Min(buffer.Length, maxLength)];
Array.Copy(buffer, truncatedBuffer, truncatedBuffer.Length);
return truncatedBuffer;
}
}

View File

@@ -7,25 +7,37 @@ namespace CinemaWeb.Layouts;
public abstract class BasicLayout
{
private static DateTime _nextLoginCheck;
protected abstract string Title { get; }
protected abstract Task RenderContentAsync(HttpRequest req, TextWriter w);
protected abstract Task RenderContentAsync(HttpRequest req, TextWriter w, CancellationToken cancel);
public async Task<IResult> ExecuteAsync(HttpRequest req)
{
int statusCode = 200;
CancellationToken cancel = req.HttpContext.RequestAborted;
StringBuilder content = new StringBuilder();
TextWriter w = new StringWriter(content);
try
{
RenderHeader(Title, w);
await RenderContentAsync(req, w);
DateTime now = DateTime.UtcNow;
if (Program._webshare != null && _nextLoginCheck < now) {
if (await Program._webshare.GetTokenAsync(cancel) == null)
// Automatic logout
Program._webshare = null;
_nextLoginCheck = now + TimeSpan.FromMinutes(10);
}
string? userName = Program._webshare != null ? Program._webshare.UserName : null;
RenderHeader(Title, userName, w);
await RenderContentAsync(req, w, cancel);
RenderFooter(w);
}
catch (Exception e)
{
content.Clear();
RenderHeader(Title + " - Error " + e.GetType().Name, w);
RenderHeader(Title + " - Error " + e.GetType().Name, null, w);
w.WriteLine("<h1>Error " + e.GetType().Name + "</h1><div><pre>" + HttpUtility.HtmlEncode(e.Message) + "</pre></div>");
RenderFooter(w);
statusCode = 500;
@@ -34,7 +46,7 @@ public abstract class BasicLayout
return Results.Content(content.ToString(), "text/html; charset=utf-8", null, statusCode);
}
private static void RenderHeader(string title, TextWriter w) {
private static void RenderHeader(string title, string? userName, TextWriter w) {
w.WriteLine("<!doctype html>");
w.WriteLine("<html><head>");
w.WriteLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
@@ -55,6 +67,8 @@ public abstract class BasicLayout
w.WriteLine(".media-episodes .media-plot { margin: 1em; overflow-y: hidden; color: #999;}");
w.WriteLine("</style>");
w.WriteLine("</head><body>");
w.WriteLine("<div style=\"float:right\">Uživatel: [<a href=\"/login\">" + (userName != null ? userName : "<em>přihlásit</em>") + "</a>]</div>");
w.WriteLine("<div style=\"font-weight:bold\">Menu: <a href=\"/\">Hledání</a></div>");
}
private static void RenderFooter(TextWriter w) {

View File

@@ -18,11 +18,11 @@ public class EpisodesPage : BasicLayout
protected override string Title => _title;
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w)
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w, CancellationToken cancel)
{
w.WriteLine("<h1>" + HttpUtility.HtmlEncode(_title) + "</h1>");
FilterResponse? res = await Metadata.ChildrenAsync(_parentId, sort: FilterSortBy.Episode, cancel: req.HttpContext.RequestAborted);
FilterResponse? res = await Metadata.ChildrenAsync(_parentId, sort: FilterSortBy.Episode, cancel: cancel);
if (res == null || res.hits == null || res.hits.hits == null || res.hits.hits.Count == 0)
{
@@ -52,7 +52,7 @@ public class EpisodesPage : BasicLayout
w.WriteLine("<h2>Sezóna " + label.title + "</h2>");
FilterResponse? resEp = await Metadata.ChildrenAsync(i._id, sort: FilterSortBy.Episode, cancel: req.HttpContext.RequestAborted);
FilterResponse? resEp = await Metadata.ChildrenAsync(i._id, sort: FilterSortBy.Episode, cancel: cancel);
if (resEp == null || resEp.hits == null || resEp.hits.hits == null || resEp.hits.hits.Count == 0)
{
w.WriteLine("<em>Nic nenalezeno</em>");

View File

@@ -25,14 +25,14 @@ public class LinkPage : BasicLayout
protected override string Title => _title;
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w)
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w, CancellationToken cancel)
{
w.WriteLine("<h1>" + HttpUtility.HtmlEncode(_title) + "</h1>");
Uri link;
switch (_provider)
{
case "webshare": link = await LinkGenerator.GenerateDownloadLinkAsync(_ident, _name, req.HttpContext.RequestAborted); break;
case "webshare": link = await LinkGenerator.GenerateDownloadLinkAsync(_ident, _name, Program._webshare, cancel); break;
default:
w.WriteLine("<em>Provider '" + HttpUtility.HtmlEncode(_provider) + "' není aktuálně podporován.</em>");
return;
@@ -45,7 +45,7 @@ public class LinkPage : BasicLayout
if (subs.Count != 0) {
w.WriteLine("<h2>Externí titulky</h2>");
foreach (string i in subs) {
(string lang, Uri uri) = await ProcessSubtitleLinkAsync(i, req.HttpContext.RequestAborted);
(string lang, Uri uri) = await ProcessSubtitleLinkAsync(i, cancel);
w.WriteLine("<div><a href=\"" + uri.ToString() + "\">" + HttpUtility.HtmlEncode(lang) + "</a></div>");
}
}
@@ -67,7 +67,7 @@ public class LinkPage : BasicLayout
Uri result;
switch (provider) {
case "webshare":
result = await LinkGenerator.GenerateDownloadLinkAsync(rawId, "", cancel);
result = await LinkGenerator.GenerateDownloadLinkAsync(rawId, "", Program._webshare, cancel);
break;
case "http":
result = new Uri(rawId);

View File

@@ -0,0 +1,75 @@
using System;
using System.Web;
using Microsoft.Extensions.Primitives;
using CinemaWeb.Layouts;
using CinemaLib.Webshare;
namespace CinemaWeb.Pages;
public class LoginPage : BasicLayout
{
private const string ParamUserName = "user";
private const string ParamPassword = "pw";
private const string ParamToken = "token";
private const string ParamLogout = "logout";
protected override string Title => "Přihlášení uživatele";
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w, CancellationToken cancel)
{
bool justLoggedIn = false;
if (req.Method == "POST")
{
//
// Execute an action
//
StringValues usernameS;
string? username;
StringValues passwordS;
string? password;
StringValues tokenS;
if ((usernameS = req.Form[ParamUserName]).Count == 1 && (username = usernameS[0]) != null
&& (passwordS = req.Form[ParamPassword]).Count == 1 && (password = passwordS[0]) != null
&& (tokenS = req.Form[ParamToken]).Count == 1)
{
// Log in
justLoggedIn = true;
Program._webshare = new Session(username, password, tokenS[0]);
}
else if (req.Form[ParamLogout].Count != 0)
{
// Log out
Program._webshare = null;
}
}
//
// Show state
//
string? token;
if (Program._webshare != null && (token = await Program._webshare.GetTokenAsync(cancel)) != null)
{
// Logged-in user
w.WriteLine("<h1>Přihlášený uživatel</h1>");
w.WriteLine("<form method=\"POST\" action=\"/login\">");
w.WriteLine("<div>Uživatel: <b>" + HttpUtility.HtmlEncode(Program._webshare.UserName) + "</b></div>");
w.WriteLine("<div>Token:&nbsp;&nbsp;<b>" + HttpUtility.HtmlEncode(token) + "</b></div>");
w.WriteLine("<input type=\"hidden\" name=\"" + ParamLogout + "\">");
w.WriteLine("<input type=\"submit\" value=\"Odhlásit\">");
w.WriteLine("</form>");
}
else
{
w.WriteLine("<h1>Přihlášení uživatele</h1>");
w.WriteLine("<form method=\"POST\" action=\"/login\">");
w.WriteLine("<div>Uživatel: <input type=\"text\" name=\"" + ParamUserName + "\" style=\"width:15em;margin:1em\"></div>");
w.WriteLine("<div>Heslo:&nbsp;&nbsp; <input type=\"password\" name=\"" + ParamPassword + "\" style=\"width:15em;margin:1em\"></div>");
w.WriteLine("<div>Token:&nbsp;&nbsp; <input type=\"text\" name=\"" + ParamToken + "\" style=\"width:15em;margin:1em\"> (nepovinné)</div>");
w.WriteLine("<input type=\"submit\" value=\"Přihlásit\">");
if (justLoggedIn)
w.WriteLine("<div style=\"color:red;font-weight:bold\">Přihlašovací údaje nejsou platné</div>");
w.WriteLine("</form>");
}
}
}

View File

@@ -18,13 +18,13 @@ public class MediaPage : BasicLayout
protected override string Title => _title;
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w)
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w, CancellationToken cancel)
{
w.WriteLine("<h1>" + HttpUtility.HtmlEncode(_title) + "</h1>");
w.WriteLine("<div>TODO</div>");
w.WriteLine("<h2>Zvolte kvalitu</h2>");
CinemaLib.API.Stream[]? res = await Metadata.StreamsAsync(_id, cancel: req.HttpContext.RequestAborted);
CinemaLib.API.Stream[]? res = await Metadata.StreamsAsync(_id, cancel: cancel);
if (res == null || res.Length == 0)
{

View File

@@ -16,15 +16,15 @@ public class SearchPage : BasicLayout
protected override string Title => "Hledat filmy a seriály";
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w)
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w, CancellationToken cancel)
{
string? expr;
if ((expr = req.Query["expr"]) == null || expr.Trim().Length == 0)
if ((expr = req.Query[ParamExpression]) == null || expr.Trim().Length == 0)
{
// Show search
w.WriteLine("<h1>Hledat filmy a seriály</h1>");
w.WriteLine("<form method=\"GET\" action=\"/\">");
w.WriteLine("<input type=\"text\" name=\"" + ParamExpression + "\" style=\"width:100%;margin:1em\">");
w.WriteLine("<input type=\"text\" name=\"" + ParamExpression + "\" style=\"width:100%;margin:1em 0em 1em 0em\">");
w.WriteLine("<input type=\"submit\" value=\"Hledat\">");
w.WriteLine("</form>");
@@ -34,7 +34,7 @@ public class SearchPage : BasicLayout
// Execute search
string? pageS = req.Query[ParamPageIdx];
int page = pageS != null && int.TryParse(pageS, out int value) ? value : 0;
FilterResponse? res = await Metadata.SearchAsync(expr, offset: page * PageSize, limit: PageSize, cancel: req.HttpContext.RequestAborted);
FilterResponse? res = await Metadata.SearchAsync(expr, offset: page * PageSize, limit: PageSize, cancel: cancel);
w.WriteLine("<h1>Hledání: " + HttpUtility.HtmlEncode(expr) + "</h1>");
if (res == null || res.hits == null || res.hits.hits == null || res.hits.hits.Count == 0)

View File

@@ -19,12 +19,12 @@ public class StreamsPage : BasicLayout
protected override string Title => _title;
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w)
protected override async Task RenderContentAsync(HttpRequest req, TextWriter w, CancellationToken cancel)
{
w.WriteLine("<h1>" + HttpUtility.HtmlEncode(_title) + "</h1>");
w.WriteLine("<div><b>Zvolte kvalitu</b></div>");
CinemaLib.API.Stream[]? res = await Metadata.StreamsAsync(_id, cancel: req.HttpContext.RequestAborted);
CinemaLib.API.Stream[]? res = await Metadata.StreamsAsync(_id, cancel: cancel);
if (res == null || res.Length == 0)
{

View File

@@ -1,13 +1,24 @@
using System.Web;
using CinemaLib.Webshare;
using CinemaWeb.Pages;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
class Program
{
internal static Session? _webshare;
app.MapGet("/", new SearchPage().ExecuteAsync);
app.MapGet("/episodes/{id}/{title}", (string id, string title, HttpRequest req) => new EpisodesPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req));
app.MapGet("/streams/{id}/{title}", (string id, string title, HttpRequest req) => new StreamsPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req));
app.MapGet("/media/{id}/{title}", (string id, string title, HttpRequest req) => new MediaPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req));
app.MapGet("/link/{provider}/{ident}/{name}/{title}", (string provider, string ident, string name, string title, HttpRequest req) => new LinkPage(provider, HttpUtility.UrlDecode(ident), HttpUtility.UrlDecode(name), HttpUtility.UrlDecode(title)).ExecuteAsync(req));
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Run();
app.MapGet("/", new SearchPage().ExecuteAsync);
app.MapGet("/login", new LoginPage().ExecuteAsync);
app.MapPost("/login", new LoginPage().ExecuteAsync);
app.MapGet("/episodes/{id}/{title}", (string id, string title, HttpRequest req) => new EpisodesPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req));
app.MapGet("/streams/{id}/{title}", (string id, string title, HttpRequest req) => new StreamsPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req));
app.MapGet("/media/{id}/{title}", (string id, string title, HttpRequest req) => new MediaPage(id, HttpUtility.UrlDecode(title)).ExecuteAsync(req));
app.MapGet("/link/{provider}/{ident}/{name}/{title}", (string provider, string ident, string name, string title, HttpRequest req) => new LinkPage(provider, HttpUtility.UrlDecode(ident), HttpUtility.UrlDecode(name), HttpUtility.UrlDecode(title)).ExecuteAsync(req));
app.Run();
}
}