diff --git a/csharp/Platform.Bot/Program.cs b/csharp/Platform.Bot/Program.cs index 521a6b95..faa00225 100644 --- a/csharp/Platform.Bot/Program.cs +++ b/csharp/Platform.Bot/Program.cs @@ -95,7 +95,7 @@ private static async Task Main(string[] args) var dbContext = new FileStorage(databaseFilePath?.FullName ?? new TemporaryFile().Filename); Console.WriteLine($"Bot has been started. {Environment.NewLine}Press CTRL+C to close"); var githubStorage = new GitHubStorage(githubUserName, githubApiToken, githubApplicationName); - var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage)); + var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage), new AddUserLinkTrigger(githubStorage, dbContext), new RemoveUserLinkTrigger(githubStorage, dbContext), new ListUserLinksTrigger(githubStorage, dbContext)); var pullRequenstTracker = new PullRequestTracker(githubStorage, new MergeDependabotBumpsTrigger(githubStorage)); var timestampTracker = new DateTimeTracker(githubStorage, new CreateAndSaveOrganizationRepositoriesMigrationTrigger(githubStorage, dbContext, Path.Combine(Directory.GetCurrentDirectory(), "/github-migrations"))); var cancellation = new CancellationTokenSource(); diff --git a/csharp/Platform.Bot/Triggers/AddUserLinkTrigger.cs b/csharp/Platform.Bot/Triggers/AddUserLinkTrigger.cs new file mode 100644 index 00000000..c439e6a1 --- /dev/null +++ b/csharp/Platform.Bot/Triggers/AddUserLinkTrigger.cs @@ -0,0 +1,140 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Interfaces; +using Octokit; +using Storage.Local; +using Storage.Remote.GitHub; + +namespace Platform.Bot.Triggers +{ + using TContext = Issue; + + /// + /// + /// Represents the add user link trigger for handling user link addition commands. + /// + /// + /// + /// + internal class AddUserLinkTrigger : ITrigger + { + private readonly GitHubStorage _storage; + private readonly FileStorage _fileStorage; + private readonly Regex _commandPattern; + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// The GitHub storage. + /// + /// + /// + /// The file storage. + /// + /// + public AddUserLinkTrigger(GitHubStorage storage, FileStorage fileStorage) + { + _storage = storage; + _fileStorage = fileStorage; + _commandPattern = new Regex(@"^add link (\w+) (.+)$", RegexOptions.IgnoreCase); + } + + /// + /// + /// Determines whether this instance condition matches the context. + /// + /// + /// + /// + /// The issue context. + /// + /// + /// + /// True if the issue title matches the add link pattern. + /// + /// + public async Task Condition(TContext context) + { + return _commandPattern.IsMatch(context.Title.Trim()); + } + + /// + /// + /// Executes the add user link action. + /// + /// + /// + /// + /// The issue context. + /// + /// + public async Task Action(TContext context) + { + var match = _commandPattern.Match(context.Title.Trim()); + if (!match.Success) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, "❌ Invalid command format. Use: `add link `"); + _storage.CloseIssue(context); + return; + } + + var platform = match.Groups[1].Value; + var url = match.Groups[2].Value; + var username = context.User.Login; + + // Validate platform is supported + if (!DomainWhitelist.GetSupportedPlatforms().Any(p => string.Equals(p, platform, StringComparison.OrdinalIgnoreCase))) + { + var supportedPlatforms = string.Join(", ", DomainWhitelist.GetSupportedPlatforms()); + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ Platform '{platform}' is not supported. Supported platforms: {supportedPlatforms}"); + _storage.CloseIssue(context); + return; + } + + // Validate URL is allowed for the platform + if (!DomainWhitelist.IsUrlAllowed(platform, url)) + { + var allowedDomains = string.Join(", ", DomainWhitelist.GetAllowedDomains(platform)); + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ URL '{url}' is not allowed for platform '{platform}'. Allowed domains: {allowedDomains}"); + _storage.CloseIssue(context); + return; + } + + try + { + // Check if user already has a link for this platform + var existingLinks = _fileStorage.GetUserLinks(username); + var existingLink = existingLinks.Find(link => string.Equals(link.Platform, platform, StringComparison.OrdinalIgnoreCase)); + + if (existingLink != null) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ You already have a {platform} link: {existingLink.Url}. Remove it first if you want to change it."); + _storage.CloseIssue(context); + return; + } + + // Add the user link + _fileStorage.AddUserLink(username, platform, url); + + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"✅ Successfully added {platform} link for @{username}: {url}"); + } + catch (Exception ex) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ Error adding link: {ex.Message}"); + } + + _storage.CloseIssue(context); + } + } +} \ No newline at end of file diff --git a/csharp/Platform.Bot/Triggers/ListUserLinksTrigger.cs b/csharp/Platform.Bot/Triggers/ListUserLinksTrigger.cs new file mode 100644 index 00000000..17ca68f5 --- /dev/null +++ b/csharp/Platform.Bot/Triggers/ListUserLinksTrigger.cs @@ -0,0 +1,144 @@ +using System; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Interfaces; +using Octokit; +using Storage.Local; +using Storage.Remote.GitHub; + +namespace Platform.Bot.Triggers +{ + using TContext = Issue; + + /// + /// + /// Represents the list user links trigger for displaying user links. + /// + /// + /// + /// + internal class ListUserLinksTrigger : ITrigger + { + private readonly GitHubStorage _storage; + private readonly FileStorage _fileStorage; + private readonly Regex _listMyLinksPattern; + private readonly Regex _listUserLinksPattern; + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// The GitHub storage. + /// + /// + /// + /// The file storage. + /// + /// + public ListUserLinksTrigger(GitHubStorage storage, FileStorage fileStorage) + { + _storage = storage; + _fileStorage = fileStorage; + _listMyLinksPattern = new Regex(@"^(list my links|my links)$", RegexOptions.IgnoreCase); + _listUserLinksPattern = new Regex(@"^list links (\w+)$", RegexOptions.IgnoreCase); + } + + /// + /// + /// Determines whether this instance condition matches the context. + /// + /// + /// + /// + /// The issue context. + /// + /// + /// + /// True if the issue title matches any list links pattern. + /// + /// + public async Task Condition(TContext context) + { + var title = context.Title.Trim(); + return _listMyLinksPattern.IsMatch(title) || _listUserLinksPattern.IsMatch(title); + } + + /// + /// + /// Executes the list user links action. + /// + /// + /// + /// + /// The issue context. + /// + /// + public async Task Action(TContext context) + { + var title = context.Title.Trim(); + var targetUsername = context.User.Login; // Default to the requesting user + var isRequestingOwnLinks = true; + + // Check if requesting links for a specific user + var userMatch = _listUserLinksPattern.Match(title); + if (userMatch.Success) + { + targetUsername = userMatch.Groups[1].Value; + isRequestingOwnLinks = string.Equals(targetUsername, context.User.Login, StringComparison.OrdinalIgnoreCase); + } + + try + { + var userLinks = _fileStorage.GetUserLinks(targetUsername); + + if (!userLinks.Any()) + { + var message = isRequestingOwnLinks + ? "You don't have any links registered." + : $"User @{targetUsername} doesn't have any links registered."; + + await _storage.CreateIssueComment(context.Repository.Id, context.Number, message); + } + else + { + var messageBuilder = new StringBuilder(); + var displayUsername = isRequestingOwnLinks ? "Your" : $"@{targetUsername}'s"; + messageBuilder.AppendLine($"## {displayUsername} Links\n"); + + foreach (var link in userLinks.OrderBy(l => l.Platform)) + { + messageBuilder.AppendLine($"**{link.Platform}**: {link.Url}"); + } + + messageBuilder.AppendLine($"\n*Total: {userLinks.Count} link{(userLinks.Count == 1 ? "" : "s")}*"); + + if (isRequestingOwnLinks) + { + messageBuilder.AppendLine("\n---"); + messageBuilder.AppendLine("**Commands:**"); + messageBuilder.AppendLine("- `add link ` - Add a new link"); + messageBuilder.AppendLine("- `remove link ` - Remove a link"); + messageBuilder.AppendLine("- `my links` - View your links"); + + var supportedPlatforms = string.Join(", ", DomainWhitelist.GetSupportedPlatforms()); + messageBuilder.AppendLine($"\n**Supported platforms:** {supportedPlatforms}"); + } + + await _storage.CreateIssueComment(context.Repository.Id, context.Number, messageBuilder.ToString()); + } + } + catch (Exception ex) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ Error retrieving links: {ex.Message}"); + } + + _storage.CloseIssue(context); + } + } +} \ No newline at end of file diff --git a/csharp/Platform.Bot/Triggers/RemoveUserLinkTrigger.cs b/csharp/Platform.Bot/Triggers/RemoveUserLinkTrigger.cs new file mode 100644 index 00000000..b17e47ea --- /dev/null +++ b/csharp/Platform.Bot/Triggers/RemoveUserLinkTrigger.cs @@ -0,0 +1,137 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Interfaces; +using Octokit; +using Storage.Local; +using Storage.Remote.GitHub; + +namespace Platform.Bot.Triggers +{ + using TContext = Issue; + + /// + /// + /// Represents the remove user link trigger for handling user link removal commands. + /// + /// + /// + /// + internal class RemoveUserLinkTrigger : ITrigger + { + private readonly GitHubStorage _storage; + private readonly FileStorage _fileStorage; + private readonly Regex _commandPattern; + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// The GitHub storage. + /// + /// + /// + /// The file storage. + /// + /// + public RemoveUserLinkTrigger(GitHubStorage storage, FileStorage fileStorage) + { + _storage = storage; + _fileStorage = fileStorage; + _commandPattern = new Regex(@"^remove link (\w+)$", RegexOptions.IgnoreCase); + } + + /// + /// + /// Determines whether this instance condition matches the context. + /// + /// + /// + /// + /// The issue context. + /// + /// + /// + /// True if the issue title matches the remove link pattern. + /// + /// + public async Task Condition(TContext context) + { + return _commandPattern.IsMatch(context.Title.Trim()); + } + + /// + /// + /// Executes the remove user link action. + /// + /// + /// + /// + /// The issue context. + /// + /// + public async Task Action(TContext context) + { + var match = _commandPattern.Match(context.Title.Trim()); + if (!match.Success) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, "❌ Invalid command format. Use: `remove link `"); + _storage.CloseIssue(context); + return; + } + + var platform = match.Groups[1].Value; + var username = context.User.Login; + + // Validate platform is supported + if (!DomainWhitelist.GetSupportedPlatforms().Any(p => string.Equals(p, platform, StringComparison.OrdinalIgnoreCase))) + { + var supportedPlatforms = string.Join(", ", DomainWhitelist.GetSupportedPlatforms()); + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ Platform '{platform}' is not supported. Supported platforms: {supportedPlatforms}"); + _storage.CloseIssue(context); + return; + } + + try + { + // Check if user has a link for this platform + var existingLinks = _fileStorage.GetUserLinks(username); + var existingLink = existingLinks.Find(link => string.Equals(link.Platform, platform, StringComparison.OrdinalIgnoreCase)); + + if (existingLink == null) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ You don't have a {platform} link to remove."); + _storage.CloseIssue(context); + return; + } + + // Remove the user link + var removed = _fileStorage.RemoveUserLink(username, platform); + + if (removed) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"✅ Successfully removed {platform} link for @{username} ({existingLink.Url})"); + } + else + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ Failed to remove {platform} link. Please try again."); + } + } + catch (Exception ex) + { + await _storage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ Error removing link: {ex.Message}"); + } + + _storage.CloseIssue(context); + } + } +} \ No newline at end of file diff --git a/csharp/Storage/LocalStorage/DomainWhitelist.cs b/csharp/Storage/LocalStorage/DomainWhitelist.cs new file mode 100644 index 00000000..1195c1fb --- /dev/null +++ b/csharp/Storage/LocalStorage/DomainWhitelist.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Storage.Local +{ + /// + /// + /// Manages the whitelist of supported domains for user links. + /// + /// + /// + public static class DomainWhitelist + { + /// + /// + /// Dictionary mapping platform names to their allowed domain patterns. + /// + /// + /// + private static readonly Dictionary> AllowedDomains = new() + { + ["GitHub"] = new HashSet { "github.com" }, + ["StackOverflow"] = new HashSet { "stackoverflow.com", "serverfault.com", "superuser.com", "askubuntu.com", "mathoverflow.net" }, + ["GitLab"] = new HashSet { "gitlab.com" } + }; + + /// + /// + /// Gets all supported platform names. + /// + /// + /// + /// + /// A collection of supported platform names. + /// + /// + public static IEnumerable GetSupportedPlatforms() => AllowedDomains.Keys; + + /// + /// + /// Validates if a URL is allowed for the specified platform. + /// + /// + /// + /// + /// The platform name. + /// + /// + /// + /// The URL to validate. + /// + /// + /// + /// True if the URL is allowed for the platform, false otherwise. + /// + /// + public static bool IsUrlAllowed(string platform, string url) + { + if (!AllowedDomains.ContainsKey(platform)) + return false; + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + var domain = uri.Host.ToLowerInvariant(); + return AllowedDomains[platform].Any(allowedDomain => + domain == allowedDomain || domain.EndsWith("." + allowedDomain)); + } + + /// + /// + /// Gets the allowed domains for a specific platform. + /// + /// + /// + /// + /// The platform name. + /// + /// + /// + /// A collection of allowed domain patterns for the platform. + /// + /// + public static IEnumerable GetAllowedDomains(string platform) + { + return AllowedDomains.ContainsKey(platform) + ? AllowedDomains[platform] + : Enumerable.Empty(); + } + } +} \ No newline at end of file diff --git a/csharp/Storage/LocalStorage/FileStorage.cs b/csharp/Storage/LocalStorage/FileStorage.cs index aa68fd6f..cb9f0d80 100644 --- a/csharp/Storage/LocalStorage/FileStorage.cs +++ b/csharp/Storage/LocalStorage/FileStorage.cs @@ -45,6 +45,7 @@ public class FileStorage : DisposableBase private readonly TLinkAddress _setMarker; private readonly TLinkAddress _fileMarker; private readonly TLinkAddress _gitHubLastMigrationTimestampMarker; + private readonly TLinkAddress _userLinkMarker; private readonly TLinkAddress Any; private TLinkAddress GetOrCreateNextMapping(TLinkAddress currentMappingIndex) => _synchronizedLinks.Exists(currentMappingIndex) ? currentMappingIndex : _synchronizedLinks.CreateAndUpdate(_meaningRoot, _synchronizedLinks.Constants.Itself); private TLinkAddress GetOrCreateMeaningRoot(TLinkAddress meaningRootIndex) => _synchronizedLinks.Exists(meaningRootIndex) ? meaningRootIndex : _synchronizedLinks.CreatePoint(); @@ -76,6 +77,7 @@ public FileStorage(string DBFilename) _setMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); _fileMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); _gitHubLastMigrationTimestampMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); + _userLinkMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); _addressToNumberConverter = new AddressToRawNumberConverter(); _numberToAddressConverter = new RawNumberToAddressConverter(); var balancedVariantConverter = new BalancedVariantConverter(_synchronizedLinks); @@ -329,6 +331,152 @@ public List GetFilesFromSet(string set) // public void SetLastGithubMigrationTimeStamp() + /// + /// + /// Adds a user link for the specified username, platform, and URL. + /// + /// + /// + /// + /// The username. + /// + /// + /// + /// The platform name. + /// + /// + /// + /// The URL. + /// + /// + /// + /// The link address of the created user link. + /// + /// + public TLinkAddress AddUserLink(string username, string platform, string url) + { + var usernameLink = CreateString(username); + var platformLink = CreateString(platform); + var urlLink = CreateString(url); + var userLinkData = _synchronizedLinks.GetOrCreate(platformLink, urlLink); + return _synchronizedLinks.GetOrCreate(_userLinkMarker, _synchronizedLinks.GetOrCreate(usernameLink, userLinkData)); + } + + /// + /// + /// Removes a user link for the specified username and platform. + /// + /// + /// + /// + /// The username. + /// + /// + /// + /// The platform name. + /// + /// + /// + /// True if the link was removed, false if it was not found. + /// + /// + public bool RemoveUserLink(string username, string platform) + { + var usernameLink = CreateString(username); + var platformLink = CreateString(platform); + + var userLinks = _synchronizedLinks.All(new Link(index: Any, source: _userLinkMarker, target: Any)); + foreach (var userLink in userLinks) + { + var userLinkTarget = _synchronizedLinks.GetTarget(userLink); + var userLinkSource = _synchronizedLinks.GetSource(userLinkTarget); + var platformAndUrl = _synchronizedLinks.GetTarget(userLinkTarget); + var storedPlatformLink = _synchronizedLinks.GetSource(platformAndUrl); + + if (userLinkSource == usernameLink && storedPlatformLink == platformLink) + { + _synchronizedLinks.Delete(userLink); + return true; + } + } + return false; + } + + /// + /// + /// Gets all user links for the specified username. + /// + /// + /// + /// + /// The username. + /// + /// + /// + /// A list of user links for the username. + /// + /// + public List GetUserLinks(string username) + { + var usernameLink = CreateString(username); + var userLinks = new List(); + + var links = _synchronizedLinks.All(new Link(index: Any, source: _userLinkMarker, target: Any)); + foreach (var link in links) + { + var linkTarget = _synchronizedLinks.GetTarget(link); + var linkSource = _synchronizedLinks.GetSource(linkTarget); + + if (linkSource == usernameLink) + { + var platformAndUrl = _synchronizedLinks.GetTarget(linkTarget); + var platformLink = _synchronizedLinks.GetSource(platformAndUrl); + var urlLink = _synchronizedLinks.GetTarget(platformAndUrl); + + userLinks.Add(new UserLink + { + Username = username, + Platform = GetString(platformLink), + Url = GetString(urlLink) + }); + } + } + return userLinks; + } + + /// + /// + /// Gets all user links in the system. + /// + /// + /// + /// + /// A list of all user links. + /// + /// + public List GetAllUserLinks() + { + var userLinks = new List(); + + var links = _synchronizedLinks.All(new Link(index: Any, source: _userLinkMarker, target: Any)); + foreach (var link in links) + { + var linkTarget = _synchronizedLinks.GetTarget(link); + var usernameLink = _synchronizedLinks.GetSource(linkTarget); + var platformAndUrl = _synchronizedLinks.GetTarget(linkTarget); + var platformLink = _synchronizedLinks.GetSource(platformAndUrl); + var urlLink = _synchronizedLinks.GetTarget(platformAndUrl); + + userLinks.Add(new UserLink + { + Username = GetString(usernameLink), + Platform = GetString(platformLink), + Url = GetString(urlLink) + }); + } + return userLinks; + } + protected override void Dispose(bool manual, bool wasDisposed) { _disposableLinks.Dispose(); diff --git a/csharp/Storage/LocalStorage/UserLink.cs b/csharp/Storage/LocalStorage/UserLink.cs new file mode 100644 index 00000000..09ead7c0 --- /dev/null +++ b/csharp/Storage/LocalStorage/UserLink.cs @@ -0,0 +1,35 @@ +namespace Storage.Local +{ + /// + /// + /// Represents a user link with platform and URL information. + /// + /// + /// + public class UserLink + { + /// + /// + /// Gets or sets the username associated with this link. + /// + /// + /// + public string Username { get; set; } + + /// + /// + /// Gets or sets the platform name (e.g., "GitHub", "StackOverflow", "GitLab"). + /// + /// + /// + public string Platform { get; set; } + + /// + /// + /// Gets or sets the URL of the user's profile on the platform. + /// + /// + /// + public string Url { get; set; } + } +} \ No newline at end of file diff --git a/examples/user-links-examples.md b/examples/user-links-examples.md new file mode 100644 index 00000000..266beff1 --- /dev/null +++ b/examples/user-links-examples.md @@ -0,0 +1,64 @@ +# User Links Feature Examples + +This document demonstrates how to use the user links feature in the LinksPlatform Bot. + +## Supported Commands + +### 1. Add a link +Create a GitHub issue with the title: `add link ` + +**Examples:** +- `add link GitHub https://github.com/username` +- `add link StackOverflow https://stackoverflow.com/users/12345/username` +- `add link GitLab https://gitlab.com/username` + +### 2. Remove a link +Create a GitHub issue with the title: `remove link ` + +**Examples:** +- `remove link GitHub` +- `remove link StackOverflow` +- `remove link GitLab` + +### 3. List your links +Create a GitHub issue with the title: `my links` or `list my links` + +### 4. List another user's links +Create a GitHub issue with the title: `list links ` + +**Example:** +- `list links konard` + +## Supported Platforms + +The bot currently supports these platforms with domain validation: + +- **GitHub**: github.com +- **StackOverflow**: stackoverflow.com, serverfault.com, superuser.com, askubuntu.com, mathoverflow.net +- **GitLab**: gitlab.com + +## Bot Responses + +The bot will respond with: +- ✅ Success messages for valid operations +- ❌ Error messages for invalid platforms, URLs, or duplicate entries +- Detailed information when listing links +- Commands reference when viewing your own links + +## Security Features + +- **Domain Whitelist**: Only approved domains are allowed for each platform +- **URL Validation**: All URLs must be valid and match the platform's allowed domains +- **User Isolation**: Each user can only manage their own links +- **Platform Uniqueness**: Each user can have only one link per platform + +## Example Workflow + +1. User creates issue: "add link GitHub https://github.com/myusername" +2. Bot validates the platform and URL +3. Bot adds the link to storage +4. Bot responds with success message and closes the issue + +If the user later creates issue: "my links" +- Bot shows all their registered links with platform and URL +- Bot provides command reference for managing links \ No newline at end of file