mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 13:50:26 +02:00
refactor: cleanup
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
# Storage for developing and testing donut-sync itself. It runs MinIO only, and
|
||||
# the sync server is expected to run on the host beside it (`pnpm start:dev`),
|
||||
# which is why MinIO is published and why the port matches the one pinned in
|
||||
# test/test-env.ts.
|
||||
#
|
||||
# This is NOT the self-hosting compose file. That one runs donut-sync in a
|
||||
# container too, and it must set S3_PUBLIC_ENDPOINT, because a server that signs
|
||||
# presigned URLs against a compose-internal host such as `http://minio:9000`
|
||||
# hands every device a URL it cannot open, while /health and /readyz stay green.
|
||||
# Take the self-hosting compose from https://donutbrowser.com/docs/self-hosting
|
||||
# rather than from here.
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
|
||||
@@ -86,6 +86,11 @@ export class SyncService implements OnModuleInit {
|
||||
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
|
||||
private presignClient: S3Client;
|
||||
private publicEndpoint: string;
|
||||
/**
|
||||
* Whether an operator chose the public endpoint, or it fell back to the
|
||||
* server's own storage address. The fallback is the shape that fails.
|
||||
*/
|
||||
private publicEndpointWasConfigured: boolean;
|
||||
private bucket: string;
|
||||
// Upper bound on presign batch array length (DoS guard).
|
||||
private static readonly MAX_BATCH_ITEMS = 1000;
|
||||
@@ -131,9 +136,11 @@ export class SyncService implements OnModuleInit {
|
||||
// network and nowhere else. Signing is bound to the host, so the presign
|
||||
// client is a second client pinned to the public address rather than a
|
||||
// string rewrite of the signed URL.
|
||||
const publicEndpoint =
|
||||
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
|
||||
const configuredPublicEndpoint =
|
||||
this.configService.get<string>("S3_PUBLIC_ENDPOINT");
|
||||
const publicEndpoint = configuredPublicEndpoint || endpoint;
|
||||
this.publicEndpoint = publicEndpoint;
|
||||
this.publicEndpointWasConfigured = Boolean(configuredPublicEndpoint);
|
||||
this.presignClient =
|
||||
publicEndpoint === endpoint
|
||||
? this.s3Client
|
||||
@@ -191,14 +198,33 @@ export class SyncService implements OnModuleInit {
|
||||
|
||||
const isSingleLabel =
|
||||
!host.includes(".") && !host.includes(":") && host !== "localhost";
|
||||
if (!isSingleLabel) return;
|
||||
|
||||
this.logger.warn(
|
||||
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
|
||||
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
|
||||
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
|
||||
"to an address your devices can reach (and publish that port).",
|
||||
);
|
||||
if (isSingleLabel) {
|
||||
this.logger.warn(
|
||||
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
|
||||
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
|
||||
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
|
||||
"to an address your devices can reach (and publish that port).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// A dotted host proves nothing. With `S3_PUBLIC_ENDPOINT` unset, clients are
|
||||
// handed whatever address this server uses for storage itself, and a
|
||||
// reachable-looking name such as `storage.internal`, or a private address on
|
||||
// a network the devices are not on, fails in exactly the same way while
|
||||
// saying nothing at all. This server cannot test the endpoint for them,
|
||||
// because it does not know where its clients are, so state what it does
|
||||
// know and leave the judgement to the operator.
|
||||
if (!this.publicEndpointWasConfigured) {
|
||||
this.logger.log(
|
||||
`S3_PUBLIC_ENDPOINT is not set, so presigned URLs will name '${this.publicEndpoint}', ` +
|
||||
"the address this server uses for storage itself. Transfers go straight from each " +
|
||||
"device to that address, and this server cannot verify a device can reach it. If " +
|
||||
"transfers fail while /health and /readyz stay green, set S3_PUBLIC_ENDPOINT to an " +
|
||||
"address your devices can reach and publish that port.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBucketExists(): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { INestApplication, Logger } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
@@ -199,3 +199,72 @@ describe("presigned URL host", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The server cannot test whether a device can reach the endpoint it signs, so
|
||||
// the only honest thing it can do is say what it is handing out. Without this,
|
||||
// the one configuration that breaks every transfer boots completely silently.
|
||||
describe("boot message about the presign endpoint", () => {
|
||||
let logs: string[];
|
||||
let warnings: string[];
|
||||
let logSpy: jest.SpyInstance;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
warnings = [];
|
||||
logSpy = jest
|
||||
.spyOn(Logger.prototype, "log")
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, "warn")
|
||||
.mockImplementation((message: unknown) => {
|
||||
warnings.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("says which host clients will be handed when S3_PUBLIC_ENDPOINT is unset", async () => {
|
||||
const app = await bootstrap(undefined);
|
||||
try {
|
||||
const spoken = [...logs, ...warnings].join("\n");
|
||||
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
|
||||
expect(spoken).toContain(TEST_S3_ENDPOINT);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A single-label host is the documented compose default and cannot work for
|
||||
// any client, so it earns a warning rather than a note.
|
||||
it("warns loudly about a container-only host", async () => {
|
||||
const app = await bootstrap("http://minio:9000");
|
||||
try {
|
||||
const spoken = warnings.join("\n");
|
||||
expect(spoken).toContain("minio");
|
||||
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
|
||||
} finally {
|
||||
delete process.env.S3_PUBLIC_ENDPOINT;
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// An operator who set the variable made a choice. Repeating the note at them
|
||||
// would train them to ignore it, and the warning above is for the value that
|
||||
// provably cannot work, not for every value the server cannot verify.
|
||||
it("stays quiet when an operator has chosen a routable endpoint", async () => {
|
||||
const app = await bootstrap(PUBLIC_ENDPOINT);
|
||||
try {
|
||||
const spoken = [...logs, ...warnings].join("\n");
|
||||
expect(spoken).not.toContain("S3_PUBLIC_ENDPOINT is not set");
|
||||
} finally {
|
||||
delete process.env.S3_PUBLIC_ENDPOINT;
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user