R1 cleanup: collAnon and dead code gone, docs current, every peer's scenario in one sweep
Build / Build (push) Successful in 1m7s
Build / Build (push) Successful in 1m7s
Starts the completion program (docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17). - Dead code: the commented-out collAnon blocks (discussions, seeding, admin user lists, HTTP/3, the old signature middleware), the empty PrivaPubWorkersConfiguration, PublicAvatarUsersService and UserPolicyType, unused helpers, the second IPasswordHasher registration, the PasswordGenerator package, SignUpAsync/LoginAsync's unused invitation parameters. "Starting PrivaPub"; a signed-in login is sent to "/", not to "/discussions". - Stored fields nothing read: RootUser.Contacts and ResetPasswordToken*, the local Avatar.AvatarServer, EmailRecovery.RecoveryCode/RequestIP/CreationDate, MongoSettings.LogsDatabase, and AppConfiguration's upload/discussion keys, RequiresFirstTimeSetup and Version (old documents still load: MongoDB.Entities ignores extra elements). DmPost's indexes are no longer made. 49 collAnon keys leave FieldsNameResource in every language. - /clientapi/data/current-version answers the build's ref. UsersIds is a list that can't throw, and the admin routes leave the administrator out without mutating the request. - deploy.yml checks SecureMode on a name no persona can have, and the browser redirect on the directory's first local persona: @thepra is the administrator's own now. - Town: decePub's e2e remnants (expect, client.jsonl, the decePub column) and town/tests go; the dead gaps/load commands too. Mobilizon no longer keeps an event for a deleted test. - tools/pasture/sweep.sh runs every peer's scenario, batch by batch, as CLAUDE.md asks before a change to what PrivaPub sends. - Docs: CLAUDE.md's repo map, route table (every frozen route) and stale lines; ROADMAP's status (setup, v1.25.0, v1.26.0, the program C), the 2026-10-17 decisions, and dated notes where earlier text no longer holds; FEDERATION.md's FEP list (7888, f228, 7628, ae0c and 8967 supported) and the deploy's persona. Verified: dotnet build PrivaPub.sln -c Release (0 errors, no new warnings); bash -n on the touched scripts; py_compile on the town; the pasture's gts, mastodon and lemmy scenarios on this build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
6727e1817a
commit
ff5e635dda
71 files changed
+238
-4767
No files matched your search
@@ -29,14 +29,20 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
RootUsersService = rootUsersService;
|
||||
}
|
||||
|
||||
// an administrator never removes, bans or unbans itself; the request's own list is left as it came
|
||||
UsersIds Others(UsersIds usersIds)
|
||||
{
|
||||
var selfId = User.GetUserId();
|
||||
return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).ToList() };
|
||||
}
|
||||
|
||||
[HttpDelete, Route("/clientapi/admin/remove/users"), Authorize(Policy = Policies.IsAdmin)]
|
||||
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
usersIds.UserIdList.Remove(User.GetUserId());
|
||||
result = await RootUsersService.RemoveUserAsync(usersIds);
|
||||
result = await RootUsersService.RemoveUserAsync(Others(usersIds));
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
@@ -57,13 +63,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
//var isUserResult = await UsersService.UserIsAdminAsync(User.GetUserId());
|
||||
//if (isUserResult.IsValid && !(bool)isUserResult.Data)
|
||||
//if (isUserResult is { IsValid: true } and not { Data: bool })
|
||||
// return Unauthorized();
|
||||
|
||||
usersIds.UserIdList.Remove(User.GetUserId());
|
||||
result = await RootUsersService.BanUserAsync(usersIds);
|
||||
result = await RootUsersService.BanUserAsync(Others(usersIds));
|
||||
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
@@ -85,12 +85,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
//var isUserResult = await UsersService.UserIsAdminAsync(User.GetUserId());
|
||||
//if (isUserResult.IsValid && !(bool)isUserResult.Data)
|
||||
// return Unauthorized();
|
||||
|
||||
usersIds.UserIdList.Remove(User.GetUserId());
|
||||
result = await RootUsersService.UnbanUserAsync(usersIds);
|
||||
result = await RootUsersService.UnbanUserAsync(Others(usersIds));
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
|
||||
@@ -102,28 +97,5 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
}
|
||||
}
|
||||
|
||||
//[HttpGet, Authorize(Policy = Policies.IsAdmin)]
|
||||
//public async Task<IActionResult> GetUsers()
|
||||
//{
|
||||
// var result = new WebResult();
|
||||
// try
|
||||
// {
|
||||
// var isUserResult = await UsersService.UserIsAdminAsync(User.GetUserId());
|
||||
// if (isUserResult.IsValid && !(bool)isUserResult.Data)
|
||||
// return Unauthorized();
|
||||
|
||||
// result = await UsersService.GetUsersAsync();
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
|
||||
// return Ok(result.Data);
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUsers)}()");
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
[HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
||||
public async Task<IActionResult> Login(LoginForm loginForm)
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
|
||||
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
||||
var result = new WebResult();
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
||||
@@ -132,7 +132,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (personaProblem != default)
|
||||
return BadRequest(result.Invalidate(personaProblem));
|
||||
|
||||
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
|
||||
result = await UsersService.SignUpAsync(signUpForm);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
@@ -171,7 +171,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
if (!invitation.IsValid)
|
||||
return StatusCode(invitation.StatusCode, invitation);
|
||||
|
||||
result = await UsersService.LoginAsync(loginForm, loginForm.InvitationCode);
|
||||
result = await UsersService.LoginAsync(loginForm);
|
||||
if (!result.IsValid)
|
||||
return StatusCode(result.StatusCode, result);
|
||||
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
||||
@@ -370,25 +370,6 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
}
|
||||
}
|
||||
|
||||
//[HttpGet, Authorize(Policy = Policies.IsUser)]
|
||||
//public async Task<IActionResult> GetUser()
|
||||
//{
|
||||
// var result = new WebResult();
|
||||
// try
|
||||
// {
|
||||
// result = await UsersService.GetUserAsync(User.GetUserId());
|
||||
// if (!result.IsValid)
|
||||
// return StatusCode(result.StatusCode, result);
|
||||
|
||||
// return Ok(result.Data);
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()");
|
||||
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||
// }
|
||||
//}
|
||||
|
||||
// what the login's media take, every persona's, and its quota when the server sets one
|
||||
[HttpGet, Route("/clientapi/user/storage"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> GetStorage([FromServices] Microsoft.Extensions.Options.IOptionsMonitor<Domain.Media.MediaOptions> media, CancellationToken token)
|
||||
|
||||
+1
-109
@@ -10,68 +10,12 @@ namespace PrivaPub.Data
|
||||
{
|
||||
public static class InitDb
|
||||
{
|
||||
public static async Task Init(this DbEntities dbClient, IPasswordHasher passwordHasher)
|
||||
public static async Task Init(this DbEntities dbClient)
|
||||
{
|
||||
await SyncLanguages(dbClient);
|
||||
await UpdateNewLanguages(dbClient);
|
||||
//await SyncUserSettings(dbClient);
|
||||
//await ClearUsers(dbClient);
|
||||
//var botsInsertionAwaiter = InsertBots(dbClient, passwordHasher);
|
||||
//var updateAnswersAwaiter = UpdateAnswersLikes(dbClient);
|
||||
|
||||
//Task.WaitAll(botsInsertionAwaiter);
|
||||
//await SyncShareDiscussions(dbClient);
|
||||
}
|
||||
|
||||
//static async Task UpdateAnswersLikes(DbCollections dbClient)
|
||||
//{
|
||||
// if (await dbClient.AnswersLikes.Find(al => al.AnswerId != null).AnyAsync()) return;
|
||||
|
||||
// foreach (var answerLikes in await dbClient.AnswersLikes.Find(al => true).ToListAsync())
|
||||
// {
|
||||
// answerLikes.AnswerId = answerLikes.Id;
|
||||
// answerLikes.Id = ObjectId.GenerateNewId();
|
||||
// await dbClient.AnswersLikes.InsertOneAsync(answerLikes);
|
||||
// _ = await dbClient.AnswersLikes.DeleteOneAsync(al => al.Id == answerLikes.AnswerId);
|
||||
// }
|
||||
//}
|
||||
|
||||
//static async Task ClearUsers(DbColl dbClient)
|
||||
//{
|
||||
// await dbClient.Users.DeleteManyAsync(u => true);
|
||||
// await dbClient.UsersSettings.DeleteManyAsync(u => true);
|
||||
// await dbClient.Answers.DeleteManyAsync(u => true);
|
||||
// await dbClient.AnswersLikes.DeleteManyAsync(u => true);
|
||||
// await dbClient.Comments.DeleteManyAsync(u => true);
|
||||
// await dbClient.EmailRecoveries.DeleteManyAsync(u => true);
|
||||
// await dbClient.Threads.DeleteManyAsync(u => true);
|
||||
// await dbClient.EDiscussions.DeleteManyAsync(u => true);
|
||||
// await dbClient.Logs.DeleteManyAsync(u => true);
|
||||
// await dbClient.ShareDiscussions.De(u => true);
|
||||
// await dbClient.DiscussionFiles.DeleteManyAsync(u => true);
|
||||
// await dbClient.UsersGroups.DeleteManyAsync(u => true);
|
||||
//}
|
||||
|
||||
// static async Task SyncShareDiscussions(DbColl dbClient)
|
||||
// {
|
||||
// var anyThread = await dbClient.Discussions.Match(t => true).ExecuteAnyAsync();
|
||||
// var anyShareDiscussion = await dbClient.ShareDiscussions.Match(st => true).ExecuteAnyAsync();
|
||||
//
|
||||
// if (anyThread == anyShareDiscussion) return;
|
||||
//
|
||||
// var discussions = await dbClient.Discussions.ManyAsync(t => true);
|
||||
//
|
||||
// foreach (var discussion in discussions)
|
||||
// {
|
||||
// var newShareDiscussion = new ShareDiscussion
|
||||
// {
|
||||
// DiscussionId = discussion.ID,
|
||||
// InvitationCode = $"{Guid.NewGuid():N}{Guid.NewGuid():N}"
|
||||
// };
|
||||
// await newShareDiscussion.SaveAsync();
|
||||
// }
|
||||
// }
|
||||
|
||||
static async Task SyncLanguages(DbEntities dbClient)
|
||||
{
|
||||
if (await dbClient.Languages.ExecuteAnyAsync()) return;
|
||||
@@ -157,57 +101,5 @@ namespace PrivaPub.Data
|
||||
await DB.Default.SaveAsync(bgLang);
|
||||
}
|
||||
}
|
||||
|
||||
// static async Task SyncUserSettings(DbColl dbClient)
|
||||
// {
|
||||
// if (!await dbClient.Users.ExecuteAnyAsync()) return;
|
||||
//
|
||||
// var users = await dbClient.Users.ManyAsync(u => true);
|
||||
// var usersId = users.Select(u => u.ID).ToList();
|
||||
// var usersSettings = await dbClient.UsersSettings
|
||||
// .ManyAsync(us => true);
|
||||
//
|
||||
// var usersWithoutSettings = users.Where(u => !usersSettings.Any(us => us.UserId == u.ID)).Select(u => u.ID).ToList();
|
||||
// if (usersWithoutSettings.Count == 0) return;
|
||||
//
|
||||
// var defaultLanguage = await dbClient.Languages
|
||||
// .Match(l => l.International2Code == "en")
|
||||
// .ExecuteFirstAsync();
|
||||
// foreach (var userId in usersWithoutSettings)
|
||||
// await DB.SaveAsync(new UserSettings
|
||||
// {
|
||||
// UserId = userId,
|
||||
// LanguageId = defaultLanguage.International2Code
|
||||
// });
|
||||
// }
|
||||
|
||||
//static async Task InsertBots(DbEntities dbClient, IPasswordHasher passwordHasher)
|
||||
//{
|
||||
// if (await dbClient.Users.Match(u => u.UserName == "bot0").ExecuteAnyAsync()) return;
|
||||
|
||||
// var newBots = new List<User>();
|
||||
// var newBotsUserSettings = new List<UserSettings>();
|
||||
|
||||
// for (int i = 0; i < 100; i++)
|
||||
// newBots.Add(new User
|
||||
// {
|
||||
// HashedPassword = passwordHasher.Hash("Asdfmov13!!!"),
|
||||
// UserName = $"bot{i}"
|
||||
// });
|
||||
// await newBots.SaveAsync();
|
||||
|
||||
// var defaultLanguage = await dbClient.Languages
|
||||
// .Match(l => l.International2Code == "en")
|
||||
// .ExecuteFirstAsync();
|
||||
|
||||
// foreach (var newBot in newBots)
|
||||
// newBotsUserSettings.Add(new()
|
||||
// {
|
||||
// UserId = newBot.ID,
|
||||
// LanguageId = defaultLanguage.International2Code,
|
||||
// });
|
||||
|
||||
// await newBotsUserSettings.SaveAsync();
|
||||
//}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace PrivaPub.Extensions
|
||||
{
|
||||
public static class Extensions
|
||||
@@ -27,22 +23,5 @@ namespace PrivaPub.Extensions
|
||||
.RequireClaim(Policies.IsUser, true.ToString().ToLower())
|
||||
.RequireClaim(Policies.IsModerator, true.ToString().ToLower())
|
||||
.Build();
|
||||
|
||||
public static string GetHostWithPath(this HttpContext httpContext) =>
|
||||
$"https://{httpContext.Request.Host}{httpContext.Request.Path}";
|
||||
|
||||
public static string GetHost(this HttpContext httpContext) =>
|
||||
$"https://{httpContext.Request.Host}";
|
||||
|
||||
public static UserPolicyType GetHighestPolicy(this ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
if (claimsPrincipal.FindFirstValue(Policies.IsAdmin) == "true")
|
||||
return UserPolicyType.IsAdmin;
|
||||
|
||||
if (claimsPrincipal.FindFirstValue(Policies.IsModerator) == "true")
|
||||
return UserPolicyType.IsModerator;
|
||||
|
||||
return UserPolicyType.IsUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using PrivaPub.ClientModels;
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace PrivaPub.Extensions
|
||||
{
|
||||
@@ -10,27 +8,5 @@ namespace PrivaPub.Extensions
|
||||
{
|
||||
return claimsPrincipal?.Claims?.FirstOrDefault(c => c.Type == ClaimTypes.UserData)?.Value;
|
||||
}
|
||||
|
||||
public static string GetUserName(this ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var userId = claimsPrincipal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
|
||||
return string.IsNullOrEmpty(userId) ? default : userId;
|
||||
}
|
||||
|
||||
public static List<string> GetUserPolicies(this ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var policies = new List<string>();
|
||||
|
||||
if (claimsPrincipal.Claims.Any(c => c.Type == Policies.IsAdmin && bool.Parse(c.Value)))
|
||||
policies.Add(Policies.IsAdmin);
|
||||
|
||||
if (claimsPrincipal.Claims.Any(c => c.Type == Policies.IsUser && bool.Parse(c.Value)))
|
||||
policies.Add(Policies.IsUser);
|
||||
|
||||
if (claimsPrincipal.Claims.Any(c => c.Type == Policies.IsModerator && bool.Parse(c.Value)))
|
||||
policies.Add(Policies.IsModerator);
|
||||
|
||||
return policies;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
continue;
|
||||
}
|
||||
await roots.UpdateOneAsync(Id(root["_id"]), Copy(root, "HashedPassword", "Email", "IsEmailValidated", "IsBanned", "Policies",
|
||||
"CredentialsChangedAt", "ResetPasswordToken", "ResetPasswordTokenSentAt"), cancellationToken: token);
|
||||
"CredentialsChangedAt"), cancellationToken: token);
|
||||
}
|
||||
|
||||
var avatars = database.GetCollection<BsonDocument>("Avatar");
|
||||
|
||||
@@ -49,9 +49,6 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<Models.Media.MediaAttachment>(token, m => m.ProfileOfAvatarId);
|
||||
await Plain<Models.Media.MediaAttachment>(token, m => m.TrashedAt);
|
||||
|
||||
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Gt(p => p.ObjectURI, ""), token);
|
||||
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
|
||||
|
||||
await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Gt(a => a.ActorURI, ""), token);
|
||||
await Plain<ForeignAvatar>(token, a => a.PublicKeyId);
|
||||
|
||||
|
||||
@@ -44,13 +44,6 @@ namespace PrivaPub.Middleware
|
||||
.Configure<AppConfiguration>(configuration.GetSection(nameof(AppConfiguration)))
|
||||
.Configure<PrivaPub.Infrastructure.RegistrationOptions>(configuration.GetSection("Registrations"));
|
||||
}
|
||||
public static IServiceCollection PrivaPubWorkersConfiguration(this IServiceCollection service)
|
||||
{
|
||||
return service;
|
||||
//.AddHostedService<DiscussionsWorker>()
|
||||
//.AddHostedService<GroupsCleanerWorker>()
|
||||
//.AddHostedService<PoliciesCleanerWorker>();
|
||||
}
|
||||
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service, IConfiguration configuration)
|
||||
{
|
||||
service.Configure<FederationOptions>(configuration.GetSection("Federation"));
|
||||
@@ -228,7 +221,6 @@ namespace PrivaPub.Middleware
|
||||
.AddTransient<IRootUsersService, RootUsersService>()
|
||||
.AddScoped<IRootSessions, RootSessions>()
|
||||
.AddScoped<IRootRemoval, RootRemoval>()
|
||||
.AddTransient<IPublicAvatarUsersService, PublicAvatarUsersService>()
|
||||
.AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>()
|
||||
.AddTransient<IGroupUsersService, GroupUsersService>()
|
||||
.AddTransient<IPostsService, PostsService>()
|
||||
@@ -240,8 +232,7 @@ namespace PrivaPub.Middleware
|
||||
.AddTransient<ITimelineService, TimelineService>()
|
||||
.AddSingleton<AppConfigurationService>()
|
||||
.AddHttpContextAccessor()
|
||||
.AddMemoryCache()
|
||||
.AddSingleton<IPasswordHasher, PasswordHasher>();
|
||||
.AddMemoryCache();
|
||||
}
|
||||
|
||||
public static IServiceCollection PrivaPubMiddlewareConfiguration(this IServiceCollection service)
|
||||
|
||||
@@ -7,11 +7,6 @@ namespace PrivaPub.Models
|
||||
{
|
||||
public class AppConfiguration : Entity
|
||||
{
|
||||
public bool RequiresFirstTimeSetup { get; set; } = true;
|
||||
public string Version { get; set; }
|
||||
public int MaxAllowedUploadFiles { get; set; }
|
||||
public int MaxAllowedFileSize { get; set; }
|
||||
public string ValidDiscussionFilesTypes { get; set; }
|
||||
public List<string> SupportedLanguages { get; set; } = new();
|
||||
public string BackendBaseAddress { get; set; }
|
||||
public string FrontendBaseAddress { get; set; }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
public class MongoSettings
|
||||
{
|
||||
public string Database { get; set; }
|
||||
public string LogsDatabase { get; set; }
|
||||
public string ConnectionString { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ namespace PrivaPub.Models.User
|
||||
public string PictureURL { get; set; }//icon
|
||||
public string ThumbnailURL { get; set; }//image
|
||||
public AvatarType AvatarType { get; set; } = AvatarType.Person;
|
||||
public AvatarServer AvatarServer { get; set; } = AvatarServer.PrivaPub;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -5,10 +5,7 @@ namespace PrivaPub.Models.User
|
||||
public class EmailRecovery : Entity
|
||||
{
|
||||
public string RootUserId { get; set; }
|
||||
public string RecoveryCode { get; set; }//plaintext codes before 2026-10-04; migration _011 removed them, never written now
|
||||
public string CodeHash { get; set; }//sha-256 of the code, hex; the code itself exists only in the email
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public string RequestIP { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,8 @@ namespace PrivaPub.Models.User
|
||||
public bool IsBanned { get; set; } = false;
|
||||
public string HashedPassword { get; set; }
|
||||
public List<string> Policies { get; set; } = new() { ClientModels.Policies.IsUser };
|
||||
public List<ContactItem> Contacts { get; set; } = new();
|
||||
public RootUserSettings Settings { get; set; } = new();
|
||||
|
||||
public string ResetPasswordToken { get; set; }
|
||||
public DateTime? ResetPasswordTokenSentAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
@@ -38,14 +34,6 @@ namespace PrivaPub.Models.User
|
||||
}
|
||||
}
|
||||
|
||||
public class ContactItem
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Contact { get; set; }
|
||||
public string Note { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class RootUserSettings
|
||||
{
|
||||
public string LanguageCode { get; set; } = "en-GB";
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace PrivaPub.Models.User
|
||||
{
|
||||
public enum UserPolicyType
|
||||
{
|
||||
IsUser,
|
||||
IsModerator,
|
||||
IsAdmin
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
<PackageReference Include="NetVips.Native.linux-x64" Version="8.18.7" />
|
||||
<PackageReference Include="OpenIddict.AspNetCore" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.MongoDb" Version="7.7.1" />
|
||||
<PackageReference Include="PasswordGenerator" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.3.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
|
||||
+3
-14
@@ -32,12 +32,7 @@ try
|
||||
{
|
||||
if (builder.Environment.IsProduction())
|
||||
{
|
||||
serverOptions.ListenLocalhost(6970
|
||||
//, options =>
|
||||
//{
|
||||
// options.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
|
||||
//}
|
||||
);
|
||||
serverOptions.ListenLocalhost(6970);
|
||||
serverOptions.UseSystemd();
|
||||
serverOptions.AddServerHeader = false;
|
||||
}
|
||||
@@ -50,7 +45,6 @@ try
|
||||
try
|
||||
{
|
||||
builder.Services.PrivaPubAppSettingsConfiguration(builder.Configuration)
|
||||
.PrivaPubWorkersConfiguration()
|
||||
.PrivaPubAuthServicesConfiguration(builder.Configuration)
|
||||
.PrivaPubInternalizationConfiguration(builder.Configuration)
|
||||
.PrivaPubOptimizationConfiguration()
|
||||
@@ -206,9 +200,6 @@ try
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
//app.UseWhen(context => context.Request.Path.StartsWithSegments("/peasants") ||
|
||||
// context.Request.Path.StartsWithSegments("/users"),
|
||||
// app => app.UseSignatureVerification().UseDigestVerification());
|
||||
|
||||
app.MapGet("/build.json", () => Results.Json(new
|
||||
{
|
||||
@@ -218,7 +209,6 @@ try
|
||||
}));
|
||||
app.MapControllers();
|
||||
app.MapRazorPages();
|
||||
//app.MapFallbackToFile("index.html");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -226,12 +216,11 @@ try
|
||||
throw;
|
||||
}
|
||||
|
||||
Log.ForContext<Program>().Information($"Starting collAnon at {nameof(Program)}()");
|
||||
Log.ForContext<Program>().Information($"Starting PrivaPub at {nameof(Program)}()");
|
||||
try
|
||||
{
|
||||
var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities;
|
||||
var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher;
|
||||
await dbClient.Init(passwordHasher);
|
||||
await dbClient.Init();
|
||||
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
|
||||
await PrivaPub.Api.Mastodon.Auth.PersonaExchange.EnsureFirstPartyClient(app.Services, CancellationToken.None);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ namespace PrivaPub.Services.ClientToServer.Public
|
||||
AppConfigurationService = appConfigurationService;
|
||||
}
|
||||
|
||||
// the build's ref (its tag, or the branch a manual deploy ran from), as /build.json and NodeInfo name it
|
||||
public string GetCurrentVersion() =>
|
||||
AppConfigurationService.AppConfiguration.Version;
|
||||
string.IsNullOrEmpty(BuildInfo.Ref) ? BuildInfo.Commit : BuildInfo.Ref;
|
||||
|
||||
public async Task<IEnumerable<Language>> GetLanguages(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -23,15 +24,5 @@ namespace PrivaPub.Services.ClientToServer.Public
|
||||
.Match(l => AppConfigurationService.AppConfiguration.SupportedLanguages.Contains(l.International2Code))
|
||||
.ExecuteAsync(cancellationToken)).OrderBy(l => l.NativeName).ToArray();
|
||||
}
|
||||
|
||||
public async Task<bool> IsValidLanguageId(string languageId)
|
||||
{
|
||||
var anyLanguageWithId = await DbEntities.Languages.Match(l => l.ID == languageId).ExecuteAnyAsync();
|
||||
if (!anyLanguageWithId)
|
||||
return false;
|
||||
var language2Code = (await DbEntities.Languages
|
||||
.Match(l => l.ID == languageId).ExecuteFirstAsync())?.International2Code;
|
||||
return AppConfigurationService.AppConfiguration.SupportedLanguages.Contains(language2Code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,5 @@ namespace PrivaPub.Services.ClientToServer.Public
|
||||
string GetCurrentVersion();
|
||||
|
||||
Task<IEnumerable<Language>> GetLanguages(CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> IsValidLanguageId(string languageId);
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Services.ClientToServer.Public
|
||||
{
|
||||
public interface IPublicAvatarUsersService
|
||||
{
|
||||
Task<WebResult> GetLocalAvatar(UserPolicyType userPolicyType, string actor, CancellationToken token);
|
||||
Task<IEnumerable<Avatar>> GetLocalAvatars(UserPolicyType userPolicyType, CancellationToken token);
|
||||
|
||||
Task<WebResult> GetForeignAvatar(UserPolicyType userPolicyType, string actor, CancellationToken token);
|
||||
Task<IEnumerable<ForeignAvatar>> GetForeignAvatars(UserPolicyType userPolicyType, CancellationToken token);
|
||||
|
||||
Task<IEnumerable<Avatar>> GetRootAvatars(string rootUserId, CancellationToken token);
|
||||
}
|
||||
|
||||
public class PublicAvatarUsersService : IPublicAvatarUsersService
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IStringLocalizer<GenericRes> _localizer;
|
||||
readonly ILogger<PublicAvatarUsersService> _logger;
|
||||
|
||||
public PublicAvatarUsersService(DbEntities dbEntities,
|
||||
IStringLocalizer<GenericRes> localizer,
|
||||
ILogger<PublicAvatarUsersService> logger)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WebResult> GetLocalAvatar(UserPolicyType userPolicyType, string actor, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var query = _dbEntities.Avatars;
|
||||
switch (userPolicyType)
|
||||
{
|
||||
case UserPolicyType.IsUser:
|
||||
query.Match(a => !a.DeletionAt.HasValue && a.UserName == actor);
|
||||
break;
|
||||
case UserPolicyType.IsModerator:
|
||||
query.Match(a => !a.DeletionAt.HasValue && a.UserName == actor);
|
||||
break;
|
||||
case UserPolicyType.IsAdmin:
|
||||
query.Match(a => a.UserName == actor);
|
||||
break;
|
||||
}
|
||||
var avatar = await query.ExecuteFirstAsync(token);
|
||||
if (avatar == default)
|
||||
return result.Invalidate(_localizer["User '{0}' not found.", actor], StatusCodes.Status404NotFound);
|
||||
result.Data = avatar;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PublicAvatarUsersService)}.{nameof(GetLocalAvatar)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.ToString()], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Avatar>> GetLocalAvatars(UserPolicyType userPolicyType, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = _dbEntities.Avatars;
|
||||
switch (userPolicyType)
|
||||
{
|
||||
case UserPolicyType.IsUser:
|
||||
query.Match(a => !a.DeletionAt.HasValue);
|
||||
break;
|
||||
case UserPolicyType.IsModerator:
|
||||
query.Match(a => !a.DeletionAt.HasValue);
|
||||
break;
|
||||
case UserPolicyType.IsAdmin:
|
||||
break;
|
||||
}
|
||||
var avatars = await query.ExecuteAsync(token);
|
||||
return avatars;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PublicAvatarUsersService)}.{nameof(GetLocalAvatars)}");
|
||||
return Enumerable.Empty<Avatar>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> GetForeignAvatar(UserPolicyType userPolicyType, string actor, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var query = _dbEntities.ForeignAvatars;
|
||||
switch (userPolicyType)
|
||||
{
|
||||
case UserPolicyType.IsUser:
|
||||
query.Match(a => !a.DeletionAt.HasValue && a.UserName == actor);
|
||||
break;
|
||||
case UserPolicyType.IsModerator:
|
||||
query.Match(a => !a.DeletionAt.HasValue && a.UserName == actor);
|
||||
break;
|
||||
case UserPolicyType.IsAdmin:
|
||||
query.Match(a => a.UserName == actor);
|
||||
break;
|
||||
}
|
||||
var avatar = await query.ExecuteFirstAsync(token);
|
||||
if (avatar == default)
|
||||
return result.Invalidate(_localizer["User '{0}' not found.", actor], StatusCodes.Status404NotFound);
|
||||
result.Data = avatar;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PublicAvatarUsersService)}.{nameof(GetForeignAvatar)}");
|
||||
return result.Invalidate(_localizer["Error: {0}", ex.ToString()], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ForeignAvatar>> GetForeignAvatars(UserPolicyType userPolicyType, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = _dbEntities.ForeignAvatars;
|
||||
switch (userPolicyType)
|
||||
{
|
||||
case UserPolicyType.IsUser:
|
||||
query.Match(a => !a.DeletionAt.HasValue);
|
||||
break;
|
||||
case UserPolicyType.IsModerator:
|
||||
query.Match(a => !a.DeletionAt.HasValue);
|
||||
break;
|
||||
case UserPolicyType.IsAdmin:
|
||||
break;
|
||||
}
|
||||
var foreignAvatars = await query.ExecuteAsync(token);
|
||||
return foreignAvatars;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PublicAvatarUsersService)}.{nameof(GetForeignAvatars)}");
|
||||
return Enumerable.Empty<ForeignAvatar>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Avatar>> GetRootAvatars(string rootUserId, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rootUser = await _dbEntities.RootUsers.MatchID(rootUserId).ExecuteFirstAsync(token);
|
||||
if (rootUser == default)
|
||||
return Enumerable.Empty<Avatar>();
|
||||
var rootToAvatars = await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId).ExecuteAsync(token);
|
||||
if (rootToAvatars.Count == 0)
|
||||
return Enumerable.Empty<Avatar>();
|
||||
var avatarIds = rootToAvatars.Select(ra => ra.AvatarId).ToArray();
|
||||
var avatars = await _dbEntities.Avatars
|
||||
.Match(a => avatarIds.Contains(a.ID))
|
||||
.ProjectExcluding(a => a.Settings)
|
||||
.ExecuteAsync(token);
|
||||
return avatars;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(PublicAvatarUsersService)}.{nameof(GetRootAvatars)}");
|
||||
return Enumerable.Empty<Avatar>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ namespace PrivaPub.Services
|
||||
{
|
||||
public interface IGroupUsersService
|
||||
{
|
||||
Task<WebResult> GetGroup(string actor, CancellationToken token);
|
||||
Task<WebResult> GetGroups(string rootUserId, string avatarId, CancellationToken token);
|
||||
Task<WebResult> InsertGroup(string rootUserId, InsertGroupForm form, CancellationToken token);
|
||||
Task<WebResult> UpdateGroup(string rootUserId, UpdateGroupForm form, CancellationToken token);
|
||||
@@ -58,27 +57,6 @@ namespace PrivaPub.Services
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WebResult> GetGroup(string actor, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
var userName = actor?.ToLowerInvariant();
|
||||
var group = await _dbEntities.Groups
|
||||
.Match(g => g.UserName == userName && !g.DeletionAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (group == default)
|
||||
return result.Invalidate(_localizer["Group '{0}' not found.", actor], StatusCodes.Status404NotFound);
|
||||
result.Data = group;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroup)}");
|
||||
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> GetGroups(string rootUserId, string avatarId, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace PrivaPub.Services
|
||||
{
|
||||
public interface IRootUsersService
|
||||
{
|
||||
Task<WebResult> LoginAsync(LoginForm loginForm, string invitationCode = default, bool isPasswordRequired = false);
|
||||
Task<WebResult> LoginAsync(LoginForm loginForm);
|
||||
|
||||
Task<WebResult> SignUpAsync(LoginForm signUpForm, string invitationCode = default, bool isPasswordRequired = false);
|
||||
Task<WebResult> SignUpAsync(LoginForm signUpForm);
|
||||
|
||||
Task<WebResult> BanUserAsync(UsersIds usersIds);
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
using MailKit.Net.Smtp;
|
||||
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
using MimeKit;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PasswordGenerator;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.Models;
|
||||
@@ -33,7 +27,6 @@ namespace PrivaPub.Services
|
||||
readonly IStringLocalizer Localizer;
|
||||
readonly ILogger<RootUsersService> Logger;
|
||||
readonly AppConfigurationService AppConfigurationService;
|
||||
readonly AuthTokenManager AuthTokenManager;
|
||||
readonly IJobQueue Jobs;
|
||||
readonly IRootSessions Sessions;
|
||||
readonly IRootRemoval Removal;
|
||||
@@ -46,14 +39,12 @@ namespace PrivaPub.Services
|
||||
ILogger<RootUsersService> logger,
|
||||
IPasswordHasher passwordHasher,
|
||||
DbEntities dbEntities,
|
||||
AppConfigurationService appConfigurationService,
|
||||
AuthTokenManager authTokenManager)
|
||||
AppConfigurationService appConfigurationService)
|
||||
{
|
||||
Jobs = jobs;
|
||||
Sessions = sessions;
|
||||
Removal = removal;
|
||||
DbEntities = dbEntities;
|
||||
AuthTokenManager = authTokenManager;
|
||||
PasswordHasher = passwordHasher;
|
||||
Localizer = localizer;
|
||||
Logger = logger;
|
||||
@@ -66,8 +57,7 @@ namespace PrivaPub.Services
|
||||
// a hash nobody's password matches, so an unknown login costs the same hashing as a wrong password
|
||||
string Decoy => _decoy ??= PasswordHasher.Hash(Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
|
||||
|
||||
public async Task<WebResult> SignUpAsync(LoginForm signUpForm, string invitationCode = default,
|
||||
bool isPasswordRequired = false)
|
||||
public async Task<WebResult> SignUpAsync(LoginForm signUpForm)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
@@ -98,23 +88,6 @@ namespace PrivaPub.Services
|
||||
};
|
||||
await DB.Default.SaveAsync(newUser);
|
||||
|
||||
//if (!string.IsNullOrEmpty(invitationCode))
|
||||
//{
|
||||
// if (isPasswordRequired)
|
||||
// result = await DiscussionService.InviteUserToDiscussion(new PwDiscussionPreviewForm
|
||||
// {
|
||||
// InvitationCode = invitationCode,
|
||||
// Password = signUpForm.InvitationPassword
|
||||
// }, newUser.ID);
|
||||
// else
|
||||
// result = await DiscussionService.InviteUserToDiscussion(new NoPwDiscussionPreviewForm
|
||||
// {
|
||||
// InvitationCode = invitationCode,
|
||||
// }, newUser.ID);
|
||||
// if (!result.IsValid)
|
||||
// return result;
|
||||
//}
|
||||
|
||||
result.Data = (newUser, ToViewSettings(newUser.Settings));
|
||||
|
||||
return result;
|
||||
@@ -126,8 +99,7 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebResult> LoginAsync(LoginForm loginForm, string invitationCode = default,
|
||||
bool isPasswordRequired = false)
|
||||
public async Task<WebResult> LoginAsync(LoginForm loginForm)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
@@ -148,23 +120,6 @@ namespace PrivaPub.Services
|
||||
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
|
||||
var userSettings = (ViewUserSettings)userSettingsResult.Data;
|
||||
|
||||
//if (!string.IsNullOrEmpty(invitationCode))
|
||||
//{
|
||||
// if (isPasswordRequired)
|
||||
// result = await DiscussionService.InviteUserToDiscussion(new PwDiscussionPreviewForm
|
||||
// {
|
||||
// InvitationCode = invitationCode,
|
||||
// Password = loginForm.InvitationPassword
|
||||
// }, user.ID);
|
||||
// else
|
||||
// result = await DiscussionService.InviteUserToDiscussion(new NoPwDiscussionPreviewForm
|
||||
// {
|
||||
// InvitationCode = invitationCode,
|
||||
// }, user.ID);
|
||||
// if (!result.IsValid)
|
||||
// return result;
|
||||
//}
|
||||
|
||||
result.Data = (user, userSettings);
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
{
|
||||
"MongoSettings": {
|
||||
"Database": "PrivaPub",
|
||||
"LogsDatabase": "logs",
|
||||
"ConnectionString": "mongodb://localhost:27017"
|
||||
},
|
||||
"Backups": {
|
||||
"Nightly": false
|
||||
},
|
||||
"AppConfiguration": {
|
||||
"RequiresFirstTimeSetup": null,
|
||||
"Version": "0.0.0",
|
||||
"MaxAllowedUploadFiles": 3,
|
||||
"MaxAllowedFileSize": 2097152,
|
||||
"ValidDiscussionFilesTypes": ".odt,.docx,.pdf,.xlsx,.ods,.odp,.pptx,.png,.jpg,.jpeg",
|
||||
"SupportedLanguages": [
|
||||
"en",
|
||||
"it",
|
||||
@@ -83,7 +77,7 @@
|
||||
"Name": "MongoDBCapped",
|
||||
"Args": {
|
||||
"databaseUrl": "mongodb://localhost:27017/logs",
|
||||
"collectionName": "collanonquick",
|
||||
"collectionName": "PrivaPub",
|
||||
"cappedMaxSizeMb": "1024",
|
||||
"cappedMaxDocuments": "10000"
|
||||
}
|
||||
|
||||
@@ -30,16 +30,11 @@
|
||||
},
|
||||
"MongoSettings": {
|
||||
"Database": "PrivaPub",
|
||||
"LogsDatabase": "logs",
|
||||
"ConnectionString": "mongodb://127.0.0.1:27022/?directConnection=true"
|
||||
},
|
||||
"AppConfiguration": {
|
||||
"Version": "0.0.0",
|
||||
"BackendBaseAddress": "https://privapub.thepra.dev",
|
||||
"FrontendBaseAddress": "https://decepub.thepra.dev",
|
||||
"MaxAllowedUploadFiles": 3,
|
||||
"MaxAllowedFileSize": 2097152,
|
||||
"ValidDiscussionFilesTypes": ".odt,.docx,.pdf,.xlsx,.ods,.odp,.pptx,.png,.jpg,.jpeg",
|
||||
"SupportedLanguages": [
|
||||
"en", "it", "de", "fr", "es", "ja", "ru", "zh", "bg", "cs", "da", "nl", "et", "fi", "el", "hu", "lv", "lt", "pl", "pt", "ro", "sk", "sl", "sv"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user