diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3270866 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,55 @@ +name: Deploy +on: + workflow_dispatch: + push: + branches: + - main + paths-ignore: + - '.github/**' + - '.gitignore' + - '.editorconfig' + - 'LICENSE.md' + - '**/*/README.md' + - '**/*/Dockerfile' +jobs: + build: + name: Build and push Docker image + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Create version tag + id: version + run: echo "version=$(date +%Y.%m.%d.%H%M%S)-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + - name: Build and push image + working-directory: src/AltGen.API + run: | + TAG=${{ secrets.DOCKERHUB_USERNAME }}/api.altgen.stevanfreeborn.com:${{ steps.version.outputs.version }} + docker build -t $TAG . + docker push $TAG + deploy: + name: Deploy to server + runs-on: ubuntu-latest + needs: build + steps: + - name: Run image on server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + script: | + docker stop api.altgen.stevanfreeborn.com + docker rm api.altgen.stevanfreeborn.com + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/api.altgen.stevanfreeborn.com:${{ needs.build.outputs.version }} + docker run -d -p 7778:8080 --name api.altgen.stevanfreeborn.com ${{ secrets.DOCKERHUB_USERNAME }}/api.altgen.stevanfreeborn.com:${{ needs.build.outputs.version }} + diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..1560cc0 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,56 @@ +name: Pull Request +defaults: + run: + working-directory: ./src +on: + workflow_dispatch: + pull_request: + paths: + - src/AltGen.API/** + - src/AltGen.API.Tests/** + branches: + - main +jobs: + format: + name: Run dotnet format + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x.x + - name: Format project + run: dotnet format --verbosity normal + - name: Commit Changes + run: | + git config user.name "GitHub Actions" + git config user.email "<>" + if [[ $(git status --porcelain) ]]; then + git add . + git commit -m "chore: format fixes [skip ci]" + git fetch origin + git pull --rebase origin ${{ github.head_ref }} + git push origin HEAD:${{ github.head_ref }} + fi + build_and_test: + name: Run tests + needs: format + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x.x + - name: Restore dependencies + run: dotnet restore + - name: Build project + run: dotnet build --no-restore + - name: Test project + run: dotnet test + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a2b2126 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2025 Stevan Freeborn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/AltGen.API/.dockerignore b/src/AltGen.API/.dockerignore new file mode 100644 index 0000000..d29dbdd --- /dev/null +++ b/src/AltGen.API/.dockerignore @@ -0,0 +1,2 @@ +appsettings*.json +Dockerfile diff --git a/src/AltGen.API/Dockerfile b/src/AltGen.API/Dockerfile new file mode 100644 index 0000000..a5bbc47 --- /dev/null +++ b/src/AltGen.API/Dockerfile @@ -0,0 +1,17 @@ +# setup stage +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS setup-stage +WORKDIR /app +COPY *.csproj ./ +RUN dotnet restore +COPY . . + +# build stage +FROM setup-stage AS build-stage +RUN dotnet publish -c Release -o dist + +# production stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS production-stage +WORKDIR /app +COPY --from=build-stage /app/dist ./ +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AltGen.API.dll"] diff --git a/src/AltGen.API/Generate/Providers/Gemini/GeminiService.cs b/src/AltGen.API/Generate/Providers/Gemini/GeminiService.cs index 4d3caae..82fe2a3 100644 --- a/src/AltGen.API/Generate/Providers/Gemini/GeminiService.cs +++ b/src/AltGen.API/Generate/Providers/Gemini/GeminiService.cs @@ -4,7 +4,8 @@ class GeminiService(HttpClient httpClient) : IGeminiService { const string BaseUri = "https://generativelanguage.googleapis.com/v1beta/models"; const string Method = "generateContent"; - const string ModelId = "gemini-1.5-flash"; + // TODO: Maybe this is configurable by the user via request + const string ModelId = "gemini-2.0-flash"; const string ApiKeyQueryKey = "key"; // TODO: Use Lazy to load the prompt resource @@ -52,6 +53,8 @@ class GeminiService(HttpClient httpClient) : IGeminiService var response = await _httpClient.PostAsJsonAsync(requestUri, request, Options); var content = await response.Content.ReadAsStringAsync(); + // TODO: Propogate the error response from gemini in the exception + // we throw here. Need to figure out how gemini returns errors if (response.IsSuccessStatusCode is false) { throw new AltGenException("Failed to generate alt text."); diff --git a/src/AltGen.API/Program.cs b/src/AltGen.API/Program.cs index f99214f..6caf5b4 100644 --- a/src/AltGen.API/Program.cs +++ b/src/AltGen.API/Program.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.Threading.RateLimiting; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); @@ -7,6 +10,41 @@ builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddKeyedSingleton(LLMProvider.Gemini); +builder.Services.AddRateLimiter( + static o => + { + o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + o.OnRejected = static (context, rateLimitInfo) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + var retryDate = DateTime.UtcNow.Add(retryAfter); + context.HttpContext.Response.Headers.Append( + "x-retry-after", + retryDate.ToString("o", CultureInfo.InvariantCulture) + ); + } + + return ValueTask.CompletedTask; + }; + + o.GlobalLimiter = PartitionedRateLimiter.Create( + static ctx => RateLimitPartition.GetFixedWindowLimiter( + partitionKey: ctx.Request.Headers["X-Forwarded-For"].ToString() + ?? ctx.Connection.RemoteIpAddress?.ToString() + ?? "unknown", + factory: static partition => new FixedWindowRateLimiterOptions() + { + PermitLimit = 10, + QueueLimit = 0, + Window = TimeSpan.FromHours(1) + } + ) + ); + } +); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -14,6 +52,11 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } +if (app.Environment.IsProduction()) +{ + app.UseRateLimiter(); +} + app.UseHttpsRedirection(); app.UseStatusCodePages();