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
15 changes: 8 additions & 7 deletions src/lib/components/variables/createVariableModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { Link } from '$lib/elements';
import { page } from '$app/state';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { validateVariables } from '$lib/helpers/variables';

export type ProductLabel = 'site' | 'function';

Expand Down Expand Up @@ -39,13 +40,13 @@
newVariables = newVariables.map((variable) => ({ ...variable, secret: true }));
}

newVariables.forEach((variable) => {
if (('' + variable.value).length > 8192) {
throw new Error(
`Variable ${variable.key} is longer than 8192 allowed characters`
);
}
});
const validationError = validateVariables(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Wouldn’t this also validate keys that are already stored? 🤔 If a project contains a legacy key (examplews MY-KEY), updating only its value would fail here before sdkUpdateVariable(..., undefined, ...) is ever reached. Should we only validate keys for newly added variables and keep validating values for existing ones?
cc @greptile

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.

Good catch, but the concern is scoped to the wrong component. In createVariableModal.svelte, validateVariables is only called on newVariables — the rows the user is currently entering in the form. The pre-existing variables prop is never passed through validation here, so legacy stored keys are safe.

The concern is real in updateVariableModal.svelte though. There, pair is initialized from selectedVar (the stored variable), and validateVariables([pair]) runs unconditionally on submit — it doesn't check whether pair.key actually changed. So if a user opens the update modal on a legacy MY-KEY variable to change only the value, the key validation fires and blocks them before sdkUpdateVariable (with undefined key) is ever reached. That's the breakage the PR description says it fixes, but looking at the actual code in updateVariableModal.svelte the guard isn't there.

The fix would be to skip key validation when the key is unchanged:

function handleVariable() {
    const keyChanged = pair.key !== selectedVar?.key;
    const toValidate = keyChanged ? pair : { key: undefined, value: pair.value };
    const validationError = validateVariables([toValidate]);
    ...
}

But validateVariablesgetVariableKeyError returns 'Variable key is required' for an undefined key, so validateVariables would also need to treat key === undefined as "skip key check" (i.e. only validate value). Worth addressing before merge — as-is, updateVariableModal.svelte still breaks value-only edits on any pre-existing invalid key.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Checked both create modals and both update modals — no change needed here, and the split you describe is what is already implemented.

Create modals (lib/components/variables/createVariableModal.svelte, project-[region]-[project]/createVariableModal.svelte): validateVariables only ever receives newVariables, the rows being typed into the form. The pre-existing variables prop is never passed through it, so stored keys are untouched.

Update modal (project-[region]-[project]/updateVariablesModal.svelte) — this is the one reachable from the post-create variables table, and it already guards on the key having changed:

const keyChanged = pair.key !== originalKey;
const validationError = keyChanged
    ? validateVariables([pair])
    : getVariableValueError(pair.key, pair.value);

so a value-only edit on a legacy MY-KEY validates the value only, and dispatches key: undefined so the API keeps the stored key.

lib/components/variables/updateVariableModal.svelte (the singular one, which greptile pointed at) validates unconditionally, but as you noted in the other thread it is only reachable through EnvironmentVariables in the two create-flow configuration components, where the key is always user-entered.

newVariables.filter((variable) => variable.key || variable.value)
);
if (validationError) {
throw new Error(validationError);
}

const updatedVariables = [...variables];
newVariables.forEach((newVar) => {
if (!newVar.key) {
Expand Down
24 changes: 23 additions & 1 deletion src/lib/components/variables/environmentVariables.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import UpdateVariableModal from './updateVariableModal.svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import { isSmallViewport } from '$lib/stores/viewport';
import { isValidVariableKey } from '$lib/helpers/variables';

const DOCS_LINKS: Record<ProductLabel, string> = {
site: 'https://appwrite.io/docs/products/sites/develop#accessing-environment-variables',
Expand Down Expand Up @@ -146,7 +147,28 @@
</svelte:fragment>
{#each paginatedItems as variable}
<Table.Row.Base {root}>
<Table.Cell column="key" {root}>{variable.key}</Table.Cell>
<Table.Cell column="key" {root}>
<Layout.Stack
gap="xxs"
alignItems="center"
direction="row"
inline>
{#if !isValidVariableKey(variable.key)}
<Tooltip maxWidth="26rem">
<span
class="icon-exclamation u-color-text-danger"
aria-hidden="true"></span>
<svelte:fragment slot="tooltip">
This key can't be used as an environment
variable name. Rename it using only letters,
digits and underscores, without starting
with a digit.
</svelte:fragment>
</Tooltip>
{/if}
{variable.key}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="value" {root}>
<!-- TODO: fix max width -->
<div style="max-width: 100%">
Expand Down
22 changes: 12 additions & 10 deletions src/lib/components/variables/importVariablesModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Icon, Layout, Selector, Tooltip, Typography, Upload } from '@appwrite.io/pink-svelte';
import { parse } from '$lib/helpers/envfile';
import { removeFile } from '$lib/helpers/files';
import { validateVariables } from '$lib/helpers/variables';

export let show = false;
export let variables: Partial<Models.Variable>[];
Expand Down Expand Up @@ -35,19 +36,20 @@
if (!Object.keys(uploaded).length) {
throw new Error('No variables found');
}
const entries = Object.entries(uploaded);
// Drop the valueless entries first. They are never written, so an
// invalid key on one of them must not reject the whole file.
const entries = Object.entries(uploaded).filter(([, value]) => !!value);

for (const [key, value] of entries) {
if (value.length > 8192) {
throw new Error(`Variable ${key} is longer than 8192 allowed characters`);
}
const validationError = validateVariables(
entries.map(([key, value]) => ({ key, value }))
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (validationError) {
throw new Error(validationError);
}

entries
.filter(([, value]) => !!value)
.forEach(([key, value]) => {
variables.push({ key, value, secret });
});
entries.forEach(([key, value]) => {
variables.push({ key, value, secret });
});

show = false;
} catch (e) {
Expand Down
11 changes: 10 additions & 1 deletion src/lib/components/variables/updateVariableModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Layout, Selector } from '@appwrite.io/pink-svelte';
import { Link } from '$lib/elements';
import { page } from '$app/state';
import { validateVariables } from '$lib/helpers/variables';

export let show = false;
export let selectedVar: Partial<Models.Variable>;
Expand All @@ -20,7 +21,15 @@
secret: selectedVar?.secret
};

let error = '';

function handleVariable() {
const validationError = validateVariables([pair]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (validationError) {
error = validationError;
return;
}

if (selectedVar) {
variables = variables.map((variable) => {
const match = selectedVar.$id
Expand All @@ -39,7 +48,7 @@
}
</script>

<Modal bind:show onSubmit={handleVariable} title="Update variable">
<Modal bind:show onSubmit={handleVariable} title="Update variable" bind:error>
<span slot="description">
Update the environment variable for your {productLabel}. Global variables can be set in
<Link
Expand Down
10 changes: 6 additions & 4 deletions src/lib/components/variables/variableEditorModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import Button from '$lib/elements/forms/button.svelte';
import { addNotification } from '$lib/stores/notifications';
import { parse } from '$lib/helpers/envfile';
import { validateVariables } from '$lib/helpers/variables';
import { Alert, Icon, Layout, Tabs } from '@appwrite.io/pink-svelte';
import { IconDownload, IconDuplicate } from '@appwrite.io/pink-icons-svelte';
import { InputTextarea } from '$lib/elements/forms';
Expand Down Expand Up @@ -47,10 +48,11 @@
const vars = tab === 'env' ? parse(envCode) : JSON.parse(jsonCode ? jsonCode : '{}');
const entries = Object.entries(vars);

for (const [key, value] of entries) {
if (('' + value).length > 8192) {
throw new Error(`Variable ${key} is longer than 8192 allowed characters`);
}
const validationError = validateVariables(
entries.map(([key, value]) => ({ key, value }))
);
if (validationError) {
throw new Error(validationError);
}

// Update or remove editable variables
Expand Down
49 changes: 49 additions & 0 deletions src/lib/helpers/variables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from 'vitest';
import {
getVariableKeyError,
isValidVariableKey,
validateVariables,
VARIABLE_KEY_MAX_LENGTH,
VARIABLE_VALUE_MAX_LENGTH
} from '$lib/helpers/variables';

test('accept keys that are valid environment variable names', () => {
expect(isValidVariableKey('APP_TEST')).toBe(true);
expect(isValidVariableKey('_PRIVATE')).toBe(true);
expect(isValidVariableKey('key1')).toBe(true);
expect(isValidVariableKey('a'.repeat(VARIABLE_KEY_MAX_LENGTH))).toBe(true);
});

test('reject keys that cannot be used as environment variable names', () => {
expect(isValidVariableKey('MY-KEY')).toBe(false);
expect(isValidVariableKey('MY.KEY')).toBe(false);
expect(isValidVariableKey('MY KEY')).toBe(false);
expect(isValidVariableKey('9KEY')).toBe(false);
expect(isValidVariableKey('KÉY')).toBe(false);
expect(isValidVariableKey('KEY\t')).toBe(false);
expect(isValidVariableKey('')).toBe(false);
expect(isValidVariableKey('a'.repeat(VARIABLE_KEY_MAX_LENGTH + 1))).toBe(false);
});

test('report a missing key separately from an invalid one', () => {
expect(getVariableKeyError('')).toEqual('Variable key is required');
expect(getVariableKeyError('MY-KEY')).toContain('is invalid');
expect(getVariableKeyError('a'.repeat(VARIABLE_KEY_MAX_LENGTH + 1))).toContain('longer than');
expect(getVariableKeyError('APP_TEST')).toBeNull();
});

test('validate a list of variables and name the offending key', () => {
expect(validateVariables([{ key: 'APP_TEST', value: 'value' }])).toBeNull();
expect(validateVariables([{ key: 'APP_TEST', value: '' }])).toBeNull();

expect(
validateVariables([
{ key: 'APP_TEST', value: 'value' },
{ key: 'MY-KEY', value: 'value' }
])
).toContain('MY-KEY');

expect(
validateVariables([{ key: 'APP_TEST', value: 'v'.repeat(VARIABLE_VALUE_MAX_LENGTH + 1) }])
).toContain('APP_TEST');
});
66 changes: 66 additions & 0 deletions src/lib/helpers/variables.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,71 @@
import type { Models } from '@appwrite.io/console';

export const VARIABLE_KEY_MAX_LENGTH = 255;
export const VARIABLE_VALUE_MAX_LENGTH = 8192;

/**
* Variable keys become environment variable names at build and runtime, so the
* API only accepts C-style identifiers. Keys stored before that rule existed
* can still fail this check, which is why `isValidVariableKey` is also used to
* flag them in the variables table.
*/
const VARIABLE_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;

export function isValidVariableKey(key: string): boolean {
return (
typeof key === 'string' &&
key.length <= VARIABLE_KEY_MAX_LENGTH &&
VARIABLE_KEY_PATTERN.test(key)
);
}

export function getVariableKeyError(key: string): string | null {
if (!key) {
return 'Variable key is required';
}

if (key.length > VARIABLE_KEY_MAX_LENGTH) {
return `Variable key "${key}" is longer than ${VARIABLE_KEY_MAX_LENGTH} allowed characters`;
}

if (!VARIABLE_KEY_PATTERN.test(key)) {
return `Variable key "${key}" is invalid. Keys can only contain letters, digits and underscores, and cannot start with a digit`;
}

return null;
}

export function getVariableValueError(key: string, value: unknown): string | null {
if (('' + (value ?? '')).length > VARIABLE_VALUE_MAX_LENGTH) {
return `Variable ${key} is longer than ${VARIABLE_VALUE_MAX_LENGTH} allowed characters`;
}

return null;
}

/**
* Returns the first problem found, or null when every variable is accepted by
* the API. Call before submitting so a rejected key is reported against the
* file or row it came from instead of as a bare server error.
*/
export function validateVariables(
variables: Array<{ key?: string; value?: unknown }>
): string | null {
for (const { key, value } of variables) {
const keyError = getVariableKeyError(key);
if (keyError) {
return keyError;
}

const valueError = getVariableValueError(key, value);
if (valueError) {
return valueError;
}
}

return null;
}

export function normalizeDetectedVariables(
detected: Models.DetectionVariable[] = []
): Partial<Models.Variable>[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import { Alert, Layout, Selector, Button as PinkButton, Icon } from '@appwrite.io/pink-svelte';
import { Link } from '$lib/elements';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { validateVariables } from '$lib/helpers/variables';

export let isGlobal: boolean;
export let product: 'function' | 'site' = 'function';
export let showCreate = false;
let newVariables: Partial<Models.Variable>[] = [{ key: '', value: '' }];
let secret = false;
let error = '';

const dispatch = createEventDispatcher();

Expand All @@ -26,12 +28,16 @@
if (secret) {
newVariables = newVariables.map((variable) => ({ ...variable, secret: true }));
}
newVariables.forEach((variable) => {
if (('' + variable.value).length > 8192) {
throw new Error(`Variable ${variable.key} is longer than 8192 allowed characters`);
}
});
dispatch('created', newVariables);

const filled = newVariables.filter((variable) => variable.key || variable.value);

const validationError = validateVariables(filled);
if (validationError) {
error = validationError;
return;
}

dispatch('created', filled);
close();
}

Expand All @@ -48,6 +54,7 @@

<Modal
bind:show={showCreate}
bind:error
onSubmit={handleVariable}
title={`Create ${isGlobal ? 'global' : 'environment'} variables`}>
<svelte:fragment slot="description">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import { getLatestTag } from '$lib/helpers/github';
import { writable } from 'svelte/store';
import Link from '$lib/elements/link.svelte';
import { validateVariables } from '$lib/helpers/variables';

let {
data
Expand Down Expand Up @@ -97,6 +98,13 @@
$isSubmitting = true;

try {
// Reject an unusable key before the resource is created, so a
// rejected variable can't leave a half-configured resource behind.
const validationError = validateVariables(variables);
if (validationError) {
throw new Error(validationError);
}

if (!latestTag) {
latestTag = await getLatestTag(data.repository.owner, data.repository.name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { currentPlan } from '$lib/stores/organization';
import { uploader } from '$lib/stores/uploader';
import { validateVariables } from '$lib/helpers/variables';

export let data;

Expand Down Expand Up @@ -69,6 +70,13 @@
let func: Models.Function | null = null;

try {
// Reject an unusable key before the resource is created, so a
// rejected variable can't leave a half-configured resource behind.
const validationError = validateVariables(variables);
if (validationError) {
throw new Error(validationError);
}

func = await sdk.forProject(page.params.region, page.params.project).functions.create({
functionId: id || ID.unique(),
name,
Expand Down
Loading