add: add env masking extension for .env files

Introduce a new extension for CodeMirror that masks sensitive values in .env files. This ensures that sensitive information like API keys or passwords is not displayed in plain text within the editor. The extension dynamically applies masking to values in lines matching the KEY=VALUE format, improving security during development.
This commit is contained in:
KevIsDev 2025-03-27 18:52:13 +00:00
parent 95dcd0261a
commit 4b0eaf25ce
3 changed files with 87 additions and 1 deletions

View File

@ -25,6 +25,7 @@ import { BinaryContent } from './BinaryContent';
import { getTheme, reconfigureTheme } from './cm-theme';
import { indentKeyBinding } from './indent';
import { getLanguage } from './languages';
import { createEnvMaskingExtension } from './EnvMasking';
const logger = createScopedLogger('CodeMirrorEditor');
@ -134,6 +135,9 @@ export const CodeMirrorEditor = memo(
const [languageCompartment] = useState(new Compartment());
// Add a compartment for the env masking extension
const [envMaskingCompartment] = useState(new Compartment());
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
const themeRef = useRef<Theme>();
@ -214,6 +218,7 @@ export const CodeMirrorEditor = memo(
if (!doc) {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
envMaskingCompartment.of([]),
]);
view.setState(state);
@ -236,6 +241,7 @@ export const CodeMirrorEditor = memo(
if (!state) {
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
envMaskingCompartment.of([createEnvMaskingExtension(() => docRef.current?.filePath)]),
]);
editorStates.set(doc.filePath, state);

View File

@ -0,0 +1,80 @@
import { EditorView, Decoration, type DecorationSet, ViewPlugin, WidgetType } from '@codemirror/view';
// Create a proper WidgetType class for the masked text
class MaskedTextWidget extends WidgetType {
constructor(private readonly _value: string) {
super();
}
eq(other: MaskedTextWidget) {
return other._value === this._value;
}
toDOM() {
const span = document.createElement('span');
span.textContent = '*'.repeat(this._value.length);
span.className = 'cm-masked-text';
return span;
}
ignoreEvent() {
return false;
}
}
export function createEnvMaskingExtension(getFilePath: () => string | undefined) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: { docChanged: boolean; view: EditorView; viewportChanged: boolean }) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView) {
const filePath = getFilePath();
const isEnvFile = filePath?.endsWith('.env') || filePath?.includes('.env.') || filePath?.includes('/.env');
if (!isEnvFile) {
return Decoration.none;
}
const decorations: any[] = [];
const doc = view.state.doc;
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i);
const text = line.text;
// Match lines with KEY=VALUE format
const match = text.match(/^([^=]+)=(.+)$/);
if (match && !text.trim().startsWith('#')) {
const [, key, value] = match;
const valueStart = line.from + key.length + 1;
// Create a decoration that replaces the value with asterisks
decorations.push(
Decoration.replace({
inclusive: true,
widget: new MaskedTextWidget(value),
}).range(valueStart, line.to),
);
}
}
return Decoration.set(decorations);
}
},
{
decorations: (v) => v.decorations,
},
);
}

View File

@ -1,4 +1,4 @@
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { json, type ActionFunctionArgs } from '@remix-run/cloudflare';
export async function action({ request }: ActionFunctionArgs) {
try {