Skip to content
Draft
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
4 changes: 2 additions & 2 deletions packages/database/src/lib/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ export async function createEntry(
lastUpdated: extraData.lastUpdated ?? file.lastModified,
url: extraData.url ?? getUrl(file.path),
githubUrl: extraData.githubUrl ?? undefined,
language: extraData.language ?? getLanguageFromFile(file.path),
language: extraData.language ?? file.meta.language ?? getLanguageFromFile(file.path),
level: file.meta.level ?? 'beginner',
audience: parseCsvOrArray(file.meta.audience) ?? ['students', 'pro devs'],
...(searchTranslations ? { translations: await findTranslations(file.path) } : {})
...(searchTranslations ? { translations: await findTranslations(file.path, file) } : {})
};
return {
...entryWithoutId,
Expand Down
31 changes: 28 additions & 3 deletions packages/database/src/lib/language.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
import path from 'path';
import glob from 'fast-glob';
import { promises as fs } from 'fs';
import { defaultLanguage } from '../../../website/src/app/shared/constants.js';
import { ContentEntry } from '../../../website/src/app/catalog/content-entry.js';
import { getWorkshops } from './workshop.js';
import { createEntry } from './entry.js';
import { FileInfo } from './workshop.js';
import { parseCsvOrArray } from './util.js';

const languageRegex = /.*?\.([a-zA-Z]{2})\.md$/;
const languageRegex = /.*?\.([a-zA-Z]{2}(?:_[A-Z]{2})?)\.md$/;
const translationsFolder = 'translations';

export function getLanguageFromFile(filePath: string): string {
const match = languageRegex.exec(filePath);
return match ? match[1] : defaultLanguage;
}

export async function findTranslations(filePath: string): Promise<ContentEntry[]> {
export async function findTranslations(filePath: string, fileInfo?: FileInfo): Promise<ContentEntry[]> {
const dir = path.dirname(filePath);
const originalLanguage = getLanguageFromFile(filePath);
const extension = (originalLanguage !== defaultLanguage ? `.${originalLanguage}` : '') + path.extname(filePath);
const baseName = path.basename(filePath, extension);
const translationsDir = path.join(dir, translationsFolder);

// Find translations from filesystem
const translations = glob.sync(`${translationsDir}/${baseName}*.md`);
const translatedFiles = await getWorkshops(translations);

// Also check metadata for translations list
const metadataTranslations: string[] = [];
if (fileInfo?.meta?.translations) {
const translationCodes = parseCsvOrArray(fileInfo.meta.translations);
for (const langCode of translationCodes) {
const translationPath = path.join(translationsDir, `${baseName}.${langCode}.md`);
// Only add if file exists and not already in the list
try {
await fs.access(translationPath);
if (!translations.includes(translationPath)) {
metadataTranslations.push(translationPath);
}
} catch {
console.warn(`Translation file not found for language '${langCode}': ${translationPath}`);
}
}
}

const allTranslations = [...translations, ...metadataTranslations];
const translatedFiles = await getWorkshops(allTranslations);
const entriesPromises = translatedFiles.map((file) => createEntry(file, false));
return Promise.all(entriesPromises);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IconComponent } from './icon.component';
import { SidebarComponent } from './sidebar.component';
import { LanguageSelectorComponent, LanguageOption } from './language-selector.component';
import { Link } from '../link';

@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule, IconComponent],
imports: [CommonModule, IconComponent, LanguageSelectorComponent],
template: `
<header class="navbar" [ngClass]="type">
<div class="navbar-container">
Expand All @@ -20,6 +21,11 @@ import { Link } from '../link';
</div>
<div class="title text-ellipsis">{{ title }}</div>
<div class="fill"></div>
<app-language-selector
*ngIf="languages.length > 0"
[languages]="languages"
[currentLanguage]="currentLanguage"
></app-language-selector>
<div class="links">
<a *ngFor="let link of links" [href]="link.url" [target]="isExternalLink(link) ? '_blank' : '_self'">
<app-icon *ngIf="link.icon" [name]="link.icon" size="20" class="link-icon"></app-icon>{{ link.text
Expand Down Expand Up @@ -153,6 +159,8 @@ export class HeaderComponent {
@Input() links: Link[] = [];
@Input() sidebar: SidebarComponent | undefined;
@Input() type: string = '';
@Input() languages: LanguageOption[] = [];
@Input() currentLanguage: string = 'en';

toggleSidebar(event: Event) {
if (this.sidebar) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IconComponent } from './icon.component';

export interface LanguageOption {
code: string;
label: string;
url: string;
}

@Component({
selector: 'app-language-selector',
standalone: true,
imports: [CommonModule, IconComponent],
template: `
<div class="language-selector" *ngIf="languages.length > 0">
<app-icon name="globe" size="20" class="globe-icon"></app-icon>
<select
class="language-select"
[value]="currentLanguage"
(change)="onLanguageChange($event)"
aria-label="Select language"
>
<option
*ngFor="let lang of languages"
[value]="lang.code"
[attr.data-url]="lang.url"
>
{{ lang.label }}
</option>
</select>
</div>
`,
styles: [
`
.language-selector {
display: flex;
align-items: center;
gap: var(--space-xs);
}

.globe-icon {
color: var(--text-light);
}

.language-select {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
color: var(--text-light);
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-size-sm);
cursor: pointer;
transition: all var(--transition-duration);
text-transform: uppercase;

&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}

&:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}

option {
background: var(--background);
color: var(--text);
text-transform: none;
}
}
`
]
})
export class LanguageSelectorComponent {
@Input() languages: LanguageOption[] = [];
@Input() currentLanguage: string = 'en';

onLanguageChange(event: Event) {
const select = event.target as HTMLSelectElement;
const selectedOption = select.options[select.selectedIndex];
const url = selectedOption.getAttribute('data-url');
if (url) {
window.location.href = url;

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI 4 days ago

In general terms, the fix is to stop blindly trusting the string read from data-url and validate it before using it as a navigation target. Rather than assigning any arbitrary DOM-provided value to window.location.href, we should ensure it is a syntactically valid URL and, ideally, restrict it to safe schemes (e.g., http/https) or to the same origin. This prevents DOM text (or attributes) from being used to trigger navigation to malformed or potentially dangerous locations.

The best targeted fix here is to parse the url string with the standard URL constructor, check that it uses an allowed protocol and resolves either to the same origin or to http/https, and only then assign it to window.location.href. If parsing fails or the checks fail, we log an error and do not navigate. This preserves existing behavior for legitimate URLs, while blocking malicious or malformed values. No new imports are needed; the URL class is available in the browser environment where this Angular component runs.

Concretely, in packages/website/src/app/shared/components/language-selector.component.ts, we will replace the body of onLanguageChange after reading url with logic that:

  1. Returns early with an error if url is missing.
  2. Tries to construct a URL object, using window.location.origin as the base if the URL is relative.
  3. Checks that the resulting URL has protocol http: or https: (and optionally could be tightened to same-origin; here we’ll demonstrate safe-scheme enforcement).
  4. Sets window.location.href to the validated URL string if checks pass; otherwise logs an error and does not navigate.
Suggested changeset 1
packages/website/src/app/shared/components/language-selector.component.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/website/src/app/shared/components/language-selector.component.ts b/packages/website/src/app/shared/components/language-selector.component.ts
--- a/packages/website/src/app/shared/components/language-selector.component.ts
+++ b/packages/website/src/app/shared/components/language-selector.component.ts
@@ -81,10 +81,27 @@
     const select = event.target as HTMLSelectElement;
     const selectedOption = select.options[select.selectedIndex];
     const url = selectedOption.getAttribute('data-url');
-    if (url) {
-      window.location.href = url;
-    } else {
+
+    if (!url) {
       console.error('Language selector: Invalid URL for selected language');
+      return;
     }
+
+    try {
+      // Support both absolute and relative URLs by providing a base.
+      const targetUrl = new URL(url, window.location.origin);
+
+      // Only allow safe protocols to avoid javascript:, data:, etc.
+      if (targetUrl.protocol === 'http:' || targetUrl.protocol === 'https:') {
+        window.location.href = targetUrl.toString();
+      } else {
+        console.error(
+          'Language selector: Blocked navigation to URL with unsafe protocol',
+          targetUrl.toString()
+        );
+      }
+    } catch (e) {
+      console.error('Language selector: Failed to parse URL for selected language', e);
+    }
   }
 }
EOF
@@ -81,10 +81,27 @@
const select = event.target as HTMLSelectElement;
const selectedOption = select.options[select.selectedIndex];
const url = selectedOption.getAttribute('data-url');
if (url) {
window.location.href = url;
} else {

if (!url) {
console.error('Language selector: Invalid URL for selected language');
return;
}

try {
// Support both absolute and relative URLs by providing a base.
const targetUrl = new URL(url, window.location.origin);

// Only allow safe protocols to avoid javascript:, data:, etc.
if (targetUrl.protocol === 'http:' || targetUrl.protocol === 'https:') {
window.location.href = targetUrl.toString();
} else {
console.error(
'Language selector: Blocked navigation to URL with unsafe protocol',
targetUrl.toString()
);
}
} catch (e) {
console.error('Language selector: Failed to parse URL for selected language', e);
}
}
}
Copilot is powered by AI and may make mistakes. Always verify output.
} else {
console.error('Language selector: Invalid URL for selected language');
}
}
}
2 changes: 2 additions & 0 deletions packages/website/src/app/shared/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type FrontMatterData = Partial<{
oc_id: string;
audience: string;
level: string;
language: string;
translations: string | string[];
}>;

export interface FrontMatterParseResult<ExtraProperties = {}> {
Expand Down
113 changes: 112 additions & 1 deletion packages/website/src/app/workshop/workshop.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { FooterComponent } from '../shared/components/footer.component';
import { SidebarComponent } from '../shared/components/sidebar.component';
import { LoaderComponent } from '../shared/components/loader.component';
import { CopyComponent } from '../shared/components/copy.component';
import { LanguageOption } from '../shared/components/language-selector.component';
import { Workshop, loadWorkshop, createMenuLinks } from './workshop';
import { PaginationComponent } from './pagination.component';
import { MenuLink } from '../shared/link';
import { debounce } from '../shared/event';
import { scrollToId, scrollToTop } from '../shared/scroll';
import { getRepoPath } from '../shared/loader';
import { defaultLanguage } from '../shared/constants';

@Component({
selector: 'app-workshop',
Expand All @@ -29,7 +31,7 @@ import { getRepoPath } from '../shared/loader';
],
template: `
<div (click)="sidebar.toggleOpen(false)" class="full-viewport">
<app-header [title]="workshop?.shortTitle || 'Workshop'" [sidebar]="sidebar"></app-header>
<app-header [title]="workshop?.shortTitle || 'Workshop'" [sidebar]="sidebar" [languages]="languages" [currentLanguage]="currentLanguage"></app-header>
<main class="content">
<app-sidebar
#sidebar="sidebar"
Expand Down Expand Up @@ -88,6 +90,8 @@ export class WorkshopComponent {
menuLinks: MenuLink[] = [];
scrollInit: boolean = false;
enableScrollEvent: boolean = false;
languages: LanguageOption[] = [];
currentLanguage: string = defaultLanguage;

scrolled = debounce((_event: Event) => {
if (!this.scrollInit) {
Expand Down Expand Up @@ -155,6 +159,7 @@ export class WorkshopComponent {
this.workshop = await loadWorkshop(repoPath, { wtid, ocid, vars });
this.menuLinks = createMenuLinks(this.workshop);
this.updateAuthors();
this.loadLanguages(repoPath);
} catch (error) {
console.error(error);
}
Expand Down Expand Up @@ -207,4 +212,110 @@ export class WorkshopComponent {
// from browser-generated scroll events
setTimeout(() => (this.enableScrollEvent = true));
}

loadLanguages(repoPath: string) {
if (!this.workshop) {
return;
}

// Get current language from metadata or detect from path
const languageFromMeta = this.workshop.meta?.language;
const languageFromPath = this.getLanguageFromPath(repoPath);
this.currentLanguage = languageFromMeta || languageFromPath || defaultLanguage;

// Get translations from metadata
const translationsMeta = this.workshop.meta?.translations;
if (!translationsMeta || (Array.isArray(translationsMeta) && translationsMeta.length === 0)) {
// No translations defined
return;
}

// Parse translations array
const translationCodes = Array.isArray(translationsMeta)
? translationsMeta
: translationsMeta.split(',').map(t => t.trim());

// Build language options
const languages: LanguageOption[] = [];

// Determine base path
const basePath = this.getBasePath(repoPath, languageFromPath);

// Add base language first (always labeled as "default")
const baseLanguage = languageFromMeta || defaultLanguage;
languages.push({
code: baseLanguage,
label: `default (${baseLanguage})`,
url: this.buildWorkshopUrl(basePath, null)
});

// Add translations sorted alphabetically
const sortedTranslations = [...translationCodes].sort((a, b) => a.localeCompare(b));
for (const langCode of sortedTranslations) {
languages.push({
code: langCode,
label: langCode,
url: this.buildWorkshopUrl(basePath, langCode)
});
}

// Only show language selector if there are translations
if (languages.length > 1) {
this.languages = languages;
}
}

getLanguageFromPath(repoPath: string): string | null {
// Extract language code from path like "workshop.fr.md" or "translations/workshop.ja.md"
const match = repoPath.match(/\.([a-zA-Z]{2}(?:_[A-Z]{2})?)\.md$/);
return match ? match[1] : null;
}

getBasePath(repoPath: string, currentLang: string | null): string {
// Remove language code and extension from path to get base path
if (currentLang) {
// Path is like "workshops/my-workshop/translations/workshop.fr.md"
// or "workshops/my-workshop/workshop.fr.md"
// Use simple string replacement to avoid regex issues
const suffix = `.${currentLang}.md`;
if (repoPath.endsWith(suffix)) {
return repoPath.slice(0, -suffix.length) + '.md';
}
// Also handle translations folder
return repoPath.replace('/translations/', '/');
}
// Already a base path
return repoPath;
}

buildWorkshopUrl(basePath: string, langCode: string | null): string {
const { step, wtid, ocid, vars } = getQueryParams();
let workshopPath = basePath;

if (langCode) {
// Build translation path: insert "translations/" folder and language code
// basePath could be like "workshops/my-workshop/workshop.md" or "my-workshop/"
if (workshopPath.endsWith('.md')) {
const parts = workshopPath.split('/');
const fileName = parts.pop() || '';
const baseName = fileName.replace('.md', '');
workshopPath = `${parts.join('/')}/translations/${baseName}.${langCode}.md`;
} else {
// Handle directory path
workshopPath = `${workshopPath}translations/workshop.${langCode}.md`;
}
}

// Build query string
const params = new URLSearchParams();
if (step) params.set('step', step);
if (wtid) params.set('wtid', wtid);
if (ocid) params.set('ocid', ocid);
if (vars) params.set('vars', vars);

const queryString = params.toString();
const baseUrl = `${window.location.origin}${window.location.pathname.split('?')[0]}`;

return `${baseUrl}?src=${encodeURIComponent(workshopPath)}${queryString ? '&' + queryString : ''}`;
}
}
25 changes: 25 additions & 0 deletions workshops/test-translations/translations/workshop.es_ES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
published: true
type: workshop
title: Taller de Prueba con Traducciones
short_title: Prueba de Traducción
description: Un taller de prueba para demostrar el soporte de traducciones
level: beginner
authors:
- Test Author
contacts:
- test@example.com
duration_minutes: 30
tags: test, translation
language: es_ES
---

# Taller de Prueba con Traducciones

Este es un taller de prueba para demostrar la función de soporte de traducciones.

---

## Sección 2

Esta es la segunda sección del taller.
Loading