Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 239 additions & 1 deletion src/AgileConfig.Server.Apisite/Controllers/AppController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using AgileConfig.Server.Event;
using AgileConfig.Server.IService;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;

Expand Down Expand Up @@ -382,6 +383,243 @@ public async Task<IActionResult> Export([FromBody] AppExportRequest model)
return File(Encoding.UTF8.GetBytes(json), "application/json", fileName);
}

[TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })]
[HttpPost]
public async Task<IActionResult> PreviewImport(IFormFile file)
{
var importFile = await ReadImportFileAsync(file);
var preview = await BuildImportPreviewAsync(importFile);
return Json(new
{
success = !preview.Errors.Any(),
data = preview,
message = preview.Errors.FirstOrDefault()
});
}

[TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })]
[HttpPost]
public async Task<IActionResult> Import([FromBody] AppImportRequest model)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentNullException.ThrowIfNull(model.File);

var preview = await BuildImportPreviewAsync(model.File);
if (preview.Errors.Any())
return Json(new
{
success = false,
data = preview,
message = string.Join(Environment.NewLine, preview.Errors)
});

var currentUserId = await this.GetCurrentUserId(_userService);
var now = DateTime.Now;

foreach (var previewItem in preview.Apps.OrderBy(x => x.Order))
{
var importItem = model.File.Apps.First(x => string.Equals(x.App?.Id, previewItem.AppId, StringComparison.OrdinalIgnoreCase));
var app = new App
{
Id = importItem.App.Id,
Name = importItem.App.Name,
Group = importItem.App.Group,
Secret = importItem.App.Secret,
Enabled = importItem.App.Enabled,
Type = importItem.App.Inheritanced ? AppType.Inheritance : AppType.PRIVATE,
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import derives App.Type solely from Inheritanced, ignoring the exported type field included in the import schema (and likely in AppExportAppVM). This can change app types on import (e.g., any non-inheritance type becomes PRIVATE). Mandatory fix: map the exported Type value to App.Type (with validation/fallback if the value is unknown) instead of hardcoding it based on Inheritanced.

Copilot uses AI. Check for mistakes.
CreateTime = now,
Creator = currentUserId
};

var inheritanceApps = BuildInheritanceLinks(importItem.App.InheritancedApps, app.Id);
await _appService.AddAsync(app, inheritanceApps);
Comment on lines +434 to +435
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import endpoint always returns success = true even if _appService.AddAsync(...) or _configService.AddAsync(...) fails (these return bool). This can silently produce partial imports and inconsistent state. Mandatory fix: check return values and fail fast (return success=false with an error message), and consider wrapping the whole import in a transaction/unit-of-work so apps/configs are imported atomically.

Copilot uses AI. Check for mistakes.

foreach (var envConfigs in importItem.Envs)
{
foreach (var configVm in envConfigs.Value ?? new List<AppExportConfigVM>())
{
var config = new Config
{
Id = Guid.NewGuid().ToString("N"),
AppId = app.Id,
Env = envConfigs.Key,
Group = configVm.Group,
Key = configVm.Key,
Value = configVm.Value,
Description = configVm.Description,
CreateTime = now,
Status = ConfigStatus.Enabled,
OnlineStatus = OnlineStatus.WaitPublish,
EditStatus = EditStatus.Add
};
await _configService.AddAsync(config, envConfigs.Key);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import endpoint always returns success = true even if _appService.AddAsync(...) or _configService.AddAsync(...) fails (these return bool). This can silently produce partial imports and inconsistent state. Mandatory fix: check return values and fail fast (return success=false with an error message), and consider wrapping the whole import in a transaction/unit-of-work so apps/configs are imported atomically.

Copilot uses AI. Check for mistakes.
}
}
}

return Json(new
{
success = true,
data = preview
});
Comment on lines +460 to +464
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import endpoint always returns success = true even if _appService.AddAsync(...) or _configService.AddAsync(...) fails (these return bool). This can silently produce partial imports and inconsistent state. Mandatory fix: check return values and fail fast (return success=false with an error message), and consider wrapping the whole import in a transaction/unit-of-work so apps/configs are imported atomically.

Copilot uses AI. Check for mistakes.
}

private static List<AppInheritanced> BuildInheritanceLinks(List<string> parentIds, string appId)
{
var inheritanceApps = new List<AppInheritanced>();
if (parentIds == null) return inheritanceApps;

var sort = 0;
foreach (var parentId in parentIds.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))
inheritanceApps.Add(new AppInheritanced
{
Id = Guid.NewGuid().ToString("N"),
AppId = appId,
InheritancedAppId = parentId,
Sort = sort++
});

return inheritanceApps;
}

private async Task<AppExportFileVM> ReadImportFileAsync(IFormFile file)
{
if (file == null || file.Length == 0) throw new ArgumentException("file");

using var stream = file.OpenReadStream();
using var reader = new System.IO.StreamReader(stream, Encoding.UTF8);
var content = await reader.ReadToEndAsync();
var importFile = JsonConvert.DeserializeObject<AppExportFileVM>(content);
if (importFile == null) throw new ArgumentException("file");
Comment on lines +485 to +493
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReadImportFileAsync reads the entire uploaded file into memory with no upper bound and throws ArgumentException("file") on bad input, which will surface as 500s unless handled elsewhere. Mandatory fix: enforce a maximum upload size (reject large files early using file.Length and/or request limits) and return a structured 400-style JSON error for invalid/empty/invalid-JSON uploads instead of throwing generic exceptions.

Copilot uses AI. Check for mistakes.

return importFile;
}

private async Task<AppImportPreviewVM> BuildImportPreviewAsync(AppExportFileVM importFile)
{
var preview = new AppImportPreviewVM();
if (importFile?.Apps == null || !importFile.Apps.Any())
{
preview.Errors.Add("Import file does not contain any apps.");
return preview;
}

var appItems = importFile.Apps
.Where(x => x?.App != null)
.ToList();
if (!appItems.Any())
{
preview.Errors.Add("Import file does not contain any valid app entries.");
return preview;
}

var duplicateIds = appItems
.GroupBy(x => x.App.Id ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1)
.Select(x => x.Key)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
preview.Errors.AddRange(duplicateIds.Select(x => $"Duplicate AppId in import file: {x}."));

var duplicateNames = appItems
.GroupBy(x => x.App.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1)
.Select(x => x.Key)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
preview.Errors.AddRange(duplicateNames.Select(x => $"Duplicate app name in import file: {x}."));

foreach (var item in appItems)
{
if (string.IsNullOrWhiteSpace(item.App.Id)) preview.Errors.Add("Imported app is missing AppId.");
if (string.IsNullOrWhiteSpace(item.App.Name)) preview.Errors.Add("Imported app is missing Name.");
}

var importedAppIds = new HashSet<string>(appItems.Select(x => x.App.Id).Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
var existingApps = await _appService.GetAllAppsAsync();

foreach (var item in appItems.Where(x => x.App != null && !string.IsNullOrWhiteSpace(x.App.Id)))
{
if (existingApps.Any(x => string.Equals(x.Id, item.App.Id, StringComparison.OrdinalIgnoreCase)))
preview.Errors.Add($"AppId already exists: {item.App.Id}.");
if (!string.IsNullOrWhiteSpace(item.App.Name) && existingApps.Any(x => string.Equals(x.Name, item.App.Name, StringComparison.OrdinalIgnoreCase)))
preview.Errors.Add($"App name already exists: {item.App.Name}.");
}

foreach (var item in appItems)
{
foreach (var parentId in item.App.InheritancedApps?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase) ?? new List<string>())
{
if (importedAppIds.Contains(parentId)) continue;
if (existingApps.Any(x => string.Equals(x.Id, parentId, StringComparison.OrdinalIgnoreCase))) continue;
preview.Errors.Add($"App '{item.App.Id}' references missing parent '{parentId}'. Parent must already exist or be included in the import file.");
}
}

Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildImportPreviewAsync can add validation errors for missing/duplicate AppIds but still proceeds to TryTopologicalSort. ToDictionary(x => x.App.Id, ...) will throw when an AppId is null/whitespace or duplicated, turning a user error into a 500. Mandatory fix: short-circuit before topo sort when preview.Errors.Any(), and/or pass only items with non-empty unique AppIds into TryTopologicalSort so preview errors are returned as JSON instead of throwing.

Suggested change
if (preview.Errors.Any()) return preview;

Copilot uses AI. Check for mistakes.
var orderLookup = TryTopologicalSort(appItems, importedAppIds, preview.Errors);
if (preview.Errors.Any()) return preview;

preview.Apps = appItems
.OrderBy(x => orderLookup[x.App.Id])
.Select(x => new AppImportPreviewItemVM
{
AppId = x.App.Id,
Name = x.App.Name,
Group = x.App.Group,
Enabled = x.App.Enabled,
Inheritanced = x.App.Inheritanced,
InheritancedApps = x.App.InheritancedApps?.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? new List<string>(),
EnvCount = x.Envs?.Count ?? 0,
ConfigCount = x.Envs?.Sum(env => env.Value?.Count ?? 0) ?? 0,
Order = orderLookup[x.App.Id]
})
.ToList();

return preview;
}

private static Dictionary<string, int> TryTopologicalSort(List<AppExportItemVM> appItems, HashSet<string> importedAppIds, List<string> errors)
{
var dependencyMap = appItems.ToDictionary(
x => x.App.Id,
x => (x.App.InheritancedApps ?? new List<string>())
.Where(parentId => !string.IsNullOrWhiteSpace(parentId) && importedAppIds.Contains(parentId))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
StringComparer.OrdinalIgnoreCase);
Comment on lines +581 to +589
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildImportPreviewAsync can add validation errors for missing/duplicate AppIds but still proceeds to TryTopologicalSort. ToDictionary(x => x.App.Id, ...) will throw when an AppId is null/whitespace or duplicated, turning a user error into a 500. Mandatory fix: short-circuit before topo sort when preview.Errors.Any(), and/or pass only items with non-empty unique AppIds into TryTopologicalSort so preview errors are returned as JSON instead of throwing.

Copilot uses AI. Check for mistakes.

var inDegree = dependencyMap.ToDictionary(x => x.Key, _ => 0, StringComparer.OrdinalIgnoreCase);
var childMap = dependencyMap.Keys.ToDictionary(x => x, _ => new List<string>(), StringComparer.OrdinalIgnoreCase);

foreach (var entry in dependencyMap)
{
inDegree[entry.Key] = entry.Value.Count;
foreach (var parentId in entry.Value) childMap[parentId].Add(entry.Key);
}

var queue = new Queue<string>(inDegree.Where(x => x.Value == 0).Select(x => x.Key).OrderBy(x => x, StringComparer.OrdinalIgnoreCase));
var ordered = new List<string>();
while (queue.Any())
{
var next = queue.Dequeue();
ordered.Add(next);

foreach (var child in childMap[next].OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
inDegree[child]--;
if (inDegree[child] == 0) queue.Enqueue(child);
}
}

if (ordered.Count != dependencyMap.Count)
{
var cyclicApps = inDegree.Where(x => x.Value > 0).Select(x => x.Key).OrderBy(x => x, StringComparer.OrdinalIgnoreCase);
errors.Add($"Cyclic inheritance detected among imported apps: {string.Join(", ", cyclicApps)}.");
}

return ordered.Select((appId, index) => new { appId, index }).ToDictionary(x => x.appId, x => x.index + 1, StringComparer.OrdinalIgnoreCase);
}

/// <summary>
/// Get all applications that can be inherited.
/// </summary>
Expand Down Expand Up @@ -462,4 +700,4 @@ public async Task<IActionResult> GetAppGroups()
data = groups.OrderBy(x => x)
});
}
}
}
45 changes: 45 additions & 0 deletions src/AgileConfig.Server.Apisite/Models/AppExportVM.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public class AppExportRequest
public List<string> AppIds { get; set; }
}

public class AppImportRequest
{
[JsonProperty("file")]
public AppExportFileVM File { get; set; }
}

public class AppExportFileVM
{
[JsonProperty("schemaVersion")]
Expand Down Expand Up @@ -72,3 +78,42 @@ public class AppExportConfigVM
[JsonProperty("description")]
public string Description { get; set; }
}

public class AppImportPreviewVM
{
[JsonProperty("apps")]
public List<AppImportPreviewItemVM> Apps { get; set; } = new();

[JsonProperty("errors")]
public List<string> Errors { get; set; } = new();
}

public class AppImportPreviewItemVM
{
[JsonProperty("appId")]
public string AppId { get; set; }

[JsonProperty("name")]
public string Name { get; set; }

[JsonProperty("group")]
public string Group { get; set; }

[JsonProperty("enabled")]
public bool Enabled { get; set; }

[JsonProperty("inheritanced")]
public bool Inheritanced { get; set; }

[JsonProperty("inheritancedApps")]
public List<string> InheritancedApps { get; set; } = new();

[JsonProperty("envCount")]
public int EnvCount { get; set; }

[JsonProperty("configCount")]
public int ConfigCount { get; set; }

[JsonProperty("order")]
public int Order { get; set; }
}
16 changes: 16 additions & 0 deletions src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ export default {
'pages.app.table.cols.admin': 'Administrator',
'pages.app.table.cols.action.auth': 'Authorization',
'pages.app.table.group_aggregation': 'Group Aggregation',
'pages.app.import.button': 'Import',
'pages.app.import.title': 'Import Apps',
'pages.app.import.tip': 'Import only creates new apps and config records. Parent apps must already exist or be included in the same file.',
'pages.app.import.select_file': 'Select JSON File',
'pages.app.import.preview.success': 'Import preview generated successfully',
'pages.app.import.preview.failed': 'Import preview failed',
'pages.app.import.preview.order': 'Order',
'pages.app.import.preview.parents': 'Parents',
'pages.app.import.preview.envCount': 'Envs',
'pages.app.import.preview.configCount': 'Configs',
'pages.app.import.validation_failed': 'Import validation failed',
'pages.app.import.no_valid_preview': 'Resolve preview errors before importing',
'pages.app.import.importing': 'Importing apps...',
'pages.app.import.success': 'Apps imported successfully',
'pages.app.import.failed': 'App import failed',
'pages.app.import.empty': 'Upload a file to preview the import',

'pages.app.auth.title': 'User Authorization',
'pages.app.auth.bind_users': 'Bind Users',
Expand Down
16 changes: 16 additions & 0 deletions src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ export default {
'pages.app.table.cols.admin': '管理员',
'pages.app.table.cols.action.auth': '授权',
'pages.app.table.group_aggregation': '分组聚合',
'pages.app.import.button': '导入',
'pages.app.import.title': '导入应用',
'pages.app.import.tip': '导入仅创建新应用和新的配置记录。父应用必须已存在于目标系统中,或同时包含在导入文件内。',
'pages.app.import.select_file': '选择 JSON 文件',
'pages.app.import.preview.success': '导入预览生成成功',
'pages.app.import.preview.failed': '导入预览失败',
'pages.app.import.preview.order': '顺序',
'pages.app.import.preview.parents': '父应用',
'pages.app.import.preview.envCount': '环境数',
'pages.app.import.preview.configCount': '配置数',
'pages.app.import.validation_failed': '导入校验失败',
'pages.app.import.no_valid_preview': '请先解决预览中的错误再导入',
'pages.app.import.importing': '正在导入应用...',
'pages.app.import.success': '应用导入成功',
'pages.app.import.failed': '应用导入失败',
'pages.app.import.empty': '上传文件后可预览导入内容',

'pages.app.auth.title': '用户授权',
'pages.app.auth.bind_users': '绑定用户',
Expand Down
Loading
Loading