Compare commits

...

9 Commits

Author SHA1 Message Date
zaccaria.majid f7d40af040 fix condizione modifica nel readme 2022-11-15 17:42:48 +01:00
zaccaria.majid b141f5cfa2 minor fix 2022-11-15 17:39:04 +01:00
Samuele Locatelli 1a153ef4be Update (quasi) completo x gestione sessione utente 2022-11-15 16:23:40 +01:00
Samuele Locatelli 37082d5d62 Merge tag 'MyabeFixCiCdPowershellSCript' into develop
Update x gestioens cript post compilazione
2022-11-15 14:55:30 +01:00
Samuele Locatelli a75226fa45 Merge branch 'Release/MyabeFixCiCdPowershellSCript' 2022-11-15 14:55:22 +01:00
Samuele Locatelli c14e04bbfd test fix CI/CD 2022-11-15 14:54:44 +01:00
Samuele Locatelli 97d727f815 Update CI-CD x deploy script ps1 versioni 2022-11-15 14:51:33 +01:00
Samuele Locatelli 771d136a82 Update x forzare chiamata script fix parametri progetto 2022-11-15 14:45:23 +01:00
Samuele Locatelli 0b6d4930ac Merge tag 'SpecFixRedisCacheFluxLog' into develop
Update cache parametri
2022-11-15 14:06:24 +01:00
17 changed files with 431 additions and 71 deletions
+11
View File
@@ -24,6 +24,11 @@ variables:
dotnet nuget add source https://nexus.steamware.net/repository/nuget-proxy-v3/index.json -n nexus-proxy-v3 -u nugetUser -p viaDante16 --store-password-in-clear-text
echo "Has Source: $hasSource"
.fixVers: &fixVers
- |
$VersScript = $env:APP_NAME + "\bin\publish\post-build.ps1 -ProjectDir $CI_PROJECT_DIR\$env:APP_NAME -ProjectPath $CI_PROJECT_DIR\$env:APP_NAME\$env:APP_NAME.csproj"
echo "Script called: $VersScript"
# helper creazione hash files x IIS
.hashBuild: &hashBuild
- |
@@ -61,6 +66,7 @@ variables:
mCurl -v -u GitLab:$NEXUS_PASSWD --upload-file "$env:APP_NAME\Resources\manifest.xml" https://nexus.steamware.net/repository/SWS/$env:NEXUS_PATH/$version/LAST/manifest.xml
mCurl -v -u GitLab:$NEXUS_PASSWD --upload-file "$env:APP_NAME\Resources\ChangeLog.html" https://nexus.steamware.net/repository/SWS/$env:NEXUS_PATH/$version/LAST/ChangeLog.html
# Stages previsti
stages:
- build
@@ -463,6 +469,7 @@ LAND:installer:
script:
- dotnet publish -p:PublishProfile=IISProfile.pubxml -p:RunCodeAnalysis=false -p:Configuration=Release $env:APP_NAME/$env:APP_NAME.csproj -o:publish
# qui il deploy su nexus...
- *fixVers
- *hashBuild
- *nexusUpload
@@ -484,6 +491,7 @@ PROG:installer:
script:
- dotnet publish -p:PublishProfile=IISProfile.pubxml -p:RunCodeAnalysis=false -p:Configuration=Release $env:APP_NAME/$env:APP_NAME.csproj -o:publish
# qui il deploy su nexus...
- *fixVers
- *hashBuild
- *nexusUpload
@@ -505,6 +513,7 @@ STAT:installer:
script:
- dotnet publish -p:PublishProfile=IISProfile.pubxml -p:RunCodeAnalysis=false -p:Configuration=Release $env:APP_NAME/$env:APP_NAME.csproj -o:publish
# qui il deploy su nexus...
- *fixVers
- *hashBuild
- *nexusUpload
@@ -526,6 +535,7 @@ MON:installer:
script:
- dotnet publish -p:PublishProfile=IISProfile.pubxml -p:RunCodeAnalysis=false -p:Configuration=Release $env:APP_NAME/$env:APP_NAME.csproj -o:publish
# qui il deploy su nexus...
- *fixVers
- *hashBuild
- *nexusUpload
@@ -569,6 +579,7 @@ SPEC:installer:
script:
- dotnet publish -p:PublishProfile=IISProfile.pubxml -p:RunCodeAnalysis=false -p:Configuration=Release $env:APP_NAME/$env:APP_NAME.csproj -o:publish
# qui il deploy su nexus...
- *fixVers
- *hashBuild
- *nexusUpload
+19
View File
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MP.Data.DTO
{
public class OperatoreDTO
{
public int MatrOpr { get; set; } = 0;
public string Cognome { get; set; } = "";
public string Nome { get; set; } = "";
public bool isAdmin { get; set; } = false;
public string authKey { get; set; } = "";
public string CodOprExt { get; set; } = "";
public string userJWT { get; set; } = "";
}
}
+1
View File
@@ -7,6 +7,7 @@
<div class="d-flex w-100 justify-content-between">
<div class="px-2">
<button class="btn btn-primary" @onclick="() => logOut()" title="LogOut utente"> LogOut </button>
<i class="fas fa-user-alt"></i> <b>@userName</b>
</div>
<div class="pe-2">
+42 -8
View File
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.JSInterop;
using MP.INVE.Data;
namespace MP.INVE.Components
{
public partial class CmpTop
public partial class CmpTop:IDisposable
{
#region Public Methods
@@ -17,6 +18,13 @@ namespace MP.INVE.Components
NavManager.NavigateTo(NavManager.Uri, true);
}
public void Dispose()
{
LServ.EA_LogIn -= LServ_EA_LogIn;
LServ.EA_LogOut -= LServ_EA_LogOut;
GC.Collect();
}
#endregion Public Methods
#region Protected Properties
@@ -24,20 +32,34 @@ namespace MP.INVE.Components
[Inject]
protected IJSRuntime JSRuntime { get; set; } = null!;
[Inject]
protected LoginService LServ { get; set; } = null!;
#endregion Protected Properties
#region Protected Methods
protected override async Task OnInitializedAsync()
{
LServ.EA_LogIn += LServ_EA_LogIn;
LServ.EA_LogOut += LServ_EA_LogOut;
await forceReload();
}
private void LServ_EA_LogOut()
{
NavManager.NavigateTo("OperatoreLogin", true);
}
private void LServ_EA_LogIn()
{
NavManager.NavigateTo("Starter", true);
}
#endregion Protected Methods
#region Private Fields
private string userName = "";
#endregion Private Fields
@@ -54,15 +76,27 @@ namespace MP.INVE.Components
private async Task forceReload()
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity != null && user.Identity.IsAuthenticated)
await Task.Delay(1);
// controllo per login
if (LServ.matrOpr <= 0 && !NavManager.Uri.Contains("OperatoreLogin"))
{
userName = $"{user.Identity.Name}";
NavManager.NavigateTo("OperatoreLogin", true);
}
else
}
private async Task logOut()
{
await Task.Delay(1);
LServ.LogOut();
}
private string userName
{
get
{
userName = "N.A.";
string answ = "ND";
answ = $"{LServ.Cognome} {LServ.Cognome} ({LServ.matrOpr})";
return answ;
}
}
+255
View File
@@ -0,0 +1,255 @@
using MP.Data.DTO;
using Newtonsoft.Json;
using NLog;
using StackExchange.Redis;
using System.Diagnostics;
namespace MP.INVE.Data
{
public class LoginService : IDisposable
{
#region Public Constructors
public LoginService(IConfiguration configuration, ILogger<LoginService> logger, HttpClient httpClient,
IHttpContextAccessor httpContextAccessor)
{
this.HttpClient = httpClient;
HttpContextAccessor = httpContextAccessor;
_logger = logger;
_logger.LogInformation("Starting LoginService INIT");
_configuration = configuration;
// setup compoenti REDIS
redisConn = ConnectionMultiplexer.Connect(_configuration.GetConnectionString("Redis"));
redisConnAdmin = ConnectionMultiplexer.Connect(_configuration.GetConnectionString("RedisAdmin"));
redisDb = redisConn.GetDatabase();
// leggo cache lungo periodo
int.TryParse(_configuration.GetValue<string>("ServerConf:redisLongTimeCache"), out redisLongTimeCache);
_logger.LogInformation("Redis LoginService INIT");
}
#endregion Public Constructors
#region Public Events
public event Action EA_LogIn = null!;
public event Action EA_LogOut = null!;
#endregion Public Events
#region Public Properties
public string Cognome
{
get
{
string answ = "NA";
if (matrOpr > 0 && !string.IsNullOrEmpty(authKey))
{
var currUser = UserDTO(matrOpr, authKey);
if (currUser != null)
{
answ = currUser.Cognome;
}
}
return answ;
}
}
public int matrOpr
{
get
{
int answ = 0;
if (HttpContextAccessor.HttpContext != null)
{
#if false
var token = HttpContextAccessor.HttpContext.Request.Cookies["userId_token"];
if (token != null)
{
int.TryParse(token, out answ);
}
#endif
var currUser = UserDTO(matrOpr, authKey);
if (currUser != null)
{
answ = currUser.MatrOpr;
}
}
return answ;
}
set
{
CookieOptions options = new CookieOptions();
options.Expires = DateTime.Now.AddDays(1);
if (HttpContextAccessor.HttpContext != null)
{
HttpContextAccessor.HttpContext.Response.Cookies.Append("userId_token", $"{value}", options);
}
}
}
public string Nome
{
get
{
string answ = "NA";
if (matrOpr > 0 && !string.IsNullOrEmpty(authKey))
{
var currUser = UserDTO(matrOpr, authKey);
if (currUser != null)
{
answ = currUser.Nome;
}
}
return answ;
}
}
#endregion Public Properties
#region Public Methods
public void Dispose()
{
}
public void LogOut()
{
OperatoreDTO resetData = new OperatoreDTO();
UserDTOSave(resetData);
}
/// <summary>
/// Ricerca su REDIS dell'utente loggato
/// NB: da rifare con unico JWT che contenga tutto
/// </summary>
/// <param name="matrOpr"></param>
/// <param name="authKey"></param>
/// <returns></returns>
public OperatoreDTO? UserDTO(int matrOpr, string authKey)
{
Stopwatch stopWatch = new Stopwatch();
stopWatch.Start();
OperatoreDTO? result = null;
string source = "REDIS";
// cerco in redis...
RedisValue rawData = redisDb.StringGet($"{redisUserSession}:{matrOpr}");
if (!string.IsNullOrEmpty($"{rawData}"))
{
try
{
result = JsonConvert.DeserializeObject<OperatoreDTO>($"{rawData}");
}
catch
{ }
}
#if false
else
{
result = await Task.FromResult(dbController.AnagStatiComm());
// serializzo e salvo...
rawData = JsonConvert.SerializeObject(result);
await redisDb.StringSetAsync(redisUserSession, rawData, getRandTOut(redisLongTimeCache));
source = "DB";
}
#endif
stopWatch.Stop();
TimeSpan ts = stopWatch.Elapsed;
Log.Debug($"LoggedUser Read from {source}: {ts.TotalMilliseconds}ms");
// restituisco
return result;
}
/// <summary>
/// Salva su REDIS dati dell'utente loggato
/// </summary>
/// <returns></returns>
public bool UserDTOSave(OperatoreDTO userData)
{
bool fatto = false;
Stopwatch stopWatch = new Stopwatch();
stopWatch.Start();
string source = "REDIS";
// cerco in redis...
string rawData = JsonConvert.SerializeObject(userData);
fatto = redisDb.StringSet($"{redisUserSession}:{userData.MatrOpr}", rawData, TimeSpan.FromMinutes(60));
stopWatch.Stop();
TimeSpan ts = stopWatch.Elapsed;
Log.Debug($"UserDTO write to {source}: {ts.TotalMilliseconds}ms");
// restituisco
return fatto;
}
#endregion Public Methods
#region Protected Properties
protected string authKey
{
get
{
string answ = "";
if (HttpContextAccessor.HttpContext != null)
{
var token = HttpContextAccessor.HttpContext.Request.Cookies["authKey_token"];
if (token != null)
{
answ = token;
}
}
return answ;
}
set
{
CookieOptions options = new CookieOptions();
options.Expires = DateTime.Now.AddDays(1);
if (HttpContextAccessor.HttpContext != null)
{
HttpContextAccessor.HttpContext.Response.Cookies.Append("authKey_token", value, options);
}
}
}
protected HttpClient HttpClient { get; set; }
protected IHttpContextAccessor HttpContextAccessor { get; set; }
#endregion Protected Properties
#region Private Fields
private const string redisBaseAddr = "MP:INVE";
private const string redisUserSession = redisBaseAddr + ":User:";
private static IConfiguration _configuration = null!;
private static ILogger<LoginService> _logger = null!;
private static Logger Log = LogManager.GetCurrentClassLogger();
/// <summary>
/// Oggetto per connessione a REDIS
/// </summary>
private ConnectionMultiplexer redisConn = null!;
/// <summary>
/// Oggetto per connessione a REDIS modalità admin (ex flux dati)
/// </summary>
private ConnectionMultiplexer redisConnAdmin = null!;
/// <summary>
/// Oggetto DB redis da impiegare x chiamate R/W
/// </summary>
private IDatabase redisDb = null!;
private int redisLongTimeCache = 5;
#endregion Private Fields
}
}
+1 -1
View File
@@ -53,7 +53,7 @@
get => searchVal;
set
{
//if (searchVal != value)
//if (_nome != value)
//{
searchVal = value;
+16 -16
View File
@@ -1090,37 +1090,37 @@ namespace MP.INVE.Data
#region Private Fields
private const string redisArtByDossier = redisBaseAddr + "SPEC:Cache:ArtByDossier";
private const string redisArtByDossier = redisBaseAddr + ":Cache:ArtByDossier";
private const string redisArtList = redisBaseAddr + "SPEC:Cache:ArtList";
private const string redisArtList = redisBaseAddr + ":Cache:ArtList";
private const string redisBaseAddr = "MP:";
private const string redisBaseAddr = "MP:SPEC";
private const string redisConfKey = redisBaseAddr + "SPEC:Cache:Config";
private const string redisConfKey = redisBaseAddr + ":Cache:Config";
private const string redisDossByMac = redisBaseAddr + "SPEC:Cache:DossByMac";
private const string redisDossByMac = redisBaseAddr + ":Cache:DossByMac";
private const string redisFluxByMac = redisBaseAddr + "SPEC:Cache:FluxByMac";
private const string redisFluxByMac = redisBaseAddr + ":Cache:FluxByMac";
private const string redisFluxLogFilt = redisBaseAddr + "SPEC:Cache:FluxLogFilt";
private const string redisFluxLogFilt = redisBaseAddr + ":Cache:FluxLogFilt";
private const string redisMacByFlux = redisBaseAddr + "SPEC:Cache:MacByFlux";
private const string redisMacByFlux = redisBaseAddr + ":Cache:MacByFlux";
private const string redisMacList = redisBaseAddr + "SPEC:Cache:MacList";
private const string redisMacList = redisBaseAddr + ":Cache:MacList";
private const string redisOdlCurrByMac = redisBaseAddr + "SPEC:Cache:OdlByMac";
private const string redisOdlCurrByMac = redisBaseAddr + ":Cache:OdlByMac";
private const string redisPOdlList = redisBaseAddr + "SPEC:Cache:POdlList";
private const string redisPOdlList = redisBaseAddr + ":Cache:POdlList";
private const string redisPOdlByPOdl = redisBaseAddr + "SPEC:Cache:POdlByPOdl";
private const string redisPOdlByPOdl = redisBaseAddr + ":Cache:POdlByPOdl";
private const string redisPOdlByOdl = redisBaseAddr + "SPEC:Cache:POdlByOdl";
private const string redisPOdlByOdl = redisBaseAddr + ":Cache:POdlByOdl";
private const string redisStatoCom = redisBaseAddr + "SPEC:Cache:StatoCom";
private const string redisStatoCom = redisBaseAddr + ":Cache:StatoCom";
private const string redisTipoArt = redisBaseAddr + "SPEC:Cache:TipoArt";
private const string redisTipoArt = redisBaseAddr + ":Cache:TipoArt";
private const string redisVocabolario = redisBaseAddr + "SPEC:Cache:Vocabolario";
private const string redisVocabolario = redisBaseAddr + ":Cache:Vocabolario";
private static IConfiguration _configuration = null!;
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MP.INVE</RootNamespace>
<Version>6.16.2211.1510</Version>
<Version>6.16.2211.1516</Version>
</PropertyGroup>
<ItemGroup>
+6 -6
View File
@@ -3,23 +3,23 @@
<h3>OperatoreLogin</h3>
<div>
<div class="mb-3">
<label class="form-label">Inserire auth key</label>
<input class="form-control" type="password" @bind="@authKey" />
</div>
<div>
<select class="form-select" aria-label="Default select example" @bind="@idOperatore">
@if (elencoOperatori != null)
{
@foreach (var item in elencoOperatori)
{
<option value="@item.MatrOpr">@item.Nome</option>
<option value="@item.MatrOpr">@item.Cognome @item.Nome (@item.MatrOpr)</option>
}
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Inserire auth key</label>
<input class="form-control" type="password" @bind="@authKey" />
</div>
<div>
<button class="btn btn-primary" @onclick="login">SUBMIT</button>
<button class="btn btn-primary" @onclick="login">Login</button>
</div>
</div>
+26 -4
View File
@@ -16,6 +16,7 @@ using MP.INVE.Data;
using MP.INVE.Shared;
using MP.INVE.Components;
using MP.Data.DatabaseModels;
using MP.Data.DTO;
namespace MP.INVE.Pages
{
@@ -26,8 +27,11 @@ namespace MP.INVE.Pages
[Inject]
private NavigationManager NavManager { get; set; } = null!;
private int idOperatore { get; set; }
private string authKey { get; set; }
[Inject]
protected LoginService LServ { get; set; } = null!;
private int idOperatore { get; set; } = 0;
private string authKey { get; set; } = "";
private List<AnagOperatoriModel>? elencoOperatori;
@@ -44,12 +48,30 @@ namespace MP.INVE.Pages
if (ok)
{
NavManager.NavigateTo("/Starter", true);
// recupero operatore
var currOpr = elencoOperatori.Where(x => x.MatrOpr == idOperatore).FirstOrDefault();
if (currOpr != null)
{
var oprDto = new OperatoreDTO()
{
authKey= currOpr.authKey,
CodOprExt= currOpr.CodOprExt,
Cognome= currOpr.Cognome,
isAdmin=currOpr.isAdmin,
userJWT="",
MatrOpr= currOpr.MatrOpr,
Nome= currOpr.Nome
};
// salvo valori operatore
LServ.UserDTOSave(oprDto);
}
NavManager.NavigateTo("Starter", true);
}
else
{
}
}
}
}
}
-30
View File
@@ -9,34 +9,4 @@
@*<img src="https://qrcode.steamware.net//HOME/QR_site/JSON?val={'baseUrl':'https://iis02.egalware.com/MP/MAG/SMART/PLScanner?{0}','parameters':['MatrOpr=102']}" />*@
@code {
[Inject]
private IConfiguration Configuration { get; set; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("clearContent", $"qrCodeImg_{101}");
await JSRuntime.InvokeVoidAsync("displayQr", $"qrCodeImg_{101}", rawCode);
}
}
protected string BaseUrlTab
{
get => $"{Configuration["ServerConf:BaseUrl"]}";
}
protected string rawCode
{
get
{
string answ = "";
answ = $"{BaseUrlTab}MatrOpr={101}&UserAuthKey={12345}";
return answ;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using System.Net.Http;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.Virtualization;
using Microsoft.JSInterop;
using MP.INVE;
using MP.INVE.Shared;
using MP.INVE.Components;
namespace MP.INVE.Pages
{
public partial class Starter
{
[Inject]
private IConfiguration Configuration { get; set; } = null !;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("clearContent", $"qrCodeImg_{101}");
await JSRuntime.InvokeVoidAsync("displayQr", $"qrCodeImg_{101}", rawCode);
}
}
protected string BaseUrlTab { get => $"{Configuration["ServerConf:BaseUrl"]}"; }
protected string rawCode
{
get
{
string answ = "";
answ = $"{BaseUrlTab}MatrOpr={101}&UserAuthKey={12345}";
return answ;
}
}
}
}
+2
View File
@@ -35,9 +35,11 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IConnectionMultiplexer>(redisMultiplexer);
builder.Services.AddSingleton<MiDataService>();
builder.Services.AddScoped<MessageService>();
builder.Services.AddScoped<LoginService>();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<IOApiService>();
+3 -2
View File
@@ -106,7 +106,7 @@ Operativamente una volta letti questi codici dovrà svolgersi il seguente proces
* richiesta all'operatore di verifica che si tratti di prima lettura (per evitare doppie letture l'operatore dovrebbe marcare/siglare ogni collo con un simbolo/data/etichetta comprovante l'effettuata lettura inventario')
* presentazione all'operatore dei dati per conferma OBBLIGATORIA ad ogni lettura
* Salvataggio, in caso di modifica dei valori proposti la prima volta, della "Forzatura"
* Salvataggio, in caso di modifica dei valori proposti la prima volta, della "Forzatura", previo controllo della presenza del lotto all'interno delle anagrafiche
Questo significa che è possibile avere in uscita liste di giacenza
* con possibili "errori di doppia lettura"
@@ -132,7 +132,7 @@ Operativamente una volta letti questi codici dovrà svolgersi il seguente proces
* in alternativa proposta quantità/collo, articolo e lotto a partire dall'ultimo valore letto di tipo (C), salvataggio successivo in MAPO del codice + dati specifici
* richiesta all'operatore di verifica che si tratti di prima lettura (per evitare doppie letture l'operatore dovrebbe marcare/siglare ogni collo con un simbolo/data/etichetta comprovante l'effettuata lettura inventario')
* presentazione all'operatore dei dati per conferma OBBLIGATORIA ad ogni lettura
* Salvataggio, in caso di modifica dei valori proposti la prima volta, della "Forzatura"
* Salvataggio, in caso di modifica dei valori proposti la prima volta, della "Forzatura" previo controllo della presenza del lotto all'interno delle anagrafiche
Questo significa che è possibile avere in uscita liste di giacenza
* con possibili "errori di doppia lettura"
@@ -189,3 +189,4 @@ Invio aggregato dei dati di
|------------|----------------|:-------:|-----------------:|
| 2022.11.10 | S.E. Locatelli | 0.1 | Initial draft |
| 2022.11.10 | Gian / Zac | 0.2 | Second draft |
| 2022.11.15 | Zac | 0.3 | Aggiunta appunto su modifica post forzatura |
+1 -1
View File
@@ -1,6 +1,6 @@
<body>
<i>Modulo MAPOINVE </i>
<h4>Versione: 6.16.2211.1510</h4>
<h4>Versione: 6.16.2211.1516</h4>
<br /> Note di rilascio:
<ul>
<li>
+1 -1
View File
@@ -1 +1 @@
6.16.2211.1510
6.16.2211.1516
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<item>
<version>6.16.2211.1510</version>
<version>6.16.2211.1516</version>
<url>https://nexus.steamware.net/repository/SWS/MP-INVE/stable/LAST/MP.INVE.zip</url>
<changelog>https://nexus.steamware.net/repository/SWS/MP-INVE/stable/LAST/ChangeLog.html</changelog>
<mandatory>false</mandatory>