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
10 changes: 10 additions & 0 deletions docs/technical_logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ perfectly ordinary idle poll comes to look like a hang.

A pane rendering the persisted log, most recent first, updating live as entries arrive.

Every row is a fixed width local timestamp, a three letter level, then the message:

```
26/08/18 08:21:36 ERR connection test failed
26/08/18 08:21:36 INF testing connection
```

Both leading parts are the same width on every row, so the messages line up as a column and the
level reads as colour and shape long before it is read as a word.

It is always a straight render of what the sink holds: every change re-reads and redraws rather than
mutating the DOM in place. Read only, with no way to write entries, only to display what the sink
recorded.
Expand Down
13 changes: 7 additions & 6 deletions docs/technical_settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Settings persist to `data.json` in the plugin's own folder.
| `provider` | `r2`, `s3`, `custom`, or `minio` |
| `accountId` | Cloudflare account, which R2 derives endpoint and region from |
| `endpoint` | The S3 compatible endpoint, for a MinIO or custom provider |
| `region` | The region, for Amazon S3, a MinIO server, or a custom provider |
| `region` | The region, for Amazon S3 or a custom provider |
| `bucket` | The bucket name |
| `prefix` | The folder inside the bucket the vault lives under |
| `accessKeyId` | The access key |
Expand All @@ -45,13 +45,14 @@ is the same S3 API for all four.
| ------------------ | --------------------------------- | --------------------- |
| `r2` Cloudflare R2 | Derived from `accountId` | Always `auto` |
| `s3` Amazon S3 | Derived from `region` | The `region` as typed |
| `minio` MinIO | Typed in full | The `region` as typed |
| `minio` MinIO | Typed in full | Always `us-east-1` |
| `custom` | Typed in full | The `region` as typed |

MinIO is a real provider for the self-hosted audience the project serves: pick it, type your server's
endpoint, and the region it signs with (usually `us-east-1`). Custom only appears in development
builds, where esbuild defines `NODE_ENV`. It exists as an escape hatch for any other S3 compatible
endpoint, while the named providers cover the setups worth naming.
MinIO is a real provider for the self-hosted audience the project serves: pick it, type your
server's endpoint, and you're done. It asks for no region, because MinIO ignores the one it is sent
unless the server sets `MINIO_REGION`; a server pinned to another region is a custom provider.
Custom only appears in development builds, where esbuild defines `NODE_ENV`. It exists as an escape
hatch for any other S3 compatible endpoint, while the named providers cover the setups worth naming.

Amazon S3 puts the region straight into the endpoint host, so the region is the endpoint. A value
carrying URL authority delimiters, `x@attacker.example:443#`, would otherwise send signed requests
Expand Down
43 changes: 43 additions & 0 deletions src/log/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import {
createLogger,
createMemorySink,
formatLogLine,
formatTime,
type LogEntry,
type LogLevel,
levelEnabled,
levelLabel,
parseLogLine,
trimLogLines,
} from "./log.ts";
Expand Down Expand Up @@ -169,6 +172,46 @@ for (const { level, minLevel, want } of levelEnabledCases) {
});
}

// Local time in, local time out: the cases build their Date from parts rather than an ISO string,
// so they assert the same thing in every timezone the tests run in.
const formatTimeCases: { name: string; at: Date; want: string }[] = [
{
name: "two digit parts are rendered as typed",
at: new Date(2026, 7, 18, 18, 21, 36),
want: "26/08/18 18:21:36",
},
{
name: "single digit parts are padded",
at: new Date(2026, 0, 2, 3, 4, 5),
want: "26/01/02 03:04:05",
},
{
name: "a year ending in a single digit keeps its leading zero",
at: new Date(2005, 10, 30, 23, 59, 59),
want: "05/11/30 23:59:59",
},
];

for (const { name, at, want } of formatTimeCases) {
test(`formatTime: ${name}`, () => {
assert.equal(formatTime(at.getTime()), want);
});
}

const levelLabelCases: { level: LogLevel; want: string }[] = [
{ level: "debug", want: "DBG" },
{ level: "info", want: "INF" },
{ level: "warn", want: "WRN" },
{ level: "error", want: "ERR" },
];

for (const { level, want } of levelLabelCases) {
test(`levelLabel: ${level}`, () => {
assert.equal(levelLabel(level), want);
assert.equal(want.length, 3);
});
}

test("createMemorySink: append then read returns entries in order", async () => {
const sink = createMemorySink(10);

Expand Down
30 changes: 30 additions & 0 deletions src/log/log.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
// LEVEL_LABELS are the tags the log view shows, all three letters so every message starts in the
// same column without the level needing a fixed width column of its own.
const LEVEL_LABELS: Record<LogLevel, string> = {
debug: "DBG",
info: "INF",
warn: "WRN",
error: "ERR",
};

const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 0,
info: 1,
Expand Down Expand Up @@ -129,11 +138,27 @@ export function formatLogLine(entry: LogEntry): string {
return `${new Date(entry.time).toISOString()}\t${entry.level}\t${escapeMessage(entry.message)}`;
}

// formatTime renders a timestamp for the log view as "YY/MM/DD HH:MM:SS" in local time; every part
// is fixed width, so rows line up and the clock, the part actually read, stays short.
export function formatTime(time: number): string {
const at = new Date(time);
const year = pad2(at.getFullYear() % 100);
const date = `${year}/${pad2(at.getMonth() + 1)}/${pad2(at.getDate())}`;
const clock = `${pad2(at.getHours())}:${pad2(at.getMinutes())}:${pad2(at.getSeconds())}`;

return `${date} ${clock}`;
}

// levelEnabled reports whether a message at level should be logged when the minimum is minLevel.
export function levelEnabled(level: LogLevel, minLevel: LogLevel): boolean {
return LEVEL_ORDER[level] >= LEVEL_ORDER[minLevel];
}

// levelLabel returns the tag the log view shows for level.
export function levelLabel(level: LogLevel): string {
return LEVEL_LABELS[level];
}

// parseLogLine reverses formatLogLine. A malformed line (a corrupt or truncated file) is dropped
// rather than thrown, consistent with how state.json failures fail open elsewhere.
export function parseLogLine(line: string): LogEntry | undefined {
Expand Down Expand Up @@ -236,3 +261,8 @@ function notify(listener: LogListener | undefined, entry: LogEntry): void {
console.error(`geode: log listener failed: ${err}`);
}
}

// pad2 renders a date or clock part as two digits, so every timestamp is the same width.
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
6 changes: 3 additions & 3 deletions src/log/view.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ItemView, type WorkspaceLeaf } from "obsidian";
import type { LogBus, LogEntry, LogSink } from "./log.ts";
import { formatTime, type LogBus, type LogEntry, type LogSink, levelLabel } from "./log.ts";
import { nextRender, selectionOverlaps } from "./selection.ts";

// LOG_VIEW_TYPE identifies geode's log pane to Obsidian's workspace leaf API.
Expand All @@ -24,8 +24,8 @@ function renderLogView(containerEl: HTMLElement, entries: LogEntry[]): void {
// renderRow draws one entry into list, colour coded by level via a geode-log-row.is-<level> class.
function renderRow(list: HTMLElement, entry: LogEntry): void {
const row = list.createDiv({ cls: `geode-log-row is-${entry.level}` });
row.createSpan({ cls: "geode-log-time", text: new Date(entry.time).toLocaleString() });
row.createSpan({ cls: "geode-log-level", text: entry.level.toUpperCase() });
row.createSpan({ cls: "geode-log-time", text: formatTime(entry.time) });
row.createSpan({ cls: "geode-log-level", text: levelLabel(entry.level) });
row.createSpan({ cls: "geode-log-message", text: entry.message });
}

Expand Down
46 changes: 15 additions & 31 deletions src/settings/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
hasConnectionConfig,
isAwsRegion,
isCurrentConnectionResult,
MINIO_REGION,
normalizePrefix,
normalizeSettings,
prefixError,
Expand Down Expand Up @@ -396,6 +397,11 @@ const regionCases: { name: string; input: GeodeSettings; want: string }[] = [
input: { ...DEFAULT_SETTINGS, provider: "custom", region: " eu-west-2 " },
want: "eu-west-2",
},
{
name: "minio signs with its own region, ignoring a stale configured one",
input: { ...DEFAULT_SETTINGS, provider: "minio", region: "eu-west-2" },
want: MINIO_REGION,
},
];

for (const { name, input, want } of regionCases) {
Expand Down Expand Up @@ -547,21 +553,27 @@ for (const { name, input, want } of prefixErrorCases) {

test("providerOptions: production excludes the custom provider", () => {
assert.deepStrictEqual(providerOptions(false), {
minio: "MinIO",
r2: "Cloudflare R2",
s3: "Amazon S3",
minio: "MinIO",
});
});

test("providerOptions: local development includes the custom provider", () => {
assert.deepStrictEqual(providerOptions(true), {
minio: "MinIO",
r2: "Cloudflare R2",
s3: "Amazon S3",
minio: "MinIO",
custom: "Custom",
});
});

// The dropdown renders in insertion order, so the order itself is part of the contract.
test("providerOptions: lists providers in the order they are offered", () => {
assert.deepStrictEqual(Object.keys(providerOptions(false)), ["minio", "r2", "s3"]);
assert.deepStrictEqual(Object.keys(providerOptions(true)), ["minio", "r2", "s3", "custom"]);
});

const settingsEqualCases: { name: string; a: GeodeSettings; b: GeodeSettings; want: boolean }[] = [
{
name: "identical values are equal",
Expand Down Expand Up @@ -743,32 +755,18 @@ const hasConnectionConfigCases: { name: string; input: GeodeSettings; want: bool
input: {
...DEFAULT_SETTINGS,
provider: "minio",
region: "us-east-1",
bucket: "b",
accessKeyId: "a",
secretId: "s",
},
want: false,
},
{
name: "minio missing region is incomplete",
input: {
...DEFAULT_SETTINGS,
provider: "minio",
endpoint: "http://localhost:9000",
bucket: "b",
accessKeyId: "a",
secretId: "s",
},
want: false,
},
{
name: "minio with all fields is complete",
name: "minio needs no region of its own",
input: {
...DEFAULT_SETTINGS,
provider: "minio",
endpoint: "http://localhost:9000",
region: "us-east-1",
bucket: "b",
accessKeyId: "a",
secretId: "s",
Expand All @@ -781,20 +779,6 @@ const hasConnectionConfigCases: { name: string; input: GeodeSettings; want: bool
...DEFAULT_SETTINGS,
provider: "minio",
endpoint: " ",
region: "us-east-1",
bucket: "b",
accessKeyId: "a",
secretId: "s",
},
want: false,
},
{
name: "minio with a whitespace only region is incomplete",
input: {
...DEFAULT_SETTINGS,
provider: "minio",
endpoint: "http://localhost:9000",
region: " ",
bucket: "b",
accessKeyId: "a",
secretId: "s",
Expand Down
19 changes: 15 additions & 4 deletions src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const DEFAULT_SETTINGS: GeodeSettings = {
secretId: "",
};

// MINIO_REGION is the region MinIO signs with out of the box; it ignores the value entirely unless
// the server sets MINIO_REGION, so asking the user for one buys nothing.
export const MINIO_REGION = "us-east-1";

// ConnectionStatus is the current in-memory state of a Test Connection check.
export type ConnectionStatus = "unknown" | "checking" | "ok" | "error";

Expand All @@ -21,6 +25,8 @@ export type GeodeSettings = {
provider: Provider;
accountId: string;
endpoint: string;
// region is the signing region for Amazon S3 and a custom provider; R2 and MinIO supply their
// own, so the value is left untouched while one of those is selected.
region: string;
bucket: string;
// prefix is the folder inside the bucket the vault lives under, stored exactly as typed and
Expand Down Expand Up @@ -115,7 +121,8 @@ export function hasConnectionConfig(settings: GeodeSettings): boolean {
return isAwsRegion(regionFor(settings));
}

// MinIO and a custom provider both take their endpoint as typed, with a region for signing.
// MinIO and a custom provider both take their endpoint as typed; only the custom one also needs
// a region, which regionFor supplies for MinIO.
return normalizeEndpoint(settings.endpoint) !== "" && regionFor(settings) !== "";
}

Expand Down Expand Up @@ -225,14 +232,14 @@ export function prefixError(raw: string): string {
export function providerOptions(localDev: boolean): Record<string, string> {
if (localDev) {
return {
minio: "MinIO",
r2: "Cloudflare R2",
s3: "Amazon S3",
minio: "MinIO",
custom: "Custom",
};
}

return { r2: "Cloudflare R2", s3: "Amazon S3", minio: "MinIO" };
return { minio: "MinIO", r2: "Cloudflare R2", s3: "Amazon S3" };
}

// providerOr returns a known provider, defaulting unknown values to "r2".
Expand All @@ -245,11 +252,15 @@ export function providerOr(v: unknown): Provider {
}

// regionFor returns the signing region for settings, trimmed at the point of use; R2 always signs
// with "auto", so Amazon S3, MinIO, and a custom provider need one specified.
// with "auto" and MinIO with its own default, so only Amazon S3 and a custom provider need one
// specified. A MinIO server pinned to another region is a custom provider.
export function regionFor(settings: GeodeSettings): string {
if (settings.provider === "r2") {
return "auto";
}
if (settings.provider === "minio") {
return MINIO_REGION;
}
Comment on lines +261 to +263

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 MinIO signing region is forced

If a production MinIO server uses a non-default MINIO_REGION, regionFor now ignores the persisted region and signs both connection tests and sync requests for us-east-1, causing signature verification to fail with no production-visible configuration path to correct it.

Knowledge Base Used:


return settings.region.trim();
}
Expand Down
Loading