Compare commits
10 Commits
3e9c245bb3
...
2701b8a483
| Author | SHA1 | Date | |
|---|---|---|---|
| 2701b8a483 | |||
| 645a7e3722 | |||
| 88339c51eb | |||
| a7e5e13ac8 | |||
| 751030dcef | |||
| e5ae228738 | |||
| b5be4c6914 | |||
| db5c4e59a4 | |||
| 720583de0a | |||
| b862bf6f0f |
@@ -0,0 +1,13 @@
|
|||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/.vs
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
backend.tests
|
||||||
|
*.md
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
@@ -14,3 +14,4 @@ backend.tests/obj/
|
|||||||
backend/*.db
|
backend/*.db
|
||||||
backend/*.db-shm
|
backend/*.db-shm
|
||||||
backend/*.db-wal
|
backend/*.db-wal
|
||||||
|
deploy.json
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# ---- Backend build ----
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY Assistant.sln ./
|
||||||
|
COPY backend/backend.csproj backend/
|
||||||
|
COPY backend.tests/backend.tests.csproj backend.tests/
|
||||||
|
RUN dotnet restore backend/backend.csproj
|
||||||
|
COPY backend/ backend/
|
||||||
|
RUN dotnet publish backend/backend.csproj -c Release -o /publish
|
||||||
|
|
||||||
|
# ---- Client bundle ----
|
||||||
|
FROM alpine:3.20 AS bundle
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache tar gzip
|
||||||
|
COPY client/ client/
|
||||||
|
COPY requirements.txt requirements-nodeps.txt ./
|
||||||
|
RUN tar czf /client.tar.gz client/ requirements.txt requirements-nodeps.txt
|
||||||
|
|
||||||
|
# ---- Final ----
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /publish/ ./
|
||||||
|
RUN mkdir -p ./wwwroot && mkdir -p /data /keys
|
||||||
|
COPY --from=bundle /client.tar.gz ./wwwroot/client.tar.gz
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV ConnectionStrings__Default="Data Source=/data/assistant.db"
|
||||||
|
ENV Install__ClientTarballPath="/app/wwwroot/client.tar.gz"
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["dotnet", "backend.dll"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using backend.Devices;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class DeviceTokenServiceTests
|
||||||
|
{
|
||||||
|
private readonly DeviceTokenService _svc = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_returns_url_safe_token_of_expected_length()
|
||||||
|
{
|
||||||
|
var (plaintext, hash) = _svc.Generate();
|
||||||
|
plaintext.Should().MatchRegex("^[A-Za-z0-9_-]+$");
|
||||||
|
plaintext.Length.Should().BeGreaterThanOrEqualTo(40);
|
||||||
|
hash.Should().NotBeNullOrEmpty();
|
||||||
|
hash.Should().NotBe(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hash_is_deterministic_for_same_input()
|
||||||
|
{
|
||||||
|
var (plaintext, hash) = _svc.Generate();
|
||||||
|
_svc.Hash(plaintext).Should().Be(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_accepts_matching_plaintext_and_rejects_others()
|
||||||
|
{
|
||||||
|
var (plaintext, hash) = _svc.Generate();
|
||||||
|
_svc.Verify(plaintext, hash).Should().BeTrue();
|
||||||
|
_svc.Verify(plaintext + "x", hash).Should().BeFalse();
|
||||||
|
_svc.Verify("totally-different", hash).Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class DevicesEndpointsTests
|
||||||
|
{
|
||||||
|
private static async Task<(HttpClient signedIn, string code)> SetupWithPairCode(ApiFactory factory, string email = "owner@example.com")
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
await client.PostAsJsonAsync("/api/auth/register",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
await client.PostAsJsonAsync("/api/auth/login",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||||
|
var body = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||||
|
return (client, body!["code"].ToString()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task User_sees_only_their_own_devices()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var (alice, codeA) = await SetupWithPairCode(factory, "alice@example.com");
|
||||||
|
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code = codeA, hostname = "rpi", client_version = "0.1" });
|
||||||
|
|
||||||
|
var (bob, codeB) = await SetupWithPairCode(factory, "bob@example.com");
|
||||||
|
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code = codeB, hostname = "rpi", client_version = "0.1" });
|
||||||
|
|
||||||
|
var aliceList = await alice.GetStringAsync("/api/devices");
|
||||||
|
var bobList = await bob.GetStringAsync("/api/devices");
|
||||||
|
|
||||||
|
aliceList.Should().Contain("kitchen");
|
||||||
|
bobList.Should().Contain("kitchen");
|
||||||
|
aliceList.Should().NotBe(bobList);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rename_device_updates_name()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var (client, code) = await SetupWithPairCode(factory);
|
||||||
|
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code, hostname = "rpi", client_version = "0.1" });
|
||||||
|
|
||||||
|
var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
|
||||||
|
var id = listJson![0]["id"].ToString();
|
||||||
|
|
||||||
|
var patchRes = await client.PatchAsJsonAsync($"/api/devices/{id}", new { name = "living-room" });
|
||||||
|
patchRes.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var after = await client.GetStringAsync($"/api/devices/{id}");
|
||||||
|
after.Should().Contain("living-room");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Revoke_device_sets_is_revoked_true()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var (client, code) = await SetupWithPairCode(factory);
|
||||||
|
await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code, hostname = "rpi", client_version = "0.1" });
|
||||||
|
|
||||||
|
var listJson = await client.GetFromJsonAsync<List<Dictionary<string, object>>>("/api/devices");
|
||||||
|
var id = listJson![0]["id"].ToString();
|
||||||
|
|
||||||
|
var del = await client.DeleteAsync($"/api/devices/{id}");
|
||||||
|
del.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var after = await client.GetStringAsync($"/api/devices/{id}");
|
||||||
|
after.Should().Contain("\"isRevoked\":true");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class InstallEndpointsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Install_sh_returns_shell_script_with_request_host()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var res = await client.GetAsync("/install.sh");
|
||||||
|
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
res.Content.Headers.ContentType!.MediaType.Should().Be("text/x-shellscript");
|
||||||
|
|
||||||
|
var body = await res.Content.ReadAsStringAsync();
|
||||||
|
body.Should().StartWith("#!/usr/bin/env bash");
|
||||||
|
body.Should().Contain("BACKEND_URL=\"http://localhost\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_tarball_returns_octet_stream()
|
||||||
|
{
|
||||||
|
var tmp = Path.Combine(Path.GetTempPath(), $"client-test-{Guid.NewGuid():N}.tar.gz");
|
||||||
|
File.WriteAllBytes(tmp, new byte[] { 0x1f, 0x8b }); // tiny gzip-ish marker
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var client = factory.WithWebHostBuilder(b =>
|
||||||
|
{
|
||||||
|
b.ConfigureAppConfiguration((_, cfg) =>
|
||||||
|
{
|
||||||
|
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Install:ClientTarballPath"] = tmp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
var res = await client.GetAsync("/client.tar.gz");
|
||||||
|
res.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
res.Content.Headers.ContentType!.MediaType.Should().Be("application/gzip");
|
||||||
|
(await res.Content.ReadAsByteArrayAsync()).Length.Should().Be(2);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tmp)) File.Delete(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using backend.Install;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class InstallScriptBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_inlines_the_backend_url_into_the_script()
|
||||||
|
{
|
||||||
|
var script = new InstallScriptBuilder().Build("https://example.test");
|
||||||
|
script.Should().Contain("BACKEND_URL=\"https://example.test\"");
|
||||||
|
script.Should().Contain("#!/usr/bin/env bash");
|
||||||
|
script.Should().Contain("$BACKEND_URL/client.tar.gz");
|
||||||
|
script.Should().Contain("client.pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class PairingEndpointsTests
|
||||||
|
{
|
||||||
|
private static async Task<HttpClient> SignedInClient(ApiFactory factory, string email = "owner@example.com")
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
await client.PostAsJsonAsync("/api/auth/register",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
await client.PostAsJsonAsync("/api/auth/login",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Anonymous_user_cannot_request_a_pairing_code()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var res = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||||
|
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pair_code_then_pair_returns_token_and_device_id()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var client = await SignedInClient(factory);
|
||||||
|
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||||
|
codeRes.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var codeBody = await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||||
|
var code = codeBody!["code"].ToString()!;
|
||||||
|
|
||||||
|
var pairRes = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code, hostname = "rpi", client_version = "0.1" });
|
||||||
|
pairRes.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
var body = await pairRes.Content.ReadAsStringAsync();
|
||||||
|
body.Should().Contain("device_id");
|
||||||
|
body.Should().Contain("device_token");
|
||||||
|
body.Should().Contain("backend_ws");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pair_with_invalid_code_returns_404()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var res = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code = "ZZZZZZZZ", hostname = "rpi", client_version = "0.1" });
|
||||||
|
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pair_with_consumed_code_returns_404()
|
||||||
|
{
|
||||||
|
using var factory = new ApiFactory();
|
||||||
|
var client = await SignedInClient(factory);
|
||||||
|
var codeRes = await client.PostAsJsonAsync("/api/pair-code", new { name = "kitchen" });
|
||||||
|
var code = (await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>())!["code"].ToString()!;
|
||||||
|
|
||||||
|
var first = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code, hostname = "rpi", client_version = "0.1" });
|
||||||
|
first.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var second = await factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code, hostname = "rpi", client_version = "0.1" });
|
||||||
|
second.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using backend.Pairing;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class PairingServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Generated_codes_use_only_safe_alphabet_and_are_8_chars()
|
||||||
|
{
|
||||||
|
var svc = new PairingService();
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
var code = svc.GenerateCode();
|
||||||
|
code.Length.Should().Be(8);
|
||||||
|
code.Should().MatchRegex("^[A-HJ-KM-NP-Z2-9]+$");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generated_codes_are_not_all_identical()
|
||||||
|
{
|
||||||
|
var svc = new PairingService();
|
||||||
|
var codes = Enumerable.Range(0, 20).Select(_ => svc.GenerateCode()).ToHashSet();
|
||||||
|
codes.Count.Should().BeGreaterThan(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using backend.Auth;
|
using backend.Auth;
|
||||||
using backend.Devices;
|
using backend.Devices;
|
||||||
|
using backend.Pairing;
|
||||||
using backend.Settings;
|
using backend.Settings;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
@@ -13,6 +14,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<Device> Devices => Set<Device>();
|
public DbSet<Device> Devices => Set<Device>();
|
||||||
public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
|
public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
|
||||||
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||||||
|
public DbSet<PairingCode> PairingCodes => Set<PairingCode>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
@@ -32,5 +34,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
b.Entity<DeviceConfig>().HasKey(c => c.DeviceId);
|
b.Entity<DeviceConfig>().HasKey(c => c.DeviceId);
|
||||||
b.Entity<SystemSettings>().HasKey(s => s.Id);
|
b.Entity<SystemSettings>().HasKey(s => s.Id);
|
||||||
b.Entity<SystemSettings>().Property(s => s.Id).ValueGeneratedNever();
|
b.Entity<SystemSettings>().Property(s => s.Id).ValueGeneratedNever();
|
||||||
|
b.Entity<PairingCode>().HasKey(p => p.Code);
|
||||||
|
b.Entity<PairingCode>().HasIndex(p => p.ExpiresAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace backend.Devices;
|
||||||
|
|
||||||
|
public class DeviceTokenService
|
||||||
|
{
|
||||||
|
public (string Plaintext, string Hash) Generate()
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[32];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
var plaintext = Base64UrlEncode(bytes);
|
||||||
|
return (plaintext, Hash(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Hash(string plaintext)
|
||||||
|
{
|
||||||
|
Span<byte> hash = stackalloc byte[32];
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(plaintext), hash);
|
||||||
|
return Convert.ToHexString(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Verify(string plaintext, string expectedHash)
|
||||||
|
{
|
||||||
|
var actual = Hash(plaintext);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
Encoding.UTF8.GetBytes(actual),
|
||||||
|
Encoding.UTF8.GetBytes(expectedHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
|
||||||
|
=> Convert.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using backend.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace backend.Devices;
|
||||||
|
|
||||||
|
public record RenameRequest(string Name);
|
||||||
|
|
||||||
|
public static class DevicesEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapDevicesEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var grp = app.MapGroup("/api/devices").RequireAuthorization();
|
||||||
|
|
||||||
|
grp.MapGet("/", async (AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var uid = GetUserId(http);
|
||||||
|
var devs = await db.Devices
|
||||||
|
.Where(d => d.OwnerUserId == uid)
|
||||||
|
.Select(d => new
|
||||||
|
{
|
||||||
|
id = d.Id,
|
||||||
|
name = d.Name,
|
||||||
|
pairedAt = d.PairedAt,
|
||||||
|
lastSeenAt = d.LastSeenAt,
|
||||||
|
isRevoked = d.IsRevoked,
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return Results.Ok(devs);
|
||||||
|
});
|
||||||
|
|
||||||
|
grp.MapGet("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var uid = GetUserId(http);
|
||||||
|
var d = await db.Devices.Include(x => x.Config)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
|
||||||
|
return d is null
|
||||||
|
? Results.NotFound()
|
||||||
|
: Results.Ok(new
|
||||||
|
{
|
||||||
|
id = d.Id,
|
||||||
|
name = d.Name,
|
||||||
|
pairedAt = d.PairedAt,
|
||||||
|
lastSeenAt = d.LastSeenAt,
|
||||||
|
isRevoked = d.IsRevoked,
|
||||||
|
config = d.Config,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
grp.MapPatch("/{id:guid}", async (
|
||||||
|
Guid id, [FromBody] RenameRequest req,
|
||||||
|
AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var uid = GetUserId(http);
|
||||||
|
var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
|
||||||
|
if (d is null) return Results.NotFound();
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Name))
|
||||||
|
return Results.BadRequest(new { error = "name is required" });
|
||||||
|
d.Name = req.Name.Trim();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
grp.MapDelete("/{id:guid}", async (Guid id, AppDbContext db, HttpContext http, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var uid = GetUserId(http);
|
||||||
|
var d = await db.Devices.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == uid, ct);
|
||||||
|
if (d is null) return Results.NotFound();
|
||||||
|
d.IsRevoked = true;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http)
|
||||||
|
{
|
||||||
|
var s = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return Guid.TryParse(s, out var g) ? g : Guid.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace backend.Install;
|
||||||
|
|
||||||
|
public static class InstallEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapInstallEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/install.sh", (HttpContext http, InstallScriptBuilder builder) =>
|
||||||
|
{
|
||||||
|
var scheme = http.Request.Scheme;
|
||||||
|
var host = http.Request.Host.ToString();
|
||||||
|
var script = builder.Build($"{scheme}://{host}");
|
||||||
|
return Results.Text(script, "text/x-shellscript");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/client.tar.gz", (IConfiguration cfg, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var path = cfg["Install:ClientTarballPath"]
|
||||||
|
?? Path.Combine(env.WebRootPath ?? "wwwroot", "client.tar.gz");
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return Results.NotFound(new { error = "client.tar.gz not found at " + path });
|
||||||
|
return Results.File(path, "application/gzip", "client.tar.gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace backend.Install;
|
||||||
|
|
||||||
|
public class InstallScriptBuilder
|
||||||
|
{
|
||||||
|
public string Build(string backendUrl)
|
||||||
|
{
|
||||||
|
return $$"""
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smart Assistant installer (idempotent). Re-run to upgrade.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKEND_URL="{{backendUrl}}"
|
||||||
|
ASSISTANT_HOME="$HOME/assistant"
|
||||||
|
RESET=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--reset) RESET=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Installing system deps (sudo password may be prompted)..."
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends python3-venv libportaudio2 alsa-utils
|
||||||
|
|
||||||
|
mkdir -p "$ASSISTANT_HOME/code" "$ASSISTANT_HOME/state"
|
||||||
|
|
||||||
|
if [ ! -d "$ASSISTANT_HOME/.venv" ]; then
|
||||||
|
python3 -m venv "$ASSISTANT_HOME/.venv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading client bundle from $BACKEND_URL ..."
|
||||||
|
curl -fsSL "$BACKEND_URL/client.tar.gz" | tar -xz -C "$ASSISTANT_HOME/code"
|
||||||
|
|
||||||
|
"$ASSISTANT_HOME/.venv/bin/pip" install --quiet -r "$ASSISTANT_HOME/code/requirements.txt"
|
||||||
|
"$ASSISTANT_HOME/.venv/bin/pip" install --quiet --no-deps -r "$ASSISTANT_HOME/code/requirements-nodeps.txt"
|
||||||
|
|
||||||
|
if [ "$RESET" -eq 1 ]; then
|
||||||
|
rm -f "$ASSISTANT_HOME/state/config.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ASSISTANT_HOME/state/config.json" ]; then
|
||||||
|
echo
|
||||||
|
read -r -p "Pairing code: " CODE
|
||||||
|
"$ASSISTANT_HOME/.venv/bin/python" -m client.pair --backend "$BACKEND_URL" --code "$CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.config/systemd/user"
|
||||||
|
cat > "$HOME/.config/systemd/user/assistant.service" <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Smart Assistant Pi client
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment=PA_ALSA_PLUGHW=1
|
||||||
|
ExecStart=$ASSISTANT_HOME/.venv/bin/python -m client.main
|
||||||
|
WorkingDirectory=$ASSISTANT_HOME/code
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
loginctl enable-linger "$USER" 2>/dev/null || true
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now assistant
|
||||||
|
echo "Done. systemctl --user status assistant"
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using backend.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace backend.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260611185048_AddPairingCodes")]
|
||||||
|
partial class AddPairingCodes
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRevoked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("PairedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Devices");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("DeviceId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EnabledToolsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("IdleTimeoutSeconds")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Voice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("DeviceId");
|
||||||
|
|
||||||
|
b.ToTable("DeviceConfigs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Pairing.PairingCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Code");
|
||||||
|
|
||||||
|
b.HasIndex("ExpiresAt");
|
||||||
|
|
||||||
|
b.ToTable("PairingCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("DefaultIdleTimeoutSeconds")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultModel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultSystemPrompt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultVoice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("SystemSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OwnerUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Devices.Device", null)
|
||||||
|
.WithOne("Config")
|
||||||
|
.HasForeignKey("backend.Devices.DeviceConfig", "DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Config");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPairingCodes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PairingCodes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
OwnerUserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
DeviceName = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
ExpiresAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||||
|
ConsumedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PairingCodes", x => x.Code);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PairingCodes_ExpiresAt",
|
||||||
|
table: "PairingCodes",
|
||||||
|
column: "ExpiresAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PairingCodes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,6 +273,31 @@ namespace backend.Migrations
|
|||||||
b.ToTable("DeviceConfigs");
|
b.ToTable("DeviceConfigs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Pairing.PairingCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Code");
|
||||||
|
|
||||||
|
b.HasIndex("ExpiresAt");
|
||||||
|
|
||||||
|
b.ToTable("PairingCodes");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace backend.Pairing;
|
||||||
|
|
||||||
|
public class PairingCode
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = ""; // 8 chars, primary key
|
||||||
|
public Guid OwnerUserId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = "";
|
||||||
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
public DateTimeOffset? ConsumedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace backend.Pairing;
|
||||||
|
|
||||||
|
public record IssueCodeRequest(string Name);
|
||||||
|
public record PairRequest(string Code, string Hostname, string Client_version);
|
||||||
|
|
||||||
|
public static class PairingEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapPost("/api/pair-code", async (
|
||||||
|
[FromBody] IssueCodeRequest req,
|
||||||
|
PairingService svc,
|
||||||
|
HttpContext http,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var uidStr = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (!Guid.TryParse(uidStr, out var uid)) return Results.Unauthorized();
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Name))
|
||||||
|
return Results.BadRequest(new { error = "name is required" });
|
||||||
|
var pc = await svc.IssueAsync(uid, req.Name.Trim(), ct);
|
||||||
|
return Results.Ok(new { code = pc.Code, expires_at = pc.ExpiresAt });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPost("/api/pair", async (
|
||||||
|
[FromBody] PairRequest req,
|
||||||
|
PairingService svc,
|
||||||
|
HttpContext http,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await svc.ConsumeAsync(req.Code, ct);
|
||||||
|
if (result is null) return Results.NotFound(new { error = "code invalid or expired" });
|
||||||
|
var scheme = http.Request.Scheme == "https" ? "wss" : "ws";
|
||||||
|
var host = http.Request.Host.ToString();
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
device_id = result.DeviceId,
|
||||||
|
device_token = result.DeviceToken,
|
||||||
|
backend_ws = $"{scheme}://{host}/device",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using backend.Data;
|
||||||
|
using backend.Devices;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace backend.Pairing;
|
||||||
|
|
||||||
|
public record PairResult(Guid DeviceId, string DeviceToken);
|
||||||
|
|
||||||
|
public class PairingService
|
||||||
|
{
|
||||||
|
private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O, 0, I, 1, L
|
||||||
|
private const int CodeLength = 8;
|
||||||
|
public static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
private readonly AppDbContext? _db;
|
||||||
|
private readonly DeviceTokenService? _tokens;
|
||||||
|
|
||||||
|
public PairingService() { }
|
||||||
|
|
||||||
|
public PairingService(AppDbContext db, DeviceTokenService tokens)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tokens = tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateCode()
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[CodeLength];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
Span<char> chars = stackalloc char[CodeLength];
|
||||||
|
for (var i = 0; i < CodeLength; i++)
|
||||||
|
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PairingCode> IssueAsync(Guid ownerUserId, string deviceName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_db is null) throw new InvalidOperationException("PairingService not constructed with DB context.");
|
||||||
|
var entity = new PairingCode
|
||||||
|
{
|
||||||
|
Code = GenerateCode(),
|
||||||
|
OwnerUserId = ownerUserId,
|
||||||
|
DeviceName = deviceName,
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeTtl),
|
||||||
|
};
|
||||||
|
_db.PairingCodes.Add(entity);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PairResult?> ConsumeAsync(string code, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_db is null || _tokens is null)
|
||||||
|
throw new InvalidOperationException("PairingService not constructed with DB context + token service.");
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var pc = await _db.PairingCodes.FirstOrDefaultAsync(p => p.Code == code, ct);
|
||||||
|
if (pc is null || pc.ConsumedAt is not null || pc.ExpiresAt < now)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var (plaintext, hash) = _tokens.Generate();
|
||||||
|
var defaults = await _db.SystemSettings.FirstAsync(ct);
|
||||||
|
|
||||||
|
var device = new Device
|
||||||
|
{
|
||||||
|
OwnerUserId = pc.OwnerUserId,
|
||||||
|
Name = pc.DeviceName,
|
||||||
|
PairedAt = now,
|
||||||
|
TokenHash = hash,
|
||||||
|
};
|
||||||
|
device.Config = new DeviceConfig
|
||||||
|
{
|
||||||
|
DeviceId = device.Id,
|
||||||
|
SystemPrompt = defaults.DefaultSystemPrompt,
|
||||||
|
Voice = defaults.DefaultVoice,
|
||||||
|
Model = defaults.DefaultModel,
|
||||||
|
IdleTimeoutSeconds = defaults.DefaultIdleTimeoutSeconds,
|
||||||
|
};
|
||||||
|
_db.Devices.Add(device);
|
||||||
|
pc.ConsumedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return new PairResult(device.Id, plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,31 @@
|
|||||||
using backend.Auth;
|
using backend.Auth;
|
||||||
using backend.Data;
|
using backend.Data;
|
||||||
|
using backend.Devices;
|
||||||
|
using backend.Install;
|
||||||
|
using backend.Pairing;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var keysDir = Environment.GetEnvironmentVariable("DataProtection__KeysDir") ?? "/keys";
|
||||||
|
if (Directory.Exists(keysDir))
|
||||||
|
{
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
|
||||||
|
.SetApplicationName("smart-assistant");
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(opt =>
|
builder.Services.AddDbContext<AppDbContext>(opt =>
|
||||||
opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));
|
opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));
|
||||||
|
|
||||||
builder.Services.AddAppIdentity();
|
builder.Services.AddAppIdentity();
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
|
||||||
|
builder.Services.AddScoped<backend.Pairing.PairingService>();
|
||||||
|
builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
@@ -30,6 +46,9 @@ app.UseAuthentication();
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapAuthEndpoints();
|
app.MapAuthEndpoints();
|
||||||
|
app.MapPairingEndpoints();
|
||||||
|
app.MapDevicesEndpoints();
|
||||||
|
app.MapInstallEndpoints();
|
||||||
|
|
||||||
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.*" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.*" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.*" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Smart Assistant client entry point. Implemented in Plan 3."""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
raise SystemExit("client.main: placeholder; full implementation comes in Plan 3.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Smart Assistant pairing CLI. Implemented in Plan 3."""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
raise SystemExit("client.pair: placeholder; full implementation comes in Plan 3.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_URLS=http://+:8080
|
||||||
|
- 'ConnectionStrings__Default=Data Source=/data/assistant.db'
|
||||||
|
- Install__ClientTarballPath=/app/wwwroot/client.tar.gz
|
||||||
|
- SERVICE_FQDN_APP_8080
|
||||||
|
volumes:
|
||||||
|
- assistant-data:/data
|
||||||
|
- assistant-keys:/keys
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
assistant-data:
|
||||||
|
assistant-keys:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
openwakeword==0.6.0
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
sounddevice==0.5.1
|
||||||
|
numpy>=2.0
|
||||||
|
scipy>=1.13
|
||||||
|
onnxruntime>=1.18
|
||||||
|
scikit-learn
|
||||||
|
tqdm
|
||||||
|
requests
|
||||||
|
websockets>=12.0
|
||||||
Reference in New Issue
Block a user