Compare commits
2 Commits
3ee5e5367d
...
4e9943e6a2
Author | SHA1 | Date | |
---|---|---|---|
|
4e9943e6a2 | ||
|
b3cc623168 |
1884
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
generated
Normal file
1884
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
114
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
Normal file
114
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddApiKeys : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||||
|
table: "auth_sessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "label",
|
||||||
|
table: "auth_sessions");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "challenge_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "api_keys",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
session_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_api_keys", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_api_keys_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_api_keys_auth_sessions_session_id",
|
||||||
|
column: x => x.session_id,
|
||||||
|
principalTable: "auth_sessions",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_api_keys_account_id",
|
||||||
|
table: "api_keys",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_api_keys_session_id",
|
||||||
|
table: "api_keys",
|
||||||
|
column: "session_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
column: "challenge_id",
|
||||||
|
principalTable: "auth_challenges",
|
||||||
|
principalColumn: "id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||||
|
table: "auth_sessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "api_keys");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "challenge_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uuid",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "label",
|
||||||
|
table: "auth_sessions",
|
||||||
|
type: "character varying(1024)",
|
||||||
|
maxLength: 1024,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
column: "challenge_id",
|
||||||
|
principalTable: "auth_challenges",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -800,6 +800,51 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.ToTable("account_statuses", (string)null);
|
b.ToTable("account_statuses", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("label");
|
||||||
|
|
||||||
|
b.Property<Guid>("SessionId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("session_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_api_keys");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_api_keys_account_id");
|
||||||
|
|
||||||
|
b.HasIndex("SessionId")
|
||||||
|
.HasDatabaseName("ix_api_keys_session_id");
|
||||||
|
|
||||||
|
b.ToTable("api_keys", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -961,7 +1006,7 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("app_id");
|
.HasColumnName("app_id");
|
||||||
|
|
||||||
b.Property<Guid>("ChallengeId")
|
b.Property<Guid?>("ChallengeId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("challenge_id");
|
.HasColumnName("challenge_id");
|
||||||
|
|
||||||
@@ -977,11 +1022,6 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("expired_at");
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
b.Property<string>("Label")
|
|
||||||
.HasMaxLength(1024)
|
|
||||||
.HasColumnType("character varying(1024)")
|
|
||||||
.HasColumnName("label");
|
|
||||||
|
|
||||||
b.Property<Instant?>("LastGrantedAt")
|
b.Property<Instant?>("LastGrantedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_granted_at");
|
.HasColumnName("last_granted_at");
|
||||||
@@ -1632,6 +1672,27 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_api_keys_accounts_account_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Pass.Auth.AuthSession", "Session")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SessionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_api_keys_auth_sessions_session_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
|
||||||
|
b.Navigation("Session");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||||
@@ -1675,8 +1736,6 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge")
|
b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("ChallengeId")
|
.HasForeignKey("ChallengeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired()
|
|
||||||
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
|
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
|
||||||
|
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Sphere.Publisher;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
@@ -8,7 +8,8 @@ namespace DysonNetwork.Sphere.WebReader;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/feeds")]
|
[Route("/api/feeds")]
|
||||||
public class WebFeedPublicController(
|
public class WebFeedPublicController(
|
||||||
AppDatabase db
|
AppDatabase db,
|
||||||
|
WebFeedService webFeed
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -102,9 +103,9 @@ public class WebFeedPublicController(
|
|||||||
/// List all feeds the current user is subscribed to
|
/// List all feeds the current user is subscribed to
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>List of subscribed feeds</returns>
|
/// <returns>List of subscribed feeds</returns>
|
||||||
[HttpGet("me")]
|
[HttpGet("subscribed")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> GetMySubscriptions(
|
public async Task<ActionResult<WebFeed>> GetSubscribedFeeds(
|
||||||
[FromQuery] int offset = 0,
|
[FromQuery] int offset = 0,
|
||||||
[FromQuery] int take = 20
|
[FromQuery] int take = 20
|
||||||
)
|
)
|
||||||
@@ -129,4 +130,141 @@ public class WebFeedPublicController(
|
|||||||
Response.Headers["X-Total"] = totalCount.ToString();
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
return Ok(subscriptions);
|
return Ok(subscriptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get articles from subscribed feeds
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<WebFeed>> GetWebFeedArticles(
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int take = 20
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var subscribedFeedIds = await db.WebFeedSubscriptions
|
||||||
|
.Where(s => s.AccountId == accountId)
|
||||||
|
.Select(s => s.FeedId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var query = db.WebFeeds
|
||||||
|
.Where(f => subscribedFeedIds.Contains(f.Id))
|
||||||
|
.Include(f => f.Publisher)
|
||||||
|
.OrderByDescending(f => f.CreatedAt);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var feeds = await query
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
return Ok(feeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get feed metadata by ID (public endpoint)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="feedId">The ID of the feed</param>
|
||||||
|
/// <returns>Feed metadata</returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("{feedId:guid}")]
|
||||||
|
public async Task<ActionResult<WebFeed>> GetFeedById(Guid feedId)
|
||||||
|
{
|
||||||
|
var feed = await webFeed.GetFeedAsync(feedId);
|
||||||
|
if (feed == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get articles from a specific feed (public endpoint)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="feedId">The ID of the feed</param>
|
||||||
|
/// <param name="offset">Number of articles to skip</param>
|
||||||
|
/// <param name="take">Maximum number of articles to return</param>
|
||||||
|
/// <returns>List of articles from the feed</returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("{feedId:guid}/articles")]
|
||||||
|
public async Task<ActionResult<WebArticle>> GetFeedArticles(
|
||||||
|
[FromRoute] Guid feedId,
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int take = 20
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Check if feed exists
|
||||||
|
var feedExists = await db.WebFeeds.AnyAsync(f => f.Id == feedId);
|
||||||
|
if (!feedExists)
|
||||||
|
return NotFound("Feed not found");
|
||||||
|
|
||||||
|
var query = db.WebArticles
|
||||||
|
.Where(a => a.FeedId == feedId)
|
||||||
|
.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.Include(a => a.Feed)
|
||||||
|
.ThenInclude(f => f.Publisher);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var articles = await query
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
return Ok(articles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explore available web feeds
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("explore")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<WebFeed>> ExploreFeeds(
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int take = 20,
|
||||||
|
[FromQuery] string? query = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
// Get IDs of already subscribed feeds
|
||||||
|
var subscribedFeedIds = await db.WebFeedSubscriptions
|
||||||
|
.Where(s => s.AccountId == accountId)
|
||||||
|
.Select(s => s.FeedId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var feedsQuery = db.WebFeeds
|
||||||
|
.Include(f => f.Publisher)
|
||||||
|
.Where(f => !subscribedFeedIds.Contains(f.Id))
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
// Apply search filter if query is provided
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
var searchTerm = $"%{query}%";
|
||||||
|
feedsQuery = feedsQuery.Where(f =>
|
||||||
|
EF.Functions.ILike(f.Title, searchTerm) ||
|
||||||
|
(f.Description != null && EF.Functions.ILike(f.Description, searchTerm))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by most recently created first
|
||||||
|
feedsQuery = feedsQuery.OrderByDescending(f => f.CreatedAt);
|
||||||
|
|
||||||
|
var totalCount = await feedsQuery.CountAsync();
|
||||||
|
var feeds = await feedsQuery
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
return Ok(feeds);
|
||||||
|
}
|
||||||
}
|
}
|
@@ -31,7 +31,10 @@ public class WebFeedService(
|
|||||||
|
|
||||||
public async Task<WebFeed?> GetFeedAsync(Guid id, Guid? publisherId = null)
|
public async Task<WebFeed?> GetFeedAsync(Guid id, Guid? publisherId = null)
|
||||||
{
|
{
|
||||||
var query = database.WebFeeds.Where(a => a.Id == id).AsQueryable();
|
var query = database.WebFeeds
|
||||||
|
.Include(a => a.Publisher)
|
||||||
|
.Where(a => a.Id == id)
|
||||||
|
.AsQueryable();
|
||||||
if (publisherId.HasValue)
|
if (publisherId.HasValue)
|
||||||
query = query.Where(a => a.PublisherId == publisherId.Value);
|
query = query.Where(a => a.PublisherId == publisherId.Value);
|
||||||
return await query.FirstOrDefaultAsync();
|
return await query.FirstOrDefaultAsync();
|
||||||
|
Reference in New Issue
Block a user