diff --git a/Source/AudibleUtilities/Mkb79Auth.cs b/Source/AudibleUtilities/Mkb79Auth.cs new file mode 100644 index 00000000..ec0f0520 --- /dev/null +++ b/Source/AudibleUtilities/Mkb79Auth.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AudibleApi; +using AudibleApi.Authorization; +using Dinah.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AudibleUtilities +{ + public partial class Mkb79Auth : IIdentityMaintainer + { + [JsonProperty("website_cookies")] + private JObject _websiteCookies { get; set; } + + [JsonProperty("adp_token")] + public string AdpToken { get; private set; } + + [JsonProperty("access_token")] + public string AccessToken { get; private set; } + + [JsonProperty("refresh_token")] + public string RefreshToken { get; private set; } + + [JsonProperty("device_private_key")] + public string DevicePrivateKey { get; private set; } + + [JsonProperty("store_authentication_cookie")] + private JObject _storeAuthenticationCookie { get; set; } + + [JsonProperty("device_info")] + public DeviceInfo DeviceInfo { get; private set; } + + [JsonProperty("customer_info")] + public CustomerInfo CustomerInfo { get; private set; } + + [JsonProperty("expires")] + private double _expires { get; set; } + + [JsonProperty("locale_code")] + public string LocaleCode { get; private set; } + + [JsonProperty("activation_bytes")] + public string ActivationBytes { get; private set; } + + + [JsonIgnore] + public Dictionary WebsiteCookies + { + get => _websiteCookies.ToObject>(); + private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings)); + } + + [JsonIgnore] + public string StoreAuthenticationCookie + { + get => _storeAuthenticationCookie.ToObject>()["cookie"]; + private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary() { { "cookie", value } }, Converter.Settings)); + } + + [JsonIgnore] + public DateTime AccessTokenExpires + { + get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime; + private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d; + } + + + [JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime(); + [JsonIgnore] public Locale Locale => Localization.Get(LocaleCode); + [JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber; + [JsonIgnore] public string DeviceType => DeviceInfo.DeviceType; + [JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId; + + public Task GetAccessTokenAsync() + => Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires)); + + public Task GetAdpTokenAsync() + => Task.FromResult(new AdpToken(AdpToken)); + + public Task GetPrivateKeyAsync() + => Task.FromResult(new PrivateKey(DevicePrivateKey)); + } + public partial class StoreAuthenticationCookie + { + [JsonProperty("cookie")] + public string Cookie { get; set; } + } + + public partial class CustomerInfo + { + [JsonProperty("account_pool")] + public string AccountPool { get; set; } + + [JsonProperty("user_id")] + public string UserId { get; set; } + + [JsonProperty("home_region")] + public string HomeRegion { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("given_name")] + public string GivenName { get; set; } + } + + public partial class DeviceInfo + { + [JsonProperty("device_name")] + public string DeviceName { get; set; } + + [JsonProperty("device_serial_number")] + public string DeviceSerialNumber { get; set; } + + [JsonProperty("device_type")] + public string DeviceType { get; set; } + } + + public partial class Mkb79Auth + { + public static Mkb79Auth FromJson(string json) + => JsonConvert.DeserializeObject(json, Converter.Settings); + + public async Task ToAccountAsync() + { + var api = new Api(this); + + if ((DateTime.Now - AccessTokenExpires).TotalMinutes >= 59) + { + var authorize = new Authorize(Locale); + var newToken = await authorize.RefreshAccessTokenAsync(new RefreshToken(RefreshToken)); + AccessToken = newToken.TokenValue; + AccessTokenExpires = newToken.Expires; + } + + var email = await api.GetEmailAsync(); + var account = new Account(email); + + var privateKey = await GetPrivateKeyAsync(); + var adpToken = await GetAdpTokenAsync(); + var accessToken = await GetAccessTokenAsync(); + var cookies = WebsiteCookies.Select(c => new KeyValuePair(c.Key, c.Value)); + + account.IdentityTokens = new Identity(Locale); + account.IdentityTokens.Update( + privateKey, + adpToken, accessToken, + new RefreshToken(RefreshToken), + cookies, + DeviceSerialNumber, + DeviceType, + AmazonAccountId, + DeviceInfo.DeviceName, + StoreAuthenticationCookie); + + account.DecryptKey = ActivationBytes; + account.AccountName = $"{email} - {Locale.Name}"; + + return account; + } + + public static Mkb79Auth FromAccount(Account account) + => new() + { + AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue, + ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey, + AdpToken = account.IdentityTokens.AdpToken.Value, + CustomerInfo = new CustomerInfo + { + AccountPool = "Amazon", + GivenName = string.Empty, + HomeRegion = "NA", + Name = string.Empty, + UserId = account.IdentityTokens.AmazonAccountId + }, + DeviceInfo = new DeviceInfo + { + DeviceName = account.IdentityTokens.DeviceName, + DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber, + DeviceType = account.IdentityTokens.DeviceType, + }, + DevicePrivateKey = account.IdentityTokens.PrivateKey, + AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires, + LocaleCode = account.Locale.CountryCode, + RefreshToken = account.IdentityTokens.RefreshToken.Value, + StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie, + WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()), + }; + } + + public static class Serialize + { + public static string ToJson(this Mkb79Auth self) + => JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented); + } + + internal static class Converter + { + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + }; + } +} diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs index ed64f90e..4cfa4680 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs @@ -31,7 +31,9 @@ this.cancelBtn = new System.Windows.Forms.Button(); this.saveBtn = new System.Windows.Forms.Button(); this.dataGridView1 = new System.Windows.Forms.DataGridView(); + this.importBtn = new System.Windows.Forms.Button(); this.DeleteAccount = new System.Windows.Forms.DataGridViewButtonColumn(); + this.ExportAccount = new System.Windows.Forms.DataGridViewButtonColumn(); this.LibraryScan = new System.Windows.Forms.DataGridViewCheckBoxColumn(); this.AccountId = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.Locale = new System.Windows.Forms.DataGridViewComboBoxColumn(); @@ -43,9 +45,10 @@ // this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel; - this.cancelBtn.Location = new System.Drawing.Point(713, 415); + this.cancelBtn.Location = new System.Drawing.Point(832, 479); + this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.cancelBtn.Name = "cancelBtn"; - this.cancelBtn.Size = new System.Drawing.Size(75, 23); + this.cancelBtn.Size = new System.Drawing.Size(88, 27); this.cancelBtn.TabIndex = 2; this.cancelBtn.Text = "Cancel"; this.cancelBtn.UseVisualStyleBackColor = true; @@ -54,9 +57,10 @@ // saveBtn // this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.saveBtn.Location = new System.Drawing.Point(612, 415); + this.saveBtn.Location = new System.Drawing.Point(714, 479); + this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.saveBtn.Name = "saveBtn"; - this.saveBtn.Size = new System.Drawing.Size(75, 23); + this.saveBtn.Size = new System.Drawing.Size(88, 27); this.saveBtn.TabIndex = 1; this.saveBtn.Text = "Save"; this.saveBtn.UseVisualStyleBackColor = true; @@ -71,60 +75,83 @@ this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { this.DeleteAccount, + this.ExportAccount, this.LibraryScan, this.AccountId, this.Locale, this.AccountName}); - this.dataGridView1.Location = new System.Drawing.Point(12, 12); + this.dataGridView1.Location = new System.Drawing.Point(14, 14); + this.dataGridView1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.dataGridView1.MultiSelect = false; this.dataGridView1.Name = "dataGridView1"; - this.dataGridView1.Size = new System.Drawing.Size(776, 397); + this.dataGridView1.Size = new System.Drawing.Size(905, 458); this.dataGridView1.TabIndex = 0; this.dataGridView1.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView1_CellContentClick); this.dataGridView1.DefaultValuesNeeded += new System.Windows.Forms.DataGridViewRowEventHandler(this.dataGridView1_DefaultValuesNeeded); // + // importBtn + // + this.importBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.importBtn.Location = new System.Drawing.Point(14, 480); + this.importBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + this.importBtn.Name = "importBtn"; + this.importBtn.Size = new System.Drawing.Size(156, 27); + this.importBtn.TabIndex = 1; + this.importBtn.Text = "Import from audible-cli"; + this.importBtn.UseVisualStyleBackColor = true; + this.importBtn.Click += new System.EventHandler(this.importBtn_Click); + // // DeleteAccount // this.DeleteAccount.HeaderText = "Delete"; this.DeleteAccount.Name = "DeleteAccount"; this.DeleteAccount.ReadOnly = true; this.DeleteAccount.Text = "x"; - this.DeleteAccount.Width = 44; + this.DeleteAccount.Width = 46; + // + // ExportAccount + // + this.ExportAccount.HeaderText = "Export"; + this.ExportAccount.Name = "ExportAccount"; + this.ExportAccount.Text = "Export to audible-cli"; + this.ExportAccount.Width = 47; // // LibraryScan // this.LibraryScan.HeaderText = "Include in library scan?"; this.LibraryScan.Name = "LibraryScan"; - this.LibraryScan.Width = 83; + this.LibraryScan.Width = 94; // // AccountId // this.AccountId.HeaderText = "Audible email/login"; this.AccountId.Name = "AccountId"; - this.AccountId.Width = 111; + this.AccountId.Width = 125; // // Locale // this.Locale.HeaderText = "Locale"; this.Locale.Name = "Locale"; - this.Locale.Width = 45; + this.Locale.Width = 47; // // AccountName // this.AccountName.HeaderText = "Account nickname (optional)"; this.AccountName.Name = "AccountName"; - this.AccountName.Width = 152; + this.AccountName.Width = 170; // // AccountsDialog // this.AcceptButton = this.saveBtn; - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.CancelButton = this.cancelBtn; - this.ClientSize = new System.Drawing.Size(800, 450); + this.ClientSize = new System.Drawing.Size(933, 519); this.Controls.Add(this.dataGridView1); + this.Controls.Add(this.importBtn); this.Controls.Add(this.saveBtn); this.Controls.Add(this.cancelBtn); + this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.Name = "AccountsDialog"; this.Text = "Audible Accounts"; ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); @@ -137,7 +164,9 @@ private System.Windows.Forms.Button cancelBtn; private System.Windows.Forms.Button saveBtn; private System.Windows.Forms.DataGridView dataGridView1; + private System.Windows.Forms.Button importBtn; private System.Windows.Forms.DataGridViewButtonColumn DeleteAccount; + private System.Windows.Forms.DataGridViewButtonColumn ExportAccount; private System.Windows.Forms.DataGridViewCheckBoxColumn LibraryScan; private System.Windows.Forms.DataGridViewTextBoxColumn AccountId; private System.Windows.Forms.DataGridViewComboBoxColumn Locale; diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.cs b/Source/LibationWinForms/Dialogs/AccountsDialog.cs index e2a90e2a..0169ed0f 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.cs +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Windows.Forms; using AudibleApi; @@ -10,6 +11,7 @@ namespace LibationWinForms.Dialogs public partial class AccountsDialog : Form { private const string COL_Delete = nameof(DeleteAccount); + private const string COL_Export = nameof(ExportAccount); private const string COL_LibraryScan = nameof(LibraryScan); private const string COL_AccountId = nameof(AccountId); private const string COL_AccountName = nameof(AccountName); @@ -44,12 +46,20 @@ namespace LibationWinForms.Dialogs return; foreach (var account in accounts) - dataGridView1.Rows.Add( + AddAccountToGrid(account); + } + + private void AddAccountToGrid(Account account) + { + int row = dataGridView1.Rows.Add( "X", + "Export", account.LibraryScan, account.AccountId, account.Locale.Name, account.AccountName); + + dataGridView1[COL_Export, row].ToolTipText = "Export account authorization to audible-cli"; } private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e) @@ -73,6 +83,11 @@ namespace LibationWinForms.Dialogs if (e.RowIndex < dgv.RowCount - 1) dgv.Rows.Remove(row); break; + case COL_Export: + // if final/edit row: do nothing + if (e.RowIndex < dgv.RowCount - 1) + Export((string)row.Cells[COL_AccountId].Value, (string)row.Cells[COL_Locale].Value); + break; //case COL_MoveUp: // // if top: do nothing // if (e.RowIndex < 1) @@ -194,5 +209,74 @@ namespace LibationWinForms.Dialogs LibraryScan = (bool)r.Cells[COL_LibraryScan].Value }) .ToList(); + + private void Export(string accountId, string locale) + { + // without transaction, accounts persister will write ANY EDIT immediately to file + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + + var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == accountId && a.Locale.Name == locale); + + if (account is null) + return; + + if (account.IdentityTokens?.IsValid != true) + { + MessageBox.Show("This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated"); + return; + } + + SaveFileDialog sfd = new(); + sfd.Filter = "JSON File|*.json"; + + if (sfd.ShowDialog() != DialogResult.OK) return; + + try + { + var mkbAuth = Mkb79Auth.FromAccount(account); + var jsonText = mkbAuth.ToJson(); + + File.WriteAllText(sfd.FileName, jsonText); + + MessageBox.Show($"Successfully exported {account.AccountName} to\r\n\r\n{sfd.FileName}", "Success!"); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Unable to export account: {0}", account); + MessageBox.Show($"An error occured while exporting account:\r\n{account.AccountName}", "Error Exporting Account"); + } + } + private async void importBtn_Click(object sender, EventArgs e) + { + OpenFileDialog ofd = new(); + ofd.Filter = "JSON File|*.json"; + + if (ofd.ShowDialog() != DialogResult.OK) return; + + try + { + var jsonText = File.ReadAllText(ofd.FileName); + var mkbAuth = Mkb79Auth.FromJson(jsonText); + var account = await mkbAuth.ToAccountAsync(); + + // without transaction, accounts persister will write ANY EDIT immediately to file + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + + if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name)) + { + MessageBox.Show($"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account"); + return; + } + + persister.AccountsSettings.Add(account); + + AddAccountToGrid(account); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Unable to import audible-cli auth file: {0}", ofd.FileName); + MessageBox.Show($"An error occured while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?", "Error Importing Account"); + } + } } } diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.resx b/Source/LibationWinForms/Dialogs/AccountsDialog.resx index f1117452..d9e7d106 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.resx +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.resx @@ -1,5 +1,4 @@ - - + @@ -58,10 +57,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + True - + True @@ -70,10 +69,10 @@ True - - True - True + + True + \ No newline at end of file