Compare commits

..

No commits in common. "4fd257cbd641b04253f5be00e34ea261a2d3d62b" and "d6760d0f840b81ded3d5c694538e35925c26683c" have entirely different histories.

26 changed files with 168 additions and 655 deletions

File diff suppressed because one or more lines are too long

View File

@ -68,36 +68,6 @@ AI_EMBEDDINGS_MODEL=mistral-embed
# User rate limiting (queries per minute)
AI_RATE_LIMIT_MAX_REQUESTS=4
# ============================================================================
# 🎥 VIDEO EMBEDDING - PRODUCTION CONFIGURATION
# ============================================================================
# Enable local caching of Nextcloud videos (highly recommended)
VIDEO_CACHE_ENABLED=true
# Directory for cached videos (ensure it's writable and has sufficient space)
# This directory will grow over time as videos are cached permanently
VIDEO_CACHE_DIR=./cache/videos
# Emergency cleanup threshold in MB - videos are cached indefinitely
# Only triggers cleanup when approaching this limit to prevent disk full
# Recommended: 2000MB (2GB) for small deployments, 5000MB+ for larger ones
VIDEO_CACHE_MAX_SIZE=2000
# Maximum individual video file size for caching in MB
# Videos larger than this will stream directly without caching
VIDEO_MAX_SIZE=200
# ============================================================================
# CACHING BEHAVIOR
# ============================================================================
# - Videos downloaded once, cached permanently
# - No time-based expiration
# - Dramatically improves loading times after first download
# - Emergency cleanup only when approaching disk space limit
# - Perfect for manually curated forensics training content
# ============================================================================
# ============================================================================
# 🎛️ PERFORMANCE TUNING - SENSIBLE DEFAULTS PROVIDED
# ============================================================================

View File

@ -1,6 +1,5 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import { remarkVideoPlugin } from './src/utils/remarkVideoPlugin.ts';
export default defineConfig({
output: 'server',
@ -8,13 +7,6 @@ export default defineConfig({
mode: 'standalone'
}),
markdown: {
remarkPlugins: [
remarkVideoPlugin
],
extendDefaultPlugins: true
},
build: {
assets: '_astro'
},

View File

@ -11,8 +11,6 @@
},
"dependencies": {
"@astrojs/node": "^9.3.0",
"@aws-sdk/client-s3": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.864.0",
"astro": "^5.12.3",
"cookie": "^1.0.2",
"dotenv": "^16.4.5",

View File

@ -193,6 +193,7 @@ domains.forEach((domain: any) => {
</div>
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
// Ensure isToolHosted is available
if (!window.isToolHosted) {
window.isToolHosted = function(tool) {
return tool.projectUrl !== undefined &&
@ -764,12 +765,14 @@ domains.forEach((domain: any) => {
hideToolDetails('both');
}
// Register all functions globally
window.showToolDetails = showToolDetails;
window.hideToolDetails = hideToolDetails;
window.hideAllToolDetails = hideAllToolDetails;
window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
window.showShareDialog = showShareDialog;
// Register matrix-prefixed versions for delegation
window.matrixShowToolDetails = showToolDetails;
window.matrixHideToolDetails = hideToolDetails;

View File

@ -1,46 +0,0 @@
---
// src/components/Video.astro - SIMPLE responsive video component
export interface Props {
src: string;
title?: string;
controls?: boolean;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
aspectRatio?: '16:9' | '4:3' | '1:1';
preload?: 'none' | 'metadata' | 'auto';
}
const {
src,
title = 'Video',
controls = true,
autoplay = false,
muted = false,
loop = false,
aspectRatio = '16:9',
preload = 'metadata'
} = Astro.props;
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
---
<div class={`video-container ${aspectClass}`}>
<video
src={src}
controls={controls}
autoplay={autoplay}
muted={muted}
loop={loop}
preload={preload}
style="width: 100%; height: 100%;"
data-video-title={title}
>
<p>Your browser does not support the video element.</p>
</video>
{title !== 'Video' && (
<div class="video-metadata">
<div class="video-title">{title}</div>
</div>
)}
</div>

View File

@ -18,7 +18,6 @@ sections:
review_status: "published"
---
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!

View File

@ -18,8 +18,6 @@ sections:
advanced_topics: true
review_status: "published"
---
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!

View File

@ -37,6 +37,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
} catch (error) {
console.error('Failed to load utility functions:', error);
// Provide fallback implementations
(window as any).createToolSlug = (toolName: string) => {
if (!toolName || typeof toolName !== 'string') return '';
return toolName.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
@ -118,6 +119,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
(window as any).prioritizeSearchResults = prioritizeSearchResults;
document.addEventListener('DOMContentLoaded', async () => {
// CRITICAL: Load utility functions FIRST before any URL handling
await loadUtilityFunctions();
const THEME_KEY = 'dfir-theme';
@ -171,32 +173,33 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
getStoredTheme
};
(window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
let attempts = 0;
const maxAttempts = 50;
(window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
const tryDelegate = () => {
const matrixShowToolDetails = (window as any).matrixShowToolDetails;
let attempts = 0;
const maxAttempts = 50;
if (matrixShowToolDetails && typeof matrixShowToolDetails === 'function') {
return matrixShowToolDetails(toolName, modalType);
}
const tryDelegate = () => {
const matrixShowToolDetails = (window as any).matrixShowToolDetails;
const directShowToolDetails = (window as any).directShowToolDetails;
if (directShowToolDetails && typeof directShowToolDetails === 'function') {
return directShowToolDetails(toolName, modalType);
}
if (matrixShowToolDetails && typeof matrixShowToolDetails === 'function') {
return matrixShowToolDetails(toolName, modalType);
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(tryDelegate, 100);
} else {
}
};
const directShowToolDetails = (window as any).directShowToolDetails;
if (directShowToolDetails && typeof directShowToolDetails === 'function') {
return directShowToolDetails(toolName, modalType);
}
tryDelegate();
attempts++;
if (attempts < maxAttempts) {
setTimeout(tryDelegate, 100);
} else {
}
};
tryDelegate();
};
(window as any).hideToolDetails = function(modalType: string = 'both') {
const matrixHideToolDetails = (window as any).matrixHideToolDetails;
if (matrixHideToolDetails && typeof matrixHideToolDetails === 'function') {
@ -226,7 +229,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
authRequired: data.aiAuthRequired,
expires: data.expires
};
case 'gatedcontent':
case 'gatedcontent': // ADD THIS CASE
return {
authenticated: data.gatedContentAuthenticated,
authRequired: data.gatedContentAuthRequired,
@ -350,36 +353,6 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
};
initAIButton();
});
document.addEventListener('DOMContentLoaded', () => {
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') ||
navigator.userAgent.toLowerCase().includes('librewolf');
if (isFirefox) {
console.log('[VIDEO] Firefox detected - setting up error recovery');
document.querySelectorAll('video').forEach(video => {
let errorCount = 0;
video.addEventListener('error', () => {
errorCount++;
console.log(`[VIDEO] Error ${errorCount} in Firefox for: ${video.getAttribute('data-video-title')}`);
if (errorCount === 1 && video.src.includes('/download')) {
console.log('[VIDEO] Trying /preview URL for Firefox compatibility');
video.src = video.src.replace('/download', '/preview');
video.load();
} else if (errorCount === 1) {
console.log('[VIDEO] Video failed to load in Firefox');
}
});
video.addEventListener('loadedmetadata', () => {
const title = video.getAttribute('data-video-title') || 'Video';
console.log(`[VIDEO] Successfully loaded: ${title}`);
});
});
}
});
</script>
</head>
<body>

View File

@ -1,7 +1,5 @@
// src/pages/api/auth/login.ts
import type { APIRoute } from 'astro';
import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js';
import { serialize } from 'cookie';
export const prerender = false;
@ -10,27 +8,14 @@ export const GET: APIRoute = async ({ url, redirect }) => {
const state = generateState();
const authUrl = generateAuthUrl(state);
console.log('[AUTH] Generated auth URL:', authUrl);
console.log('Generated auth URL:', authUrl);
const returnTo = url.searchParams.get('returnTo') || '/';
logAuthEvent('Login initiated', { returnTo, authUrl });
const stateData = JSON.stringify({ state, returnTo });
const publicBaseUrl = process.env.PUBLIC_BASE_URL || '';
const isProduction = process.env.NODE_ENV === 'production';
const isSecure = publicBaseUrl.startsWith('https://') || isProduction;
const stateCookie = serialize('auth_state', stateData, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 600, // 10 minutes
path: '/'
});
console.log('[AUTH] Setting auth state cookie:', stateCookie.substring(0, 50) + '...');
const stateCookie = `auth_state=${encodeURIComponent(stateData)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`;
return new Response(null, {
status: 302,

View File

@ -1,4 +1,4 @@
// src/pages/api/auth/process.ts
// src/pages/api/auth/process.ts (FIXED - Proper cookie handling)
import type { APIRoute } from 'astro';
import {
verifyAuthState,
@ -7,7 +7,7 @@ import {
createSessionWithCookie,
logAuthEvent
} from '../../../utils/auth.js';
import { apiError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
import { apiError, apiSpecial, apiWithHeaders, handleAPIRequest } from '../../../utils/api.js';
export const prerender = false;
@ -30,15 +30,9 @@ export const POST: APIRoute = async ({ request }) => {
const stateVerification = verifyAuthState(request, state);
if (!stateVerification.isValid || !stateVerification.stateData) {
logAuthEvent('State verification failed', {
error: stateVerification.error,
hasStateData: !!stateVerification.stateData
});
return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
}
console.log('[AUTH] State verification successful, exchanging code for tokens');
const tokens = await exchangeCodeForTokens(code);
const userInfo = await getUserInfo(tokens.access_token);
@ -49,12 +43,6 @@ export const POST: APIRoute = async ({ request }) => {
email: sessionResult.userEmail
});
const returnUrl = new URL(stateVerification.stateData.returnTo, request.url);
returnUrl.searchParams.set('auth', 'success');
const redirectUrl = returnUrl.toString();
console.log('[AUTH] Redirecting to:', redirectUrl);
const responseHeaders = new Headers();
responseHeaders.set('Content-Type', 'application/json');
@ -63,7 +51,7 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({
success: true,
redirectTo: redirectUrl
redirectTo: stateVerification.stateData.returnTo
}), {
status: 200,
headers: responseHeaders

View File

@ -9,16 +9,16 @@ export const GET: APIRoute = async ({ request }) => {
return await handleAPIRequest(async () => {
const contributionAuth = await withAPIAuth(request, 'contributions');
const aiAuth = await withAPIAuth(request, 'ai');
const gatedContentAuth = await withAPIAuth(request, 'gatedcontent');
const gatedContentAuth = await withAPIAuth(request, 'gatedcontent'); // ADDED
return apiResponse.success({
authenticated: contributionAuth.authenticated || aiAuth.authenticated || gatedContentAuth.authenticated,
contributionAuthRequired: contributionAuth.authRequired,
aiAuthRequired: aiAuth.authRequired,
gatedContentAuthRequired: gatedContentAuth.authRequired,
gatedContentAuthRequired: gatedContentAuth.authRequired, // ADDED
contributionAuthenticated: contributionAuth.authenticated,
aiAuthenticated: aiAuth.authenticated,
gatedContentAuthenticated: gatedContentAuth.authenticated,
gatedContentAuthenticated: gatedContentAuth.authenticated, // ADDED
expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
});
}, 'Status check failed');

View File

@ -1,4 +1,4 @@
// src/pages/api/contribute/knowledgebase.ts
// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation
import type { APIRoute } from 'astro';
import { withAPIAuth } from '../../../utils/auth.js';
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';

View File

@ -1,4 +1,4 @@
// src/pages/api/contribute/tool.ts
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses + related_software)
import type { APIRoute } from 'astro';
import { withAPIAuth } from '../../../utils/auth.js';
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
@ -82,27 +82,31 @@ function sanitizeInput(obj: any): any {
}
function preprocessFormData(body: any): any {
// Handle comma-separated strings from autocomplete inputs
if (body.tool) {
// Handle tags
if (typeof body.tool.tags === 'string') {
body.tool.tags = body.tool.tags.split(',').map((t: string) => t.trim()).filter(Boolean);
}
// Handle related concepts
if (body.tool.relatedConcepts) {
if (typeof body.tool.relatedConcepts === 'string') {
body.tool.related_concepts = body.tool.relatedConcepts.split(',').map((t: string) => t.trim()).filter(Boolean);
} else {
body.tool.related_concepts = body.tool.relatedConcepts;
}
delete body.tool.relatedConcepts;
delete body.tool.relatedConcepts; // Remove the original key
}
// Handle related software
if (body.tool.relatedSoftware) {
if (typeof body.tool.relatedSoftware === 'string') {
body.tool.related_software = body.tool.relatedSoftware.split(',').map((t: string) => t.trim()).filter(Boolean);
} else {
body.tool.related_software = body.tool.relatedSoftware;
}
delete body.tool.relatedSoftware;
delete body.tool.relatedSoftware; // Remove the original key
}
}
@ -138,11 +142,14 @@ async function validateToolData(tool: any, action: string): Promise<{ valid: boo
}
}
// Validate related items exist (optional validation - could be enhanced)
if (tool.related_concepts && tool.related_concepts.length > 0) {
// Could validate that referenced concepts actually exist
console.log('[VALIDATION] Related concepts provided:', tool.related_concepts);
}
if (tool.related_software && tool.related_software.length > 0) {
// Could validate that referenced software actually exists
console.log('[VALIDATION] Related software provided:', tool.related_software);
}

View File

@ -35,6 +35,7 @@ export const POST: APIRoute = async ({ request }) => {
);
}
/* --- (rest of the handler unchanged) -------------------------- */
const { embeddingsService } = await import('../../../utils/embeddings.js');
if (!embeddingsService.isEnabled()) {

View File

@ -23,6 +23,7 @@ const editToolName = Astro.url.searchParams.get('edit');
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
const isEdit = !!editTool;
// Extract data for autocomplete
const allTags = [...new Set(existingTools.flatMap(tool => tool.tags || []))].sort();
const allSoftwareAndMethods = existingTools
.filter(tool => tool.type === 'software' || tool.type === 'method')
@ -299,6 +300,7 @@ const allConcepts = existingTools
</BaseLayout>
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware, allTags, allSoftwareAndMethods, allConcepts }}>
// Consolidated Autocomplete Functionality - inlined to avoid module loading issues
class AutocompleteManager {
constructor(inputElement, dataSource, options = {}) {
this.input = inputElement;
@ -335,6 +337,7 @@ class AutocompleteManager {
this.dropdown = document.createElement('div');
this.dropdown.className = 'autocomplete-dropdown';
// Insert dropdown after input
this.input.parentNode.style.position = 'relative';
this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
}
@ -355,6 +358,7 @@ class AutocompleteManager {
});
this.input.addEventListener('blur', (e) => {
// Delay to allow click events on dropdown items
setTimeout(() => {
if (!this.dropdown.contains(document.activeElement)) {
this.hideDropdown();
@ -446,6 +450,7 @@ class AutocompleteManager {
})
.join('');
// Bind click events
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
option.addEventListener('click', () => {
this.selectItem(this.filteredData[index]);
@ -479,6 +484,7 @@ class AutocompleteManager {
this.hideDropdown();
}
// Trigger change event
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
}));
@ -504,6 +510,7 @@ class AutocompleteManager {
`)
.join('');
// Bind remove events
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
@ -629,6 +636,7 @@ class ContributionForm {
}
setupAutocomplete() {
// Tags autocomplete
if (this.elements.tagsInput && this.elements.tagsHidden) {
const tagsManager = new AutocompleteManager(this.elements.tagsInput, allTags, {
allowMultiple: true,
@ -636,6 +644,7 @@ class ContributionForm {
placeholder: 'Beginne zu tippen, um Tags hinzuzufügen...'
});
// Set initial values if editing
if (this.editTool?.tags) {
tagsManager.setSelectedItems(this.editTool.tags);
}
@ -643,6 +652,7 @@ class ContributionForm {
this.autocompleteManagers.set('tags', tagsManager);
}
// Related concepts autocomplete
if (this.elements.relatedConceptsInput && this.elements.relatedConceptsHidden) {
const conceptsManager = new AutocompleteManager(this.elements.relatedConceptsInput, allConcepts, {
allowMultiple: true,
@ -650,6 +660,7 @@ class ContributionForm {
placeholder: 'Beginne zu tippen, um Konzepte zu finden...'
});
// Set initial values if editing
if (this.editTool?.related_concepts) {
conceptsManager.setSelectedItems(this.editTool.related_concepts);
}
@ -657,6 +668,7 @@ class ContributionForm {
this.autocompleteManagers.set('relatedConcepts', conceptsManager);
}
// Related software autocomplete
if (this.elements.relatedSoftwareInput && this.elements.relatedSoftwareHidden) {
const softwareManager = new AutocompleteManager(this.elements.relatedSoftwareInput, allSoftwareAndMethods, {
allowMultiple: true,
@ -664,6 +676,7 @@ class ContributionForm {
placeholder: 'Beginne zu tippen, um Software/Methoden zu finden...'
});
// Set initial values if editing
if (this.editTool?.related_software) {
softwareManager.setSelectedItems(this.editTool.related_software);
}
@ -671,6 +684,7 @@ class ContributionForm {
this.autocompleteManagers.set('relatedSoftware', softwareManager);
}
// Listen for autocomplete changes to update YAML preview
Object.values(this.autocompleteManagers).forEach(manager => {
if (manager.input) {
manager.input.addEventListener('autocomplete:select', () => {
@ -712,10 +726,14 @@ class ContributionForm {
updateFieldVisibility() {
const type = this.elements.typeSelect.value;
// Only hide/show software-specific fields (platforms, license)
// Relations should always be visible since all tool types can have relationships
this.elements.softwareFields.style.display = type === 'software' ? 'block' : 'none';
// Always show relations - all tool types can have relationships
this.elements.relationsFields.style.display = 'block';
// Only mark platform/license as required for software
if (this.elements.platformsRequired) {
this.elements.platformsRequired.style.display = type === 'software' ? 'inline' : 'none';
}
@ -723,6 +741,7 @@ class ContributionForm {
this.elements.licenseRequired.style.display = type === 'software' ? 'inline' : 'none';
}
// Always show both relation sections - let users decide what's relevant
const conceptsSection = document.getElementById('related-concepts-section');
const softwareSection = document.getElementById('related-software-section');
if (conceptsSection) conceptsSection.style.display = 'block';
@ -787,16 +806,19 @@ class ContributionForm {
tool.knowledgebase = true;
}
// Handle tags from autocomplete
const tagsValue = this.elements.tagsHidden?.value || '';
if (tagsValue) {
tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
}
// Handle related concepts from autocomplete
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
if (relatedConceptsValue) {
tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
}
// Handle related software from autocomplete
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
if (relatedSoftwareValue) {
tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
@ -961,16 +983,19 @@ class ContributionForm {
}
};
// Handle tags from autocomplete
const tagsValue = this.elements.tagsHidden?.value || '';
if (tagsValue) {
submission.tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
}
// Handle related concepts from autocomplete
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
if (relatedConceptsValue) {
submission.tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
}
// Handle related software from autocomplete
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
if (relatedSoftwareValue) {
submission.tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
@ -1047,6 +1072,7 @@ class ContributionForm {
}
destroy() {
// Clean up autocomplete managers
this.autocompleteManagers.forEach(manager => {
manager.destroy();
});

View File

@ -686,6 +686,8 @@ if (aiAuthRequired) {
window.switchToAIView = () => switchToView('ai');
window.switchToView = switchToView;
// CRITICAL: Handle shared URLs AFTER everything is set up
// Increased timeout to ensure all components and utility functions are loaded
setTimeout(() => {
handleSharedURL();
}, 1000);

View File

@ -10,6 +10,7 @@ const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) =>
return entry.data.published !== false;
});
// Check if gated content authentication is enabled globally
const gatedContentAuthEnabled = isGatedContentAuthRequired();
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
@ -26,7 +27,8 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
difficulty: entry.data.difficulty,
categories: entry.data.categories || [],
tags: entry.data.tags || [],
gated_content: entry.data.gated_content || false,
gated_content: entry.data.gated_content || false, // NEW: Include gated content flag
tool_name: entry.data.tool_name,
related_tools: entry.data.related_tools || [],
associatedTool,
@ -43,6 +45,7 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
// Count gated vs public articles for statistics
const gatedCount = knowledgebaseEntries.filter(entry => entry.gated_content).length;
const publicCount = knowledgebaseEntries.length - gatedCount;
---

View File

@ -21,6 +21,7 @@ export async function getStaticPaths() {
const { entry }: { entry: any } = Astro.props;
// Check if this article is gated and if gated content auth is required globally
const isGatedContent = entry.data.gated_content === true;
const gatedContentAuthRequired = isGatedContentAuthRequired();
const requiresAuth = isGatedContent && gatedContentAuthRequired;
@ -61,28 +62,24 @@ const currentUrl = Astro.url.href;
<BaseLayout title={entry.data.title} description={entry.data.description}>
{requiresAuth && (
<script define:vars={{ requiresAuth, articleTitle: entry.data.title }}>
// Client-side authentication check for gated content
document.addEventListener('DOMContentLoaded', async () => {
if (!requiresAuth) return;
console.log('[GATED CONTENT] Checking client-side auth for: ' + articleTitle);
const urlParams = new URLSearchParams(window.location.search);
const authSuccess = urlParams.get('auth') === 'success';
// Hide content immediately while checking auth
const contentArea = document.querySelector('.article-content');
const sidebar = document.querySelector('.article-sidebar');
if (contentArea) {
contentArea.style.display = 'none';
}
if (authSuccess) {
console.log('[GATED CONTENT] Auth success detected, waiting for session...');
await new Promise(resolve => setTimeout(resolve, 1000));
const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
window.history.replaceState({}, document.title, cleanUrl);
}
// DON'T hide the sidebar container - just prevent TOC generation
//if (sidebar) {
//sidebar.innerHTML = ''; // Clear any content instead of hiding
//}
try {
const response = await fetch('/api/auth/status');
@ -96,6 +93,7 @@ const currentUrl = Astro.url.href;
if (authRequired && !isAuthenticated) {
console.log('[GATED CONTENT] Access denied - showing auth required message: ' + articleTitle);
// Show authentication required message (no auto-redirect)
if (contentArea) {
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
contentArea.innerHTML = [
@ -123,9 +121,11 @@ const currentUrl = Astro.url.href;
}
} else {
console.log('[GATED CONTENT] Access granted for: ' + articleTitle);
// Show content for authenticated users
if (contentArea) {
contentArea.style.display = 'block';
}
// Let TOC generate normally for authenticated users
setTimeout(() => {
if (typeof generateTOCContent === 'function') {
generateTOCContent();
@ -134,6 +134,7 @@ const currentUrl = Astro.url.href;
}
} catch (error) {
console.error('[GATED CONTENT] Auth check failed:', error);
// On error, show auth required message
if (requiresAuth && contentArea) {
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
contentArea.innerHTML = [
@ -401,10 +402,29 @@ const currentUrl = Astro.url.href;
}
function generateSidebarTOC() {
// NEW: Don't generate TOC for gated content that requires auth
if (requiresAuth) {
fetch('/api/auth/status')
.then(response => response.json())
.then(authStatus => {
const isAuthenticated = authStatus.gatedContentAuthenticated || false;
const authRequired = authStatus.gatedContentAuthRequired || false;
// Only generate TOC if user is authenticated for gated content
if (authRequired && !isAuthenticated) {
return; // Don't generate TOC
} else {
generateTOCContent(); // Generate TOC for authenticated users
}
})
.catch(() => {
// On error, don't generate TOC for gated content
return;
});
return;
}
// For non-gated content, generate TOC normally
generateTOCContent();
}
@ -510,14 +530,17 @@ const currentUrl = Astro.url.href;
pre.dataset.copyEnhanced = 'true';
pre.style.position ||= 'relative';
// Try to find an existing copy button we can reuse
let btn =
pre.querySelector('.copy-btn') ||
pre.querySelector('.copy-btn') || // our class
pre.querySelector('.btn-copy, .copy-button, .code-copy, .copy-code, button[aria-label*="copy" i]');
// If there is an "old" button that is NOT ours, prefer to reuse it by giving it our class.
if (btn && !btn.classList.contains('copy-btn')) {
btn.classList.add('copy-btn');
}
// If no button at all, create one
if (!btn) {
btn = document.createElement('button');
btn.type = 'button';
@ -532,6 +555,7 @@ const currentUrl = Astro.url.href;
pre.appendChild(btn);
}
// If there is a SECOND old button lingering (top-left in your case), hide it
const possibleOldButtons = pre.querySelectorAll(
'.btn-copy, .copy-button, .code-copy, .copy-code, button[aria-label*="copy" i]'
);
@ -539,6 +563,7 @@ const currentUrl = Astro.url.href;
if (b !== btn) b.style.display = 'none';
});
// Success pill
if (!pre.querySelector('.copied-pill')) {
const pill = document.createElement('div');
pill.className = 'copied-pill';
@ -546,6 +571,7 @@ const currentUrl = Astro.url.href;
pre.appendChild(pill);
}
// Screen reader live region
if (!pre.querySelector('.sr-live')) {
const live = document.createElement('div');
live.className = 'sr-live';
@ -588,13 +614,12 @@ const currentUrl = Astro.url.href;
});
}
// Make generateTOCContent available globally for the auth check script
window.generateTOCContent = generateTOCContent;
// Initialize everything on page load
// keep your existing DOMContentLoaded; just ensure this is called
document.addEventListener('DOMContentLoaded', () => {
// existing:
calculateReadingTime();
generateSidebarTOC();
// new/updated:
enhanceCodeCopy();
});
</script>

View File

@ -688,245 +688,3 @@
/* Expand content */
.article-main { max-width: 100% !important; }
}
/* ==========================================================================
VIDEO EMBEDDING - Add to knowledgebase.css
========================================================================== */
/* Video Container and Responsive Wrapper */
:where(.markdown-content) .video-container {
position: relative;
width: 100%;
margin: 2rem 0;
border-radius: var(--radius-lg, 0.75rem);
overflow: hidden;
background-color: var(--color-bg-tertiary, #000);
box-shadow: var(--shadow-lg, 0 12px 30px rgba(0,0,0,0.16));
}
/* Responsive 16:9 aspect ratio by default */
:where(.markdown-content) .video-container.aspect-16-9 {
aspect-ratio: 16 / 9;
}
:where(.markdown-content) .video-container.aspect-4-3 {
aspect-ratio: 4 / 3;
}
:where(.markdown-content) .video-container.aspect-1-1 {
aspect-ratio: 1 / 1;
}
/* Video Element Styling */
:where(.markdown-content) .video-container video {
width: 100%;
height: 100%;
object-fit: contain;
background-color: #000;
border: none;
outline: none;
}
/* Custom Video Controls Enhancement */
:where(.markdown-content) video::-webkit-media-controls-panel {
background-color: rgba(0, 0, 0, 0.8);
}
:where(.markdown-content) video::-webkit-media-controls-current-time-display,
:where(.markdown-content) video::-webkit-media-controls-time-remaining-display {
color: white;
text-shadow: none;
}
/* Video Loading State */
:where(.markdown-content) .video-container .video-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
:where(.markdown-content) .video-container .video-loading .spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border);
border-top: 3px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Video Error State */
:where(.markdown-content) .video-container .video-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--color-error, #dc3545);
padding: 2rem;
}
:where(.markdown-content) .video-container .video-error .error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
/* Video Metadata Overlay */
:where(.markdown-content) .video-metadata {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-top: none;
padding: 1rem 1.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
border-radius: 0 0 var(--radius-lg, 0.75rem) var(--radius-lg, 0.75rem);
}
:where(.markdown-content) .video-metadata .video-title {
font-weight: 600;
color: var(--color-text);
margin-bottom: 0.5rem;
}
:where(.markdown-content) .video-metadata .video-info {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
:where(.markdown-content) .video-metadata .video-duration,
:where(.markdown-content) .video-metadata .video-size,
:where(.markdown-content) .video-metadata .video-format {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Fullscreen Support */
:where(.markdown-content) .video-container video:fullscreen {
background-color: #000;
}
:where(.markdown-content) .video-container video:-webkit-full-screen {
background-color: #000;
}
:where(.markdown-content) .video-container video:-moz-full-screen {
background-color: #000;
}
/* Video Thumbnail/Poster Styling */
:where(.markdown-content) .video-container video[poster] {
object-fit: cover;
}
/* Protected Video Overlay */
:where(.markdown-content) .video-container .video-protected {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
padding: 2rem;
}
:where(.markdown-content) .video-container .video-protected .lock-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.8;
}
/* Responsive Design */
@media (max-width: 768px) {
:where(.markdown-content) .video-container {
margin: 1.5rem -0.5rem; /* Extend to edges on mobile */
border-radius: 0;
}
:where(.markdown-content) .video-metadata {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
border-radius: 0;
}
:where(.markdown-content) .video-metadata .video-info {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
}
/* Dark Theme Adjustments */
[data-theme="dark"] :where(.markdown-content) .video-container {
box-shadow: 0 12px 30px rgba(0,0,0,0.4);
}
[data-theme="dark"] :where(.markdown-content) .video-metadata {
background-color: var(--color-bg-tertiary);
border-color: color-mix(in srgb, var(--color-border) 60%, transparent);
}
/* Video Caption/Description Support */
:where(.markdown-content) .video-caption {
margin-top: 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
font-style: italic;
line-height: 1.5;
}
/* Video Gallery Support (multiple videos) */
:where(.markdown-content) .video-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin: 2rem 0;
}
:where(.markdown-content) .video-gallery .video-container {
margin: 0;
}
/* Accessibility Improvements */
:where(.markdown-content) .video-container video:focus {
outline: 3px solid var(--color-primary);
outline-offset: 3px;
}
/* Print Media - Hide Videos */
@media print {
:where(.markdown-content) .video-container {
display: none !important;
}
:where(.markdown-content) .video-container::after {
content: "[Video: " attr(data-video-title, "Embedded Video") "]";
display: block;
padding: 1rem;
background-color: #f5f5f5;
border: 1px solid #ddd;
text-align: center;
font-style: italic;
color: #666;
}
}

View File

@ -1083,6 +1083,7 @@ class ImprovedMicroTaskAIPipeline {
return;
}
// Step 1: AI selection of tools for completion
const selectionPrompt = AI_PROMPTS.generatePhaseCompletionPrompt(originalQuery, phase, phaseTools, phaseConcepts);
const selectionResult = await this.callMicroTaskAI(selectionPrompt, context, 800);
@ -1107,6 +1108,7 @@ class ImprovedMicroTaskAIPipeline {
return;
}
// Step 2: Generate detailed reasoning for each selected tool
for (const tool of validTools) {
console.log('[AI-PIPELINE] Generating reasoning for phase completion tool:', tool.name);

View File

@ -1,4 +1,4 @@
// src/utils/auth.js
// src/utils/auth.js (ENHANCED - Added gated content support)
import type { AstroGlobal } from 'astro';
import crypto from 'crypto';
import { config } from 'dotenv';
@ -390,10 +390,12 @@ export function getAuthRequirementForContext(context: AuthContextType): boolean
return getAuthRequirement(context);
}
// NEW: Helper function to check if gated content requires authentication
export function isGatedContentAuthRequired(): boolean {
return getAuthRequirement('gatedcontent');
}
// NEW: Check if specific content should be gated
export function shouldGateContent(isGatedContent: boolean): boolean {
return isGatedContent && isGatedContentAuthRequired();
}

View File

@ -1,5 +1,5 @@
// src/utils/clientUtils.ts
// Client-side utilities that mirror server-side toolHelpers.ts
export function createToolSlug(toolName: string): string {
if (!toolName || typeof toolName !== 'string') {
@ -8,10 +8,10 @@ export function createToolSlug(toolName: string): string {
}
return toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
@ -30,6 +30,7 @@ export function isToolHosted(tool: any): boolean {
tool.projectUrl.trim() !== "";
}
// Consolidated Autocomplete Functionality
interface AutocompleteOptions {
minLength?: number;
maxResults?: number;
@ -96,6 +97,7 @@ export class AutocompleteManager {
display: none;
`;
// Insert dropdown after input
const parentElement = this.input.parentNode as HTMLElement;
parentElement.style.position = 'relative';
parentElement.insertBefore(this.dropdown, this.input.nextSibling);
@ -117,6 +119,7 @@ export class AutocompleteManager {
});
this.input.addEventListener('blur', () => {
// Delay to allow click events on dropdown items
setTimeout(() => {
const activeElement = document.activeElement;
if (!activeElement || !this.dropdown.contains(activeElement)) {
@ -223,6 +226,7 @@ export class AutocompleteManager {
})
.join('');
// Bind click events
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
option.addEventListener('click', () => {
this.selectItem(this.filteredData[index]);
@ -256,6 +260,7 @@ export class AutocompleteManager {
this.hideDropdown();
}
// Trigger change event
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
}));
@ -302,6 +307,7 @@ export class AutocompleteManager {
`)
.join('');
// Bind remove events
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();

View File

@ -1,85 +0,0 @@
// src/utils/remarkVideoPlugin.ts
import { visit } from 'unist-util-visit';
import type { Plugin } from 'unified';
import type { Root } from 'hast';
export const remarkVideoPlugin: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
if (node.value && node.value.includes('<video') && typeof index === 'number') {
const srcMatch = node.value.match(/src=["']([^"']+)["']/);
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
if (srcMatch) {
const originalSrc = srcMatch[1];
const title = titleMatch?.[1] || 'Video';
const finalSrc = processNextcloudUrl(originalSrc);
const hasControls = node.value.includes('controls');
const hasAutoplay = node.value.includes('autoplay');
const hasMuted = node.value.includes('muted');
const hasLoop = node.value.includes('loop');
const hasPreload = node.value.match(/preload=["']([^"']+)["']/);
const enhancedHTML = `
<div class="video-container aspect-16-9">
<video
src="${escapeHtml(finalSrc)}"
${hasControls ? 'controls' : ''}
${hasAutoplay ? 'autoplay' : ''}
${hasMuted ? 'muted' : ''}
${hasLoop ? 'loop' : ''}
${hasPreload ? `preload="${hasPreload[1]}"` : 'preload="metadata"'}
style="width: 100%; height: 100%;"
data-video-title="${escapeHtml(title)}"
data-original-src="${escapeHtml(originalSrc)}"
>
<p>Your browser does not support the video element.</p>
</video>
${title !== 'Video' ? `
<div class="video-metadata">
<div class="video-title">${escapeHtml(title)}</div>
</div>
` : ''}
</div>
`.trim();
parent.children[index] = { type: 'html', value: enhancedHTML };
console.log(`[VIDEO] Processed: ${title}`);
console.log(`[VIDEO] Final URL: ${finalSrc}`);
}
}
});
};
};
function processNextcloudUrl(originalUrl: string): string {
if (isNextcloudShareUrl(originalUrl) && !originalUrl.includes('/download')) {
const downloadUrl = `${originalUrl}/download`;
console.log(`[VIDEO] Auto-added /download: ${originalUrl}${downloadUrl}`);
return downloadUrl;
}
return originalUrl;
}
function isNextcloudShareUrl(url: string): boolean {
const pattern = /\/s\/[a-zA-Z0-9]+/;
return pattern.test(url) && (url.includes('nextcloud') || url.includes('cloud.'));
}
function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@ -1,5 +1,3 @@
// src/utils/toolHelpers.ts
export interface Tool {
name: string;
type?: 'software' | 'method' | 'concept';
@ -15,8 +13,31 @@ export interface Tool {
related_concepts?: string[];
}
export {
createToolSlug,
findToolByIdentifier,
isToolHosted
} from './clientUtils.js';
export function createToolSlug(toolName: string): string {
if (!toolName || typeof toolName !== 'string') {
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
return '';
}
return toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
export function findToolByIdentifier(tools: Tool[], identifier: string): Tool | undefined {
if (!identifier || !Array.isArray(tools)) return undefined;
return tools.find(tool =>
tool.name === identifier ||
createToolSlug(tool.name) === identifier.toLowerCase()
);
}
export function isToolHosted(tool: Tool): boolean {
return tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
}

View File

@ -1,115 +0,0 @@
// src/utils/videoUtils.ts - SIMPLIFIED - Basic utilities only
import 'dotenv/config';
export interface SimpleVideoMetadata {
title?: string;
description?: string;
}
export function getVideoMimeType(url: string): string {
let extension: string | undefined;
try {
const pathname = new URL(url).pathname;
extension = pathname.split('.').pop()?.toLowerCase();
} catch {
extension = url.split('?')[0].split('.').pop()?.toLowerCase();
}
const mimeTypes: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
m4v: 'video/m4v',
mkv: 'video/x-matroska',
flv: 'video/x-flv'
};
return (extension && mimeTypes[extension]) || 'video/mp4';
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export function generateVideoHTML(
src: string,
options: {
title?: string;
controls?: boolean;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
preload?: 'none' | 'metadata' | 'auto';
aspectRatio?: '16:9' | '4:3' | '1:1';
showMetadata?: boolean;
} = {}
): string {
const {
title = 'Video',
controls = true,
autoplay = false,
muted = false,
loop = false,
preload = 'metadata',
aspectRatio = '16:9',
showMetadata = true
} = options;
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
const videoAttributes = [
controls ? 'controls' : '',
autoplay ? 'autoplay' : '',
muted ? 'muted' : '',
loop ? 'loop' : '',
`preload="${preload}"`
].filter(Boolean).join(' ');
const metadataHTML = showMetadata && title !== 'Video' ? `
<div class="video-metadata">
<div class="video-title">${escapeHtml(title)}</div>
</div>
` : '';
return `
<div class="video-container ${aspectClass}">
<video
src="${escapeHtml(src)}"
${videoAttributes}
style="width: 100%; height: 100%;"
data-video-title="${escapeHtml(title)}"
>
<p>Your browser does not support the video element.</p>
</video>
${metadataHTML}
</div>
`.trim();
}