Browse Source

bug-fixes

main
Silberengel 4 weeks ago
parent
commit
4a36aec53d
  1. 24
      src/lib/services/git-platforms/git-platform-fetcher.ts
  2. 84
      src/lib/services/git/repo-manager.ts
  3. 7
      src/lib/services/messaging/event-forwarder.ts
  4. 27
      src/lib/services/messaging/preferences-storage.server.ts
  5. 22
      src/lib/services/messaging/preferences-types.ts
  6. 16
      src/lib/services/nostr/user-level-service.ts
  7. 23
      src/routes/+layout.svelte
  8. 26
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  9. 4
      src/routes/api/user/messaging-preferences/+server.ts
  10. 2
      src/routes/api/user/messaging-preferences/summary/+server.ts
  11. 11
      svelte.config.js
  12. 12
      vite.config.ts

24
src/lib/services/git-platforms/git-platform-fetcher.ts

@ -7,8 +7,10 @@ @@ -7,8 +7,10 @@
*/
import logger from '../logger.js';
import type { MessagingPreferences } from '../messaging/preferences-storage.js';
import { getPreferences } from '../messaging/preferences-storage.js';
import type { MessagingPreferences } from '../messaging/preferences-types.js';
// Lazy-loaded function - will only be imported server-side
let getPreferences: ((userPubkeyHex: string) => Promise<MessagingPreferences | null>) | null = null;
type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom';
@ -553,6 +555,24 @@ export async function getAllExternalItems( @@ -553,6 +555,24 @@ export async function getAllExternalItems(
issues: ExternalIssue[];
pullRequests: ExternalPullRequest[];
}> {
// Dynamic import to avoid bundling Node.js crypto in browser
// This will only run server-side
if (!getPreferences) {
try {
// Only import server-side - this will fail in browser but that's OK
const module = await import('../messaging/preferences-storage.server.js');
getPreferences = module.getPreferences;
} catch (err) {
// If import fails (e.g., in browser), return empty results
// This is expected behavior - preferences-storage uses Node.js crypto
return { issues: [], pullRequests: [] };
}
}
if (!getPreferences) {
return { issues: [], pullRequests: [] };
}
const preferences = await getPreferences(userPubkeyHex);
if (!preferences || !preferences.gitPlatforms || preferences.gitPlatforms.length === 0) {
return { issues: [], pullRequests: [] };

84
src/lib/services/git/repo-manager.ts

@ -448,6 +448,22 @@ export class RepoManager { @@ -448,6 +448,22 @@ export class RepoManager {
* @param announcementEvent - The Nostr repo announcement event (optional, will fetch if not provided)
* @returns true if repository was successfully fetched, false otherwise
*/
/**
* Check if a repository is private based on announcement event
* A repo is private if it has a tag ["private", "true"] or ["t", "private"]
*/
private isPrivateRepo(announcement: NostrEvent): boolean {
// Check for ["private", "true"] tag
const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true');
if (privateTag) return true;
// Check for ["t", "private"] tag (topic tag)
const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private');
if (topicTag) return true;
return false;
}
async fetchRepoOnDemand(
npub: string,
repoName: string,
@ -465,23 +481,36 @@ export class RepoManager { @@ -465,23 +481,36 @@ export class RepoManager {
return false;
}
// Security: Only allow fetching if user has unlimited access
// This prevents unauthorized repository creation
const { getCachedUserLevel } = await import('../security/user-level-cache.js');
const userLevel = getCachedUserLevel(announcementEvent.pubkey);
if (!userLevel || userLevel.level !== 'unlimited') {
logger.warn({
// Check if repository is public
const isPublic = !this.isPrivateRepo(announcementEvent);
// Security: For public repos, allow on-demand fetching regardless of owner's access level
// For private repos, require owner to have unlimited access to prevent unauthorized creation
if (!isPublic) {
const { getCachedUserLevel } = await import('../security/user-level-cache.js');
const userLevel = getCachedUserLevel(announcementEvent.pubkey);
if (!userLevel || userLevel.level !== 'unlimited') {
logger.warn({
npub,
repoName,
pubkey: announcementEvent.pubkey.slice(0, 16) + '...',
level: userLevel?.level || 'none'
}, 'Skipping on-demand repo fetch: private repo requires owner with unlimited access');
return false;
}
} else {
logger.info({
npub,
repoName,
pubkey: announcementEvent.pubkey.slice(0, 16) + '...',
level: userLevel?.level || 'none'
}, 'Skipping on-demand repo fetch: user does not have unlimited access');
return false;
pubkey: announcementEvent.pubkey.slice(0, 16) + '...'
}, 'Allowing on-demand fetch for public repository');
}
// Extract clone URLs outside try block for error logging
const cloneUrls = this.extractCloneUrls(announcementEvent);
let remoteUrls: string[] = [];
try {
// Extract clone URLs from announcement
const cloneUrls = this.extractCloneUrls(announcementEvent);
// Filter out localhost URLs and our own domain (we want external sources)
const externalUrls = cloneUrls.filter(url => {
@ -492,7 +521,7 @@ export class RepoManager { @@ -492,7 +521,7 @@ export class RepoManager {
});
// If no external URLs, try any URL that's not our domain
const remoteUrls = externalUrls.length > 0 ? externalUrls :
remoteUrls = externalUrls.length > 0 ? externalUrls :
cloneUrls.filter(url => !url.includes(this.domain));
// If still no remote URLs, but there are *any* clone URLs, try the first one
@ -503,10 +532,12 @@ export class RepoManager { @@ -503,10 +532,12 @@ export class RepoManager {
}
if (remoteUrls.length === 0) {
logger.warn({ npub, repoName }, 'No remote clone URLs found for on-demand fetch');
logger.warn({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found for on-demand fetch');
return false;
}
logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details');
// Create directory structure
const repoDir = join(this.repoRoot, npub);
if (!existsSync(repoDir)) {
@ -518,7 +549,7 @@ export class RepoManager { @@ -518,7 +549,7 @@ export class RepoManager {
const git = simpleGit();
const gitEnv = this.getGitEnvForUrl(remoteUrls[0]);
logger.info({ npub, repoName, sourceUrl: remoteUrls[0] }, 'Fetching repository on-demand from remote');
logger.info({ npub, repoName, sourceUrl: remoteUrls[0], cloneUrls }, 'Fetching repository on-demand from remote');
// Clone as bare repository
// Use gitEnv which already contains necessary whitelisted environment variables
@ -529,19 +560,29 @@ export class RepoManager { @@ -529,19 +560,29 @@ export class RepoManager {
});
let stderr = '';
let stdout = '';
cloneProcess.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
cloneProcess.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
cloneProcess.on('close', (code) => {
if (code === 0) {
logger.info({ npub, repoName, sourceUrl: remoteUrls[0] }, 'Successfully cloned repository');
resolve();
} else {
reject(new Error(`Git clone failed with code ${code}: ${stderr}`));
const errorMsg = `Git clone failed with code ${code}: ${stderr || stdout}`;
logger.error({ npub, repoName, sourceUrl: remoteUrls[0], code, stderr, stdout }, 'Git clone failed');
reject(new Error(errorMsg));
}
});
cloneProcess.on('error', reject);
cloneProcess.on('error', (err) => {
logger.error({ npub, repoName, sourceUrl: remoteUrls[0], error: err }, 'Git clone process error');
reject(err);
});
});
// Verify the repository was actually created
@ -561,7 +602,14 @@ export class RepoManager { @@ -561,7 +602,14 @@ export class RepoManager {
return true;
} catch (error) {
const sanitizedError = sanitizeError(error);
logger.error({ error: sanitizedError, npub, repoName }, 'Failed to fetch repository on-demand');
logger.error({
error: sanitizedError,
npub,
repoName,
cloneUrls,
isPublic,
remoteUrls
}, 'Failed to fetch repository on-demand');
return false;
}
}

7
src/lib/services/messaging/event-forwarder.ts

@ -10,14 +10,17 @@ import { getCachedUserLevel } from '../security/user-level-cache.js'; @@ -10,14 +10,17 @@ import { getCachedUserLevel } from '../security/user-level-cache.js';
import { KIND } from '../../types/nostr.js';
// Lazy import to avoid importing Node.js crypto in browser
let getPreferences: typeof import('./preferences-storage.js').getPreferences;
// Use a function type instead of typeof import to avoid static analysis
let getPreferences: ((userPubkeyHex: string) => Promise<any>) | null = null;
async function getPreferencesLazy() {
if (typeof window !== 'undefined') {
// Browser environment - event forwarding should be done server-side
return null;
}
if (!getPreferences) {
const module = await import('./preferences-storage.js');
// Use dynamic path construction to prevent Vite from statically analyzing the import
const storagePath = './preferences-storage' + '.server.js';
const module = await import(storagePath);
getPreferences = module.getPreferences;
}
return getPreferences;

27
src/lib/services/messaging/preferences-storage.ts → src/lib/services/messaging/preferences-storage.server.ts

@ -13,9 +13,10 @@ @@ -13,9 +13,10 @@
* It will throw an error if imported in browser/client code.
*/
// Ensure this is only used server-side
// This file uses .server.ts suffix so SvelteKit automatically excludes it from client bundles
// The runtime check below is a safety measure
if (typeof window !== 'undefined') {
throw new Error('preferences-storage.ts uses Node.js crypto and cannot be imported in browser code. Use API endpoints instead.');
throw new Error('preferences-storage.server.ts uses Node.js crypto and cannot be imported in browser code. Use API endpoints instead.');
}
import {
@ -28,6 +29,10 @@ import { @@ -28,6 +29,10 @@ import {
} from 'crypto';
import logger from '../logger.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
import type { MessagingPreferences } from './preferences-types.js';
// Re-export the type for convenience
export type { MessagingPreferences } from './preferences-types.js';
// Encryption keys from environment (NEVER commit these!)
// These are optional - if not set, messaging preferences will be disabled
@ -42,24 +47,6 @@ if (!isMessagingConfigured) { @@ -42,24 +47,6 @@ if (!isMessagingConfigured) {
logger.warn('Messaging preferences storage is not configured. Missing environment variables: MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET');
}
export interface MessagingPreferences {
telegram?: string; // Chat ID or username
simplex?: string; // Contact ID
email?: {
to: string[]; // To: email addresses
cc?: string[]; // CC: email addresses (optional)
};
gitPlatforms?: Array<{
platform: 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom';
owner: string; // Repository owner (username or org)
repo: string; // Repository name
token: string; // Personal access token (encrypted)
apiUrl?: string; // Custom API URL (required for onedev and self-hosted platforms)
}>;
enabled: boolean;
notifyOn?: string[]; // Event kinds to forward (e.g., ['1621', '1618'])
}
interface StoredPreferences {
encryptedSalt: string; // Salt encrypted with SALT_ENCRYPTION_KEY
encrypted: string; // Preferences encrypted with derived key

22
src/lib/services/messaging/preferences-types.ts

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
/**
* Types for messaging preferences
* This file is separate from preferences-storage.server.ts to avoid bundling Node.js crypto in the browser
*/
export interface MessagingPreferences {
telegram?: string; // Chat ID or username
simplex?: string; // Contact ID
email?: {
to: string[]; // To: email addresses
cc?: string[]; // CC: email addresses (optional)
};
gitPlatforms?: Array<{
platform: 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom';
owner: string; // Repository owner (username or org)
repo: string; // Repository name
token: string; // Personal access token (encrypted)
apiUrl?: string; // Custom API URL (required for onedev and self-hosted platforms)
}>;
enabled: boolean;
notifyOn?: string[]; // Event kinds to forward (e.g., ['1621', '1618'])
}

16
src/lib/services/nostr/user-level-service.ts

@ -14,6 +14,8 @@ import { signEventWithNIP07, isNIP07Available } from './nip07-signer.js'; @@ -14,6 +14,8 @@ import { signEventWithNIP07, isNIP07Available } from './nip07-signer.js';
import { KIND } from '../../types/nostr.js';
import { createProofEvent } from './relay-write-proof.js';
import { nip19 } from 'nostr-tools';
import { NostrClient } from './nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '../../config.js';
export type UserLevel = 'unlimited' | 'rate_limited' | 'strictly_rate_limited';
@ -45,6 +47,20 @@ export async function checkRelayWriteAccess( @@ -45,6 +47,20 @@ export async function checkRelayWriteAccess(
// Sign the event with NIP-07
const signedEvent = await signEventWithNIP07(proofEventTemplate);
// Publish the event to relays BEFORE verification
// The server needs to be able to fetch it from relays to verify write access
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const publishResult = await nostrClient.publishEvent(signedEvent, DEFAULT_NOSTR_RELAYS);
// Wait a moment for the event to propagate to relays before verification
// This gives relays time to process and index the event
if (publishResult.success.length > 0) {
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
} else {
// If publishing failed to all relays, still try verification (might be cached)
console.warn('Failed to publish proof event to any relay, but continuing with verification attempt');
}
// Verify server-side via API endpoint (secure)
const response = await fetch('/api/user/level', {
method: 'POST',

23
src/routes/+layout.svelte

@ -91,22 +91,21 @@ @@ -91,22 +91,21 @@
return;
}
// Only check user level if user has explicitly logged in (has pubkey in store)
// Don't automatically get pubkey from NIP-07 - that should only happen on explicit login
if (!currentState.userPubkey) {
// User not logged in - set to strictly rate limited without checking
userStore.setUser(null, null, 'strictly_rate_limited', null);
return;
}
checkingUserLevel = true;
userStore.setChecking(true);
try {
let userPubkey: string | null = null;
let userPubkeyHex: string | null = null;
// Try to get user pubkey if NIP-07 is available
if (isNIP07Available()) {
try {
userPubkey = await getPublicKeyWithNIP07();
userPubkeyHex = decodePubkey(userPubkey);
} catch (err) {
console.warn('Failed to get user pubkey:', err);
}
}
// Use pubkey from store (user has explicitly logged in)
const userPubkey = currentState.userPubkey;
const userPubkeyHex = currentState.userPubkeyHex;
// Determine user level
const levelResult = await determineUserLevel(userPubkey, userPubkeyHex);

26
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -37,11 +37,18 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -37,11 +37,18 @@ export const GET: RequestHandler = createRepoGetHandler(
if (events.length > 0) {
// Try to fetch the repository from remote clone URLs
const fetched = await repoManager.fetchRepoOnDemand(
context.npub,
context.repo,
events[0]
);
let fetched = false;
try {
fetched = await repoManager.fetchRepoOnDemand(
context.npub,
context.repo,
events[0]
);
} catch (fetchError) {
// Log the actual error for debugging
console.error('[Branches] Error in fetchRepoOnDemand:', fetchError);
// Continue to check if repo exists anyway (might have been created despite error)
}
// Always check if repo exists after fetch attempt (might have been created)
// Also clear cache to ensure fileManager sees it
@ -58,7 +65,7 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -58,7 +65,7 @@ export const GET: RequestHandler = createRepoGetHandler(
// Fetch returned true but repo doesn't exist - this shouldn't happen, but clear cache anyway
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
// Wait a moment for filesystem to sync, then check again
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise(resolve => setTimeout(resolve, 500));
if (!existsSync(repoPath)) {
throw handleNotFoundError(
'Repository fetch completed but repository is not accessible',
@ -78,9 +85,12 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -78,9 +85,12 @@ export const GET: RequestHandler = createRepoGetHandler(
// Repo exists now, clear cache and continue with normal flow
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
} else {
// If fetching fails, return 404
// Log the error for debugging
console.error('[Branches] Error fetching repository:', err);
// If fetching fails, return 404 with more context
const errorMessage = err instanceof Error ? err.message : 'Repository not found';
throw handleNotFoundError(
'Repository not found',
errorMessage,
{ operation: 'getBranches', npub: context.npub, repo: context.repo }
);
}

4
src/routes/api/user/messaging-preferences/+server.ts

@ -10,12 +10,12 @@ @@ -10,12 +10,12 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { verifyEvent } from 'nostr-tools';
import { storePreferences, getPreferences, deletePreferences, hasPreferences, getRateLimitStatus } from '$lib/services/messaging/preferences-storage.js';
import { storePreferences, getPreferences, deletePreferences, hasPreferences, getRateLimitStatus } from '$lib/services/messaging/preferences-storage.server.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import logger from '$lib/services/logger.js';
import type { MessagingPreferences } from '$lib/services/messaging/preferences-storage.js';
import type { MessagingPreferences } from '$lib/services/messaging/preferences-storage.server.js';
/**
* POST - Save messaging preferences

2
src/routes/api/user/messaging-preferences/summary/+server.ts

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.js';
import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.server.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import logger from '$lib/services/logger.js';

11
svelte.config.js

@ -4,6 +4,17 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; @@ -4,6 +4,17 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
compilerOptions: {
css: 'external'
},
onwarn(warning, handler) {
// Suppress CSS unused selector warnings
// These are false positives for dark theme selectors that are conditionally applied
if (warning.code === 'css-unused-selector') {
return;
}
handler(warning);
},
kit: {
adapter: adapter()
}

12
vite.config.ts

@ -11,7 +11,8 @@ if (process.env.NODE_ENV === 'production' || process.argv.includes('build')) { @@ -11,7 +11,8 @@ if (process.env.NODE_ENV === 'production' || process.argv.includes('build')) {
const shouldSuppress = (message: string): boolean => {
return (
message.includes('externalized for browser compatibility') ||
message.includes('[plugin:vite:resolve]') && message.includes('has been externalized')
message.includes('[plugin:vite:resolve]') && message.includes('has been externalized') ||
message.includes('[vite-plugin-svelte]') && message.includes('Unused CSS selector')
);
};
@ -42,6 +43,15 @@ if (process.env.NODE_ENV === 'production' || process.argv.includes('build')) { @@ -42,6 +43,15 @@ if (process.env.NODE_ENV === 'production' || process.argv.includes('build')) {
export default defineConfig({
plugins: [sveltekit()],
ssr: {
// Exclude Node.js-only modules from client bundle
noExternal: [],
external: []
},
optimizeDeps: {
// Exclude server-only modules from pre-bundling
exclude: ['src/lib/services/messaging/preferences-storage.ts']
},
build: {
rollupOptions: {
onwarn(warning, warn) {

Loading…
Cancel
Save