mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
More Fixes from Recent PRs (#1995)
* Added extra debugging for logout issue * Fixed the null issue with ISBN * Allow web links to be cleared out * More logging on refresh token * More key fallback when building Table of Contents * Added better fallback implementation for building table of contents based on the many different ways epubs are packed and referenced. * Updated dependencies * Fixed up refresh token refresh which was invalidating sessions for no reason. Added it to update last active time as well.
This commit is contained in:
parent
95df0a0825
commit
2ce4ddcaa4
@ -10,8 +10,8 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
||||||
<PackageReference Include="Moq" Version="4.18.4" />
|
<PackageReference Include="Moq" Version="4.18.4" />
|
||||||
<PackageReference Include="NSubstitute" Version="4.4.0" />
|
<PackageReference Include="NSubstitute" Version="4.4.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.22" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.26" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.22" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.26" />
|
||||||
<PackageReference Include="xunit" Version="2.4.2" />
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
@ -102,7 +102,7 @@
|
|||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.30.1" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.30.1" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="19.2.22" />
|
<PackageReference Include="System.IO.Abstractions" Version="19.2.26" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
|
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.0-alpha1" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.0-alpha1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
@ -443,6 +443,9 @@ public class AccountController : BaseApiController
|
|||||||
if (!roleResult.Succeeded) return BadRequest(roleResult.Errors);
|
if (!roleResult.Succeeded) return BadRequest(roleResult.Errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We might want to check if they had admin and no longer, if so:
|
||||||
|
// await _userManager.UpdateSecurityStampAsync(user); to force them to re-authenticate
|
||||||
|
|
||||||
|
|
||||||
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
||||||
List<Library> libraries;
|
List<Library> libraries;
|
||||||
|
@ -165,16 +165,16 @@ public class Program
|
|||||||
|
|
||||||
var env = hostingContext.HostingEnvironment;
|
var env = hostingContext.HostingEnvironment;
|
||||||
|
|
||||||
config.AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: false)
|
config.AddJsonFile("config/appsettings.json", optional: false, reloadOnChange: false)
|
||||||
.AddJsonFile($"config/appsettings.{env.EnvironmentName}.json",
|
.AddJsonFile($"config/appsettings.{env.EnvironmentName}.json",
|
||||||
optional: true, reloadOnChange: false);
|
optional: false, reloadOnChange: false);
|
||||||
})
|
})
|
||||||
.ConfigureWebHostDefaults(webBuilder =>
|
.ConfigureWebHostDefaults(webBuilder =>
|
||||||
{
|
{
|
||||||
webBuilder.UseKestrel((opts) =>
|
webBuilder.UseKestrel((opts) =>
|
||||||
{
|
{
|
||||||
var ipAddresses = Configuration.IpAddresses;
|
var ipAddresses = Configuration.IpAddresses;
|
||||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker || string.IsNullOrEmpty(ipAddresses) || ipAddresses.Equals(Configuration.DefaultIpAddresses))
|
if (new OsInfo().IsDocker || string.IsNullOrEmpty(ipAddresses) || ipAddresses.Equals(Configuration.DefaultIpAddresses))
|
||||||
{
|
{
|
||||||
opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; });
|
opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; });
|
||||||
}
|
}
|
||||||
@ -195,9 +195,6 @@ public class Program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
webBuilder.UseStartup<Startup>();
|
webBuilder.UseStartup<Startup>();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -435,7 +435,7 @@ public class BookService : IBookService
|
|||||||
};
|
};
|
||||||
ComicInfo.CleanComicInfo(info);
|
ComicInfo.CleanComicInfo(info);
|
||||||
|
|
||||||
foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers.Where(id => id.Scheme.Equals("ISBN")))
|
foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers.Where(id => !string.IsNullOrEmpty(id.Scheme) && id.Scheme.Equals("ISBN")))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(identifier.Identifier)) continue;
|
if (string.IsNullOrEmpty(identifier.Identifier)) continue;
|
||||||
var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty);
|
var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty);
|
||||||
@ -494,7 +494,7 @@ public class BookService : IBookService
|
|||||||
break;
|
break;
|
||||||
case "title-type":
|
case "title-type":
|
||||||
break;
|
break;
|
||||||
// This is currently not possible until VersOne update's to allow EPUB 3 Title to have attributes
|
// This is currently not possible until VersOne update's to allow EPUB 3 Title to have attributes (3.3 update)
|
||||||
if (!metadataItem.Content.Equals("collection")) break;
|
if (!metadataItem.Content.Equals("collection")) break;
|
||||||
var titleId = metadataItem.Refines.Replace("#", string.Empty);
|
var titleId = metadataItem.Refines.Replace("#", string.Empty);
|
||||||
var readingListElem = epubBook.Schema.Package.Metadata.MetaItems.FirstOrDefault(item =>
|
var readingListElem = epubBook.Schema.Package.Metadata.MetaItems.FirstOrDefault(item =>
|
||||||
@ -855,7 +855,7 @@ public class BookService : IBookService
|
|||||||
/// <param name="mappings"></param>
|
/// <param name="mappings"></param>
|
||||||
/// <param name="key"></param>
|
/// <param name="key"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private static string CoalesceKey(EpubBookRef book, IDictionary<string, int> mappings, string key)
|
private static string CoalesceKey(EpubBookRef book, IReadOnlyDictionary<string, int> mappings, string key)
|
||||||
{
|
{
|
||||||
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
|
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
|
||||||
|
|
||||||
@ -866,6 +866,19 @@ public class BookService : IBookService
|
|||||||
key = correctedKey;
|
key = correctedKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stepsBack = CountParentDirectory(book.Content.NavigationHtmlFile.FileName);
|
||||||
|
if (mappings.TryGetValue(key, out _))
|
||||||
|
{
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifiedKey = RemovePathSegments(key, stepsBack);
|
||||||
|
if (mappings.TryGetValue(modifiedKey, out _))
|
||||||
|
{
|
||||||
|
return modifiedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -904,7 +917,7 @@ public class BookService : IBookService
|
|||||||
{
|
{
|
||||||
if (navigationItem.NestedItems.Count == 0)
|
if (navigationItem.NestedItems.Count == 0)
|
||||||
{
|
{
|
||||||
CreateToCChapter(navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
|
CreateToCChapter(book, navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -913,19 +926,19 @@ public class BookService : IBookService
|
|||||||
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
|
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
|
||||||
{
|
{
|
||||||
var key = CoalesceKey(book, mappings, nestedChapter.Link.ContentFileName);
|
var key = CoalesceKey(book, mappings, nestedChapter.Link.ContentFileName);
|
||||||
if (mappings.ContainsKey(key))
|
if (mappings.TryGetValue(key, out var mapping))
|
||||||
{
|
{
|
||||||
nestedChapters.Add(new BookChapterItem
|
nestedChapters.Add(new BookChapterItem
|
||||||
{
|
{
|
||||||
Title = nestedChapter.Title,
|
Title = nestedChapter.Title,
|
||||||
Page = mappings[key],
|
Page = mapping,
|
||||||
Part = nestedChapter.Link.Anchor ?? string.Empty,
|
Part = nestedChapter.Link.Anchor ?? string.Empty,
|
||||||
Children = new List<BookChapterItem>()
|
Children = new List<BookChapterItem>()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings);
|
CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chaptersList.Count != 0) return chaptersList;
|
if (chaptersList.Count != 0) return chaptersList;
|
||||||
@ -964,6 +977,38 @@ public class BookService : IBookService
|
|||||||
return chaptersList;
|
return chaptersList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int CountParentDirectory(string path)
|
||||||
|
{
|
||||||
|
const string pattern = @"\.\./";
|
||||||
|
var matches = Regex.Matches(path, pattern);
|
||||||
|
|
||||||
|
return matches.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes paths segments from the beginning of a path. Returns original path if any issues.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path"></param>
|
||||||
|
/// <param name="segmentsToRemove"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
private static string RemovePathSegments(string path, int segmentsToRemove)
|
||||||
|
{
|
||||||
|
if (segmentsToRemove <= 0)
|
||||||
|
return path;
|
||||||
|
|
||||||
|
var startIndex = 0;
|
||||||
|
for (var i = 0; i < segmentsToRemove; i++)
|
||||||
|
{
|
||||||
|
var slashIndex = path.IndexOf('/', startIndex);
|
||||||
|
if (slashIndex == -1)
|
||||||
|
return path; // Not enough segments to remove
|
||||||
|
|
||||||
|
startIndex = slashIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.Substring(startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader,
|
/// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader,
|
||||||
/// all css is scoped, etc.
|
/// all css is scoped, etc.
|
||||||
@ -1028,7 +1073,7 @@ public class BookService : IBookService
|
|||||||
throw new KavitaException("Could not find the appropriate html for that page");
|
throw new KavitaException("Could not find the appropriate html for that page");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters,
|
private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters,
|
||||||
ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings)
|
ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings)
|
||||||
{
|
{
|
||||||
if (navigationItem.Link == null)
|
if (navigationItem.Link == null)
|
||||||
@ -1047,7 +1092,7 @@ public class BookService : IBookService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var groupKey = CleanContentKeys(navigationItem.Link.ContentFileName);
|
var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFileName);
|
||||||
if (mappings.ContainsKey(groupKey))
|
if (mappings.ContainsKey(groupKey))
|
||||||
{
|
{
|
||||||
chaptersList.Add(new BookChapterItem
|
chaptersList.Add(new BookChapterItem
|
||||||
|
@ -98,10 +98,10 @@ public class SeriesService : ISeriesService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This shouldn't be needed post v0.5.3 release
|
// This shouldn't be needed post v0.5.3 release
|
||||||
if (string.IsNullOrEmpty(series.Metadata.Summary))
|
// if (string.IsNullOrEmpty(series.Metadata.Summary))
|
||||||
{
|
// {
|
||||||
series.Metadata.Summary = string.Empty;
|
// series.Metadata.Summary = string.Empty;
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata.Summary))
|
if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata.Summary))
|
||||||
{
|
{
|
||||||
@ -120,7 +120,10 @@ public class SeriesService : ISeriesService
|
|||||||
series.Metadata.LanguageLocked = true;
|
series.Metadata.LanguageLocked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata?.WebLinks))
|
if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata?.WebLinks))
|
||||||
|
{
|
||||||
|
series.Metadata.WebLinks = string.Empty;
|
||||||
|
} else
|
||||||
{
|
{
|
||||||
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks
|
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks
|
||||||
.Split(",")
|
.Split(",")
|
||||||
|
@ -5,10 +5,12 @@ using System.Linq;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.Data;
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using static System.Security.Claims.ClaimTypes;
|
using static System.Security.Claims.ClaimTypes;
|
||||||
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;
|
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;
|
||||||
@ -27,13 +29,17 @@ public interface ITokenService
|
|||||||
public class TokenService : ITokenService
|
public class TokenService : ITokenService
|
||||||
{
|
{
|
||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
|
private readonly ILogger<TokenService> _logger;
|
||||||
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly SymmetricSecurityKey _key;
|
private readonly SymmetricSecurityKey _key;
|
||||||
private const string RefreshTokenName = "RefreshToken";
|
private const string RefreshTokenName = "RefreshToken";
|
||||||
|
|
||||||
public TokenService(IConfiguration config, UserManager<AppUser> userManager)
|
public TokenService(IConfiguration config, UserManager<AppUser> userManager, ILogger<TokenService> logger, IUnitOfWork unitOfWork)
|
||||||
{
|
{
|
||||||
|
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
|
_logger = logger;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"] ?? string.Empty));
|
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"] ?? string.Empty));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,12 +84,28 @@ public class TokenService : ITokenService
|
|||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
var tokenContent = tokenHandler.ReadJwtToken(request.Token);
|
var tokenContent = tokenHandler.ReadJwtToken(request.Token);
|
||||||
var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.Name)?.Value;
|
var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.Name)?.Value;
|
||||||
if (string.IsNullOrEmpty(username)) return null;
|
if (string.IsNullOrEmpty(username))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
var user = await _userManager.FindByNameAsync(username);
|
var user = await _userManager.FindByNameAsync(username);
|
||||||
if (user == null) return null; // This forces a logout
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("[RefreshToken] failed to validate due to not finding user in DB");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken);
|
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken);
|
||||||
if (!validated) return null;
|
if (!validated)
|
||||||
await _userManager.UpdateSecurityStampAsync(user);
|
{
|
||||||
|
|
||||||
|
_logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.UpdateLastActive();
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
return new TokenRequestDto()
|
return new TokenRequestDto()
|
||||||
{
|
{
|
||||||
@ -93,11 +115,13 @@ public class TokenService : ITokenService
|
|||||||
} catch (SecurityTokenExpiredException ex)
|
} catch (SecurityTokenExpiredException ex)
|
||||||
{
|
{
|
||||||
// Handle expired token
|
// Handle expired token
|
||||||
|
_logger.LogError(ex, "Failed to validate refresh token");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Handle other exceptions
|
// Handle other exceptions
|
||||||
|
_logger.LogError(ex, "Failed to validate refresh token");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ public static class Configuration
|
|||||||
{
|
{
|
||||||
public const string DefaultIpAddresses = "0.0.0.0,::";
|
public const string DefaultIpAddresses = "0.0.0.0,::";
|
||||||
public const string DefaultBaseUrl = "/";
|
public const string DefaultBaseUrl = "/";
|
||||||
|
public const int DefaultHttpPort = 5000;
|
||||||
public const string DefaultXFrameOptions = "SAMEORIGIN";
|
public const string DefaultXFrameOptions = "SAMEORIGIN";
|
||||||
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||||
|
|
||||||
@ -307,7 +308,7 @@ public static class Configuration
|
|||||||
// ReSharper disable once MemberHidesStaticFromOuterClass
|
// ReSharper disable once MemberHidesStaticFromOuterClass
|
||||||
public int Port { get; set; }
|
public int Port { get; set; }
|
||||||
// ReSharper disable once MemberHidesStaticFromOuterClass
|
// ReSharper disable once MemberHidesStaticFromOuterClass
|
||||||
public string IpAddresses { get; set; }
|
public string IpAddresses { get; set; } = string.Empty;
|
||||||
// ReSharper disable once MemberHidesStaticFromOuterClass
|
// ReSharper disable once MemberHidesStaticFromOuterClass
|
||||||
public string BaseUrl { get; set; }
|
public string BaseUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.2.7"
|
"version": "0.7.2.8"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user