Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,23 @@ There is also an optional `delete_after_days` variable. Uncomment this to set an

## Configuring LiftLog to use the remote backup

Save the file `output.txt` somewhere safe. Open it and note the url and api key. Add these to the remote backup configuration in LiftLog and when you click 'Test', it should work.
Save the file `output.txt` somewhere safe. Under **Settings → Backends**, add a
**Backup endpoint only** backend using the complete API URL (including
`/prod/backup`). Add an `X-API-Key` header with the API key, then select this
backend for automatic remote backup.

The **Test** button sends an empty POST with `X-LiftLog-Probe: true`. API Gateway
checks the API key as usual, and the Lambda returns `200` without writing a file.
This checks connectivity and authentication; it does not exercise S3 uploads.
An empty request without the probe header is rejected.

If Test reports **No file uploaded or body is empty**, an older Lambda may still
be deployed. Updating your checkout alone does not update AWS: rebuild the package
and follow the Terraform upgrade steps above. See [the backup protocol](../../../../docs/RemoteBackup.md).

To verify storage after upgrading, perform a real backup, download the new S3
object, and check that it decompresses successfully. For current SQLite backups,
`PRAGMA integrity_check` on the decompressed database should return `ok`.

To access your backup, log into the AWS Console, navigate to S3, find your bucket and your files will be organised into folders by date. Object versioning is enabled so accidental overwrites and deletes can be recovered from S3.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { describe, it } from "node:test";
import { gunzipSync } from "node:zlib";
import { APIGatewayProxyEvent } from "aws-lambda";
import { createHandler } from "./index";

const gzipBody = Buffer.from([0x1f, 0x8b, 0x08, 0x00]);
// A complete gzipped SQLite fixture with one synthetic workout. The digest below
// was recorded from the original database, before compression or upload.
const gzipBody = Buffer.from(
"H4sIAAAAAAAC/wsO9MksSVVIyy/KTSxRMGZgYmBkZHBQUGBgADIhGAYYgZgFjU8IMDHo9f7gBSlmPMgARKOA2sCWkU1cVpbRvyQxKSe1PL8oO7+0pBhGMzkHuTqGuCqEODr5uCrARBU08hJzUxVCXCNCNCFx84EBiEbBCAB8jEyqIanFJbDEAACq0lLxAAQAAA==",
"base64",
);
const sqliteSha256 =
"f127f2053426517c57f0d84a92965dd82060512226b26b96c6c5ca67d2ce557f";

function event(overrides: Partial<APIGatewayProxyEvent> = {}): APIGatewayProxyEvent {
return {
Expand Down Expand Up @@ -39,6 +48,11 @@ describe("backup handler", () => {
ContentType: "application/octet-stream",
},
]);
const storedBody = (uploads[0] as { Body: Buffer }).Body;
assert.equal(
createHash("sha256").update(gunzipSync(storedBody)).digest("hex"),
sqliteSha256,
);
});

it("rejects requests that are not POST /backup without uploading", async () => {
Expand Down Expand Up @@ -85,6 +99,22 @@ describe("backup handler", () => {
assert.equal(uploads, 0);
});

it("answers a probe when HTTP header names use mixed casing", async () => {
const uploads: unknown[] = [];
const handler = createHandler(
{ send: async (command) => uploads.push(command.input) },
() => new Date(),
{ BUCKET_NAME: "backup-bucket" },
);

const result = await handler(
event({ body: "", headers: { "X-LIFTLOG-PROBE": "true" } }),
);

assert.equal(result.statusCode, 200);
assert.deepEqual(uploads, []);
});

it("preserves bytes from legacy non-base64 binary requests", async () => {
const uploads: unknown[] = [];
const handler = createHandler(
Expand All @@ -102,6 +132,11 @@ describe("backup handler", () => {

assert.equal(result.statusCode, 200);
assert.deepEqual((uploads[0] as { Body: Buffer }).Body, gzipBody);
const storedBody = (uploads[0] as { Body: Buffer }).Body;
assert.equal(
createHash("sha256").update(gunzipSync(storedBody)).digest("hex"),
sqliteSha256,
);
});

it("rejects unsupported non-base64 content without uploading", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ export function createHandler(

// The app checks its backup target by posting an empty body with this header. Answer it, but do
// not store it - see docs/RemoteBackup.md.
if (event.headers["X-LiftLog-Probe"] || event.headers["x-liftlog-probe"]) {
if (
Object.entries(event.headers).some(
([name, value]) => name.toLowerCase() === "x-liftlog-probe" && value,
)
) {
return getReturnResult(200, "Probe acknowledged. Nothing was stored.");
}

Expand Down
15 changes: 12 additions & 3 deletions tests/LiftLog.Tests.Api/Integration/InboxControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ namespace LiftLog.Tests.Api.Integration;
public class InboxControllerTests(ApiFactory factory)
{
// RSA can only encrypt up to the key size, so a message arrives as ordered chunks.
private static readonly byte[][] chunks = [[0x01, 0x02], [0x03, 0x04], [0x05, 0x06]];
private static readonly byte[][] chunks =
[
[0x01, 0x02],
[0x03, 0x04],
[0x05, 0x06],
];

[Test]
public async Task PutThenGet_RoundTripsEveryChunkInOrder()
Expand Down Expand Up @@ -43,8 +48,12 @@ public async Task Get_DrainsTheInbox()
).EnsureSuccessStatusCode();

var request = new GetInboxMessagesRequest(alice.Id, alice.Password);
var first = await (await client.PostAsJsonAsync("/inbox", request)).Content.ReadFromJsonAsync<GetInboxMessagesResponse>();
var second = await (await client.PostAsJsonAsync("/inbox", request)).Content.ReadFromJsonAsync<GetInboxMessagesResponse>();
var first = await (
await client.PostAsJsonAsync("/inbox", request)
).Content.ReadFromJsonAsync<GetInboxMessagesResponse>();
var second = await (
await client.PostAsJsonAsync("/inbox", request)
).Content.ReadFromJsonAsync<GetInboxMessagesResponse>();

await Assert.That(first!.InboxMessages).Count().IsEqualTo(1);
await Assert.That(second!.InboxMessages).IsEmpty();
Expand Down
4 changes: 3 additions & 1 deletion tests/LiftLog.Tests.Api/Integration/UsersControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ public async Task Post_ReturnsEveryRequestedUserKeyedById()
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<GetUsersResponse>();

await Assert.That(body!.Users.Keys.Order()).IsEquivalentTo(new[] { alice.Id, bob.Id }.Order());
await Assert
.That(body!.Users.Keys.Order())
.IsEquivalentTo(new[] { alice.Id, bob.Id }.Order());
await Assert.That(body.Users[alice.Id].Lookup).IsEqualTo(alice.Lookup);
}

Expand Down
Loading