-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFunction.cs
More file actions
254 lines (215 loc) · 8.91 KB
/
Copy pathFunction.cs
File metadata and controls
254 lines (215 loc) · 8.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
using Google.Cloud.Functions.Framework;
using Google.Cloud.Functions.Hosting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace SendHttpRequest;
public class Startup : FunctionsStartup
{
public override void ConfigureServices(WebHostBuilderContext context, IServiceCollection services)
{
services.AddHttpClient<IHttpFunction, Function>();
}
}
[FunctionsStartup(typeof(Startup))]
public class Function : IHttpFunction
{
private readonly HttpClient _httpClient;
private readonly ILogger<Function> _logger;
// ── 設定 ──
private const string AllowedOrigin = "https://marsantony.github.io";
private static readonly int PerIpPerMinuteLimit = int.TryParse(Environment.GetEnvironmentVariable("PER_IP_PER_MINUTE_LIMIT"), out var v1) ? v1 : 10;
private static readonly int GlobalDailyLimit = int.TryParse(Environment.GetEnvironmentVariable("GLOBAL_DAILY_LIMIT"), out var v2) ? v2 : 500;
private static readonly int DuplicateWindowSeconds = int.TryParse(Environment.GetEnvironmentVariable("DUPLICATE_WINDOW_SECONDS"), out var v3) ? v3 : 30;
private static readonly Regex SteamIdPattern = new(@"^\d{17}$", RegexOptions.Compiled);
// ── gameId → gameName 快取(in-memory,max instance = 1 所以安全)──
private static readonly ConcurrentDictionary<string, string> _gameNameCache = new();
// ── 速率限制(in-memory)──
private static readonly ConcurrentDictionary<string, List<DateTime>> _ipRequestTimestamps = new();
private static int _globalDailyCount = 0;
private static DateTime _globalDailyReset = DateTime.UtcNow.Date.AddDays(1);
// ── 重複請求限制(per steamid)──
private static readonly ConcurrentDictionary<string, (DateTime time, string response)> _duplicateCache = new();
public Function(HttpClient httpClient, ILogger<Function> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task HandleAsync(HttpContext context)
{
var origin = context.Request.Headers["Origin"].FirstOrDefault() ?? "";
// Origin 不符就直接拒絕(擋掉其他網站 + 沒帶 Origin 的工具)
if (origin != AllowedOrigin)
{
context.Response.StatusCode = 403;
return;
}
context.Response.Headers["Access-Control-Allow-Origin"] = AllowedOrigin;
// 處理 preflight
if (context.Request.Method == "OPTIONS")
{
context.Response.Headers["Access-Control-Allow-Methods"] = "GET";
context.Response.Headers["Access-Control-Max-Age"] = "3600";
context.Response.StatusCode = 204;
return;
}
context.Response.ContentType = "application/json";
// ── steamid 參數驗證 ──
var steamId = context.Request.Query["steamid"].FirstOrDefault() ?? "";
if (!SteamIdPattern.IsMatch(steamId))
{
context.Response.StatusCode = 400;
await context.Response.WriteAsync(
JsonConvert.SerializeObject(new { error = "缺少有效的 steamid 參數" }));
return;
}
// ── 速率限制檢查 ──
var rateLimitResult = CheckRateLimit(context);
if (rateLimitResult != null)
{
context.Response.StatusCode = 429;
await context.Response.WriteAsync(
JsonConvert.SerializeObject(new { error = rateLimitResult }));
return;
}
// ── 重複請求檢查:同一個 steamid N 秒內回傳快取結果 ──
if (_duplicateCache.TryGetValue(steamId, out var cached) &&
(DateTime.UtcNow - cached.time).TotalSeconds < DuplicateWindowSeconds)
{
await context.Response.WriteAsync(cached.response);
return;
}
// ── 主邏輯 ──
var result = new Dictionary<string, string> { ["GameName"] = "" };
try
{
var steamApiKey = Environment.GetEnvironmentVariable("STEAM_API_KEY") ?? "";
var url = $"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={steamApiKey}&format=json&steamids={steamId}";
var jObjGameId = await GetUrlResponse(url);
var tokenGameId = jObjGameId.SelectToken("response.players[0].gameid");
if (tokenGameId == null)
{
await WriteResultAsync(context, steamId, result);
return;
}
var gameId = tokenGameId.Value<string>()!;
// gameId → gameName 快取:同一個 gameId 不重複查
if (_gameNameCache.TryGetValue(gameId, out var cachedName))
{
result["GameName"] = cachedName;
}
else
{
url = $"https://store.steampowered.com/api/appdetails?appids={gameId}&l=zh-tw";
var jObjGameName = await GetUrlResponse(url);
var tokenGameName = jObjGameName.SelectToken($"{gameId}.data.name");
var gameName = tokenGameName?.Value<string>();
if (!string.IsNullOrEmpty(gameName))
{
result["GameName"] = gameName;
_gameNameCache[gameId] = gameName;
}
}
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Steam API 請求失敗 (steamId: {SteamId})", steamId);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Steam API 回應 JSON 解析失敗 (steamId: {SteamId})", steamId);
}
await WriteResultAsync(context, steamId, result);
}
private static async Task WriteResultAsync(HttpContext context, string steamId, Dictionary<string, string> result)
{
var responseJson = JsonConvert.SerializeObject(result);
UpdateDuplicateCache(steamId, responseJson);
await context.Response.WriteAsync(responseJson);
}
private string? CheckRateLimit(HttpContext context)
{
var now = DateTime.UtcNow;
// 每日全域限制重置
if (now >= _globalDailyReset)
{
_globalDailyCount = 0;
_globalDailyReset = now.Date.AddDays(1);
_ipRequestTimestamps.Clear();
CleanupDuplicateCache();
}
// 全域每日限制
if (_globalDailyCount >= GlobalDailyLimit)
{
return "已達每日請求上限";
}
_globalDailyCount++;
// 每 IP 每分鐘限制(Cloud Run 環境使用 X-Forwarded-For 取得真實 IP)
var ip = context.Request.Headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? context.Connection.RemoteIpAddress?.ToString()
?? "unknown";
var timestamps = _ipRequestTimestamps.GetOrAdd(ip, _ => new List<DateTime>());
lock (timestamps)
{
var oneMinuteAgo = now.AddMinutes(-1);
timestamps.RemoveAll(t => t < oneMinuteAgo);
if (timestamps.Count >= PerIpPerMinuteLimit)
{
return "請求過於頻繁,請稍後再試";
}
timestamps.Add(now);
}
return null;
}
private static void CleanupDuplicateCache()
{
var now = DateTime.UtcNow;
var expiredKeys = _duplicateCache
.Where(kv => (now - kv.Value.time).TotalSeconds >= DuplicateWindowSeconds)
.Select(kv => kv.Key)
.ToList();
foreach (var key in expiredKeys)
{
_duplicateCache.TryRemove(key, out _);
}
}
internal static void ResetDuplicateCache()
{
_duplicateCache.Clear();
}
internal static void ResetState()
{
_gameNameCache.Clear();
_ipRequestTimestamps.Clear();
_globalDailyCount = 0;
_globalDailyReset = DateTime.UtcNow.Date.AddDays(1);
_duplicateCache.Clear();
}
internal static void SetDailyResetForTesting(DateTime resetTime)
{
_globalDailyReset = resetTime;
}
private static void UpdateDuplicateCache(string steamId, string response)
{
_duplicateCache[steamId] = (DateTime.UtcNow, response);
}
private async Task<JObject> GetUrlResponse(string url)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Accept-Language", "zh-TW");
using var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JObject.Parse(content);
}
}