Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | 1x 1x 17x 17x 17x 17x 17x 16x 16x 16x 17x 1x 1x 16x 16x 4x 4x 4x 4x 1x 1x 9x 9x 9x 9x 9x 1x 17x 17x 1x 4x 4x 4x 4x 4x 4x 1x 1x 1x 4x 4x 4x 2x 2x 2x 2x 4x 1x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 1x 1x 1x 7x 1x 3x 3x 3x 1x 3x 3x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 3x 1x | import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3";
import { AppError } from "@ontrack/backend-common";
import type { GpxContent, GpxContentStore } from "./gpx-content-store.js";
const GPX_CONTENT_TYPE = "application/gpx+xml; charset=utf-8";
/** GPX file names are single path segments; keep object keys flat under the prefix. */
function assertSingleSegment(fileName: string): string {
const name = fileName.trim();
if (
name.length === 0 ||
name.includes("/") ||
name.includes("\\") ||
name === "." ||
name === ".."
) {
throw new AppError(400, "INVALID_FILE_NAME", "File name must be a single path segment");
}
return name;
}
function errorName(err: unknown): string {
return err && typeof err === "object" && "name" in err
? String((err as { name: unknown }).name)
: "";
}
function httpStatus(err: unknown): number | undefined {
return (err as { $metadata?: { httpStatusCode?: number } } | undefined)?.$metadata?.httpStatusCode;
}
/**
* GPX content store backed by an S3-compatible bucket (Cloudflare R2).
* The `S3Client` is injected so the factory owns wiring and tests stay offline.
*/
export class R2GpxStore implements GpxContentStore {
constructor(
private readonly client: S3Client,
private readonly bucket: string,
private readonly keyPrefix = "",
private readonly trashBucket?: string,
) {}
private keyFor(fileName: string): string {
return `${this.keyPrefix}${assertSingleSegment(fileName)}`;
}
async getByFileName(fileName: string): Promise<GpxContent> {
const key = this.keyFor(fileName);
try {
const res = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
);
if (!res.Body) {
throw new AppError(404, "TRACK_FILE_NOT_FOUND", "GPX object has no body");
}
const bytes = await res.Body.transformToByteArray();
return {
body: Buffer.from(bytes),
contentType: res.ContentType ?? GPX_CONTENT_TYPE,
};
} catch (err) {
if (err instanceof AppError) {
throw err;
}
if (errorName(err) === "NoSuchKey" || httpStatus(err) === 404) {
throw new AppError(404, "TRACK_FILE_NOT_FOUND", "GPX file not found in storage");
}
throw err;
}
}
async saveNew(fileName: string, body: Buffer): Promise<void> {
const key = this.keyFor(fileName);
try {
// `IfNoneMatch: "*"` makes the write fail atomically if the object exists,
// mirroring the local store's `wx` flag. The DB unique constraint on
// file_name remains the authoritative guard.
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: GPX_CONTENT_TYPE,
IfNoneMatch: "*",
}),
);
} catch (err) {
if (errorName(err) === "PreconditionFailed" || httpStatus(err) === 412) {
throw new AppError(409, "FILE_NAME_TAKEN", "GPX file already exists in storage");
}
throw err;
}
}
async deleteByFileName(fileName: string): Promise<void> {
const key = this.keyFor(fileName);
// S3/R2 DeleteObject is idempotent — succeeds even if the key is absent.
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
async archive(fileName: string): Promise<void> {
const key = this.keyFor(fileName);
if (!this.trashBucket) {
// Корзина не настроена — деградируем до физического удаления.
await this.deleteByFileName(fileName);
return;
}
// CopySource должен быть URL-кодирован посегментно (ключи содержат пробелы/кириллицу).
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
try {
await this.client.send(
new CopyObjectCommand({
Bucket: this.trashBucket,
Key: key,
CopySource: `${this.bucket}/${encodedKey}`,
}),
);
} catch (err) {
if (errorName(err) === "NoSuchKey" || httpStatus(err) === 404) {
// Исходника нет — перенос считаем выполненным (идемпотентность).
return;
}
throw err;
}
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
}
|