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
162 changes: 162 additions & 0 deletions src/HappyNotes.Api/Controllers/FanfouAuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System.Web;
using Api.Framework;
using Api.Framework.Helper;
using Api.Framework.Models;
using Api.Framework.Result;
using HappyNotes.Entities;
using HappyNotes.Services.interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;

namespace HappyNotes.Api.Controllers;

[Authorize]
public class FanfouAuthController(
IRepositoryBase<FanfouUserAccount> fanfouUserAccountRepository,
ICurrentUser currentUser,
ILogger<FanfouAuthController> logger,
IOptions<JwtConfig> jwtConfig,
IGeneralMemoryCacheService generalMemoryCacheService) : BaseController
{
private readonly JwtConfig _jwtConfig = jwtConfig.Value;

[HttpPost]
public ApiResult SetState([FromHeader(Name = "X-State")] string state)
{
if (string.IsNullOrEmpty(state))
{
return Fail("State cannot be empty");
}

var cacheId = state;
generalMemoryCacheService.Set(cacheId, currentUser.Id);

logger.LogInformation("State set for user {UserId}", currentUser.Id);

return Success();
}

[HttpGet]
[AllowAnonymous]
public async Task<ApiResult> Callback(string oauth_token, string oauth_verifier, string state)
{
logger.LogInformation("Starting Fanfou OAuth callback. State: {State}", state);

try
{
if (string.IsNullOrEmpty(state))
{
logger.LogError("Empty state received in OAuth callback");
throw new Exception("Unexpected empty state received");
}

// Validate state
var userId = generalMemoryCacheService.Get<long>(state);
if (userId == 0)
{
logger.LogError("Invalid state {State} received", state);
throw new Exception("Invalid state");
}

logger.LogDebug("OAuth callback for user {UserId}", userId);

// Get request token and secret from cache (stored during request-token phase)
var requestTokenKey = $"fanfou_request_token_{state}";
var requestTokenData = generalMemoryCacheService.Get<Dictionary<string, string>>(requestTokenKey);
if (requestTokenData == null)
{
logger.LogError("No request token data found for state {State}", state);
throw new Exception("No request token data found");
}

var requestToken = requestTokenData["oauth_token"];
var requestTokenSecret = requestTokenData["oauth_token_secret"];
var consumerKey = requestTokenData["consumer_key"];
var consumerSecret = TextEncryptionHelper.Decrypt(requestTokenData["consumer_secret"], _jwtConfig.SymmetricSecurityKey);

// Exchange request token for access token
var (accessToken, accessTokenSecret, username) = await ExchangeRequestTokenForAccessToken(
consumerKey, consumerSecret, requestToken, requestTokenSecret, oauth_verifier);

if (string.IsNullOrEmpty(accessToken))
{
return Fail("Failed to obtain access token from Fanfou");
}

// Check if account already exists
var existingAccount = await fanfouUserAccountRepository.GetFirstOrDefaultAsync(
a => a.UserId == userId && a.Username == username);

if (existingAccount != null)
{
// Update existing account
existingAccount.AccessToken = TextEncryptionHelper.Encrypt(accessToken, _jwtConfig.SymmetricSecurityKey);
existingAccount.AccessTokenSecret = TextEncryptionHelper.Encrypt(accessTokenSecret, _jwtConfig.SymmetricSecurityKey);
existingAccount.Status = Common.Enums.FanfouUserAccountStatus.Active;
await fanfouUserAccountRepository.UpdateAsync(existingAccount);

logger.LogInformation("Updated existing Fanfou user account for user {UserId}, username {Username}",
userId, username);
}
else
{
// Create new account
var fanfouUserAccount = new FanfouUserAccount
{
UserId = userId,
Username = username,
ConsumerKey = TextEncryptionHelper.Encrypt(consumerKey, _jwtConfig.SymmetricSecurityKey),
ConsumerSecret = TextEncryptionHelper.Encrypt(consumerSecret, _jwtConfig.SymmetricSecurityKey),
AccessToken = TextEncryptionHelper.Encrypt(accessToken, _jwtConfig.SymmetricSecurityKey),
AccessTokenSecret = TextEncryptionHelper.Encrypt(accessTokenSecret, _jwtConfig.SymmetricSecurityKey),
SyncType = Common.Enums.FanfouSyncType.All,
Status = Common.Enums.FanfouUserAccountStatus.Active,
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
};
await fanfouUserAccountRepository.InsertAsync(fanfouUserAccount);

logger.LogInformation("Created new Fanfou user account for user {UserId}, username {Username}",
userId, username);
}

logger.LogInformation("Successfully authenticated user {UserId} with Fanfou as {Username}",
userId, username);

return Success("Successfully authenticated with Fanfou. You can close this page.");
}
catch (Exception ex)
{
logger.LogError(ex, "OAuth callback failed. Error: {ErrorMessage}", ex.Message);
return Fail($"Authentication failed: {ex.Message}");
}
}

private async Task<(string accessToken, string accessTokenSecret, string username)> ExchangeRequestTokenForAccessToken(
string consumerKey,
string consumerSecret,
string requestToken,
string requestTokenSecret,
string oauthVerifier)
{
logger.LogInformation("Exchanging request token for access token");

try
{
// For now, we'll need to use the FanfouStatusService or create a helper
// This is a simplified implementation - in production, you'd use proper OAuth 1.0a signing
// For the complete implementation, we need to add the token exchange logic to FanfouStatusService

// TODO: Implement the OAuth access token exchange
// This requires signing the request with OAuth 1.0a
throw new NotImplementedException("Access token exchange needs to be implemented in FanfouStatusService");
}
catch (Exception ex)
{
logger.LogError(ex, "Exception during access token exchange: {ErrorMessage}", ex.Message);
throw;
}
}
}
147 changes: 147 additions & 0 deletions src/HappyNotes.Api/Controllers/FanfouUserAccountController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using Api.Framework;
using Api.Framework.Helper;
using Api.Framework.Result;
using HappyNotes.Common.Enums;
using HappyNotes.Dto;
using HappyNotes.Entities;
using HappyNotes.Services.interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace HappyNotes.Api.Controllers;

[Authorize]
[Route("api/[controller]")]
[ApiController]
public class FanfouAccountsController(
IRepositoryBase<FanfouUserAccount> fanfouUserAccountRepository,
IFanfouUserAccountCacheService fanfouUserAccountCacheService,
ICurrentUser currentUser,
ILogger<FanfouAccountsController> logger) : BaseController
{
[HttpGet]
public async Task<ApiResult<List<FanfouUserAccountDto>>> GetAccounts()
{
try
{
var accounts = await fanfouUserAccountRepository.GetListAsync(a => a.UserId == currentUser.Id);

var dtos = accounts.Select(a => new FanfouUserAccountDto
{
Id = a.Id,
UserId = a.UserId,
Username = a.Username,
SyncType = a.SyncType,
Status = a.Status,
CreatedAt = a.CreatedAt
}).ToList();

logger.LogDebug("Retrieved {AccountCount} Fanfou accounts for user {UserId}",
dtos.Count, currentUser.Id);

return new SuccessfulResult<List<FanfouUserAccountDto>>(dtos);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting Fanfou accounts for user {UserId}", currentUser.Id);
return new FailedResult<List<FanfouUserAccountDto>>(null, $"Failed to retrieve accounts: {ex.Message}");
}
}

[HttpPost]
public async Task<ApiResult> AddAccount([FromBody] PostFanfouAccountRequest request)
{
try
{
if (string.IsNullOrEmpty(request.ConsumerKey) || string.IsNullOrEmpty(request.ConsumerSecret))
{
return Fail("Consumer key and secret are required");
}

// Create new account (will be activated after OAuth flow completes)
var account = new FanfouUserAccount
{
UserId = currentUser.Id,
Username = "Pending", // Will be updated after OAuth
ConsumerKey = TextEncryptionHelper.Encrypt(request.ConsumerKey, "TEMP_KEY"), // Will be re-encrypted
ConsumerSecret = TextEncryptionHelper.Encrypt(request.ConsumerSecret, "TEMP_KEY"), // Will be re-encrypted
AccessToken = string.Empty,
AccessTokenSecret = string.Empty,
SyncType = request.SyncType,
Status = FanfouUserAccountStatus.Disabled,
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
};

await fanfouUserAccountRepository.InsertAsync(account);

// Clear cache
fanfouUserAccountCacheService.ClearCache(currentUser.Id);

logger.LogInformation("Created new Fanfou account for user {UserId}, pending OAuth", currentUser.Id);

return Success("Account created. Please complete OAuth authorization.");
}
catch (Exception ex)
{
logger.LogError(ex, "Error adding Fanfou account for user {UserId}", currentUser.Id);
return Fail($"Failed to add account: {ex.Message}");
}
}

[HttpPut("{id}")]
public async Task<ApiResult> UpdateAccount(long id, [FromBody] PutFanfouAccountRequest request)
{
try
{
var account = await fanfouUserAccountRepository.GetFirstOrDefaultAsync(a => a.Id == id && a.UserId == currentUser.Id);
if (account == null)
{
return Fail("Account not found");
}

account.SyncType = request.SyncType;
await fanfouUserAccountRepository.UpdateAsync(account);

// Clear cache
fanfouUserAccountCacheService.ClearCache(currentUser.Id);

logger.LogInformation("Updated Fanfou account {AccountId} sync type to {SyncType} for user {UserId}",
id, request.SyncType, currentUser.Id);

return Success("Account updated successfully");
}
catch (Exception ex)
{
logger.LogError(ex, "Error updating Fanfou account {AccountId} for user {UserId}", id, currentUser.Id);
return Fail($"Failed to update account: {ex.Message}");
}
}

[HttpDelete("{id}")]
public async Task<ApiResult> DeleteAccount(long id)
{
try
{
var account = await fanfouUserAccountRepository.GetFirstOrDefaultAsync(a => a.Id == id && a.UserId == currentUser.Id);
if (account == null)
{
return Fail("Account not found");
}

await fanfouUserAccountRepository.DeleteAsync(account);

// Clear cache
fanfouUserAccountCacheService.ClearCache(currentUser.Id);

logger.LogInformation("Deleted Fanfou account {AccountId} for user {UserId}", id, currentUser.Id);

return Success("Account deleted successfully");
}
catch (Exception ex)
{
logger.LogError(ex, "Error deleting Fanfou account {AccountId} for user {UserId}", id, currentUser.Id);
return Fail($"Failed to delete account: {ex.Message}");
}
}
}
4 changes: 2 additions & 2 deletions src/HappyNotes.Api/Controllers/SyncQueueAdminController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using HappyNotes.Common;
using HappyNotes.Services.SyncQueue.Interfaces;
using HappyNotes.Services.SyncQueue.Models;
using HappyNotes.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -165,4 +165,4 @@ public async Task<ActionResult> HealthCheck()
});
}
}
}
}
4 changes: 4 additions & 0 deletions src/HappyNotes.Api/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ public static void RegisterServices(this IServiceCollection services)
services.AddScoped<INoteRepository, NoteRepository>();
services.AddSingleton<ITelegramService, TelegramService>();
services.AddSingleton<IMastodonTootService, MastodonTootService>();
services.AddSingleton<IFanfouStatusService, FanfouStatusService>();
services.AddSingleton<ITextToImageService, TextToImageService>();
services.AddSingleton<IMemoryCache, MemoryCache>();
services.AddScoped<ITelegramSettingsCacheService, TelegramSettingsCacheService>();
services.AddScoped<IMastodonUserAccountCacheService, MastodonUserAccountCacheService>();
services.AddScoped<IFanfouUserAccountCacheService, FanfouUserAccountCacheService>();
services.AddSingleton<IGeneralMemoryCacheService, GeneralMemoryCacheService>();
services.AddScoped<ISyncNoteService, MastodonSyncNoteService>();
services.AddScoped<ISyncNoteService, TelegramSyncNoteService>();
services.AddScoped<ISyncNoteService, ManticoreSyncNoteService>();
services.AddScoped<ISyncNoteService, FanfouSyncNoteService>();
services.AddScoped<ISearchService, SearchService>();
services.AddScoped<IDatabaseClient, DatabaseClient>();
}
Expand Down
4 changes: 3 additions & 1 deletion src/HappyNotes.Common/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public static class Constants
public const int MaxPageSize = 60;
public const int TelegramMessageLength = 4096;
public const int MastodonTootLength = 500;
public const int FanfouStatusLength = 140;
public const int TelegramCaptionLength = 1024;
public const string TelegramSameTokenFlag = "the same token as the last setting";
public const string MastodonAppName = "HappyNotes";
Expand All @@ -16,9 +17,10 @@ public static class Constants
public const string TelegramService = "telegram";
public const string MastodonService = "mastodon";
public const string ManticoreSearchService = "manticoresearch";
public const string FanfouService = "fanfou";

/// <summary>
/// All available sync services
/// </summary>
public static readonly string[] AllSyncServices = { TelegramService, MastodonService, ManticoreSearchService };
public static readonly string[] AllSyncServices = { TelegramService, MastodonService, ManticoreSearchService, FanfouService };
}
33 changes: 33 additions & 0 deletions src/HappyNotes.Common/Enums/FanfouSyncType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace HappyNotes.Common.Enums;

public enum FanfouSyncType
{
/// <summary>
/// Sync all notes
/// </summary>
All = 1,

/// <summary>
/// Public notes only
/// </summary>
PublicOnly = 2,

/// <summary>
/// Sync notes that have a fanfou tag
/// </summary>
TagFanfouOnly = 3,
}

public static class FanfouSyncTypeExtensions
{
public static FanfouSyncType Next(this FanfouSyncType syncType)
{
return syncType switch
{
FanfouSyncType.All => FanfouSyncType.PublicOnly,
FanfouSyncType.PublicOnly => FanfouSyncType.TagFanfouOnly,
FanfouSyncType.TagFanfouOnly => FanfouSyncType.All,
_ => FanfouSyncType.All,
};
}
}
Loading