diff --git a/.github/workflows/checkout.yaml b/.github/workflows/checkout.yaml new file mode 100644 index 0000000..77ccafc --- /dev/null +++ b/.github/workflows/checkout.yaml @@ -0,0 +1,50 @@ +on: + push: + branches: + - development + +env: + IMAGE_NAME: sigmaproductions/sigmachatserver + +jobs: + build: + runs-on: [self-hosted, build] + steps: + - name: Set build tag + run: | + echo "IMAGE_TAG=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" + - name: Checkout repository + uses: actions/checkout@v2 + - name: Configure appsettings.json + env: + appsettings: ${{ secrets.appsettings }} + run: | + echo "$appsettings" > appsettings.json + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build the Docker image + run: docker build . --file Dockerfile --tag ${IMAGE_NAME}:${IMAGE_TAG} + - name: Publish the Docker image + run: docker push ${IMAGE_NAME}:${IMAGE_TAG} + deploy: + needs: build + runs-on: [self-hosted, deploy] + steps: + - name: Set build tag + run: | + echo "IMAGE_TAG=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" + - name: Checkout repository + uses: actions/checkout@v2 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Pull and deploy + env: + AZURITE_ACCOUNTS: ${{ secrets.azurite_accounts }} + run: docker-compose -f compose.prod.yaml pull && + docker-compose -f compose.prod.yaml up -d diff --git a/.gitignore b/.gitignore index cdb73de..07b68d3 100644 --- a/.gitignore +++ b/.gitignore @@ -253,3 +253,5 @@ paket-files/ # JetBrains Rider .idea/ *.sln.iml + +appsettings.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..395948c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "Build: SigmaChatServer.fsproj", + "program": "${workspaceFolder}/bin/Debug/net7.0/SigmaChatServer.App.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "internalConsole" + }, + { + "name": "F#: Debug", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/SigmaChatServer.fsproj" + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..c00cbb4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "msbuild", + "problemMatcher": ["$msCompile"], + "group": "build", + "label": "Build: SigmaChatServer.fsproj", + "detail": "Build the SigmaChatServer.fsproj project using dotnet build" + } + ] +} diff --git a/BlobHandlers.fs b/BlobHandlers.fs new file mode 100644 index 0000000..4938128 --- /dev/null +++ b/BlobHandlers.fs @@ -0,0 +1,90 @@ +namespace SigmaChatServer + +module BlobHandlers = + + open Microsoft.AspNetCore.Http + open Giraffe + open System.Data + open SigmaChatServer.Models + open Npgsql + open Azure.Storage.Blobs + open System + open Giraffe.HttpStatusCodeHandlers.RequestErrors + open UserQueries + open System.IO + open UserQueries + open Minio + open Minio.DataModel.Args + open Microsoft.Extensions.Configuration + + [] + type FormModel = { Image: IFormFile; Test: string } + // Check if file is an image based on MIME type + let isImage (contentType: string) = + match contentType with + | "image/jpeg" + | "image/png" + | "image/gif" -> true + | _ -> false + + let getPublicBlobUrl (config: IConfiguration) blobName = + let minioSettings = config.GetSection("Minio") + minioSettings.GetValue("PublicBucketUrl") + blobName + + let private processHttpFileRequest (next: HttpFunc) (ctx: HttpContext) handler = + task { + match ctx.Request.HasFormContentType with + | true -> + let! form = ctx.Request.ReadFormAsync() |> Async.AwaitTask + let files = form.Files + + if files.Count > 0 then + let file = files.[0] + return! handler file + else + return! badRequest (text "No files uploaded") next ctx + | false -> return! badRequest (text "Unsupported media type") next ctx + } + + let private uploadProfilePicture (userId: string) (next: HttpFunc) (ctx: HttpContext) (file: IFormFile) = + task { + let containerClient = ctx.GetService() + let minioSettings = ctx.GetService().GetSection("Minio") + + let guid = Guid.NewGuid().ToString() + Path.GetExtension(file.FileName) + + let fileModel = + { UserId = userId + BlobName = guid + OriginalFilename = file.FileName } + + use stream = file.OpenReadStream() + + let putArgs = + (new PutObjectArgs()) + .WithBucket(minioSettings.GetValue("PublicBucketName")) + .WithObject(fileModel.BlobName) + .WithObjectSize(stream.Length) + .WithContentType(file.ContentType) + .WithStreamData(stream) + + let! _ = containerClient.PutObjectAsync(putArgs) + let! _ = upsertProfilePicture ctx fileModel + + let blobUrl = getPublicBlobUrl minioSettings fileModel.BlobName + return! text blobUrl next ctx + } + + let profilePictureUploadHandler (next: HttpFunc) (ctx: HttpContext) = + let userId = ctx.User.Identity.Name + uploadProfilePicture userId next ctx |> processHttpFileRequest next ctx + + +//Note: +// let presignesArgs = +// (new PresignedGetObjectArgs()) +// .WithBucket("public-jehovahs-pictures") +// .WithObject(guidName) +// .WithExpiry(60) + +// let! z = containerClient.PresignedGetObjectAsync(presignesArgs) diff --git a/ChatDb.fs b/ChatDb.fs deleted file mode 100644 index 3742cab..0000000 --- a/ChatDb.fs +++ /dev/null @@ -1,18 +0,0 @@ - -namespace SigmaChatServer - -module ChatDb = - - open SigmaChatServer.Models - open Microsoft.AspNetCore.Http - open Giraffe - open System.Data - open Dapper - - let getChat (ctx: HttpContext)= - task { - use connection = ctx.GetService() - let! chat = connection.QueryAsync - "SELECT * FROM Chats" - return chat - } \ No newline at end of file diff --git a/DapperHelper.fs b/DapperHelper.fs new file mode 100644 index 0000000..63eb707 --- /dev/null +++ b/DapperHelper.fs @@ -0,0 +1,39 @@ +module Dapper.Extensions + +open System +open Dapper + +let extractValue (x: obj) = + match x with + | null -> null + | _ -> + match x.GetType().GetProperty("Value") with + | null -> x + | prop -> prop.GetValue(x) + +let (+>) (map: Map) (key, value) = map.Add(key, extractValue value) +let singleParam (key, value) = (Map.empty) +> (key, value) + +type OptionHandler<'T>() = + inherit SqlMapper.TypeHandler>() + + override __.SetValue(param, value) = + let valueOrNull = + match value with + | Some x -> box x + | None -> null + + param.Value <- valueOrNull + + override __.Parse value = + if isNull value || value = box DBNull.Value then + None + else + Some(value :?> 'T) + +let registerTypeHandlers () = + SqlMapper.AddTypeHandler(OptionHandler()) + SqlMapper.AddTypeHandler(OptionHandler()) + SqlMapper.AddTypeHandler(OptionHandler()) + SqlMapper.AddTypeHandler(OptionHandler()) + SqlMapper.AddTypeHandler(OptionHandler()) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd969f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build + +WORKDIR /app + +# Copy source code and compile +COPY ./ ./ +RUN dotnet restore + +RUN dotnet publish -o bin + +# Build runtime image +FROM mcr.microsoft.com/dotnet/aspnet:7.0-alpine AS runtime + +WORKDIR /app +COPY --from=build /app/bin . + +ENTRYPOINT ["dotnet", "SigmaChatServer.App.dll"] diff --git a/HttpHandlers.fs b/HttpHandlers.fs index 40c86e2..d16f884 100644 --- a/HttpHandlers.fs +++ b/HttpHandlers.fs @@ -5,10 +5,162 @@ module HttpHandlers = open Microsoft.AspNetCore.Http open Giraffe open SigmaChatServer.Models - open SigmaChatServer.ChatDb + open SigmaChatServer.ChatQueries + open System.Data + open System + open SigmaChatServer.WebPush + open Hub + open Microsoft.AspNetCore.SignalR + open UserQueries + open SigmaChatServer.BlobHandlers + open System.Threading.Tasks + open Microsoft.Extensions.Configuration + open Minio.DataModel.Args + open Minio - let handleGetChat (next : HttpFunc) (ctx : HttpContext) = - task { - let! chat = getChat ctx - return! json chat next ctx - } \ No newline at end of file + let handleGetChats (chatId: int) (next: HttpFunc) (ctx: HttpContext) = + task { + let! chat = getChat ctx chatId + + return! + match chat with + | Some chat -> json chat next ctx + | None -> json (RequestErrors.NOT_FOUND(text "Basic")) next ctx + } + + let handlePostChat (next: HttpFunc) (ctx: HttpContext) = + task { + let! chatId = postChat ctx + return! json chatId next ctx + } + + + let updateSchema (next: HttpFunc) (ctx: HttpContext) = + task { + let connection = ctx.GetService() + + do setupDatabaseSchema connection |> ignore + + let settings = ctx.GetService() + let client = ctx.GetService() + let minioSection = settings.GetSection("Minio") + + let checkArgs = + (new BucketExistsArgs()).WithBucket(minioSection.["PublicBucketName"]) + + do + client.BucketExistsAsync(checkArgs) + |> (fun exists -> + task { + let! exists = exists + + return + match exists with + | false -> + let createArgs = + (new MakeBucketArgs()).WithBucket(minioSection.["PublicBucketName"]) + + let policy = minioSection.["Policy"] + + let policyArgs = + (new SetPolicyArgs()) + .WithBucket(minioSection.["PublicBucketName"]) + .WithPolicy(policy) + + client.MakeBucketAsync(createArgs) |> ignore + client.SetPolicyAsync(policyArgs) |> ignore + | true -> () + }) + |> ignore + + return! json Ok next ctx + } + + let handleGetMessages (chatId: int, paginationDate: string) (next: HttpFunc) (ctx: HttpContext) = + task { + return! + match DateTime.TryParse(paginationDate) with + | true, date -> + task { + let! messages = getMessages ctx chatId (date.ToUniversalTime()) + return! json messages next ctx + } + | _ -> RequestErrors.BAD_REQUEST (text "Couldnt parse pagination date") next ctx + } + + let handlePostMessage (next: HttpFunc) (ctx: HttpContext) = + task { + let hub = ctx.GetService>() + let userId = ctx.User.Identity.Name + + let processTooShortMessage () = + task { return! RequestErrors.BAD_REQUEST (text "Basic") next ctx } + + let processCorrectMessage model = + task { + let! createdMessage = insertMessage ctx model userId + let! allUserids = getAllUserIds ctx + do! notifyNewMessageCreated hub createdMessage + let! _ = webpushMessageForUser ctx allUserids model + + return! json createdMessage next ctx + } + + //Note: left for next story + // let handlePostAttachmentMessage (next:HttpFunc)(ctx: HttpContext) model= + // task{ + + + // } + + let! createMessageModel = ctx.BindJsonAsync() + + return! + match createMessageModel with + | model when model.Text.Length = 0 -> processTooShortMessage () + | model -> processCorrectMessage model + } + + let handleCallback (next: HttpFunc) (ctx: HttpContext) = + task { + let userId = ctx.User.Identity.Name + let! userInDb = getUser ctx userId + + let! resultingUser = + match userInDb with + | Some user -> Task.FromResult(user) + | None -> createUser ctx userId + + return! json resultingUser next ctx + } + + let handleUpdateMeProfile (next: HttpFunc) (ctx: HttpContext) = + task { + let userId = ctx.User.Identity.Name + let! updateMeModel = ctx.BindJsonAsync() + do! updateUser ctx userId updateMeModel + + return! json None next ctx + } + + let handleGetUserMe (next: HttpFunc) (ctx: HttpContext) = + task { + let userId = ctx.User.Identity.Name + let! user = getUser ctx userId + + let embelishWithProfileUrl (u: User) = + let configuration = ctx.GetService() + + match u.ProfilePictureBlob with + | Some profilePictureBlob -> + getPublicBlobUrl configuration profilePictureBlob + |> fun url -> { u with ProfilePictureBlob = Some url } + | None -> u + + let res = + match user with + | Some u -> json (u |> embelishWithProfileUrl) next ctx + | None -> RequestErrors.UNAUTHORIZED "Basic" "" "You must be logged in." next ctx + + return! res + } diff --git a/Hub.fs b/Hub.fs new file mode 100644 index 0000000..517fab2 --- /dev/null +++ b/Hub.fs @@ -0,0 +1,16 @@ +namespace SigmaChatServer + +module Hub = + open Microsoft.AspNetCore.SignalR + open System + open SigmaChatServer.Models + + type ChatHub() = + inherit Hub() + + override this.OnConnectedAsync() = + Console.WriteLine("connected: " + this.Context.ConnectionId) + base.OnConnectedAsync() + + let notifyNewMessageCreated (hubContext: IHubContext) (message: MessageModel) = + task { return! hubContext.Clients.All.SendAsync("ReceiveMessage", message) } diff --git a/Models.fs b/Models.fs index a24f3d7..c1f3c01 100644 --- a/Models.fs +++ b/Models.fs @@ -1,13 +1,32 @@ namespace SigmaChatServer.Models +open System + +[] +type MessageModel = + { MessageId: int + ChatId: int + UserNickname: string option + UserProfilePicture: string option + Text: string + DateCreated: DateTime } + +type CreateMessageModel = { ChatId: int; Text: string } + [] -type Message ={ - Sender: string - Text: string -} +type WebPushSubscriptionModel = { ChatId: int; Json: string } [] type Chat = - { - Messages: List - } \ No newline at end of file + { ChatId: int + Messages: List } + +[] +type User = + { Id: string + Email: string option + Nickname: string + ProfilePictureBlob: string option } + +[] +type UpdateMeModel = { Nickname: string } diff --git a/Program.fs b/Program.fs index c3c3cb0..0abed04 100644 --- a/Program.fs +++ b/Program.fs @@ -8,31 +8,40 @@ open Microsoft.Extensions.Hosting open Microsoft.Extensions.Logging open Microsoft.Extensions.DependencyInjection open Giraffe -open SigmaChatServer.HttpHandlers -open SigmaChatServer.ChatDb +open SigmaChatServer.Routing open System.Data open Microsoft.Extensions.Configuration -open Microsoft.Data.SqlClient - +open Npgsql +open Microsoft.AspNetCore.Http +open System.Threading.Tasks +open Microsoft.AspNet.SignalR +open Microsoft.AspNet.SignalR.Hubs +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.DependencyInjection +open Microsoft.IdentityModel.Tokens +open Microsoft.AspNetCore.Hosting +open Microsoft.AspNetCore.Authentication.JwtBearer +open Azure.Storage.Blobs +open Hub +open Microsoft.IdentityModel.Claims +open Azure.Storage.Blobs.Models +open Dapper.Extensions +open Newtonsoft.Json +open Microsoft.FSharpLu.Json +open Newtonsoft.Json.Serialization +open Minio +open Minio.DataModel.Args // --------------------------------- // Web app // --------------------------------- -let webApp = - choose [ - subRoute "/api" - (choose [ - GET >=> choose [ - route "/chat" >=> handleGetChat - ] - ]) - setStatusCode 404 >=> text "Not Found" ] +let webApp = routing // --------------------------------- // Error handler // --------------------------------- -let errorHandler (ex : Exception) (logger : ILogger) = +let errorHandler (ex: Exception) (logger: ILogger) = logger.LogError(ex, "An unhandled exception has occurred while executing the request.") clearResponse >=> setStatusCode 500 >=> text ex.Message @@ -40,48 +49,109 @@ let errorHandler (ex : Exception) (logger : ILogger) = // Config and Main // --------------------------------- -let configureCors (builder : CorsPolicyBuilder) = +let configureCors (builder: CorsPolicyBuilder) = builder + // .AllowAnyOrigin() .WithOrigins( "http://localhost:5000", - "https://localhost:5001") - .AllowAnyMethod() - .AllowAnyHeader() - |> ignore + "https://localhost:5001", + "http://localhost:3000", + "https://sigmachat.cc", + "http://frontend:3000", + "http://frontend" + ) + .AllowAnyMethod() + .AllowAnyHeader() + |> ignore -let configureApp (app : IApplicationBuilder) = +let configureApp (app: IApplicationBuilder) = let env = app.ApplicationServices.GetService() + (match env.IsDevelopment() with - | true -> - app.UseDeveloperExceptionPage() - | false -> - app .UseGiraffeErrorHandler(errorHandler) - .UseHttpsRedirection()) + | true -> app.UseDeveloperExceptionPage() + | false -> app.UseGiraffeErrorHandler(errorHandler).UseHttpsRedirection()) + .UseRouting() .UseCors(configureCors) + .UseAuthentication() + // .UseAuthorization() + .UseEndpoints(fun endpoints -> endpoints.MapHub("/hub") |> ignore) .UseGiraffe(webApp) -let configureServices (services : IServiceCollection) = - services.AddTransient( fun serviceProvider -> - // The configuration information is in appsettings.json - let settings = serviceProvider.GetService() - upcast new SqlConnection(settings.["DbConnectionString"]) ) |> ignore - services.AddCors() |> ignore +let configureServices (services: IServiceCollection) = + // stupid dapper config + registerTypeHandlers () |> ignore + + services.AddTransient(fun serviceProvider -> + // The configuration information is in appsettings.json + let settings = serviceProvider.GetService() + let connection = new NpgsqlConnection(settings.["DbConnectionString"]) + upcast connection) + |> ignore + + services.AddTransient(fun serviceProvider -> + let settings = serviceProvider.GetService() + + let minioSection = settings.GetSection("Minio") + let endpoint = minioSection.["Endpoint"] + let publicKey = minioSection.["AccessKey"] + let secretKey = minioSection.["SecretKey"] + + let blobServiceClient = new MinioClient() + + let client = + blobServiceClient + .WithEndpoint(endpoint) + .WithCredentials(publicKey, secretKey) + .Build() + + client) + |> ignore + + services.AddCors() |> ignore + + services.AddSignalR(fun conf -> + conf.EnableDetailedErrors = Nullable true |> ignore + conf.KeepAliveInterval = Nullable(TimeSpan.FromSeconds(5)) |> ignore + conf.HandshakeTimeout = Nullable(TimeSpan.FromSeconds(5)) |> ignore) + |> ignore + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(fun (options) -> + // TODO unhardcode this stuff + let domain = "https://dev-szyhz3rxdab8xgmo.us.auth0.com/" + let audience = "SigmaChatBackend" + + options.Authority <- domain + options.Audience <- audience + + options.TokenValidationParameters <- TokenValidationParameters(NameClaimType = ClaimTypes.NameIdentifier)) + |> ignore + services.AddGiraffe() |> ignore -let configureLogging (builder : ILoggingBuilder) = - builder.AddConsole() - .AddDebug() |> ignore + let customSettings = JsonSerializerSettings() + customSettings.ContractResolver <- CamelCasePropertyNamesContractResolver() + // this is for options serializing striaght to just/null values + customSettings.Converters.Add(CompactUnionJsonConverter(true)) + + services.AddSingleton(NewtonsoftJson.Serializer(customSettings)) + |> ignore + +let configureLogging (builder: ILoggingBuilder) = + builder.AddConsole().AddDebug() |> ignore [] let main args = - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults( - fun webHostBuilder -> - webHostBuilder - .Configure(Action configureApp) - .ConfigureServices(configureServices) - .ConfigureLogging(configureLogging) - |> ignore) + Host + .CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(fun webHostBuilder -> + webHostBuilder + .Configure(Action configureApp) + .ConfigureServices(configureServices) + .ConfigureLogging(configureLogging) + |> ignore) .Build() .Run() - 0 \ No newline at end of file + + 0 diff --git a/Routing.fs b/Routing.fs new file mode 100644 index 0000000..ed9a4e3 --- /dev/null +++ b/Routing.fs @@ -0,0 +1,36 @@ +namespace SigmaChatServer + +module Routing = + + open Giraffe + open Microsoft.AspNetCore.Http + open HttpHandlers + open WebPush + open BlobHandlers + + let notLoggedIn = RequestErrors.UNAUTHORIZED "Basic" "" "You must be logged in." + + let mustBeLoggedIn: HttpFunc -> HttpContext -> HttpFuncResult = + requiresAuthentication notLoggedIn + + let messages: HttpFunc -> HttpContext -> HttpFuncResult = + choose [ GET >=> routef "/%i/%s" handleGetMessages; POST >=> handlePostMessage ] + + let routing: HttpFunc -> HttpContext -> HttpFuncResult = + choose + [ subRoute + "/api" + (choose + [ subRoute "/chat" mustBeLoggedIn + >=> (choose [ GET >=> routef "/%i" (fun id -> handleGetChats id); POST >=> handlePostChat ]) + subRoute "/db" (choose [ GET >=> updateSchema ]) + subRoute "/messages" mustBeLoggedIn >=> (messages) + subRoute "/user/me" mustBeLoggedIn + >=> (choose + [ subRoute "/profile-picture" POST >=> profilePictureUploadHandler + GET >=> handleGetUserMe + PATCH >=> handleUpdateMeProfile ]) + subRoute "/callback" mustBeLoggedIn >=> (handleCallback) + subRoute "/web-push/subscribe" mustBeLoggedIn >=> (handleNewSubscription) + subRoute "/web-push/key" mustBeLoggedIn >=> (handleGetVapidKey) ]) + setStatusCode 404 >=> text "Not Found" ] diff --git a/SigmaChatServer.fsproj b/SigmaChatServer.fsproj index 6a686d9..d812597 100644 --- a/SigmaChatServer.fsproj +++ b/SigmaChatServer.fsproj @@ -6,17 +6,37 @@ true - + + - + + + + + + + + + - + + + + + + + + + + + + \ No newline at end of file diff --git a/WebPush.fs b/WebPush.fs new file mode 100644 index 0000000..3357a11 --- /dev/null +++ b/WebPush.fs @@ -0,0 +1,105 @@ +namespace SigmaChatServer + +module WebPush = + open WebPush + open Microsoft.AspNetCore.Http + open Giraffe + open WebPushQueries + open Microsoft.Extensions.Configuration + open SigmaChatServer.Models + open System.Text.Json.Nodes + open Newtonsoft.Json.Linq + open System.Threading.Tasks + open System.Collections.Generic + open System.Text.Json + + let private getVapidDetails (configuration: IConfiguration) = + let vapidSection = configuration.GetSection("Vapid") + let vapidPublic = vapidSection.["Public"] + let vapidPrivate = vapidSection.["Private"] + let vapidSubject = vapidSection.["Subject"] + + new VapidDetails(vapidSubject, vapidPublic, vapidPrivate) + + let private pushPayload (subscription: PushSubscription) (vapidDetails: VapidDetails) (payload: string) = + task { + let webPushClient = new WebPushClient() + return! webPushClient.SendNotificationAsync(subscription, payload, vapidDetails) + } + + let private parseSubscription (json: string) = + let j = JObject.Parse json + + new PushSubscription( + j.SelectToken "endpoint" |> string, + j.SelectToken "keys.p256dh" |> string, + j.SelectToken "keys.auth" |> string + ) + + let webpushMessageForUser (ctx: HttpContext) (userIds: string seq) (createdMessageModel: CreateMessageModel) = + task { + let! subscriptionEntities = getSubscriptions ctx userIds + let configuration = ctx.GetService() + + let payload = + JsonSerializer.Serialize + {| title = createdMessageModel.Text + // probable svg wont work todo test this + icon = "https://sigmachat.cc/cc.svg" |} + + let vapidDetails = getVapidDetails configuration + + return! + subscriptionEntities + |> Seq.map (fun a -> parseSubscription a.Json) + |> Seq.map (fun a -> + try + pushPayload a vapidDetails payload + with _ -> + task { return () }) + |> Task.WhenAll + } + + let handleNewSubscription (next: HttpFunc) (ctx: HttpContext) = + task { + let userId = ctx.User.Identity.Name + let! subJson = ctx.ReadBodyFromRequestAsync() + + do! upsertSubscription ctx subJson userId + + return! json None next ctx + } + + let handleGetVapidKey (next: HttpFunc) (ctx: HttpContext) = + let configuration = ctx.GetService() + + let vapidSection = configuration.GetSection("Vapid") + let vapidPublic = vapidSection.["Public"] + + task { return! json {| PublicKey = vapidPublic |} next ctx } + + let handlePushCustomMessage (next: HttpFunc) (ctx: HttpContext) = + task { + let! message = ctx.ReadBodyFromRequestAsync() + let configuration = ctx.GetService() + + let! parsedSubscriptions = getAllSubscriptions ctx + let vapidDetails = getVapidDetails configuration + + let subs = + parsedSubscriptions |> List.ofSeq |> Seq.map (fun a -> parseSubscription a.Json) + + + let payload = + JsonSerializer.Serialize + {| title = message + options = {| body = message |} |} + + let! _ = + Task.WhenAll( + subs + |> Seq.map (fun subscription -> pushPayload subscription vapidDetails payload) + ) + + return! json None next ctx + } diff --git a/_appsettings.json b/_appsettings.json new file mode 100644 index 0000000..77222e6 --- /dev/null +++ b/_appsettings.json @@ -0,0 +1,21 @@ +{ + "DbConnectionString": "User ID=sa;Password=JHVHjhvh!;Host=localhost;Port=5432;Database=SigmaChatDb;Pooling=true;Minimum Pool Size=0;Maximum Pool Size=100;Connection Lifetime=0;SSL Mode=Disable;Trust Server Certificate=true", + "MinIO":{ + "Endpoint":"minio", + "AccessKey":"admin", + "SecretKey":"jhvhJHVH!" + }, + "Vapid":{ + "Subject":"", + "Private":"", + "Public":"" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/appsettings.json b/appsettings.json deleted file mode 100644 index fc702b4..0000000 --- a/appsettings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "DbConnectionString": "Server=tcp:localhost,1433;Initial Catalog=SigmaChatDb;Persist Security Info=False;User ID=sa;Password=JHVHjhvh!;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=True;Connection Timeout=30;", - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..2d60f96 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,41 @@ +# Everything incorrect todo fix + +services: + backend: + image: sigmaproductions/sigmachatserver:${IMAGE_TAG} + ports: + - "5000:80" + networks: + - sigmaChat + sqlServer: + image: "postgres" + volumes: + - sql_data:/var/lib/mysql + ports: + - "5432:5432" + environment: + - POSTGRES_PASSWORD=JHVHjhvh! + - POSTGRES_USER=sa + - POSTGRES_DB=SigmaChatDb + networks: + - sigmaChat + azurite: + image: mcr.microsoft.com/azure-storage/azurite:latest + hostname: azurite + restart: always + + ports: + - "10000:10000" + - "10001:10001" + volumes: + - azurite:/workspace + environment: + - AZURITE_ACCOUNTS=${AZURITE_ACCOUNTS} + +volumes: + sql_data: + azurite: + +networks: + sigmaChat: + name: sigmaChat diff --git a/compose.yaml b/compose.yaml index 43ed666..9cb9054 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,18 +1,41 @@ services: - # web: - # build: . - # ports: - # - "8000:5000" - sqlServer: - image: "mcr.microsoft.com/mssql/server" + backend: + build: . + ports: + - "5000:80" + networks: + - sigmaChat + postgres: + image: "postgres" volumes: - - sql_data:/var/lib/mysql + - sql_data:/var/lib/postgresql/data ports: - - "1433:1433" + - "5432:5432" environment: - NAME: "SigmaChat" - MSSQL_SA_PASSWORD: "JHVHjhvh!" - ACCEPT_EULA: "Y" - + - POSTGRES_PASSWORD=JHVHjhvh! + - POSTGRES_USER=sa + - POSTGRES_DB=SigmaChatDb + networks: + - sigmaChat + minio: + image: minio/minio:latest + entrypoint: ["sh", "-c", "minio server /data --console-address ':9001'"] + ports: + - "9000:9000" + - "9001:9001" + networks: + - sigmaChat + volumes: + - "minio_data:/data" + environment: + - MINIO_ROOT_USER=admin + - MINIO_ROOT_PASSWORD=jhvhJHVH! + - MINIO_DEFAULT_BUCKETS=jehovahs-pictures volumes: sql_data: + minio_data: + driver: local + +networks: + sigmaChat: + name: sigmaChat diff --git a/sql/ChatQueries.fs b/sql/ChatQueries.fs new file mode 100644 index 0000000..d305786 --- /dev/null +++ b/sql/ChatQueries.fs @@ -0,0 +1,104 @@ +namespace SigmaChatServer + +module ChatQueries = + + open SigmaChatServer.Models + open Microsoft.AspNetCore.Http + open Giraffe + open System.Data + open Dapper + open Migrations + open System + + let generateVersionInsert version = + "\n" + + $""" + INSERT INTO "Migrations" ("Version") + VALUES ({version}); + """ + + let generateMigrationScript (migrationsTable: string array) (version: int) = + let (sql: string, _: int) = + Array.fold + (fun (accu, vers) next -> (accu + "\r\n " + next + (generateVersionInsert (vers + 1)), vers + 1)) + ("", version) + migrationsTable[version..] + + sql + + let lastMigrationVersion (connection: IDbConnection) = + task { + try + let! version = connection.QueryFirstAsync<{| Version: int |}>(GetVersion) + return version.Version + with _ -> + return 0 + } + + let setupDatabaseSchema (connection: IDbConnection) = + task { + let! version = lastMigrationVersion connection + return! version |> generateMigrationScript Migrations |> connection.QueryAsync + } + + let postChat (ctx: HttpContext) = + task { + use connection = ctx.GetService() + let sql = """INSERT INTO "Chats" DEFAULT VALUES RETURNING "ChatId" """ + let! id = connection.ExecuteScalarAsync(sql) + return id + } + + let insertMessage (ctx: HttpContext) (createMessageModel: CreateMessageModel) (userId: string) = + task { + use connection = ctx.GetService() + + let sql = + """ + WITH new_message AS (INSERT INTO "Messages" ("ChatId", "UserId", "Text", "DateCreated") VALUES (@chatId, @userId, @text, NOW()) RETURNING *), + message_model as (SELECT new_message.*, "Users"."Nickname" AS "UserNickname" FROM new_message LEFT JOIN "Users" ON new_message."UserId" = "Users"."Id" ) + SELECT message_model.* from message_model;""" + + let sqlParams = + {| chatId = createMessageModel.ChatId + userId = userId + text = createMessageModel.Text |} + + let! createdMessage = connection.QuerySingleOrDefaultAsync(sql, sqlParams) + + return createdMessage + } + + let getMessages (ctx: HttpContext) (chatId: int) (paginationDate: DateTime) = + task { + use connection = ctx.GetService() + + let sql = + """SELECT * FROM (SELECT "Messages".*, "Users"."Nickname" as "UserNickname", "UserProfilePictures"."BlobName" as "UserProfilePicture" FROM "Messages" + LEFT JOIN "Users" ON "Messages"."UserId"="Users"."Id" + LEFT JOIN "UserProfilePictures" ON "Users"."Id" = "UserProfilePictures"."UserId" + WHERE "ChatId"= @chatId AND "Messages"."DateCreated" < @paginationDate + ORDER BY "MessageId" DESC + LIMIT 30) + ORDER BY "MessageId" ASC;""" + + let data = + {| chatId = chatId + paginationDate = paginationDate |} + + let! messages = connection.QueryAsync(sql, data) + return messages + } + + let getChat (ctx: HttpContext) (chatId: int) = + task { + let! messages = getMessages ctx chatId DateTime.UtcNow + + try + return + Some + { ChatId = chatId + Messages = messages |> Seq.toList } + with :? InvalidOperationException -> + return None + } diff --git a/sql/Migrations.fs b/sql/Migrations.fs new file mode 100644 index 0000000..93198e1 --- /dev/null +++ b/sql/Migrations.fs @@ -0,0 +1,77 @@ +namespace SigmaChatServer + +module Migrations = + let GetVersion = + """ + SELECT "Version" FROM "Migrations" + ORDER BY "Version" DESC + LIMIT 1; + """ + + let Migrations = + [| """ + CREATE TABLE "Migrations"( + "Version" INT PRIMARY KEY + ); + """ + """ + CREATE TABLE "Chats"( + "ChatId" serial PRIMARY KEY + ); + + INSERT INTO "Chats" DEFAULT VALUES ; + + CREATE TABLE "Messages"( + "ChatId" INT NOT NULL, + "MessageId" serial PRIMARY KEY, + "Sender" VARCHAR(100) NOT NULL, + "Text" VARCHAR(500) NOT NULL, + "DateCreated" TIMESTAMP NOT NULL, + FOREIGN KEY ("ChatId") + REFERENCES "Chats" ("ChatId") + ); + """ + """ + CREATE TABLE "Users"( + "Id" VARCHAR(50) PRIMARY KEY, + "Email" VARCHAR(500), + "Nickname" VARCHAR(500) + ); + + ALTER TABLE "Messages" + DROP COLUMN "Sender", + ADD COLUMN "UserId" VARCHAR(50) NOT NULL REFERENCES "Users"("Id"); + """ + """ + CREATE TABLE "PushSubscriptions"( + "Id" serial PRIMARY KEY, + "UserId" VARCHAR(50), + "Json" VARCHAR(4000), + "DateCreated" TIMESTAMP NOT NULL, + FOREIGN KEY ("UserId") + REFERENCES "Users" ("Id") + ); + """ + """ + DROP TABLE "PushSubscriptions"; + + CREATE TABLE "PushSubscriptions"( + "UserId" VARCHAR(50) PRIMARY KEY, + "Json" VARCHAR(4000), + "DateCreated" TIMESTAMP NOT NULL, + FOREIGN KEY ("UserId") + REFERENCES "Users" ("Id") + ); + """ + """ + CREATE TABLE "UserProfilePictures"( + "UserId" VARCHAR(50) PRIMARY KEY, + "BlobName" VARCHAR(50) NOT NULL, + "DateCreated" TIMESTAMP NOT NULL, + "OriginalFilename" VARCHAR(255) NOT NULL, + FOREIGN KEY ("UserId") + REFERENCES "Users" ("Id") + ); + + CREATE UNIQUE INDEX idx_UserProfilePictures_BlobName ON "UserProfilePictures" ("BlobName"); + """ |] diff --git a/sql/UserQueries.fs b/sql/UserQueries.fs new file mode 100644 index 0000000..085cf36 --- /dev/null +++ b/sql/UserQueries.fs @@ -0,0 +1,92 @@ +namespace SigmaChatServer + +module UserQueries = + open Microsoft.AspNetCore.Http + open Giraffe + open System.Data + open Dapper + open SigmaChatServer.Models + open System + + let createUser (ctx: HttpContext) (userId: string) = + task { + use connection = ctx.GetService() + + let sql = + """INSERT INTO "Users" ("Id") + VALUES (@userId) RETURNING *;""" + + let sqlParams = {| userId = userId |} + + let! user = connection.QueryFirstAsync(sql, sqlParams) + return (user) + } + + let updateUser (ctx: HttpContext) (userId: string) (model: UpdateMeModel) = + task { + use connection = ctx.GetService() + + let sql = + """UPDATE "Users" SET "Nickname" = @nickname + WHERE "Id" = @userId; """ + + let sqlParams = + {| userId = userId + nickname = model.Nickname |} + + let! _ = connection.ExecuteAsync(sql, sqlParams) + return () + } + + let getUser (ctx: HttpContext) (userId: string) = + task { + use connection = ctx.GetService() + + let sql = + """SELECT "Users".*, "UserProfilePictures"."BlobName" as "ProfilePictureBlob" FROM "Users" + LEFT JOIN "UserProfilePictures" ON "Id" = "UserId" + WHERE "Id" = @userId;""" + + let sqlParams = {| userId = userId |} + + let! user = connection.QueryFirstOrDefaultAsync(sql, sqlParams) + + let optioned = + match box user with + | null -> None + | _ -> Some user + + return optioned + } + + let getAllUserIds (ctx: HttpContext) = + task { + use connection = ctx.GetService() + + let sql = """SELECT "Id" FROM "Users";""" + + let! userIds = connection.QueryAsync(sql) + return userIds + } + + type ProfilePictureModel = + { UserId: string + BlobName: string + OriginalFilename: string } + + let upsertProfilePicture (ctx: HttpContext) (model: ProfilePictureModel) = + task { + use connection = ctx.GetService() + + let sql = + """ + INSERT INTO "UserProfilePictures" ("UserId", "BlobName", "OriginalFilename", "DateCreated") + VALUES (@userId, @blobName, @originalFilename, NOW()) + ON CONFLICT ("UserId") DO UPDATE + SET "BlobName" = EXCLUDED."BlobName", + "OriginalFilename" = EXCLUDED."OriginalFilename", + "DateCreated" = NOW(); + """ + + return! connection.QueryAsync(sql, model) + } diff --git a/sql/WebPushQueries.fs b/sql/WebPushQueries.fs new file mode 100644 index 0000000..d19b855 --- /dev/null +++ b/sql/WebPushQueries.fs @@ -0,0 +1,60 @@ +namespace SigmaChatServer + +module WebPushQueries = + open Microsoft.AspNetCore.Http + open Giraffe + open System.Data + open Dapper + open SigmaChatServer.Models + open System + open Microsoft.FSharp.Core + + let upsertSubscription (ctx: HttpContext) (json: string) (userId: string) = + task { + use connection = ctx.GetService() + + let sql = + """ + INSERT INTO "PushSubscriptions" ( "UserId", "Json", "DateCreated") VALUES ( @userId, @json, NOW()) + ON CONFLICT ("UserId") DO UPDATE + SET "Json" = EXCLUDED."Json", + "DateCreated" = EXCLUDED."DateCreated";; + """ + + let sqlParams = {| userId = userId; json = json |} + + let! _ = connection.ExecuteScalarAsync(sql, sqlParams) + + return () + } + + let getSubscriptions (ctx: HttpContext) (userId: string seq) = + task { + use connection = ctx.GetService() + + let sql = + """WITH LatestSubscriptions AS ( + SELECT "UserId", MAX("DateCreated") AS MaxDate + FROM "PushSubscriptions" + WHERE "UserId" = ANY(@userIds) + GROUP BY "UserId" + ) + SELECT PS.* + FROM "PushSubscriptions" PS + INNER JOIN LatestSubscriptions LS ON PS."UserId" = LS."UserId" AND PS."DateCreated" = LS.MaxDate;""" + + let data = {| userIds = userId |} + + let! subscription = connection.QueryAsync(sql, data) + return subscription + } + + let getAllSubscriptions (ctx: HttpContext) = + task { + use connection = ctx.GetService() + + let sql = """SELECT * FROM "PushSubscriptions";""" + + let! subscriptions = connection.QueryAsync(sql) + return subscriptions + }