Compare commits

...

39 Commits

Author SHA1 Message Date
Archi
1fd6c8a477 Minimize dependencies for starting IPC server
Previously WebApplication didn't offer any advantages over generic Host, but with release of .NET 8 there is now slim and empty builders, which limit amount of initialized dependencies and allow us to skip some unnecessary features in default pipeline.
2024-03-09 18:24:15 +01:00
Archi
e7bdd408be Bump, misc 2024-03-09 16:59:05 +01:00
renovate[bot]
f73b4737b6 chore(deps): update docker/build-push-action action to v5.2.0 2024-03-08 10:22:24 +00:00
ArchiBot
14c905b8ef Automatic translations update 2024-03-07 01:56:16 +00:00
Łukasz Domeradzki
fa7849460c Update Bug-report.yml 2024-03-06 00:23:41 +01:00
renovate[bot]
4d12c9117f chore(deps): update actions/download-artifact action to v4.1.4 2024-03-02 04:14:53 +00:00
ArchiBot
e2f08fe35b Automatic translations update 2024-03-02 02:03:09 +00:00
Archi
0089a87018 Misc 2024-03-02 01:22:47 +01:00
ArchiBot
6325c454bc Automatic translations update 2024-03-01 02:06:50 +00:00
renovate[bot]
6b1f64579a chore(deps): update github/codeql-action action to v3.24.6 2024-02-29 17:05:50 +00:00
renovate[bot]
14cfd61615 chore(deps): update asf-ui digest to 3c96528 2024-02-29 04:11:33 +00:00
ArchiBot
f2a8768f80 Automatic translations update 2024-02-29 02:03:36 +00:00
Archi
556f3fdac0 Misc 2024-02-28 21:40:54 +01:00
renovate[bot]
59124fcf68 chore(deps): update asf-ui digest to bec199f 2024-02-28 17:47:01 +00:00
ArchiBot
71643446db Automatic translations update 2024-02-28 02:04:29 +00:00
renovate[bot]
a93ca58e9c chore(deps): update dependency microsoft.identitymodel.jsonwebtokens to v7.4.0 2024-02-27 23:03:52 +00:00
Archi
f12e87c2f4 Update SUPPORT.md 2024-02-27 23:58:35 +01:00
renovate[bot]
d48c96604b chore(deps): update docker/setup-buildx-action action to v3.1.0 2024-02-27 10:07:20 +00:00
renovate[bot]
0976bbfd2d chore(deps): update actions/download-artifact action to v4.1.3 2024-02-27 00:46:20 +00:00
Archi
6f66518607 Bump 2024-02-27 01:45:51 +01:00
Archi
e4c20df4a8 Misc 2024-02-27 01:44:21 +01:00
Archi
5135677360 Add back missing STJ feature of non-standard Guid deserialization 2024-02-27 01:34:56 +01:00
Archi
f6004f558b Closes #3147 2024-02-27 00:42:32 +01:00
renovate[bot]
ddf08c4dc0 chore(deps): update asf-ui digest to f916fb3 2024-02-26 19:35:51 +00:00
ArchiBot
388eaf614d Automatic translations update 2024-02-26 02:05:51 +00:00
renovate[bot]
3a0768f9ef chore(deps): update asf-ui digest to 05de7b9 2024-02-25 21:40:17 +00:00
ArchiBot
cb0e04c022 Automatic translations update 2024-02-25 02:05:54 +00:00
Archi
55cdac205d Merge branch 'main' of https://github.com/JustArchiNET/ArchiSteamFarm 2024-02-24 18:39:16 +01:00
Archi
1bc0d6f16c Archi had too much coding lately 2024-02-24 18:39:09 +01:00
renovate[bot]
f7377a7043 chore(deps): update asf-ui digest to d0d90a5 2024-02-24 04:29:58 +00:00
ArchiBot
76f1ad45dd Automatic translations update 2024-02-24 02:03:45 +00:00
renovate[bot]
f21ffde803 chore(deps): update github/codeql-action action to v3.24.5 2024-02-23 16:55:14 +00:00
renovate[bot]
e9c96f175f chore(deps): update asf-ui digest to f2ef756 2024-02-23 14:02:04 +00:00
Archi
0f9a4c7c31 Update Commands.cs 2024-02-23 14:19:09 +01:00
Archi
87451615e8 Extract addlicense logic to actions 2024-02-23 14:14:16 +01:00
ArchiBot
fa19aaae2e Automatic translations update 2024-02-23 02:08:38 +00:00
Archi
6b8280fceb Bump 2024-02-23 02:49:20 +01:00
Archi
7a13895429 Fix false positives 2024-02-23 02:49:03 +01:00
Archi
75668ea099 Bump 2024-02-23 02:43:06 +01:00
35 changed files with 748 additions and 596 deletions

View File

@@ -149,6 +149,7 @@ body:
Ensure that your config has redacted (but NOT removed) potentially-sensitive properties, such as:
- IPCPassword (recommended)
- LicenseID (mandatory)
- SteamOwnerID (optionally)
- WebProxy (optionally, if exposing private details)
- WebProxyPassword (optionally, if exposing private details)

2
.github/SUPPORT.md vendored
View File

@@ -2,6 +2,6 @@
Our **[wiki](https://github.com/JustArchiNET/ArchiSteamFarm/wiki)** is the official online documentation which covers at least a significant majority (if not all) of ASF subjects you could be interested in. We recommend to start with **[setting up](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Setting-up)**, **[configuration](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Configuration)** and our **[FAQ](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/FAQ)** which should help you with setting up ASF, configuring it, as well as answering the most common questions that you might have. For more advanced matters, as well as further elaboration, we have other pages available on our **[wiki](https://github.com/JustArchiNET/ArchiSteamFarm/wiki)** that you can visit.
We also have three independent support channels dedicated to our ASF users, in case you couldn't manage to solve the issue yourself. We answer all support and technical matters in our **[GitHub discussions](https://github.com/JustArchiNET/ArchiSteamFarm/discussions/categories/support)**, **[Steam group](https://steamcommunity.com/groups/archiasf/discussions/1)**, and on our **[Discord server](https://discord.gg/hSQgt8j)**. You're free to use the support channel that matches your preferences, although keep in mind that you have a higher chance solving your issue on the GitHub or Steam, where we're doing our best to answer all questions that couldn't be answered by our community itself (as opposed to Discord server where we're not active 24/7 and therefore not always able to answer).
We also have three independent support channels dedicated to our ASF users, in case you couldn't manage to solve the issue yourself. We answer all support and technical matters in our **[GitHub discussions](https://github.com/JustArchiNET/ArchiSteamFarm/discussions/categories/support-english)**, **[Steam group](https://steamcommunity.com/groups/archiasf/discussions/1)**, and on our **[Discord server](https://discord.gg/hSQgt8j)**. You're free to use the support channel that matches your preferences, although keep in mind that you have a higher chance solving your issue on the GitHub or Steam, where we're doing our best to answer all questions that couldn't be answered by our community itself (as opposed to Discord server where we're not active 24/7 and therefore not always able to answer).
GitHub **issues** (unlike discussions), are being used solely for ASF development, especially in regards to bugs and enhancements. We have a very strict policy regarding that, as GitHub issues is **not** a general support channel, it's dedicated exclusively to ASF development and we're not answering common ASF matters there, as we have appropriate support channels (mentioned above) for that. Common matters include not only general questions or issues that are obviously related to program usage, but also users reporting "bugs" that are clearly considered intended behaviour coming for example (and mainly) from misconfiguration or lack of understanding how the program works. If you're not sure whether your matter relates to ASF development or not, especially if you're not sure if it's a bug or intended behaviour, we recommend to use a support channel instead, where we'll answer you in calm atmosphere and forward your matter as GitHub issue if deemed appropriate. Invalid GitHub issues will be closed immediately and won't be answered.

View File

@@ -40,6 +40,6 @@ jobs:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
- name: Report Qodana results to GitHub
uses: github/codeql-action/upload-sarif@v3.24.4
uses: github/codeql-action/upload-sarif@v3.24.6
with:
sarif_file: ${{ runner.temp }}/qodana/results/qodana.sarif.json

View File

@@ -25,10 +25,10 @@ jobs:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.1.0
- name: Build ${{ matrix.configuration }} Docker image from ${{ matrix.file }}
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v5.2.0
with:
context: .
file: ${{ matrix.file }}

View File

@@ -24,7 +24,7 @@ jobs:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.1.0
- name: Login to ghcr.io
uses: docker/login-action@v3.0.0
@@ -59,7 +59,7 @@ jobs:
echo "DH_REPOSITORY=$(echo ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Build and publish Docker image from Dockerfile.Service
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v5.2.0
with:
context: .
file: Dockerfile.Service

View File

@@ -25,7 +25,7 @@ jobs:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.1.0
- name: Login to ghcr.io
uses: docker/login-action@v3.0.0
@@ -59,7 +59,7 @@ jobs:
echo "DH_REPOSITORY=$(echo ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Build and publish Docker image from Dockerfile
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v5.2.0
with:
context: .
platforms: ${{ env.PLATFORMS }}

View File

@@ -25,7 +25,7 @@ jobs:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.1.0
- name: Login to ghcr.io
uses: docker/login-action@v3.0.0
@@ -60,7 +60,7 @@ jobs:
echo "DH_REPOSITORY=$(echo ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Build and publish Docker image from Dockerfile
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v5.2.0
with:
context: .
platforms: ${{ env.PLATFORMS }}

View File

@@ -88,7 +88,7 @@ jobs:
run: dotnet --info
- name: Download previously built ASF-ui
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: ASF-ui
path: ASF-ui/dist
@@ -425,49 +425,49 @@ jobs:
show-progress: false
- name: Download ASF-generic artifact from ubuntu-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: ubuntu-latest_ASF-generic
path: out
- name: Download ASF-linux-arm artifact from ubuntu-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: ubuntu-latest_ASF-linux-arm
path: out
- name: Download ASF-linux-arm64 artifact from ubuntu-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: ubuntu-latest_ASF-linux-arm64
path: out
- name: Download ASF-linux-x64 artifact from ubuntu-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: ubuntu-latest_ASF-linux-x64
path: out
- name: Download ASF-osx-arm64 artifact from macos-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: macos-latest_ASF-osx-arm64
path: out
- name: Download ASF-osx-x64 artifact from macos-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: macos-latest_ASF-osx-x64
path: out
- name: Download ASF-win-arm64 artifact from windows-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: windows-latest_ASF-win-arm64
path: out
- name: Download ASF-win-x64 artifact from windows-latest
uses: actions/download-artifact@v4.1.2
uses: actions/download-artifact@v4.1.4
with:
name: windows-latest_ASF-win-x64
path: out

2
ASF-ui

Submodule ASF-ui updated: cd1173a0d6...3c96528592

View File

@@ -383,7 +383,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable {
(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (asset.RealAppID, asset.Type, asset.Rarity);
if (state.TryGetValue(key, out Dictionary<ulong, uint>? set)) {
set[asset.ClassID] = set.TryGetValue(asset.ClassID, out uint amount) ? amount + asset.Amount : asset.Amount;
set[asset.ClassID] = set.GetValueOrDefault(asset.ClassID) + asset.Amount;
} else {
state[key] = new Dictionary<ulong, uint> { { asset.ClassID, asset.Amount } };
}
@@ -977,7 +977,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable {
(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (asset.RealAppID, asset.Type, asset.Rarity);
if (setsState.TryGetValue(key, out Dictionary<ulong, uint>? set)) {
set[asset.ClassID] = set.TryGetValue(asset.ClassID, out uint amount) ? amount + asset.Amount : asset.Amount;
set[asset.ClassID] = set.GetValueOrDefault(asset.ClassID) + asset.Amount;
} else {
setsState[key] = new Dictionary<ulong, uint> { { asset.ClassID, asset.Amount } };
}
@@ -1331,15 +1331,16 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable {
continue;
}
foreach ((ulong theirItem, uint theirTradableAmount) in theirTradableItems.OrderBy(item => ourFullSet.TryGetValue(item.Key, out uint ourAmountOfTheirItem) ? ourAmountOfTheirItem : 0)) {
foreach ((ulong theirItem, uint theirTradableAmount) in theirTradableItems.OrderBy(item => ourFullSet.GetValueOrDefault(item.Key))) {
if (ourFullSet.TryGetValue(theirItem, out uint ourAmountOfTheirItem) && (ourFullAmount <= ourAmountOfTheirItem + 1)) {
continue;
}
if (!listedUser.MatchEverything) {
// We have a potential match, let's check fairness for them
fairClassIDsToGive.TryGetValue(ourItem, out uint fairGivenAmount);
fairClassIDsToReceive.TryGetValue(theirItem, out uint fairReceivedAmount);
uint fairGivenAmount = fairClassIDsToGive.GetValueOrDefault(ourItem);
uint fairReceivedAmount = fairClassIDsToReceive.GetValueOrDefault(theirItem);
fairClassIDsToGive[ourItem] = ++fairGivenAmount;
fairClassIDsToReceive[theirItem] = ++fairReceivedAmount;
@@ -1373,11 +1374,11 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable {
skippedSetsThisTrade.Add(set);
// Update our state based on given items
classIDsToGive[ourItem] = classIDsToGive.TryGetValue(ourItem, out uint ourGivenAmount) ? ourGivenAmount + 1 : 1;
classIDsToGive[ourItem] = classIDsToGive.GetValueOrDefault(ourItem) + 1;
ourFullSet[ourItem] = ourFullAmount - 1; // We don't need to remove anything here because we can guarantee that ourItem.Value is at least 2
// Update our state based on received items
classIDsToReceive[theirItem] = classIDsToReceive.TryGetValue(theirItem, out uint ourReceivedAmount) ? ourReceivedAmount + 1 : 1;
classIDsToReceive[theirItem] = classIDsToReceive.GetValueOrDefault(theirItem) + 1;
ourFullSet[theirItem] = ourAmountOfTheirItem + 1;
if (ourTradableAmount > 1) {
@@ -1515,11 +1516,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable {
throw new InvalidOperationException(nameof(fullAmounts));
}
if (!fullAmounts.TryGetValue(itemToReceive.ClassID, out uint fullAmount)) {
fullAmount = 0;
}
fullAmounts[itemToReceive.ClassID] = itemToReceive.Amount + fullAmount;
fullAmounts[itemToReceive.ClassID] = fullAmounts.GetValueOrDefault(itemToReceive.ClassID) + itemToReceive.Amount;
}
skippedSetsThisUser.UnionWith(skippedSetsThisTrade);

View File

@@ -32,21 +32,27 @@ namespace ArchiSteamFarm.OfficialPlugins.SteamTokenDumper.Data;
internal sealed class SubmitRequest {
private readonly ulong SteamID;
#pragma warning disable CA1822 // We can't make it static, STJ doesn't serialize it otherwise
[JsonInclude]
[JsonPropertyName("guid")]
private string Guid => ASF.GlobalDatabase?.Identifier.ToString("N") ?? throw new InvalidOperationException(nameof(ASF.GlobalDatabase.Identifier));
#pragma warning restore CA1822 // We can't make it static, STJ doesn't serialize it otherwise
[JsonInclude]
[JsonPropertyName("steamid")]
private string SteamIDText => new SteamID(SteamID).Render();
#pragma warning disable CA1822 // We can't make it static, STJ doesn't serialize it otherwise
[JsonInclude]
[JsonPropertyName("token")]
private string Token => SharedInfo.Token;
#pragma warning restore CA1822 // We can't make it static, STJ doesn't serialize it otherwise
#pragma warning disable CA1822 // We can't make it static, STJ doesn't serialize it otherwise
[JsonInclude]
[JsonPropertyName("v")]
private byte Version => SharedInfo.ApiVersion;
#pragma warning restore CA1822 // We can't make it static, STJ doesn't serialize it otherwise
[JsonInclude]
[JsonPropertyName("apps")]

View File

@@ -168,9 +168,9 @@
<value>STD genel önbelleği yükleniyor...</value>
</data>
<data name="ValidatingGlobalCacheIntegrity" xml:space="preserve">
<value>STD genel önbellek bütünlüğünü doğrulama...</value>
<value>STD genel önbellek bütünlüğünü doğruluyor...</value>
</data>
<data name="GlobalCacheIntegrityValidationFailed" xml:space="preserve">
<value>STD genel önbellek bütünlüğü doğrulanamadı. Bu, olası bir dosya / bellek bozulmasına işaret eder, bunun yerine yeni bir örnek başlatılır.</value>
<value>STD genel önbellek bütünlüğü doğrulanamadı. Bu, olası bir dosya/bellek bozulmasına işaret eder, bunun yerine yeni bir örnek başlatılır.</value>
</data>
</root>

View File

@@ -64,7 +64,7 @@ public sealed class ArchiCacheable<T> : IDisposable {
} catch (OperationCanceledException e) {
ASF.ArchiLogger.LogGenericDebuggingException(e);
return ReturnFailedValueFor(cacheFallback);
return GetFailedValueFor(cacheFallback);
}
try {
@@ -75,7 +75,7 @@ public sealed class ArchiCacheable<T> : IDisposable {
(bool success, T? result) = await ResolveFunction(cancellationToken).ConfigureAwait(false);
if (!success) {
return ReturnFailedValueFor(cacheFallback, result);
return GetFailedValueFor(cacheFallback, result);
}
InitializedValue = result;
@@ -85,7 +85,7 @@ public sealed class ArchiCacheable<T> : IDisposable {
} catch (OperationCanceledException e) {
ASF.ArchiLogger.LogGenericDebuggingException(e);
return ReturnFailedValueFor(cacheFallback);
return GetFailedValueFor(cacheFallback);
} finally {
InitSemaphore.Release();
}
@@ -110,7 +110,7 @@ public sealed class ArchiCacheable<T> : IDisposable {
}
}
private (bool Success, T? Result) ReturnFailedValueFor(ECacheFallback cacheFallback, T? result = default) {
private (bool Success, T? Result) GetFailedValueFor(ECacheFallback cacheFallback, T? result = default) {
if (!Enum.IsDefined(cacheFallback)) {
throw new InvalidEnumArgumentException(nameof(cacheFallback), (int) cacheFallback, typeof(ECacheFallback));
}

View File

@@ -0,0 +1,56 @@
// _ _ _ ____ _ _____
// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
// |
// Copyright 2015-2024 Łukasz "JustArchi" Domeradzki
// Contact: JustArchi@JustArchi.net
// |
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// |
// http://www.apache.org/licenses/LICENSE-2.0
// |
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ArchiSteamFarm.Helpers.Json;
/// <inheritdoc />
/// <summary>
/// TODO: This class exists purely because STJ can't deserialize Guid in other formats than default, at least for now
/// https://github.com/dotnet/runtime/issues/30692
/// </summary>
internal sealed class GuidJsonConverter : JsonConverter<Guid> {
internal static readonly GuidJsonConverter Shared = new();
public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
if (reader.TryGetGuid(out Guid result)) {
// Great, we can work with it
return result;
}
try {
// Try again using more flexible implementation, sigh
return Guid.Parse(reader.GetString()!);
} catch {
// Throw JsonException instead, which will be converted into standard message by STJ
throw new JsonException();
}
}
public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options) {
ArgumentNullException.ThrowIfNull(writer);
writer.WriteStringValue(value.ToString());
}
}

View File

@@ -42,6 +42,7 @@ public static class JsonUtilities {
public static readonly JsonSerializerOptions IndentedJsonSerialierOptions = CreateDefaultJsonSerializerOptions(true);
[PublicAPI]
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
public static JsonElement ToJsonElement<T>(this T obj) where T : notnull {
ArgumentNullException.ThrowIfNull(obj);
@@ -49,9 +50,11 @@ public static class JsonUtilities {
}
[PublicAPI]
public static T? ToJsonObject<T>(this JsonElement jsonElement, CancellationToken cancellationToken = default) => jsonElement.Deserialize<T>(DefaultJsonSerialierOptions);
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
public static T? ToJsonObject<T>(this JsonElement jsonElement) => jsonElement.Deserialize<T>(DefaultJsonSerialierOptions);
[PublicAPI]
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
public static async ValueTask<T?> ToJsonObject<T>(this Stream stream, CancellationToken cancellationToken = default) {
ArgumentNullException.ThrowIfNull(stream);
@@ -59,6 +62,7 @@ public static class JsonUtilities {
}
[PublicAPI]
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
public static T? ToJsonObject<T>([StringSyntax(StringSyntaxAttribute.Json)] this string json) {
ArgumentException.ThrowIfNullOrEmpty(json);
@@ -66,6 +70,7 @@ public static class JsonUtilities {
}
[PublicAPI]
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
public static string ToJsonText<T>(this T obj, bool writeIndented = false) => JsonSerializer.Serialize(obj, writeIndented ? IndentedJsonSerialierOptions : DefaultJsonSerialierOptions);
private static void ApplyCustomModifiers(JsonTypeInfo jsonTypeInfo) {
@@ -102,9 +107,11 @@ public static class JsonUtilities {
}
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
private static JsonSerializerOptions CreateDefaultJsonSerializerOptions(bool writeIndented = false) =>
new() {
AllowTrailingCommas = true,
Converters = { GuidJsonConverter.Shared },
PropertyNamingPolicy = null,
ReadCommentHandling = JsonCommentHandling.Skip,
TypeInfoResolver = new DefaultJsonTypeInfoResolver { Modifiers = { ApplyCustomModifiers } },

View File

@@ -65,7 +65,7 @@ public abstract class SerializableFile : IDisposable {
ArgumentNullException.ThrowIfNull(serializableFile);
if (string.IsNullOrEmpty(serializableFile.FilePath)) {
throw new InvalidOperationException(nameof(FilePath));
throw new InvalidOperationException(nameof(serializableFile.FilePath));
}
if (serializableFile.ReadOnly) {

View File

@@ -20,29 +20,50 @@
// limitations under the License.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using ArchiSteamFarm.Core;
using ArchiSteamFarm.Helpers.Json;
using ArchiSteamFarm.IPC.Controllers.Api;
using ArchiSteamFarm.IPC.Integration;
using ArchiSteamFarm.Localization;
using ArchiSteamFarm.NLog;
using ArchiSteamFarm.NLog.Targets;
using ArchiSteamFarm.Plugins;
using ArchiSteamFarm.Plugins.Interfaces;
using ArchiSteamFarm.Storage;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
using Microsoft.OpenApi.Models;
using NLog.Web;
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
namespace ArchiSteamFarm.IPC;
internal static class ArchiKestrel {
internal static bool IsRunning => KestrelWebHost != null;
internal static bool IsRunning => WebApplication != null;
internal static HistoryTarget? HistoryTarget { get; private set; }
private static IHost? KestrelWebHost;
private static WebApplication? WebApplication;
internal static void OnNewHistoryTarget(HistoryTarget? historyTarget = null) {
if (HistoryTarget != null) {
@@ -57,102 +78,424 @@ internal static class ArchiKestrel {
}
internal static async Task Start() {
if (KestrelWebHost != null) {
if (WebApplication != null) {
return;
}
ASF.ArchiLogger.LogGenericInfo(Strings.IPCStarting);
// The order of dependency injection matters, pay attention to it
HostBuilder builder = new();
string customDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.WebsiteDirectory);
string websiteDirectory = Directory.Exists(customDirectory) ? customDirectory : Path.Combine(AppContext.BaseDirectory, SharedInfo.WebsiteDirectory);
// Set default content root
builder.UseContentRoot(SharedInfo.HomeDirectory);
// Check if custom config is available
string absoluteConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.ConfigDirectory);
string customConfigPath = Path.Combine(absoluteConfigDirectory, SharedInfo.IPCConfigFile);
bool customConfigExists = File.Exists(customConfigPath);
if (customConfigExists && Debugging.IsDebugConfigured) {
try {
string json = await File.ReadAllTextAsync(customConfigPath).ConfigureAwait(false);
if (!string.IsNullOrEmpty(json)) {
JsonNode? jsonNode = JsonNode.Parse(json);
ASF.ArchiLogger.LogGenericDebug($"{SharedInfo.IPCConfigFile}: {jsonNode?.ToJsonText(true) ?? "null"}");
}
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);
}
}
// Enable NLog integration for logging
builder.ConfigureLogging(
static logging => {
logging.ClearProviders();
logging.SetMinimumLevel(Debugging.IsUserDebugging ? LogLevel.Trace : LogLevel.Warning);
}
);
builder.UseNLog(new NLogAspNetCoreOptions { ShutdownOnDispose = false });
builder.ConfigureWebHostDefaults(
webBuilder => {
// Set default web root
if (Directory.Exists(websiteDirectory)) {
webBuilder.UseWebRoot(websiteDirectory);
}
// Now conditionally initialize settings that are not possible to override
if (customConfigExists) {
// Set up custom config to be used
webBuilder.UseConfiguration(new ConfigurationBuilder().SetBasePath(absoluteConfigDirectory).AddJsonFile(SharedInfo.IPCConfigFile, false, Program.ConfigWatch).Build());
// Use custom config for Kestrel configuration
webBuilder.UseKestrel(static (builderContext, options) => options.Configure(builderContext.Configuration.GetSection("Kestrel")));
} else {
// Use ASF defaults for Kestrel
webBuilder.UseKestrel(static options => options.ListenLocalhost(1242));
}
// Specify Startup class for IPC
webBuilder.UseStartup<Startup>();
}
);
// Init history logger for /Api/Log usage
Logging.InitHistoryLogger();
// Start the server
IHost? kestrelWebHost = null;
WebApplication webApplication = await CreateWebApplication().ConfigureAwait(false);
try {
kestrelWebHost = builder.Build();
await kestrelWebHost.StartAsync().ConfigureAwait(false);
// Start the server
await webApplication.StartAsync().ConfigureAwait(false);
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);
kestrelWebHost?.Dispose();
await webApplication.DisposeAsync().ConfigureAwait(false);
return;
}
KestrelWebHost = kestrelWebHost;
WebApplication = webApplication;
ASF.ArchiLogger.LogGenericInfo(Strings.IPCReady);
}
internal static async Task Stop() {
if (KestrelWebHost == null) {
if (WebApplication == null) {
return;
}
await KestrelWebHost.StopAsync().ConfigureAwait(false);
KestrelWebHost.Dispose();
KestrelWebHost = null;
await WebApplication.StopAsync().ConfigureAwait(false);
await WebApplication.DisposeAsync().ConfigureAwait(false);
WebApplication = null;
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "PathString is a primitive, it's unlikely to be trimmed to the best of our knowledge")]
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3000", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
private static void ConfigureApp([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] ConfigurationManager configuration, IApplicationBuilder app) {
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(app);
// The order of dependency injection is super important, doing things in wrong order will break everything
// https://docs.microsoft.com/aspnet/core/fundamentals/middleware
// This one is easy, it's always in the beginning
if (Debugging.IsUserDebugging) {
app.UseDeveloperExceptionPage();
}
// Add support for proxies, this one comes usually after developer exception page, but could be before
app.UseForwardedHeaders();
if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
// Add support for response caching - must be called before static files as we want to cache those as well
app.UseResponseCaching();
}
// Add support for response compression - must be called before static files as we want to compress those as well
app.UseResponseCompression();
// It's not apparent when UsePathBase() should be called, but definitely before we get down to static files
// TODO: Maybe eventually we can get rid of this, https://github.com/aspnet/AspNetCore/issues/5898
PathString pathBase = configuration.GetSection("Kestrel").GetValue<PathString>("PathBase");
if (!string.IsNullOrEmpty(pathBase) && (pathBase != "/")) {
app.UsePathBase(pathBase);
}
// The default HTML file (usually index.html) is responsible for IPC GUI routing, so re-execute all non-API calls on /
// This must be called before default files, because we don't know the exact file name that will be used for index page
app.UseWhen(static context => !context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseStatusCodePagesWithReExecute("/"));
// Add support for default root path redirection (GET / -> GET /index.html), must come before static files
app.UseDefaultFiles();
Dictionary<string, string> pluginPaths = new(StringComparer.Ordinal);
if (PluginsCore.ActivePlugins.Count > 0) {
foreach (IWebInterface plugin in PluginsCore.ActivePlugins.OfType<IWebInterface>()) {
if (string.IsNullOrEmpty(plugin.PhysicalPath) || string.IsNullOrEmpty(plugin.WebPath)) {
// Invalid path provided
continue;
}
string physicalPath = plugin.PhysicalPath;
if (!Path.IsPathRooted(physicalPath)) {
// Relative path
string? assemblyDirectory = Path.GetDirectoryName(plugin.GetType().Assembly.Location);
if (string.IsNullOrEmpty(assemblyDirectory)) {
// Invalid path provided
continue;
}
physicalPath = Path.Combine(assemblyDirectory, plugin.PhysicalPath);
}
if (!Directory.Exists(physicalPath)) {
// Non-existing path provided
continue;
}
pluginPaths[physicalPath] = plugin.WebPath;
if (plugin.WebPath != "/") {
app.UseDefaultFiles(plugin.WebPath);
}
}
}
// Add support for static files from custom plugins (e.g. HTML, CSS and JS)
foreach ((string physicalPath, string webPath) in pluginPaths) {
app.UseStaticFiles(
new StaticFileOptions {
FileProvider = new PhysicalFileProvider(physicalPath),
OnPrepareResponse = OnPrepareResponse,
RequestPath = webPath
}
);
}
// Add support for static files (e.g. HTML, CSS and JS from IPC GUI)
app.UseStaticFiles(
new StaticFileOptions {
OnPrepareResponse = OnPrepareResponse
}
);
// Use routing for our API controllers, this should be called once we're done with all the static files mess
app.UseRouting();
// We want to protect our API with IPCPassword and additional security, this should be called after routing, so the middleware won't have to deal with API endpoints that do not exist
app.UseWhen(static context => context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseMiddleware<ApiAuthenticationMiddleware>());
string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
if (!string.IsNullOrEmpty(ipcPassword)) {
// We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API, this should be called before response compression, but can't be due to how our flow works
// We apply CORS policy only with IPCPassword set as an extra authentication measure
app.UseCors();
}
// Add support for websockets that we use e.g. in /Api/NLog
app.UseWebSockets();
// Finally register proper API endpoints once we're done with routing
app.UseEndpoints(static endpoints => endpoints.MapControllers());
// Add support for swagger, responsible for automatic API documentation generation, this should be on the end, once we're done with API
app.UseSwagger();
// Add support for swagger UI, this should be after swagger, obviously
app.UseSwaggerUI(
static options => {
options.DisplayRequestDuration();
options.EnableDeepLinking();
options.ShowExtensions();
options.SwaggerEndpoint($"{SharedInfo.ASF}/swagger.json", $"{SharedInfo.ASF} API");
}
);
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
private static void ConfigureServices([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] ConfigurationManager configuration, IServiceCollection services) {
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(services);
// The order of dependency injection is super important, doing things in wrong order will break everything
// Order in Configure() method is a good start
// Prepare knownNetworks that we'll use in a second
HashSet<string>? knownNetworksTexts = configuration.GetSection("Kestrel:KnownNetworks").Get<HashSet<string>>();
HashSet<IPNetwork>? knownNetworks = null;
if (knownNetworksTexts?.Count > 0) {
// Use specified known networks
knownNetworks = new HashSet<IPNetwork>();
foreach (string knownNetworkText in knownNetworksTexts) {
string[] addressParts = knownNetworkText.Split('/', 3, StringSplitOptions.RemoveEmptyEntries);
if ((addressParts.Length != 2) || !IPAddress.TryParse(addressParts[0], out IPAddress? ipAddress) || !byte.TryParse(addressParts[1], out byte prefixLength)) {
ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(knownNetworkText)));
ASF.ArchiLogger.LogGenericDebug($"{nameof(knownNetworkText)}: {knownNetworkText}");
continue;
}
knownNetworks.Add(new IPNetwork(ipAddress, prefixLength));
}
}
// Add support for proxies
services.Configure<ForwardedHeadersOptions>(
options => {
options.ForwardedHeaders = ForwardedHeaders.All;
if (knownNetworks != null) {
foreach (IPNetwork knownNetwork in knownNetworks) {
options.KnownNetworks.Add(knownNetwork);
}
}
}
);
if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
// Add support for response caching
services.AddResponseCaching();
}
// Add support for response compression
services.AddResponseCompression(static options => options.EnableForHttps = true);
string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
if (!string.IsNullOrEmpty(ipcPassword)) {
// We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API
// We apply CORS policy only with IPCPassword set as an extra authentication measure
services.AddCors(static options => options.AddDefaultPolicy(static policyBuilder => policyBuilder.AllowAnyOrigin()));
}
// Add support for swagger, responsible for automatic API documentation generation
services.AddSwaggerGen(
static options => {
options.AddSecurityDefinition(
nameof(GlobalConfig.IPCPassword), new OpenApiSecurityScheme {
Description = $"{nameof(GlobalConfig.IPCPassword)} authentication using request headers. Check {SharedInfo.ProjectURL}/wiki/IPC#authentication for more info.",
In = ParameterLocation.Header,
Name = ApiAuthenticationMiddleware.HeadersField,
Type = SecuritySchemeType.ApiKey
}
);
options.AddSecurityRequirement(
new OpenApiSecurityRequirement {
{
new OpenApiSecurityScheme {
Reference = new OpenApiReference {
Id = nameof(GlobalConfig.IPCPassword),
Type = ReferenceType.SecurityScheme
}
},
Array.Empty<string>()
}
}
);
// We require custom schema IDs due to conflicting type names, choosing the proper one is tricky as there is no good answer and any kind of convention has a potential to create conflict
// FullName and Name both do, ToString() for unknown to me reason doesn't, and I don't have courage to call our WebUtilities.GetUnifiedName() better than what .NET ships with (because it isn't)
// Let's use ToString() until we find a good enough reason to change it, also, the name must pass ^[a-zA-Z0-9.-_]+$ regex
options.CustomSchemaIds(static type => type.ToString().Replace('+', '-'));
options.EnableAnnotations(true, true);
options.SchemaFilter<CustomAttributesSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
options.SchemaFilter<ReadOnlyFixesSchemaFilter>();
options.SwaggerDoc(
SharedInfo.ASF, new OpenApiInfo {
Contact = new OpenApiContact {
Name = SharedInfo.GithubRepo,
Url = new Uri(SharedInfo.ProjectURL)
},
License = new OpenApiLicense {
Name = SharedInfo.LicenseName,
Url = new Uri(SharedInfo.LicenseURL)
},
Title = $"{SharedInfo.AssemblyName} API",
Version = SharedInfo.Version.ToString()
}
);
string xmlDocumentationFile = Path.Combine(AppContext.BaseDirectory, SharedInfo.AssemblyDocumentation);
if (File.Exists(xmlDocumentationFile)) {
options.IncludeXmlComments(xmlDocumentationFile);
}
}
);
// We need MVC for /Api, but we're going to use only a small subset of all available features
IMvcBuilder mvc = services.AddControllers();
// Add support for controllers declared in custom plugins
if (PluginsCore.ActivePlugins.Count > 0) {
HashSet<Assembly>? assemblies = PluginsCore.LoadAssemblies();
if (assemblies != null) {
foreach (Assembly assembly in assemblies) {
mvc.AddApplicationPart(assembly);
}
}
}
mvc.AddControllersAsServices();
mvc.AddJsonOptions(
static options => {
JsonSerializerOptions jsonSerializerOptions = Debugging.IsUserDebugging ? JsonUtilities.IndentedJsonSerialierOptions : JsonUtilities.DefaultJsonSerialierOptions;
foreach (JsonConverter converter in jsonSerializerOptions.Converters) {
options.JsonSerializerOptions.Converters.Add(converter);
}
options.JsonSerializerOptions.PropertyNamingPolicy = jsonSerializerOptions.PropertyNamingPolicy;
options.JsonSerializerOptions.TypeInfoResolver = jsonSerializerOptions.TypeInfoResolver;
options.JsonSerializerOptions.WriteIndented = jsonSerializerOptions.WriteIndented;
}
);
}
private static async Task<WebApplication> CreateWebApplication() {
string customDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.WebsiteDirectory);
string websiteDirectory = Directory.Exists(customDirectory) ? customDirectory : Path.Combine(AppContext.BaseDirectory, SharedInfo.WebsiteDirectory);
// The order of dependency injection matters, pay attention to it
WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder(
new WebApplicationOptions {
ApplicationName = SharedInfo.AssemblyName,
ContentRootPath = SharedInfo.HomeDirectory,
WebRootPath = websiteDirectory
}
);
// Enable NLog integration for logging
builder.Logging.SetMinimumLevel(Debugging.IsUserDebugging ? LogLevel.Trace : LogLevel.Warning);
builder.Logging.AddNLogWeb(new NLogAspNetCoreOptions { ShutdownOnDispose = false });
// Check if custom config is available
string absoluteConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.ConfigDirectory);
string customConfigPath = Path.Combine(absoluteConfigDirectory, SharedInfo.IPCConfigFile);
bool customConfigExists = File.Exists(customConfigPath);
if (customConfigExists) {
if (Debugging.IsDebugConfigured) {
try {
string json = await File.ReadAllTextAsync(customConfigPath).ConfigureAwait(false);
if (!string.IsNullOrEmpty(json)) {
JsonNode? jsonNode = JsonNode.Parse(json);
ASF.ArchiLogger.LogGenericDebug($"{SharedInfo.IPCConfigFile}: {jsonNode?.ToJsonText(true) ?? "null"}");
}
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);
}
}
// Set up custom config to be used
builder.WebHost.UseConfiguration(new ConfigurationBuilder().SetBasePath(absoluteConfigDirectory).AddJsonFile(SharedInfo.IPCConfigFile, false, true).Build());
}
builder.WebHost.ConfigureKestrel(
options => {
options.AddServerHeader = false;
if (customConfigExists) {
// Use custom config for Kestrel configuration
options.Configure(builder.Configuration.GetSection("Kestrel"));
} else {
// Use ASFB defaults for Kestrel
options.ListenLocalhost(1242);
}
}
);
builder.WebHost.UseKestrelCore();
ConfigureServices(builder.Configuration, builder.Services);
WebApplication result = builder.Build();
ConfigureApp(builder.Configuration, result);
return result;
}
private static void OnPrepareResponse(StaticFileResponseContext context) {
ArgumentNullException.ThrowIfNull(context);
if (context.File is not { Exists: true, IsDirectory: false } || string.IsNullOrEmpty(context.File.Name)) {
return;
}
string extension = Path.GetExtension(context.File.Name);
CacheControlHeaderValue cacheControl = new();
switch (extension.ToUpperInvariant()) {
case ".CSS" or ".JS":
// Add support for SRI-protected static files
// SRI requires from us to notify the caller (especially proxy) to avoid modifying the data
cacheControl.NoTransform = true;
goto default;
default:
// Instruct the caller to always ask us first about every file it requests
// Contrary to the name, this doesn't prevent client from caching, but rather informs it that it must verify with us first that their cache is still up-to-date
// This is used to handle ASF and user updates to WWW root, we don't want the client to ever use outdated scripts
cacheControl.NoCache = true;
// All static files are public by definition, we don't have any authorization here
cacheControl.Public = true;
break;
}
ResponseHeaders headers = context.Context.Response.GetTypedHeaders();
headers.CacheControl = cacheControl;
}
}

View File

@@ -67,6 +67,7 @@ internal sealed class ApiAuthenticationMiddleware {
}
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
[UsedImplicitly]
public async Task InvokeAsync(HttpContext context, IOptions<JsonOptions> jsonOptions) {
ArgumentNullException.ThrowIfNull(context);
@@ -164,14 +165,14 @@ internal sealed class ApiAuthenticationMiddleware {
}
try {
bool hasFailedAuthorizations = FailedAuthorizations.TryGetValue(clientIP, out attempts);
attempts = FailedAuthorizations.GetValueOrDefault(clientIP);
if (hasFailedAuthorizations && (attempts >= MaxFailedAuthorizationAttempts)) {
if (attempts >= MaxFailedAuthorizationAttempts) {
return (HttpStatusCode.Forbidden, false);
}
if (!authorized) {
FailedAuthorizations[clientIP] = hasFailedAuthorizations ? ++attempts : (byte) 1;
FailedAuthorizations[clientIP] = ++attempts;
}
} finally {
AuthorizationTasks.TryRemove(clientIP, out _);

View File

@@ -1,375 +0,0 @@
// _ _ _ ____ _ _____
// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
// |
// Copyright 2015-2024 Łukasz "JustArchi" Domeradzki
// Contact: JustArchi@JustArchi.net
// |
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// |
// http://www.apache.org/licenses/LICENSE-2.0
// |
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Text.Json;
using ArchiSteamFarm.Core;
using ArchiSteamFarm.Helpers.Json;
using ArchiSteamFarm.IPC.Integration;
using ArchiSteamFarm.Localization;
using ArchiSteamFarm.Plugins;
using ArchiSteamFarm.Plugins.Interfaces;
using ArchiSteamFarm.Storage;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Net.Http.Headers;
using Microsoft.OpenApi.Models;
using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
namespace ArchiSteamFarm.IPC;
internal sealed class Startup {
private readonly IConfiguration Configuration;
public Startup(IConfiguration configuration) {
ArgumentNullException.ThrowIfNull(configuration);
Configuration = configuration;
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "PathString is a primitive, it's unlikely to be trimmed to the best of our knowledge")]
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3000", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
[UsedImplicitly]
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(env);
// The order of dependency injection is super important, doing things in wrong order will break everything
// https://docs.microsoft.com/aspnet/core/fundamentals/middleware
// This one is easy, it's always in the beginning
if (Debugging.IsUserDebugging) {
app.UseDeveloperExceptionPage();
}
// Add support for proxies, this one comes usually after developer exception page, but could be before
app.UseForwardedHeaders();
if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
// Add support for response caching - must be called before static files as we want to cache those as well
app.UseResponseCaching();
}
// Add support for response compression - must be called before static files as we want to compress those as well
app.UseResponseCompression();
// It's not apparent when UsePathBase() should be called, but definitely before we get down to static files
// TODO: Maybe eventually we can get rid of this, https://github.com/aspnet/AspNetCore/issues/5898
PathString pathBase = Configuration.GetSection("Kestrel").GetValue<PathString>("PathBase");
if (!string.IsNullOrEmpty(pathBase) && (pathBase != "/")) {
app.UsePathBase(pathBase);
}
// The default HTML file (usually index.html) is responsible for IPC GUI routing, so re-execute all non-API calls on /
// This must be called before default files, because we don't know the exact file name that will be used for index page
app.UseWhen(static context => !context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseStatusCodePagesWithReExecute("/"));
// Add support for default root path redirection (GET / -> GET /index.html), must come before static files
app.UseDefaultFiles();
Dictionary<string, string> pluginPaths = new(StringComparer.Ordinal);
if (PluginsCore.ActivePlugins.Count > 0) {
foreach (IWebInterface plugin in PluginsCore.ActivePlugins.OfType<IWebInterface>()) {
if (string.IsNullOrEmpty(plugin.PhysicalPath) || string.IsNullOrEmpty(plugin.WebPath)) {
// Invalid path provided
continue;
}
string physicalPath = plugin.PhysicalPath;
if (!Path.IsPathRooted(physicalPath)) {
// Relative path
string? assemblyDirectory = Path.GetDirectoryName(plugin.GetType().Assembly.Location);
if (string.IsNullOrEmpty(assemblyDirectory)) {
// Invalid path provided
continue;
}
physicalPath = Path.Combine(assemblyDirectory, plugin.PhysicalPath);
}
if (!Directory.Exists(physicalPath)) {
// Non-existing path provided
continue;
}
pluginPaths[physicalPath] = plugin.WebPath;
if (plugin.WebPath != "/") {
app.UseDefaultFiles(plugin.WebPath);
}
}
}
// Add support for static files from custom plugins (e.g. HTML, CSS and JS)
foreach ((string physicalPath, string webPath) in pluginPaths) {
app.UseStaticFiles(
new StaticFileOptions {
FileProvider = new PhysicalFileProvider(physicalPath),
OnPrepareResponse = OnPrepareResponse,
RequestPath = webPath
}
);
}
// Add support for static files (e.g. HTML, CSS and JS from IPC GUI)
app.UseStaticFiles(
new StaticFileOptions {
OnPrepareResponse = OnPrepareResponse
}
);
// Use routing for our API controllers, this should be called once we're done with all the static files mess
app.UseRouting();
// We want to protect our API with IPCPassword and additional security, this should be called after routing, so the middleware won't have to deal with API endpoints that do not exist
app.UseWhen(static context => context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseMiddleware<ApiAuthenticationMiddleware>());
string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
if (!string.IsNullOrEmpty(ipcPassword)) {
// We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API, this should be called before response compression, but can't be due to how our flow works
// We apply CORS policy only with IPCPassword set as an extra authentication measure
app.UseCors();
}
// Add support for websockets that we use e.g. in /Api/NLog
app.UseWebSockets();
// Finally register proper API endpoints once we're done with routing
app.UseEndpoints(static endpoints => endpoints.MapControllers());
// Add support for swagger, responsible for automatic API documentation generation, this should be on the end, once we're done with API
app.UseSwagger();
// Add support for swagger UI, this should be after swagger, obviously
app.UseSwaggerUI(
static options => {
options.DisplayRequestDuration();
options.EnableDeepLinking();
options.ShowExtensions();
options.SwaggerEndpoint($"{SharedInfo.ASF}/swagger.json", $"{SharedInfo.ASF} API");
}
);
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "HashSet<string> isn't a primitive, but we widely use the required features everywhere and it's unlikely to be trimmed to the best of our knowledge")]
public void ConfigureServices(IServiceCollection services) {
ArgumentNullException.ThrowIfNull(services);
// The order of dependency injection is super important, doing things in wrong order will break everything
// Order in Configure() method is a good start
// Prepare knownNetworks that we'll use in a second
HashSet<string>? knownNetworksTexts = Configuration.GetSection("Kestrel:KnownNetworks").Get<HashSet<string>>();
HashSet<IPNetwork>? knownNetworks = null;
if (knownNetworksTexts?.Count > 0) {
// Use specified known networks
knownNetworks = new HashSet<IPNetwork>();
foreach (string knownNetworkText in knownNetworksTexts) {
string[] addressParts = knownNetworkText.Split('/', 3, StringSplitOptions.RemoveEmptyEntries);
if ((addressParts.Length != 2) || !IPAddress.TryParse(addressParts[0], out IPAddress? ipAddress) || !byte.TryParse(addressParts[1], out byte prefixLength)) {
ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(knownNetworkText)));
ASF.ArchiLogger.LogGenericDebug($"{nameof(knownNetworkText)}: {knownNetworkText}");
continue;
}
knownNetworks.Add(new IPNetwork(ipAddress, prefixLength));
}
}
// Add support for proxies
services.Configure<ForwardedHeadersOptions>(
options => {
options.ForwardedHeaders = ForwardedHeaders.All;
if (knownNetworks != null) {
foreach (IPNetwork knownNetwork in knownNetworks) {
options.KnownNetworks.Add(knownNetwork);
}
}
}
);
if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
// Add support for response caching
services.AddResponseCaching();
}
// Add support for response compression
services.AddResponseCompression(static options => options.EnableForHttps = true);
string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
if (!string.IsNullOrEmpty(ipcPassword)) {
// We want to apply CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API
// We apply CORS policy only with IPCPassword set as an extra authentication measure
services.AddCors(static options => options.AddDefaultPolicy(static policyBuilder => policyBuilder.AllowAnyOrigin()));
}
// Add support for swagger, responsible for automatic API documentation generation
services.AddSwaggerGen(
static options => {
options.AddSecurityDefinition(
nameof(GlobalConfig.IPCPassword), new OpenApiSecurityScheme {
Description = $"{nameof(GlobalConfig.IPCPassword)} authentication using request headers. Check {SharedInfo.ProjectURL}/wiki/IPC#authentication for more info.",
In = ParameterLocation.Header,
Name = ApiAuthenticationMiddleware.HeadersField,
Type = SecuritySchemeType.ApiKey
}
);
options.AddSecurityRequirement(
new OpenApiSecurityRequirement {
{
new OpenApiSecurityScheme {
Reference = new OpenApiReference {
Id = nameof(GlobalConfig.IPCPassword),
Type = ReferenceType.SecurityScheme
}
},
Array.Empty<string>()
}
}
);
// We require custom schema IDs due to conflicting type names, choosing the proper one is tricky as there is no good answer and any kind of convention has a potential to create conflict
// FullName and Name both do, ToString() for unknown to me reason doesn't, and I don't have courage to call our WebUtilities.GetUnifiedName() better than what .NET ships with (because it isn't)
// Let's use ToString() until we find a good enough reason to change it, also, the name must pass ^[a-zA-Z0-9.-_]+$ regex
options.CustomSchemaIds(static type => type.ToString().Replace('+', '-'));
options.EnableAnnotations(true, true);
options.SchemaFilter<CustomAttributesSchemaFilter>();
options.SchemaFilter<EnumSchemaFilter>();
options.SchemaFilter<ReadOnlyFixesSchemaFilter>();
options.SwaggerDoc(
SharedInfo.ASF, new OpenApiInfo {
Contact = new OpenApiContact {
Name = SharedInfo.GithubRepo,
Url = new Uri(SharedInfo.ProjectURL)
},
License = new OpenApiLicense {
Name = SharedInfo.LicenseName,
Url = new Uri(SharedInfo.LicenseURL)
},
Title = $"{SharedInfo.AssemblyName} API",
Version = SharedInfo.Version.ToString()
}
);
string xmlDocumentationFile = Path.Combine(AppContext.BaseDirectory, SharedInfo.AssemblyDocumentation);
if (File.Exists(xmlDocumentationFile)) {
options.IncludeXmlComments(xmlDocumentationFile);
}
}
);
// We need MVC for /Api, but we're going to use only a small subset of all available features
IMvcBuilder mvc = services.AddControllers();
// Add support for controllers declared in custom plugins
if (PluginsCore.ActivePlugins.Count > 0) {
HashSet<Assembly>? assemblies = PluginsCore.LoadAssemblies();
if (assemblies != null) {
foreach (Assembly assembly in assemblies) {
mvc.AddApplicationPart(assembly);
}
}
}
mvc.AddControllersAsServices();
mvc.AddJsonOptions(
static options => {
JsonSerializerOptions jsonSerializerOptions = Debugging.IsUserDebugging ? JsonUtilities.IndentedJsonSerialierOptions : JsonUtilities.DefaultJsonSerialierOptions;
options.JsonSerializerOptions.PropertyNamingPolicy = jsonSerializerOptions.PropertyNamingPolicy;
options.JsonSerializerOptions.TypeInfoResolver = jsonSerializerOptions.TypeInfoResolver;
options.JsonSerializerOptions.WriteIndented = jsonSerializerOptions.WriteIndented;
}
);
}
private static void OnPrepareResponse(StaticFileResponseContext context) {
ArgumentNullException.ThrowIfNull(context);
if (context.File is not { Exists: true, IsDirectory: false } || string.IsNullOrEmpty(context.File.Name)) {
return;
}
string extension = Path.GetExtension(context.File.Name);
CacheControlHeaderValue cacheControl = new();
switch (extension.ToUpperInvariant()) {
case ".CSS" or ".JS":
// Add support for SRI-protected static files
// SRI requires from us to notify the caller (especially proxy) to avoid modifying the data
cacheControl.NoTransform = true;
goto default;
default:
// Instruct the caller to always ask us first about every file it requests
// Contrary to the name, this doesn't prevent client from caching, but rather informs it that it must verify with us first that their cache is still up-to-date
// This is used to handle ASF and user updates to WWW root, we don't want the client to ever use outdated scripts
cacheControl.NoCache = true;
// All static files are public by definition, we don't have any authorization here
cacheControl.Public = true;
break;
}
ResponseHeaders headers = context.Context.Response.GetTypedHeaders();
headers.CacheControl = cacheControl;
}
}

View File

@@ -282,15 +282,22 @@
<value>Ponuda za razmjenu nije uspjela!</value>
</data>
<data name="BotLootingSuccess" xml:space="preserve">
<value>Ponuda za razmenu je uspešno poslata!</value>
</data>
<data name="BotSendingTradeToYourself" xml:space="preserve">
<value>Ne možete poslati zahtjev za razmjenu sebi!</value>
</data>
<data name="BotNotConnected" xml:space="preserve">
<value>Ova instanca bot-a nije povezana!</value>
</data>
<data name="BotPointsBalance" xml:space="preserve">
<value>Stanje bodova: {0}</value>
<comment>{0} will be replaced by the points balance value (integer)</comment>
</data>
<data name="BotReconnecting" xml:space="preserve">
<value>Ponovno povezivanje...</value>

View File

@@ -178,7 +178,7 @@ StackTrace:
<value>Prüfe auf neue Version...</value>
</data>
<data name="UpdateDownloadingNewVersion" xml:space="preserve">
<value>Lade neue Version herunter: {0} ({1} MB)... Bitte überlegen Sie es sich, uns für unsere geleistete Arbeit eine Spende zu hinterlassen, während Sie warten. :)</value>
<value>Lade neue Version herunter: {0} ({1} MB)... Während Sie warten, würden wir uns in der Zwischenzeit über Ihre Spende für unsere geleistete Arbeit eine Spende freuen. :-)</value>
<comment>{0} will be replaced by version string, {1} will be replaced by update size (in megabytes)</comment>
</data>
<data name="UpdateFinished" xml:space="preserve">

View File

@@ -532,7 +532,9 @@
<data name="BotAccountLocked" xml:space="preserve">
<value>Akun ini terkunci, proses farming tidak tersedia secara permanen!</value>
</data>
<data name="BotStatusLocked" xml:space="preserve">
<value>Bot sedang terkundi dan tidak bisa mendapatkan kartu saat farming.</value>
</data>
<data name="ErrorFunctionOnlyInHeadlessMode" xml:space="preserve">
<value>Fungsi ini tersedia hanya dalam mode headless!</value>
</data>
@@ -543,8 +545,14 @@
<data name="ErrorAccessDenied" xml:space="preserve">
<value>Akses ditolak!</value>
</data>
<data name="WarningPreReleaseVersion" xml:space="preserve">
<value>Anda menggunakan versi yang lebih baru dari versi terbaru yang dirilis untuk saluran pembaruan Anda. Harap dicatat bahwa versi pra-rilis ditujukan untuk pengguna yang mengetahui cara melaporkan bug, menangani masalah, dan memberikan umpan balik - tidak ada dukungan teknis yang akan diberikan.</value>
</data>
<data name="BotStats" xml:space="preserve">
<value>Penggunaan memori saat ini: {0} MB.
Waktu aktif proses: {1}</value>
<comment>{0} will be replaced by number (in megabytes) of memory being used, {1} will be replaced by translated TimeSpan string (such as "25 minutes"). Please note that this string should include newlines for formatting.</comment>
</data>
<data name="ClearingDiscoveryQueue" xml:space="preserve">
<value>Membersihkan antrian penemuan Steam #{0}...</value>
<comment>{0} will be replaced by queue number</comment>
@@ -560,13 +568,21 @@
<data name="BotRefreshingPackagesData" xml:space="preserve">
<value>Menyegarkan data paket...</value>
</data>
<data name="WarningDeprecated" xml:space="preserve">
<value>Penggunaan {0} tidak digunakan lagi dan akan dihapus pada versi program mendatang. Silakan gunakan {1} sebagai gantinya.</value>
<comment>{0} will be replaced by the name of deprecated property (such as argument, config property or likewise), {1} will be replaced by the name of valid replacement (such as another argument or config property)</comment>
</data>
<data name="BotAcceptedDonationTrade" xml:space="preserve">
<value>Menerima trade donasi: {0}</value>
<comment>{0} will be replaced by trade's ID (number)</comment>
</data>
<data name="WarningWorkaroundTriggered" xml:space="preserve">
<value>Solusi untuk bug {0} telah dipicu.</value>
<comment>{0} will be replaced by the bug's name provided by ASF</comment>
</data>
<data name="TargetBotNotConnected" xml:space="preserve">
<value>Bot target tidak terhubung!</value>
</data>
<data name="BotWalletBalance" xml:space="preserve">
<value>Saldo wallet: {0} {1}</value>
<comment>{0} will be replaced by wallet balance value, {1} will be replaced by currency name</comment>
@@ -578,12 +594,21 @@
<value>Bot mempunyai level {0}.</value>
<comment>{0} will be replaced by bot's level</comment>
</data>
<data name="ActivelyMatchingItems" xml:space="preserve">
<value>Mencocokkan item dari Steam, fase #{0}...</value>
<comment>{0} will be replaced by round number</comment>
</data>
<data name="DoneActivelyMatchingItems" xml:space="preserve">
<value>Selesai mencocokkan item dari Steam, fase #{0}.</value>
<comment>{0} will be replaced by round number</comment>
</data>
<data name="ErrorAborted" xml:space="preserve">
<value>Dibatalkan!</value>
</data>
<data name="WarningExcessiveBotsCount" xml:space="preserve">
<value>Anda menjalankan lebih banyak akun bot pribadi dari batas maksimum yang kami rekomendasikan ({0}). Perlu diketahui bahwa pengaturan ini tidak didukung dan mungkin menyebabkan berbagai masalah terkait Steam, termasuk penangguhan akun. Lihat FAQ untuk lebih jelasnya.</value>
<comment>{0} will be replaced by our maximum recommended bots count (number)</comment>
</data>
<data name="PluginLoaded" xml:space="preserve">
<value>{0} telah berhasil memuat!</value>
<comment>{0} will be replaced by the name of the custom ASF plugin</comment>
@@ -595,7 +620,9 @@
<data name="NothingFound" xml:space="preserve">
<value>Tidak ditemukan!</value>
</data>
<data name="PluginsWarning" xml:space="preserve">
<value>Anda telah memuat satu atau beberapa plugin khusus ke ASF. Karena kami tidak dapat menawarkan dukungan untuk pengaturan yang dimodifikasi, harap hubungi pengembang plugin yang sesuai yang Anda putuskan untuk digunakan jika terjadi masalah.</value>
</data>
<data name="PleaseWait" xml:space="preserve">
<value>Harap tunggu...</value>
</data>
@@ -605,20 +632,45 @@
<data name="Executing" xml:space="preserve">
<value>Mengeksekusi...</value>
</data>
<data name="InteractiveConsoleEnabled" xml:space="preserve">
<value>Konsol interaktif sekarang aktif, ketik 'c' untuk masuk ke mode perintah.</value>
</data>
<data name="BotGamesToRedeemInBackgroundCount" xml:space="preserve">
<value>Bot mempunyai {0} game tersisa di dalam antrian.</value>
<comment>{0} will be replaced by remaining number of games in BGR's queue</comment>
</data>
<data name="ErrorSingleInstanceRequired" xml:space="preserve">
<value>Proses ASF sudah berjalan untuk direktori kerja ini, dibatalkan!</value>
</data>
<data name="BotHandledConfirmations" xml:space="preserve">
<value>Berhasil menangani {0} konfirmasi!</value>
<comment>{0} will be replaced by number of confirmations</comment>
</data>
<data name="BotExtraIdlingCooldown" xml:space="preserve">
<value>Menunggu hingga {0} untuk memastikan bahwa kita bisa memulai farming...</value>
<comment>{0} will be replaced by translated TimeSpan string (such as "1 minute")</comment>
</data>
<data name="UpdateCleanup" xml:space="preserve">
<value>Membersihkan file lama setelah pembaruan...</value>
</data>
<data name="BotGeneratingSteamParentalCode" xml:space="preserve">
<value>Seadng membuat Steam parental code, ini bisa memakan waktu cukup lama, pertimbangkan untuk memasukkannya ke dalam konfigurasi...</value>
</data>
<data name="IPCConfigChanged" xml:space="preserve">
<value>Konfigurasi IPC telah diubah!</value>
</data>
<data name="BotTradeOfferResult" xml:space="preserve">
<value>Penawaran trade {0} ditentukan menjadi {1} karena {2}.</value>
<comment>{0} will be replaced by trade offer ID (number), {1} will be replaced by internal ASF enum name, {2} will be replaced by technical reason why the trade was determined to be in this state</comment>
</data>
<data name="BotInvalidPasswordDuringLogin" xml:space="preserve">
<value>Menerima kode kesalahan InvalidPassword {0} kali berturut-turut. Kata sandi Anda untuk akun ini kemungkinan besar salah, batalkan!</value>
<comment>{0} will be replaced by maximum allowed number of failed login attempts</comment>
</data>
<data name="Result" xml:space="preserve">
<value>Hasil: {0}</value>
<comment>{0} will be replaced by generic result of various functions that use this string</comment>
</data>

View File

@@ -89,7 +89,10 @@ Yığın Kaydı:
{2}</value>
<comment>{0} will be replaced by function name, {1} will be replaced by exception message, {2} will be replaced by entire stack trace. Please note that this string should include newlines for formatting.</comment>
</data>
<data name="ErrorExitingWithNonZeroErrorCode" xml:space="preserve">
<value>{0} hata koduyla çıkılıyor!</value>
<comment>{0} will be replaced by error code (number)</comment>
</data>
<data name="ErrorFailingRequest" xml:space="preserve">
<value>İstek başarısız: {0}</value>
<comment>{0} will be replaced by URL of the request</comment>
@@ -705,7 +708,10 @@ Süreç çalışma zamanı: {1}</value>
<value>Şifreleme anahtarınız çok kısa. En az {0} bayt (karakter) uzunluğunda bir tane kullanmanızı öneririz.</value>
<comment>{0} will be replaced by the number of bytes (characters) recommended</comment>
</data>
<data name="WarningDefaultCryptKeyUsedForHashing" xml:space="preserve">
<value>{1} özelliğinin {0} ayarını kullanıyorsunuz, ancak özel bir --cryptkey sağlamadınız. Artırılmış güvenlik için özel bir --cryptkey sağlayabilirsiniz.</value>
<comment>{0} will be replaced by the name of a particular setting (e.g. "SCrypt"), {1} will be replaced by the name of the property (e.g. "IPCPassword")</comment>
</data>
<data name="WarningDefaultCryptKeyUsedForEncryption" xml:space="preserve">
<value>{1} özelliğinin {0} ayarını kullanıyorsunuz, ancak özel bir --cryptkey sağlamadınız. Bu, ASF'nin kendi (bilinen) anahtarını kullanmak zorunda kalması nedeniyle korumayı tamamen ortadan kaldırır. Bu ayarın sunduğu güvenlik avantajından yararlanmak için özel bir --cryptkey sağlamalısınız.</value>
<comment>{0} will be replaced by the name of a particular setting (e.g. "AES"), {1} will be replaced by the name of the property (e.g. "SteamPassword")</comment>
@@ -751,6 +757,11 @@ Süreç çalışma zamanı: {1}</value>
<value>Görünüşe göre bir {0} eklentisini çalıştırmaya çalışıyorsunuz, ancak ASF sürümüyle uyuşmuyor: {1} (beklenen {2}). Bu, ya kurulumunuzda ciddi bir hata yaptığınızı gösterir, ya da gerçekten ne yaptığınızı biliyorsanız --ignore-unsupported-environment argümanını kullanarak sorunu çözün.</value>
<comment>{0} will be replaced by plugin name, {1} will be replaced by plugin's version number, {2} will be replaced by ASF's version number.</comment>
</data>
<data name="ErrorTooManyCrashes" xml:space="preserve">
<value>ASF'niz yakın zamanda çok fazla kilitlendi ve bu nedenle süreç başlatma devre dışı bırakıldı. Ya araştırın, kurulumunuzu düzeltin, ardından ASF.crash dosyasını config dizininizden kaldırın ya da ne yaptığınızı gerçekten biliyorsanız --ignore-unsupported-environment argümanını sağlayın.</value>
</data>
<data name="IdlingGameNotPossiblePrivate" xml:space="preserve">
<value>{0} ({1}) kart düşürmesi, oyun şu anda özel olarak işaretlendiğinden devre dışı bırakıldı. ASF'den bu oyunu kart düşürmesini düşünüyorsanız gizlilik ayarlarını değiştirmeyi düşünün.</value>
<comment>{0} will be replaced by game's ID (number), {1} will be replaced by game's name</comment>
</data>
</root>

View File

@@ -89,7 +89,10 @@
{2}</value>
<comment>{0} will be replaced by function name, {1} will be replaced by exception message, {2} will be replaced by entire stack trace. Please note that this string should include newlines for formatting.</comment>
</data>
<data name="ErrorExitingWithNonZeroErrorCode" xml:space="preserve">
<value>Завершення роботи з кодом помилки {0}!</value>
<comment>{0} will be replaced by error code (number)</comment>
</data>
<data name="ErrorFailingRequest" xml:space="preserve">
<value>Помилка запиту до: {0}</value>
<comment>{0} will be replaced by URL of the request</comment>

View File

@@ -708,7 +708,10 @@ Thời gian hoạt động: {1}</value>
<value>Khóa mã hóa của bạn quá ngắn. Chúng tôi đề xuất dùng một cái dài ít nhất {0} byte (kí tự).</value>
<comment>{0} will be replaced by the number of bytes (characters) recommended</comment>
</data>
<data name="WarningDefaultCryptKeyUsedForHashing" xml:space="preserve">
<value>Bạn đang sử dụng cài đặt {0} của thuộc tính {1}, nhưng bạn đã không cung cấp một --cryptkey tùy chỉnh. Bạn nên cân nhắc cung cấp một --cryptkey tùy chỉnh để tăng tính bảo mật.</value>
<comment>{0} will be replaced by the name of a particular setting (e.g. "SCrypt"), {1} will be replaced by the name of the property (e.g. "IPCPassword")</comment>
</data>
<data name="WarningDefaultCryptKeyUsedForEncryption" xml:space="preserve">
<value>Bạn đang sử dụng cài đặt {0} của thuộc tính {1}, nhưng bạn đã không cung cấp một --cryptkey tùy chỉnh. Điều này hoàn toàn tự hủy sự bảo vệ, vì ASF buộc phải sử dụng khóa (đã biết) của nó. Bạn nên cung cấp một --cryptkey tùy chỉnh để nhận được lợi ích bảo mật được cung cấp bởi cài đặt này.</value>
<comment>{0} will be replaced by the name of a particular setting (e.g. "AES"), {1} will be replaced by the name of the property (e.g. "SteamPassword")</comment>
@@ -750,7 +753,15 @@ Thời gian hoạt động: {1}</value>
<value>ASF không thể chạy ứng dụng {0} do có hạn chế liên quan đến khu vực đối với quốc gia {1} kéo dài đến {2}.</value>
<comment>{0} will be replaced by app ID (number), {1} will be replaced by short country code (string, such as "PL"), {2} will be replaced by human-readable date (string).</comment>
</data>
<data name="WarningUnsupportedOfficialPlugins" xml:space="preserve">
<value>Bạn đang cố gắng chạy trình cắm {0} trong một phiên bản ASF không khớp: {1} (lẽ ra là {2}). Việc này nghĩa là bạn đang làm sai trầm trọng điều gì đó, vui lòng sửa thiết lập của bạn hoặc thêm --ignore-unsupported-environment nếu bạn thực sự biết mình đang làm gì.</value>
<comment>{0} will be replaced by plugin name, {1} will be replaced by plugin's version number, {2} will be replaced by ASF's version number.</comment>
</data>
<data name="ErrorTooManyCrashes" xml:space="preserve">
<value>ASF của bạn đã bị văng rất nhiều lần gần đây, vì vậy quá trình khởi tạo đã bị vô hiệu hoá. Hoặc kiểm tra lại, sửa thiết lập của bạn, sau đó xoá tệp ASF.crash khỏi thư mục cấu hình, hoặc thêm --ignore-unsupported-environment nếu bạn thực sự biết mình đang làm gì.</value>
</data>
<data name="IdlingGameNotPossiblePrivate" xml:space="preserve">
<value>Việc cày {0} ({1}) đã bị vô hiệu hoá, vì trò chơi đó được đánh dấu là riêng tư. Nếu bạn muốn ASF cày trò chơi này, vui lòng thay đổi cài đặt quyền riêng tư của nó.</value>
<comment>{0} will be replaced by game's ID (number), {1} will be replaced by game's name</comment>
</data>
</root>

View File

@@ -441,9 +441,9 @@ internal static class Logging {
if (!Debugging.IsUserDebugging) {
// Silence default ASP.NET logging
config.LoggingRules.Add(new LoggingRule("Microsoft*", target) { FinalMinLevel = LogLevel.Warn });
config.LoggingRules.Add(new LoggingRule("Microsoft.Hosting.Lifetime*", target) { FinalMinLevel = LogLevel.Info });
config.LoggingRules.Add(new LoggingRule("System*", target) { FinalMinLevel = LogLevel.Warn });
config.LoggingRules.Add(new LoggingRule("Microsoft.*", target) { FinalMinLevel = LogLevel.Warn });
config.LoggingRules.Add(new LoggingRule("Microsoft.Hosting.Lifetime", target) { FinalMinLevel = LogLevel.Info });
config.LoggingRules.Add(new LoggingRule("System.*", target) { FinalMinLevel = LogLevel.Warn });
}
config.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, target));

View File

@@ -1189,7 +1189,7 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
return (optimisticDiscovery ? appID : 0, DateTime.MinValue, true);
}
SteamApps.PICSRequest request = new(appID, tokenCallback.AppTokens.TryGetValue(appID, out ulong accessToken) ? accessToken : 0);
SteamApps.PICSRequest request = new(appID, tokenCallback.AppTokens.GetValueOrDefault(appID));
AsyncJobMultiple<SteamApps.PICSProductInfoCallback>.ResultSet? productInfoResultSet = null;
@@ -1970,37 +1970,10 @@ public sealed class Bot : IAsyncDisposable, IDisposable {
throw new ArgumentNullException(nameof(gamesToRedeemInBackground));
}
HashSet<object> invalidKeys = [];
HashSet<object> invalidKeys = gamesToRedeemInBackground.Cast<DictionaryEntry>().Where(static game => !BotDatabase.IsValidGameToRedeemInBackground(game)).Select(static game => game.Key).ToHashSet();
foreach (DictionaryEntry game in gamesToRedeemInBackground) {
bool invalid = false;
string? key = game.Key as string;
if (string.IsNullOrEmpty(key)) {
invalid = true;
ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(key)));
} else if (!Utilities.IsValidCdKey(key)) {
invalid = true;
ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, key));
}
string? name = game.Value as string;
if (string.IsNullOrEmpty(name)) {
invalid = true;
ASF.ArchiLogger.LogGenericWarning(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, nameof(name)));
}
if (invalid && (key != null)) {
invalidKeys.Add(key);
}
}
if (invalidKeys.Count > 0) {
foreach (string invalidKey in invalidKeys) {
gamesToRedeemInBackground.Remove(invalidKey);
}
foreach (object invalidKey in invalidKeys) {
gamesToRedeemInBackground.Remove(invalidKey);
}
return gamesToRedeemInBackground;

View File

@@ -82,14 +82,14 @@ public sealed class Trading : IDisposable {
foreach (Asset item in itemsToGive) {
(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity);
itemsToGiveAmounts[key] = itemsToGiveAmounts.TryGetValue(key, out uint amount) ? amount + item.Amount : item.Amount;
itemsToGiveAmounts[key] = itemsToGiveAmounts.GetValueOrDefault(key) + item.Amount;
}
Dictionary<(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity), uint> itemsToReceiveAmounts = new();
foreach (Asset item in itemsToReceive) {
(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity);
itemsToReceiveAmounts[key] = itemsToReceiveAmounts.TryGetValue(key, out uint amount) ? amount + item.Amount : item.Amount;
itemsToReceiveAmounts[key] = itemsToReceiveAmounts.GetValueOrDefault(key) + item.Amount;
}
// Ensure that amount of items to give is at least amount of items to receive (per all fairness factors)
@@ -210,7 +210,7 @@ public sealed class Trading : IDisposable {
(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity);
if (fullState.TryGetValue(key, out Dictionary<ulong, uint>? fullSet)) {
fullSet[item.ClassID] = fullSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount;
fullSet[item.ClassID] = fullSet.GetValueOrDefault(item.ClassID) + item.Amount;
} else {
fullState[key] = new Dictionary<ulong, uint> { { item.ClassID, item.Amount } };
}
@@ -220,7 +220,7 @@ public sealed class Trading : IDisposable {
}
if (tradableState.TryGetValue(key, out Dictionary<ulong, uint>? tradableSet)) {
tradableSet[item.ClassID] = tradableSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount;
tradableSet[item.ClassID] = tradableSet.GetValueOrDefault(item.ClassID) + item.Amount;
} else {
tradableState[key] = new Dictionary<ulong, uint> { { item.ClassID, item.Amount } };
}
@@ -240,7 +240,7 @@ public sealed class Trading : IDisposable {
(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity);
if (tradableState.TryGetValue(key, out Dictionary<ulong, uint>? tradableSet)) {
tradableSet[item.ClassID] = tradableSet.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount;
tradableSet[item.ClassID] = tradableSet.GetValueOrDefault(item.ClassID) + item.Amount;
} else {
tradableState[key] = new Dictionary<ulong, uint> { { item.ClassID, item.Amount } };
}
@@ -391,7 +391,7 @@ public sealed class Trading : IDisposable {
(uint RealAppID, Asset.EType Type, Asset.ERarity Rarity) key = (item.RealAppID, item.Type, item.Rarity);
if (state.TryGetValue(key, out Dictionary<ulong, uint>? set)) {
set[item.ClassID] = set.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount;
set[item.ClassID] = set.GetValueOrDefault(item.ClassID) + item.Amount;
} else {
state[key] = new Dictionary<ulong, uint> { { item.ClassID, item.Amount } };
}

View File

@@ -78,6 +78,30 @@ public sealed class Actions : IAsyncDisposable, IDisposable {
}
}
[PublicAPI]
public async Task<(EResult Result, IReadOnlyCollection<uint>? GrantedApps, IReadOnlyCollection<uint>? GrantedPackages)> AddFreeLicenseApp(uint appID) {
ArgumentOutOfRangeException.ThrowIfZero(appID);
SteamApps.FreeLicenseCallback callback;
try {
callback = await Bot.SteamApps.RequestFreeLicense(appID).ToLongRunningTask().ConfigureAwait(false);
} catch (Exception e) {
Bot.ArchiLogger.LogGenericWarningException(e);
return (EResult.Timeout, null, null);
}
return (callback.Result, callback.GrantedApps, callback.GrantedPackages);
}
[PublicAPI]
public async Task<(EResult Result, EPurchaseResultDetail PurchaseResultDetail)> AddFreeLicensePackage(uint subID) {
ArgumentOutOfRangeException.ThrowIfZero(subID);
return await Bot.ArchiWebHandler.AddFreeLicense(subID).ConfigureAwait(false);
}
[PublicAPI]
public static string? Encrypt(ArchiCryptoHelper.ECryptoMethod cryptoMethod, string stringToEncrypt) {
if (!Enum.IsDefined(cryptoMethod)) {

View File

@@ -623,7 +623,7 @@ public sealed class Commands {
}
switch (type.ToUpperInvariant()) {
case "A" or "APP":
case "A" or "APP": {
HashSet<uint>? packageIDs = ASF.GlobalDatabase?.GetPackageIDs(gameID, Bot.OwnedPackageIDs.Keys, 1);
if (packageIDs is { Count: > 0 }) {
@@ -632,32 +632,34 @@ public sealed class Commands {
break;
}
SteamApps.FreeLicenseCallback callback;
(EResult result, IReadOnlyCollection<uint>? grantedApps, IReadOnlyCollection<uint>? grantedPackages) = await Bot.Actions.AddFreeLicenseApp(gameID).ConfigureAwait(false);
try {
callback = await Bot.SteamApps.RequestFreeLicense(gameID).ToLongRunningTask().ConfigureAwait(false);
} catch (Exception e) {
Bot.ArchiLogger.LogGenericWarningException(e);
response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"app/{gameID}", EResult.Timeout)));
if (((grantedApps == null) || (grantedApps.Count == 0)) && ((grantedPackages == null) || (grantedPackages.Count == 0))) {
response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"app/{gameID}", result)));
break;
}
response.AppendLine(FormatBotResponse((callback.GrantedApps.Count > 0) || (callback.GrantedPackages.Count > 0) ? string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicenseWithItems, $"app/{gameID}", callback.Result, string.Join(", ", callback.GrantedApps.Select(static appID => $"app/{appID}").Union(callback.GrantedPackages.Select(static subID => $"sub/{subID}")))) : string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"app/{gameID}", callback.Result)));
grantedApps ??= Array.Empty<uint>();
grantedPackages ??= Array.Empty<uint>();
response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicenseWithItems, $"app/{gameID}", result, string.Join(", ", grantedApps.Select(static appID => $"app/{appID}").Union(grantedPackages.Select(static subID => $"sub/{subID}"))))));
break;
default:
}
default: {
if (Bot.OwnedPackageIDs.ContainsKey(gameID)) {
response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"sub/{gameID}", $"{EResult.Fail}/{EPurchaseResultDetail.AlreadyPurchased}")));
break;
}
(EResult result, EPurchaseResultDetail purchaseResult) = await Bot.ArchiWebHandler.AddFreeLicense(gameID).ConfigureAwait(false);
(EResult result, EPurchaseResultDetail purchaseResult) = await Bot.Actions.AddFreeLicensePackage(gameID).ConfigureAwait(false);
response.AppendLine(FormatBotResponse(string.Format(CultureInfo.CurrentCulture, Strings.BotAddLicense, $"sub/{gameID}", $"{result}/{purchaseResult}")));
break;
}
}
}
@@ -1904,14 +1906,12 @@ public sealed class Commands {
return null;
}
Dictionary<string, (ushort Count, string GameName)> ownedGamesStats = new(StringComparer.Ordinal);
Dictionary<string, (ushort Count, string? GameName)> ownedGamesStats = new(StringComparer.Ordinal);
foreach ((string gameID, string gameName) in validResults.Where(static validResult => validResult.OwnedGames.Count > 0).SelectMany(static validResult => validResult.OwnedGames)) {
if (ownedGamesStats.TryGetValue(gameID, out (ushort Count, string GameName) ownedGameStats)) {
ownedGameStats.Count++;
} else {
ownedGameStats.Count = 1;
}
(ushort Count, string? GameName) ownedGameStats = ownedGamesStats.GetValueOrDefault(gameID);
ownedGameStats.Count++;
if (!string.IsNullOrEmpty(gameName)) {
ownedGameStats.GameName = gameName;

View File

@@ -242,18 +242,17 @@ public sealed class BotDatabase : GenericDatabase {
throw new ArgumentNullException(nameof(games));
}
bool save = false;
lock (GamesToRedeemInBackground) {
foreach (DictionaryEntry game in games.OfType<DictionaryEntry>().Where(game => !GamesToRedeemInBackground.Contains(game.Key))) {
GamesToRedeemInBackground.Add(game.Key, game.Value);
save = true;
foreach (DictionaryEntry game in games) {
if (!IsValidGameToRedeemInBackground(game)) {
throw new InvalidOperationException(nameof(game));
}
GamesToRedeemInBackground[game.Key] = game.Value;
}
}
if (save) {
Utilities.InBackground(Save);
}
Utilities.InBackground(Save);
}
internal static async Task<BotDatabase?> CreateOrLoad(string filePath) {
@@ -287,6 +286,16 @@ public sealed class BotDatabase : GenericDatabase {
return null;
}
(bool valid, string? errorMessage) = botDatabase.CheckValidation();
if (!valid) {
if (!string.IsNullOrEmpty(errorMessage)) {
ASF.ArchiLogger.LogGenericError(errorMessage);
}
return null;
}
botDatabase.FilePath = filePath;
return botDatabase;
@@ -295,13 +304,35 @@ public sealed class BotDatabase : GenericDatabase {
internal (string? Key, string? Name) GetGameToRedeemInBackground() {
lock (GamesToRedeemInBackground) {
foreach (DictionaryEntry game in GamesToRedeemInBackground) {
return (game.Key as string, game.Value as string);
return game.Value switch {
string name => (game.Key as string, name),
JsonElement { ValueKind: JsonValueKind.String } jsonElement => (game.Key as string, jsonElement.GetString()),
_ => throw new InvalidOperationException(nameof(game.Value))
};
}
}
return (null, null);
}
internal static bool IsValidGameToRedeemInBackground(DictionaryEntry game) {
ArgumentNullException.ThrowIfNull(game);
string? key = game.Key as string;
if (string.IsNullOrEmpty(key) || !Utilities.IsValidCdKey(key)) {
return false;
}
switch (game.Value) {
case string name when !string.IsNullOrEmpty(name):
case JsonElement { ValueKind: JsonValueKind.String } jsonElement when !string.IsNullOrEmpty(jsonElement.GetString()):
return true;
default:
return false;
}
}
internal void PerformMaintenance() {
DateTime now = DateTime.UtcNow;
@@ -324,6 +355,8 @@ public sealed class BotDatabase : GenericDatabase {
Utilities.InBackground(Save);
}
private (bool Valid, string? ErrorMessage) CheckValidation() => GamesToRedeemInBackground.Cast<DictionaryEntry>().Any(static game => !IsValidGameToRedeemInBackground(game)) ? (false, string.Format(CultureInfo.CurrentCulture, Strings.ErrorConfigPropertyInvalid, nameof(GamesToRedeemInBackground), string.Join("", GamesToRedeemInBackground))) : (true, null);
private async void OnObjectModified(object? sender, EventArgs e) {
if (string.IsNullOrEmpty(FilePath)) {
return;

View File

@@ -22,6 +22,7 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -707,6 +708,7 @@ public sealed class WebBrowser : IDisposable {
return await InternalRequest(request, HttpMethod.Post, headers, data, referer, requestOptions, httpCompletionOption, cancellationToken: cancellationToken).ConfigureAwait(false);
}
[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
private async Task<HttpResponseMessage?> InternalRequest<T>(Uri request, HttpMethod httpMethod, IReadOnlyCollection<KeyValuePair<string, string>>? headers = null, T? data = null, Uri? referer = null, ERequestOptions requestOptions = ERequestOptions.None, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, byte maxRedirections = MaxTries, CancellationToken cancellationToken = default) where T : class {
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(httpMethod);

View File

@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>6.0.0.1</Version>
<Version>6.0.1.0</Version>
</PropertyGroup>
<PropertyGroup>

View File

@@ -6,7 +6,7 @@
<PackageVersion Include="Humanizer" Version="2.14.1" />
<PackageVersion Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageVersion Include="Markdig.Signed" Version="0.35.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.3.1" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.4.0" />
<PackageVersion Include="MSTest" Version="3.2.2" />
<PackageVersion Include="Nito.AsyncEx.Coordination" Version="5.1.2" />
<PackageVersion Include="NLog.Web.AspNetCore" Version="5.3.8" />

2
wiki

Submodule wiki updated: fe15ec23f0...77caeccbb6