Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2de2aa6
fix(fe): close FE-2/FE-5 gaps + delete remaining app/* duplicates
Enes1998 May 26, 2026
0abacb2
fix(ci): use v-prefixed Trivy tag (v0.36.0)
Enes1998 May 26, 2026
1bbf640
Merge pull request #78 from Loopless-Portal/fix/duplicate-pages-and-t…
Enes1998 May 26, 2026
29d9683
ci: add develop to push + pull_request triggers
Enes1998 May 26, 2026
e96a101
Merge pull request #79 from Loopless-Portal/fix/ci-trigger-develop
Enes1998 May 26, 2026
c3327d4
ci: jest.config TS->JS (drop ts-node), develop triggers, codeql v4, t…
Enes1998 May 26, 2026
fd832a1
Merge pull request #80 from Loopless-Portal/fix/ci-trigger-develop
Enes1998 May 26, 2026
b247704
ci: jest.config TS->JS, develop triggers, trufflehog auto-detect, sar…
Enes1998 May 26, 2026
08e4af3
Merge pull request #81 from Loopless-Portal/fix/ci-trigger-develop
Enes1998 May 26, 2026
3cbeeac
Fix-Getting Started dynamically differs based on user role
EdiAsllani Jun 2, 2026
efaa951
Fix-TanStack query caches synchronously, refresh on new conversation …
EdiAsllani Jun 2, 2026
cd8a981
Fix-UnreadCount marks messages as read, file download instead of redi…
EdiAsllani Jun 2, 2026
cb5d5d6
Fix-Add DisableRateLimiting to both hub mappings, negotiate goes thro…
EdiAsllani Jun 2, 2026
dff8d7a
Fix- Update settings, update edit profile, add password change setting
EdiAsllani Jun 2, 2026
1f088c4
Merge remote-tracking branch 'origin/main' into develop
EdiAsllani Jun 10, 2026
0088b6e
fix(devops): drop dangling secrets.yaml refs from legacy kustomize ov…
EdiAsllani Jun 10, 2026
550def7
ci: exclude documented placeholder connstrings from TruffleHog
EdiAsllani Jun 10, 2026
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
27 changes: 13 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop

permissions:
contents: read
security-events: write
packages: write
actions: read

# Run Node20-based actions on Node24 (GitHub force-migrates 2026-06-16). Silences the
# deprecation warnings and future-proofs the workflow.
Expand All @@ -31,13 +34,10 @@ jobs:
- name: Run TruffleHog (verified secrets only)
uses: trufflesecurity/trufflehog@main
with:
path: ./
# On a PR: scan the PR diff. On a push/merge to main: scan the pushed range
# (before..after). The previous default-branch/HEAD config made base==head on
# main pushes, so TruffleHog scanned nothing.
base: ${{ github.event.pull_request.base.sha || github.event.before }}
head: ${{ github.event.pull_request.head.sha || github.sha }}
extra_args: --results=verified,unknown
# The action mounts the workspace at /tmp inside the scanner container.
# .trufflehog-exclude.txt skips deploy docs whose placeholder connection
# strings (Password=__DB__) false-positive the SQLServer detector.
extra_args: --results=verified,unknown --exclude-paths=/tmp/.trufflehog-exclude.txt

quality:
name: Lint, Test, Build
Expand Down Expand Up @@ -252,14 +252,13 @@ jobs:
ignore-unfixed: true
exit-code: "1"

- name: Upload Trivy SARIF report
# Code-scanning SARIF upload requires GitHub Advanced Security (not available on
# private repos by default). Keep it non-fatal — the Trivy image scans above are
# the real gate; this step only feeds the Security tab when GHAS is enabled.
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
- name: Upload Trivy SARIF report (artifact)
if: always()
uses: actions/upload-artifact@v4
with:
sarif_file: trivy-results.sarif
name: trivy-sarif
path: trivy-results.sarif
retention-days: 30

build-images:
name: Build & Push Docker Images
Expand Down
6 changes: 6 additions & 0 deletions .trufflehog-exclude.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Paths excluded from TruffleHog secret scanning (regex per line).
# These are deploy DOCUMENTATION containing placeholder connection strings
# (Password=$DB / Password=__DB__) that pattern-match the SQLServer detector
# but contain no real credentials. Never put real values in these files.
devops/k8s/project-01/README\.md
devops/k8s/project-01/secret\.example\.yaml
20 changes: 20 additions & 0 deletions backend/src/Loopless.Api/Endpoints/FileEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Loopless.Application.DTOs;
using Loopless.Application.Features.Files.GetFileDownload;
using Loopless.Application.Features.Files.GetProjectFiles;
using Loopless.Application.Features.Files.Upload;
using MediatR;
Expand Down Expand Up @@ -59,6 +60,25 @@ public static IEndpointRouteBuilder MapFileEndpoints(this IEndpointRouteBuilder
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

// Streams the file through the API so the browser downloads it directly,
// instead of redirecting to a presigned storage URL whose host (e.g.
// minio:9000) is unreachable outside the Docker network.
group.MapGet("/{projectId:guid}/files/{fileId:guid}/download", async (
[FromRoute] Guid projectId,
[FromRoute] Guid fileId,
ISender sender,
CancellationToken ct) =>
{
var file = await sender.Send(new GetFileDownloadQuery(projectId, fileId), ct);
return Results.File(file.Content, file.ContentType, file.FileName);
})
.WithName("DownloadProjectFile")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

return app;
}

Expand Down
15 changes: 15 additions & 0 deletions backend/src/Loopless.Api/Endpoints/MessageEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Loopless.Application.Features.Messages.GetConversations;
using Loopless.Application.Features.Messages.GetHistory;
using Loopless.Application.Features.Messages.MarkAsRead;
using Loopless.Application.Features.Messages.MarkConversationRead;
using Loopless.Application.Features.Messages.Send;
using MediatR;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -92,6 +93,20 @@ public static IEndpointRouteBuilder MapMessageEndpoints(this IEndpointRouteBuild
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);

group.MapPut("/conversations/{id:guid}/read", async (
[FromRoute] Guid id,
ISender sender,
CancellationToken ct) =>
{
await sender.Send(new MarkConversationReadCommand(id), ct);
return Results.NoContent();
})
.WithName("MarkConversationRead")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

return app;
}

Expand Down
17 changes: 17 additions & 0 deletions backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Loopless.Application.Features.Projects.GenerateSummary;
using Loopless.Application.Features.Projects.GetCommits;
using Loopless.Application.Features.Projects.GetDashboard;
using Loopless.Application.Features.Projects.GetMyApplications;
using Loopless.Application.Features.Projects.GetMyInvitations;
using Loopless.Application.Features.Projects.GetProjectInvitations;
using Loopless.Application.Features.Projects.GetProjectLinks;
Expand Down Expand Up @@ -501,6 +502,22 @@ public static IEndpointRouteBuilder MapInvitationEndpoints(this IEndpointRouteBu
.Produces<IReadOnlyList<InvitationDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized);

// Applications the freelancer initiated themselves. Kept separate from
// /invitations/me so the projects "Pending invitations" view (which lists
// invitations to accept/decline) is not polluted with outgoing applications.
app.MapGet("/api/v1/applications/me", async (
ISender sender,
CancellationToken ct) =>
{
var result = await sender.Send(new GetMyApplicationsQuery(), ct);
return Results.Ok(result);
})
.WithTags("Invitations")
.RequireAuthorization()
.WithName("GetMyApplications")
.Produces<IReadOnlyList<InvitationDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized);

group.MapPut("/{invitationId:guid}/accept", async (
[FromRoute] Guid invitationId,
ISender sender,
Expand Down
16 changes: 16 additions & 0 deletions backend/src/Loopless.Api/Endpoints/SettingsEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Loopless.Application.Features.Settings.ChangePassword;
using Loopless.Application.Features.Settings.UpdateNotificationSettings;
using MediatR;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -20,5 +21,20 @@ public static void MapSettingsEndpoints(this IEndpointRouteBuilder app)
})
.WithName("UpdateNotificationSettings")
.WithSummary("Toggle email notification preferences for the current user.");

group.MapPost("/password", async (
[FromBody] ChangePasswordCommand command,
IMediator mediator,
CancellationToken ct) =>
{
await mediator.Send(command, ct);
return Results.NoContent();
})
.RequireRateLimiting("auth")
.WithName("ChangePassword")
.WithSummary("Change the current user's password after verifying the existing one.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.ProducesValidationProblem();
}
}
21 changes: 14 additions & 7 deletions backend/src/Loopless.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,13 @@
app.MapFeaturesEndpoints();
app.MapNotificationEndpoints();
app.MapProfileEndpoints();
app.MapHub<MessagingHub>(MessagingHub.HubPath).RequireAuthorization();
app.MapHub<NotificationsHub>(NotificationsHub.HubPath).RequireAuthorization();
// Exempt SignalR hubs from the global HTTP rate limiter. Connection
// negotiation + reconnect attempts are connection management, not API calls,
// and would otherwise exhaust the per-IP budget and 429 the negotiate request
// (which then fails the WebSocket handshake). Hub method calls travel over the
// socket, so they aren't covered by the HTTP limiter anyway.
app.MapHub<MessagingHub>(MessagingHub.HubPath).RequireAuthorization().DisableRateLimiting();
app.MapHub<NotificationsHub>(NotificationsHub.HubPath).RequireAuthorization().DisableRateLimiting();

_ = builder.Configuration.GetConnectionString("PostgreSQL")
?? throw new InvalidOperationException("PostgreSQL connection string is required. Set ConnectionStrings__PostgreSQL in configuration.");
Expand All @@ -266,24 +271,26 @@
app.MapHangfireDashboard("/hangfire").RequireAuthorization(KeycloakAuthExtensions.AdminPolicy);
}

if (!app.Environment.IsEnvironment("Test"))
if (!app.Environment.IsEnvironment("Test") && !string.IsNullOrEmpty(pgConnForHangfire))
{
RecurringJob.AddOrUpdate<StandupReminderJob>(
var recurringJobs = app.Services.GetRequiredService<IRecurringJobManager>();

recurringJobs.AddOrUpdate<StandupReminderJob>(
StandupReminderJob.RecurringJobId,
job => job.RunAsync(CancellationToken.None),
"0 9 * * 1-5");

RecurringJob.AddOrUpdate<BlockerDetectionJob>(
recurringJobs.AddOrUpdate<BlockerDetectionJob>(
BlockerDetectionJob.RecurringJobId,
job => job.RunAsync(CancellationToken.None),
Cron.Daily);

RecurringJob.AddOrUpdate<GitHubSyncJob>(
recurringJobs.AddOrUpdate<GitHubSyncJob>(
GitHubSyncJob.RecurringJobId,
job => job.SyncAllAsync(CancellationToken.None),
"*/30 * * * *");

RecurringJob.AddOrUpdate<UnreadMessageEmailJob>(
recurringJobs.AddOrUpdate<UnreadMessageEmailJob>(
UnreadMessageEmailJob.RecurringJobId,
job => job.RunAsync(CancellationToken.None),
"*/5 * * * *");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using MediatR;

namespace Loopless.Application.Features.Files.GetFileDownload;

public sealed record GetFileDownloadQuery(Guid ProjectId, Guid FileId)
: IRequest<FileDownloadResult>;

/// <summary>Open read stream for a stored file plus the metadata needed to return it
/// as an attachment. The stream is owned by the caller (the endpoint disposes it).</summary>
public sealed record FileDownloadResult(Stream Content, string FileName, string ContentType);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Application.Interfaces;
using Loopless.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Features.Files.GetFileDownload;

public sealed class GetFileDownloadQueryHandler(
IAppDbContext db,
ICurrentUser currentUser,
IFileStorageService storage) : IRequestHandler<GetFileDownloadQuery, FileDownloadResult>
{
public async Task<FileDownloadResult> Handle(
GetFileDownloadQuery request,
CancellationToken cancellationToken)
{
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");

var user = await db.Users
.FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken)
?? throw new NotFoundException("User not found.");

var project = await db.Projects
.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken)
?? throw new NotFoundException("Project not found.");

var isOwner = project.OwnerId == user.Id;
var isAcceptedMember = await db.ProjectInvitations
.AnyAsync(
i => i.ProjectId == project.Id
&& (i.EnterpriseId == user.Id || i.FreelancerId == user.Id)
&& i.Status == InvitationStatus.Accepted,
cancellationToken);

if (!isOwner && !isAcceptedMember)
throw new ForbiddenException("Only project members can download files.");

var file = await db.ProjectFiles
.FirstOrDefaultAsync(
f => f.Id == request.FileId && f.ProjectId == project.Id,
cancellationToken)
?? throw new NotFoundException("File not found.");

var stream = await storage.OpenReadAsync(file.FileUrl, cancellationToken);

// application/octet-stream + a download name forces a download rather than
// inline rendering, which is the desired behaviour for project files.
return new FileDownloadResult(stream, file.FileName, "application/octet-stream");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using MediatR;

namespace Loopless.Application.Features.Messages.MarkConversationRead;

/// <summary>Marks every unread message the current user received in a conversation as read.
/// Returns the number of messages updated.</summary>
public sealed record MarkConversationReadCommand(Guid ConversationId) : IRequest<int>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Application.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Features.Messages.MarkConversationRead;

public sealed class MarkConversationReadCommandHandler(
IAppDbContext db,
ICurrentUser currentUser) : IRequestHandler<MarkConversationReadCommand, int>
{
public async Task<int> Handle(MarkConversationReadCommand request, CancellationToken cancellationToken)
{
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");

var me = await db.Users
.FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken)
?? throw new NotFoundException("User not found.");

var conversation = await db.Conversations
.FirstOrDefaultAsync(c => c.Id == request.ConversationId, cancellationToken)
?? throw new NotFoundException("Conversation not found.");

if (conversation.Participant1Id != me.Id && conversation.Participant2Id != me.Id)
throw new UnauthorizedException("Not a participant of this conversation.");

var unread = await db.Messages
.Where(m => m.ConversationId == conversation.Id
&& m.SenderId != me.Id
&& m.ReadAt == null)
.ToListAsync(cancellationToken);

if (unread.Count == 0)
return 0;

var now = DateTimeOffset.UtcNow;
foreach (var message in unread)
{
message.ReadAt = now;
message.UpdatedAt = now;
}

await db.SaveChangesAsync(cancellationToken);
return unread.Count;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using FluentValidation;

namespace Loopless.Application.Features.Messages.MarkConversationRead;

public sealed class MarkConversationReadCommandValidator : AbstractValidator<MarkConversationReadCommand>
{
public MarkConversationReadCommandValidator()
{
RuleFor(x => x.ConversationId).NotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Loopless.Application.DTOs;
using MediatR;

namespace Loopless.Application.Features.Projects.GetMyApplications;

public sealed record GetMyApplicationsQuery() : IRequest<IReadOnlyList<InvitationDto>>;
Loading
Loading