Skip to content
Merged
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
7 changes: 7 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
"description": "Override parser detection.",
"enum": ["react", "vue", "svelte", "ngx"]
},
"headers": {
"description": "Custom HTTP headers added to every request to the Tolgee API, e.g. when the platform is behind a custom auth/proxy layer.\n\nThe `user-agent`, `x-api-key` and `content-type` headers are reserved and ignored/overridden by the CLI. Custom headers are additive to the Tolgee API key (which is still required).",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"push": {
"type": "object",
"properties": {
Expand Down
8 changes: 7 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
CONFIG_OPT,
DEFAULT_NAMESPACE,
EXTRACTOR,
EXTRA_HEADER,
FILE_PATTERNS,
FORMAT_OPT,
STRICT_NAMESPACE,
Expand Down Expand Up @@ -41,6 +42,7 @@ import BranchCommand from './commands/branch.js';
import MergeCommand from './commands/merge.js';

import { getSingleOption } from './utils/getSingleOption.js';
import { mergeHeaders } from './utils/headers.js';
import { Schema } from './schema.js';
import { createTolgeeClient } from './client/TolgeeClient.js';
import { projectIdFromKey } from './client/ApiClient.js';
Expand Down Expand Up @@ -150,6 +152,7 @@ const preHandler = (config: Schema) =>
: config.projectId !== undefined
? Number(config.projectId)
: undefined,
headers: mergeHeaders(config.headers, opts.extraHeader),
});

cmd.setOptionValue('client', client);
Expand Down Expand Up @@ -191,9 +194,12 @@ async function run() {
program.addOption(STRICT_NAMESPACE.default(config.strictNamespace ?? true));
program.addOption(STRICT_NAMESPACE_NEGATION);
program.addOption(DEFAULT_NAMESPACE.default(config.defaultNamespace));
program.addOption(EXTRA_HEADER);

// Register commands
program.addCommand(Login);
program.addCommand(
Login(config).configureHelp({ showGlobalOptions: true })
);
program.addCommand(Logout);
program.addCommand(
PushCommand(config).configureHelp({ showGlobalOptions: true })
Expand Down
19 changes: 18 additions & 1 deletion src/client/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { API_KEY_PAK_PREFIX, USER_AGENT } from '../constants.js';
import { getApiKeyInformation } from './getApiKeyInformation.js';
import { debug, isDebugEnabled } from '../utils/logger.js';
import { errorFromLoadable } from './errorFromLoadable.js';
import { normalizeHeaderKeys } from '../utils/headers.js';

// Headers the CLI controls and that custom headers must never override.
const RESERVED_HEADERS = ['user-agent', 'content-type', 'x-api-key'];

async function parseResponse(response: Response, parseAs: ParseAs) {
// handle empty content
Expand Down Expand Up @@ -55,19 +59,32 @@ export type ApiClientProps = {
apiKey?: string;
projectId?: number | undefined;
autoThrow?: boolean;
headers?: Record<string, string>;
};

export function createApiClient({
baseUrl,
apiKey,
projectId,
autoThrow = false,
headers,
}: ApiClientProps) {
const computedProjectId =
projectId ?? (apiKey ? projectIdFromKey(apiKey) : undefined);

const custom = normalizeHeaderKeys(headers);
const ignored = RESERVED_HEADERS.filter((name) => name in custom);
if (ignored.length) {
debug(`[HTTP] Ignoring reserved custom header(s): ${ignored.join(', ')}`);
}
for (const name of ignored) {
delete custom[name];
}

const apiClient = createClient<paths>({
baseUrl,
headers: {
...custom,
'user-agent': USER_AGENT,
'x-api-key': apiKey,
},
Expand Down Expand Up @@ -110,7 +127,7 @@ export function createApiClient({
return getApiKeyInformation(apiClient, apiKey!);
},
getSettings(): ApiClientProps {
return { baseUrl, apiKey, projectId, autoThrow };
return { baseUrl, apiKey, projectId, autoThrow, headers };
},
};
}
Expand Down
72 changes: 39 additions & 33 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,39 +10,44 @@ import { debug, exitWithError, success } from '../utils/logger.js';
import { createTolgeeClient } from '../client/TolgeeClient.js';
import { printApiKeyLists } from '../utils/apiKeyList.js';
import { getStackTrace } from '../utils/getStackTrace.js';
import { mergeHeaders } from '../utils/headers.js';
import { Schema } from '../schema.js';

type Options = {
apiUrl: URL;
all: boolean;
list: boolean;
extraHeader?: string[];
};

async function loginHandler(this: Command, key?: string) {
const opts: Options = this.optsWithGlobals();
const loginHandler = (config: Schema) =>
async function (this: Command, key?: string) {
const opts: Options = this.optsWithGlobals();

if (opts.list) {
printApiKeyLists();
return;
} else if (!key) {
exitWithError('Missing argument [API Key]');
}
if (opts.list) {
printApiKeyLists();
return;
} else if (!key) {
exitWithError('Missing argument [API Key]');
}

debug(
`Logging in with API key ${key?.slice(0, 5)}...${key?.slice(-4)}.\n${getStackTrace()}`
);
debug(
`Logging in with API key ${key?.slice(0, 5)}...${key?.slice(-4)}.\n${getStackTrace()}`
);

const keyInfo = await createTolgeeClient({
baseUrl: opts.apiUrl.toString(),
apiKey: key,
}).getApiKeyInfo();
const keyInfo = await createTolgeeClient({
baseUrl: opts.apiUrl.toString(),
apiKey: key,
headers: mergeHeaders(config.headers, opts.extraHeader),
}).getApiKeyInfo();

await saveApiKey(opts.apiUrl, keyInfo);
success(
keyInfo.type === 'PAK'
? `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)} for project ${ansi.blue(String(keyInfo.project.id))} (${keyInfo.project.name}). Welcome back!`
: `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)}. Welcome back!`
);
}
await saveApiKey(opts.apiUrl, keyInfo);
success(
keyInfo.type === 'PAK'
? `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)} for project ${ansi.blue(String(keyInfo.project.id))} (${keyInfo.project.name}). Welcome back!`
: `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)}. Welcome back!`
);
};

async function logoutHandler(this: Command) {
const opts: Options = this.optsWithGlobals();
Expand All @@ -59,17 +64,18 @@ async function logoutHandler(this: Command) {
success(`You're now logged out of ${opts.apiUrl.hostname}.`);
}

export const Login = new Command()
.name('login')
.description(
'Login to Tolgee with an API key. You can be logged into multiple Tolgee instances at the same time by using --api-url'
)
.option('-l, --list', 'List existing api keys')
.argument(
'[API Key]',
'The API key. Can be either a personal access token, or a project key'
)
.action(loginHandler);
export const Login = (config: Schema) =>
new Command()
.name('login')
.description(
'Login to Tolgee with an API key. You can be logged into multiple Tolgee instances at the same time by using --api-url'
)
.option('-l, --list', 'List existing api keys')
.argument(
'[API Key]',
'The API key. Can be either a personal access token, or a project key'
)
.action(loginHandler(config));

export const Logout = new Command()
.name('logout')
Expand Down
41 changes: 41 additions & 0 deletions src/config/tolgeerc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { dirname, join, resolve } from 'node:path';
import { error, exitWithError } from '../utils/logger.js';
import type { Schema } from '../schema.js';
import { valueToArray } from '../utils/valueToArray.js';
import {
normalizeHeaderName,
validateHeaderName,
validateHeaderValue,
} from '../utils/headers.js';
import { Ajv } from 'ajv';

const explorer = cosmiconfig('tolgee', {
Expand Down Expand Up @@ -85,6 +90,42 @@ function parseConfig(input: Schema, configDir: string) {
rc.pull.path = resolve(configDir, rc.pull.path).replace(/\\/g, '/');
}

if (
rc.headers !== undefined &&
typeof rc.headers === 'object' &&
rc.headers !== null &&
!Array.isArray(rc.headers)
) {
const asConfigError = <T>(fn: () => T): T => {
try {
return fn();
} catch (e: any) {
throw new Error(`Invalid config: ${e.message}`);
}
};

const seen = new Set<string>();
const normalized: Record<string, string> = {};
for (const [name, value] of Object.entries(rc.headers)) {
asConfigError(() => validateHeaderName(name));
const key = normalizeHeaderName(name);
if (seen.has(key)) {
throw new Error(
`Invalid config: duplicate header '${key}' (header names are case-insensitive)`
);
}
seen.add(key);
// Non-string values are left for ajv to reject with a 'Tolgee config:' error.
if (typeof value === 'string') {
asConfigError(() => validateHeaderValue(value));
normalized[key] = value.trim();
} else {
normalized[key] = value;
}
}
rc.headers = normalized;
}

return rc;
}

Expand Down
16 changes: 16 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync } from 'fs';
import { resolve } from 'path';
import { Option, InvalidArgumentError } from 'commander';
import { createTolgeeClient } from './client/TolgeeClient.js';
import { parseHeaderList } from './utils/headers.js';
import { VerboseOption } from './extractor/index.js';

function parseProjectId(v: string) {
Expand Down Expand Up @@ -29,6 +30,15 @@ function parsePath(v: string) {
return path;
}

function accumulateHeader(v: string, previous: string[] = []) {
try {
parseHeaderList([v]);
} catch (e: any) {
throw new InvalidArgumentError(e.message);
}
return [...previous, v];
}

export type BaseOptions = {
apiUrl: URL;
apiKey: string;
Expand All @@ -39,6 +49,7 @@ export type BaseOptions = {
patterns: string[] | undefined;
strictNamespace: boolean | undefined;
verbose: VerboseOption[] | boolean | undefined;
extraHeader?: string[];
};

export const API_KEY_OPT = new Option(
Expand Down Expand Up @@ -141,3 +152,8 @@ export const VERBOSE = new Option(
'-v, --verbose [rules...]',
'Enable verbose logging. If you want more info to be logged pass an option.'
).choices(['extractor']);

export const EXTRA_HEADER = new Option(
'-H, --extra-header <header>',
'Extra HTTP header added to every request to the Tolgee API, e.g. "Authorization: Bearer <token>". Repeatable. The user-agent, x-api-key and content-type headers are reserved and ignored.'
).argParser(accumulateHeader);
8 changes: 8 additions & 0 deletions src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ export interface Schema {
* Override parser detection.
*/
parser?: "react" | "vue" | "svelte" | "ngx";
/**
* Custom HTTP headers added to every request to the Tolgee API, e.g. when the platform is behind a custom auth/proxy layer.
*
* The `user-agent`, `x-api-key` and `content-type` headers are reserved and ignored/overridden by the CLI. Custom headers are additive to the Tolgee API key (which is still required).
*/
headers?: {
[k: string]: string;
};
push?: {
/**
* A template that describes the structure of the local files and their location with file [structure template format](https://docs.tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format).
Expand Down
85 changes: 85 additions & 0 deletions src/utils/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// RFC 7230 token grammar for header field names.
const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;

export function normalizeHeaderName(name: string): string {
return name.trim().toLowerCase();
}

// True if the string contains an ASCII control character forbidden in a header
// field value. Per RFC 7230 a field value may contain HTAB (0x09), so only the
// other C0 controls (which include CR and LF, the injection vectors) and DEL
// are rejected.
function hasControlChar(value: string): boolean {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if ((code < 0x20 && code !== 0x09) || code === 0x7f) {
return true;
}
}
return false;
}

export function validateHeaderName(name: string): void {
const trimmed = name.trim();
if (!trimmed) {
throw new Error('header name must not be empty');
}
if (!HEADER_NAME_TOKEN.test(trimmed)) {
throw new Error(`invalid header name "${name}"`);
}
}

export function validateHeaderValue(value: string): void {
if (hasControlChar(value)) {
throw new Error('header value must not contain control characters');
}
}

export function normalizeHeaderKeys(
headers: Record<string, string> | undefined
): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, value] of Object.entries(headers ?? {})) {
out[normalizeHeaderName(name)] = value;
}
return out;
}

/**
* Parses a list of raw `Name: Value` header strings (e.g. from repeated
* `--extra-header` flags) into a name->value map. Names are lowercased; later
* entries override earlier ones. Throws on a missing colon, an empty/invalid
* name, or control characters. Empty values are allowed.
*/
export function parseHeaderList(
list: string[] | undefined
): Record<string, string> {
const out: Record<string, string> = {};
for (const entry of list ?? []) {
const separator = entry.indexOf(':');
if (separator === -1) {
throw new Error(`invalid header "${entry}", expected "Name: Value"`);
}
const name = entry.slice(0, separator);
const rawValue = entry.slice(separator + 1);
validateHeaderName(name);
validateHeaderValue(rawValue);
out[normalizeHeaderName(name)] = rawValue.trim();
}
return out;
}

/**
* Merges config-file headers with CLI `--extra-header` values into a single
* name->value map. CLI values override config values for the same (lowercased)
* name.
*/
export function mergeHeaders(
configHeaders: Record<string, string> | undefined,
extraHeaderList: string[] | undefined
): Record<string, string> {
return {
...normalizeHeaderKeys(configHeaders),
...parseHeaderList(extraHeaderList),
};
}
Loading
Loading