diff --git a/client/package.json b/client/package.json index dfeb6cd..3cc5939 100644 --- a/client/package.json +++ b/client/package.json @@ -8,9 +8,11 @@ "framer-motion": "^11.11.9", "lucide-react": "^0.452.0", "monaco-editor": "^0.52.0", + "parse-duration": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-router-dom": "^6.28.0", "react-syntax-highlighter": "^15.6.1", "vite-plugin-monaco-editor": "^1.1.0" }, diff --git a/client/src/App.tsx b/client/src/App.tsx index 5837f77..ea4bbf0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,6 +3,8 @@ import SnippetStorage from './components/snippets/SnippetStorage'; import { ToastProvider } from './components/toast/Toast'; import { AuthProvider, useAuth } from './context/AuthContext'; import LoginPage from './components/auth/LoginPage'; +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import SharedSnippetView from './components/snippets/share/SharedSnippetView'; const AuthenticatedApp: React.FC = () => { const { isAuthenticated, isAuthRequired, isLoading } = useAuth(); @@ -24,13 +26,16 @@ const AuthenticatedApp: React.FC = () => { const App: React.FC = () => { return ( - - - - - - - + + + + + } /> + } /> + + + + ); }; diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index deb5e7f..c3bdbac 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -1,3 +1,5 @@ +import { basePath } from "./basePath"; + interface AuthConfig { authRequired: boolean; } @@ -6,7 +8,6 @@ interface LoginResponse { token: string; } -export const basePath = (window as any).__BASE_PATH__ || ''; export const AUTH_API_URL = `${basePath}/api/auth`; interface ApiError extends Error { diff --git a/client/src/api/basePath.ts b/client/src/api/basePath.ts new file mode 100644 index 0000000..50a64cc --- /dev/null +++ b/client/src/api/basePath.ts @@ -0,0 +1 @@ +export const basePath = (window as any).__BASE_PATH__ || ''; \ No newline at end of file diff --git a/client/src/api/share.ts b/client/src/api/share.ts new file mode 100644 index 0000000..a02d97c --- /dev/null +++ b/client/src/api/share.ts @@ -0,0 +1,71 @@ +import { ShareSettings, Share, Snippet } from '../types/types'; +import { basePath } from './basePath'; + +const SHARE_API_URL = `${basePath}/api/share`; + +export const createShare = async ( + snippetId: string, + settings: ShareSettings +): Promise => { + const response = await fetch(SHARE_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ + snippetId, + ...settings + }) + }); + + if (!response.ok) { + throw new Error('Failed to create share'); + } + + return response.json(); +}; + +export const getSharesBySnippetId = async (snippetId: string): Promise => { + const response = await fetch(`${SHARE_API_URL}/snippet/${snippetId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + throw new Error('Failed to get shares'); + } + + return response.json(); +}; + +export const deleteShare = async (shareId: string): Promise => { + const response = await fetch(`${SHARE_API_URL}/${shareId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + throw new Error('Failed to delete share'); + } +}; + +export const getSharedSnippet = async (shareId: string): Promise => { + const response = await fetch(`${SHARE_API_URL}/${shareId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + const data = await response.json(); + const error = new Error(data.error || 'Failed to get shared snippet') as any; + error.errorCode = response.status; + throw error; + } + + return response.json(); +}; \ No newline at end of file diff --git a/client/src/api/snippets.ts b/client/src/api/snippets.ts index c16a37f..6137cdf 100644 --- a/client/src/api/snippets.ts +++ b/client/src/api/snippets.ts @@ -1,6 +1,6 @@ import { Snippet } from '../types/types'; +import { basePath } from './basePath'; -export const basePath = (window as any).__BASE_PATH__ || ''; export const API_URL = `${basePath}/api/snippets`; interface ApiError extends Error { diff --git a/client/src/components/snippets/EditSnippetModal.tsx b/client/src/components/snippets/EditSnippetModal.tsx index 7d29eb2..21e588f 100644 --- a/client/src/components/snippets/EditSnippetModal.tsx +++ b/client/src/components/snippets/EditSnippetModal.tsx @@ -50,7 +50,7 @@ const EditSnippetModal: React.FC = ({ setFragments([{ file_name: 'main', code: '', - language: 'plaintext', + language: '', position: 0 }]); setCategories([]); @@ -96,7 +96,7 @@ const EditSnippetModal: React.FC = ({ { file_name: `file${current.length + 1}`, code: '', - language: 'plaintext', + language: '', position: current.length } ]); diff --git a/client/src/components/snippets/SnippetCard.tsx b/client/src/components/snippets/SnippetCard.tsx index 40ffed3..086b045 100644 --- a/client/src/components/snippets/SnippetCard.tsx +++ b/client/src/components/snippets/SnippetCard.tsx @@ -1,11 +1,12 @@ -import React, { useState } from 'react'; -import { Pencil, Trash2, Clock, ChevronLeft, ChevronRight, FileCode } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { Pencil, Trash2, Clock, ChevronLeft, ChevronRight, FileCode, Share, Users } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import DeleteConfirmationModal from './DeleteConfirmationModal'; import { CodeFragment, Snippet } from '../../types/types'; import { getLanguageLabel } from '../../utils/languageUtils'; import PreviewCodeBlock from './PreviewCodeBlock'; import CategoryList from './categories/CategoryList'; +import { getSharesBySnippetId } from '../../api/share'; export interface SnippetCardProps { snippet: Snippet; @@ -14,6 +15,7 @@ export interface SnippetCardProps { onDelete: (id: string) => void; onEdit: (snippet: Snippet) => void; onCategoryClick: (category: string) => void; + onShare: (snippet: Snippet) => void; compactView: boolean; showCodePreview: boolean; previewLines: number; @@ -29,6 +31,7 @@ const SnippetCard: React.FC = ({ onDelete, onEdit, onCategoryClick, + onShare, compactView, showCodePreview, previewLines, @@ -38,6 +41,21 @@ const SnippetCard: React.FC = ({ }) => { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [currentFragmentIndex, setCurrentFragmentIndex] = useState(0); + const [activeShares, setActiveShares] = useState(0); + + useEffect(() => { + const loadShareStatus = async () => { + try { + const shares = await getSharesBySnippetId(snippet.id); + const validShares = shares.filter(share => !share.expired); + setActiveShares(validShares.length); + } catch (error) { + console.error('Error loading share status:', error); + } + }; + + loadShareStatus(); + }, [snippet]); const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -109,9 +127,20 @@ const SnippetCard: React.FC = ({ > - - {snippet.title} - + + + {snippet.title} + + {activeShares > 0 && ( + + + {activeShares} + + )} + {getUniqueLanguages(snippet.fragments)} @@ -122,6 +151,16 @@ const SnippetCard: React.FC = ({ + { + e.stopPropagation(); + onShare(snippet); + }} + className="p-1.5 bg-gray-700 rounded-md hover:bg-gray-600 transition-colors opacity-0 group-hover:opacity-100" + title="Share snippet" + > + + void; onEdit: (snippet: Snippet) => void; onCategoryClick: (category: string) => void; + onShare: (snippet: Snippet) => void; compactView: boolean; showCodePreview: boolean; previewLines: number; @@ -24,6 +25,7 @@ const SnippetList: React.FC = ({ onDelete, onEdit, onCategoryClick, + onShare, compactView, showCodePreview, previewLines, @@ -53,6 +55,7 @@ const SnippetList: React.FC = ({ onDelete={onDelete} onEdit={onEdit} onCategoryClick={onCategoryClick} + onShare={onShare} compactView={compactView} showCodePreview={showCodePreview} previewLines={previewLines} diff --git a/client/src/components/snippets/SnippetStorage.tsx b/client/src/components/snippets/SnippetStorage.tsx index a62c75c..7e8370f 100644 --- a/client/src/components/snippets/SnippetStorage.tsx +++ b/client/src/components/snippets/SnippetStorage.tsx @@ -11,6 +11,7 @@ import { useAuth } from '../../context/AuthContext'; import { getLanguageLabel } from '../../utils/languageUtils'; import { Snippet } from '../../types/types'; import { initializeMonaco } from '../../utils/languageUtils'; +import ShareMenu from './share/ShareMenu'; const APP_VERSION = "1.4.0"; @@ -31,6 +32,8 @@ const SnippetStorage: React.FC = () => { const [snippetToEdit, setSnippetToEdit] = useState(null); const [sortOrder, setSortOrder] = useState<'newest' | 'oldest' | 'alpha-asc' | 'alpha-desc'>('newest'); const [selectedCategories, setSelectedCategories] = useState([]); + const [isShareMenuOpen, setIsShareMenuOpen] = useState(false); + const [snippetToShare, setSnippetToShare] = useState(null); useEffect(() => { initializeMonaco(); @@ -53,6 +56,16 @@ const SnippetStorage: React.FC = () => { logout(); }; + const openShareMenu = useCallback((snippet: Snippet) => { + setSnippetToShare(snippet); + setIsShareMenuOpen(true); + }, []); + + const closeShareMenu = useCallback(() => { + setSnippetToShare(null); + setIsShareMenuOpen(false); + }, []); + const languages = useMemo(() => { const langSet = new Set(); snippets.forEach(snippet => { @@ -211,6 +224,7 @@ const SnippetStorage: React.FC = () => { onDelete={handleDeleteSnippet} onEdit={openEditSnippetModal} onCategoryClick={handleCategoryClick} + onShare={openShareMenu} compactView={compactView} showCodePreview={showCodePreview} previewLines={previewLines} @@ -249,6 +263,14 @@ const SnippetStorage: React.FC = () => { }} onSettingsChange={updateSettings} /> + + {snippetToShare && ( + + )} ); }; diff --git a/client/src/components/snippets/share/ShareMenu.tsx b/client/src/components/snippets/share/ShareMenu.tsx new file mode 100644 index 0000000..9525580 --- /dev/null +++ b/client/src/components/snippets/share/ShareMenu.tsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect } from 'react'; +import { Share as ShareIcon, Trash2, Link as LinkIcon, Check, ShieldCheck, ShieldOff } from 'lucide-react'; +import { Share, ShareSettings } from '../../../types/types'; +import { createShare, getSharesBySnippetId, deleteShare } from '../../../api/share'; +import { useToast } from '../../toast/Toast'; +import Modal from '../../common/Modal'; +import { basePath } from '../../../api/basePath'; +import parseDuration from 'parse-duration'; +import { formatDistanceToNow } from 'date-fns'; + +interface ShareMenuProps { + snippetId: string; + isOpen: boolean; + onClose: () => void; +} + +const ShareMenu: React.FC = ({ snippetId, isOpen, onClose }) => { + const [shares, setShares] = useState([]); + const [requiresAuth, setRequiresAuth] = useState(false); + const [expiresIn, setExpiresIn] = useState(''); + const [durationError, setDurationError] = useState(''); + const [copiedStates, setCopiedStates] = useState>({}); + const { addToast } = useToast(); + + useEffect(() => { + if (isOpen) { + loadShares(); + setCopiedStates({}); + } + }, [isOpen, snippetId]); + + const loadShares = async () => { + try { + const loadedShares = await getSharesBySnippetId(snippetId); + setShares(loadedShares); + } catch (error) { + addToast('Failed to load shares', 'error'); + } + }; + + const handleCreateShare = async () => { + if (expiresIn) { + const seconds = parseDuration(expiresIn, 's'); + if (!seconds) { + setDurationError('Invalid duration format. Use 1h, 2d, 30m etc.'); + return; + } + setDurationError(''); + } + + try { + const settings: ShareSettings = { + requiresAuth, + expiresIn: expiresIn ? Math.floor(parseDuration(expiresIn, 's')!) : undefined + }; + + await createShare(snippetId, settings); + await loadShares(); + addToast('Share link created', 'success'); + + setRequiresAuth(false); + setExpiresIn(''); + } catch (error) { + addToast('Failed to create share link', 'error'); + } + }; + + const handleDeleteShare = async (shareId: string) => { + try { + await deleteShare(shareId); + setShares(shares.filter(share => share.id !== shareId)); + addToast('Share link deleted', 'success'); + } catch (error) { + addToast('Failed to delete share link', 'error'); + } + }; + + const copyShareLink = async (shareId: string) => { + const url = `${window.location.origin}${basePath}/s/${shareId}`; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(url); + } else { + const textArea = document.createElement('textarea'); + textArea.value = url; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + } finally { + textArea.remove(); + } + } + + setCopiedStates(prev => ({ ...prev, [shareId]: true })); + setTimeout(() => { + setCopiedStates(prev => ({ ...prev, [shareId]: false })); + }, 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + const getRelativeExpiryTime = (expiresAt: string): string => { + try { + const expiryDate = new Date(expiresAt); + return `Expires in ${formatDistanceToNow(expiryDate)}`; + } catch (error) { + console.error('Error formatting expiry date:', error); + return 'Unknown expiry time'; + } + }; + + return ( + + + Share Snippet + + } + > + + + Create New Share Link + + + + setRequiresAuth(e.target.checked)} + className="form-checkbox h-4 w-4" + /> + Require authentication + + + + Expires in (e.g. 1h, 2d, 30m) + { + setExpiresIn(e.target.value); + setDurationError(''); + }} + placeholder="Never" + className="w-full px-3 py-2 bg-gray-700 rounded-md" + /> + {durationError && ( + {durationError} + )} + + + + Create Share Link + + + + + + Active Share Links + + {shares.length === 0 ? ( + No active share links + ) : ( + + {shares.map(share => ( + + + + /{share.id} + {share.requires_auth === 1 && ( + + + + )} + {share.requires_auth === 0 && ( + + + + )} + + {share.expired === 1 && ( + + Expired + + )} + {share.expires_at && share.expired === 0 && ( + + {getRelativeExpiryTime(share.expires_at)} + + )} + {share.expires_at === null && ( + + Never Expires + + )} + + + + + copyShareLink(share.id)} + className="p-2 hover:bg-gray-600 rounded-md transition-colors" + title="Copy link" + > + {copiedStates[share.id] ? ( + + ) : ( + + )} + + handleDeleteShare(share.id)} + className="p-2 hover:bg-gray-600 rounded-md transition-colors text-red-400" + title="Delete share link" + > + + + + + ))} + + )} + + + + ); +}; + +export default ShareMenu; \ No newline at end of file diff --git a/client/src/components/snippets/share/SharedSnippetView.tsx b/client/src/components/snippets/share/SharedSnippetView.tsx new file mode 100644 index 0000000..49ea2ba --- /dev/null +++ b/client/src/components/snippets/share/SharedSnippetView.tsx @@ -0,0 +1,127 @@ +import React, { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { getSharedSnippet } from '../../../api/share'; +import { Snippet } from '../../../types/types'; +import FullCodeBlock from '../FullCodeBlock'; +import CategoryList from '../categories/CategoryList'; +import { FileCode, Clock } from 'lucide-react'; +import { getLanguageLabel } from '../../../utils/languageUtils'; +import { formatDistanceToNow } from 'date-fns'; +import { useAuth } from '../../../context/AuthContext'; +import LoginPage from '../../auth/LoginPage'; + +const SharedSnippetView: React.FC = () => { + const { shareId } = useParams<{ shareId: string }>(); + const [snippet, setSnippet] = useState(null); + const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { isAuthenticated } = useAuth(); + + useEffect(() => { + loadSharedSnippet(); + }, [shareId, isAuthenticated]); + + const loadSharedSnippet = async () => { + if (!shareId) return; + + try { + setIsLoading(true); + const shared = await getSharedSnippet(shareId); + setSnippet(shared); + setError(null); + } catch (err: any) { + setErrorCode(err.errorCode); + setError(err.error); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( + + Loading... + + ); + } + + if (errorCode === 401 && !isAuthenticated) { + return ; + } + + if (errorCode === 410) { + return ( + + Shared snippet has expired + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!snippet) { + return ( + + Snippet not found + + ); + } + + return ( + + + + {snippet.title} + {snippet.description} + + + + + Updated {formatDistanceToNow(new Date(snippet.updated_at), { addSuffix: true })} + + + + {snippet.categories.length > 0 && ( + {}} + className="mt-4" + variant="clickable" + showAll={true} + /> + )} + + + + {snippet.fragments.map((fragment, index) => ( + + + + + {fragment.file_name} + + {getLanguageLabel(fragment.language)} + + + + + + ))} + + + + ); +}; + +export default SharedSnippetView; \ No newline at end of file diff --git a/client/src/hooks/useSnippets.ts b/client/src/hooks/useSnippets.ts index d5b578b..40f8df8 100644 --- a/client/src/hooks/useSnippets.ts +++ b/client/src/hooks/useSnippets.ts @@ -18,7 +18,7 @@ export const useSnippets = () => { } }; - const loadSnippets = useCallback(async () => { + const loadSnippets = useCallback(async (force: boolean) => { if (!isLoading || hasLoadedRef.current) return; try { @@ -29,7 +29,9 @@ export const useSnippets = () => { setSnippets(sortedSnippets); if (!hasLoadedRef.current) { - addToast('Snippets loaded successfully', 'success'); + if (!force) { + addToast('Snippets loaded successfully', 'success'); + } hasLoadedRef.current = true; } } catch (error: any) { @@ -42,13 +44,13 @@ export const useSnippets = () => { }, [isLoading, addToast, logout]); useEffect(() => { - loadSnippets(); + loadSnippets(false); }, [loadSnippets]); const reloadSnippets = useCallback(() => { hasLoadedRef.current = false; setIsLoading(true); - loadSnippets(); + loadSnippets(true); }, [loadSnippets]); const addSnippet = async (snippetData: Omit) => { diff --git a/client/src/types/global.d.ts b/client/src/types/global.d.ts new file mode 100644 index 0000000..0ea3f92 --- /dev/null +++ b/client/src/types/global.d.ts @@ -0,0 +1,3 @@ +interface Window { + __BASE_PATH__: string; +} \ No newline at end of file diff --git a/client/src/types/types.ts b/client/src/types/types.ts index 49291c5..5810391 100644 --- a/client/src/types/types.ts +++ b/client/src/types/types.ts @@ -23,4 +23,20 @@ export interface FragmentEditorProps { onDelete: () => void; showLineNumbers: boolean; dragHandleProps?: any; +} + +export interface ShareSettings { + requiresAuth: boolean; + expiresIn?: number; +} + +export interface Share { + id: string; + snippet_id: number; + requires_auth: number; + view_limit: number | null; + view_count: number; + expires_at: string; + created_at: string; + expired: number; } \ No newline at end of file diff --git a/client/src/utils/languageUtils.ts b/client/src/utils/languageUtils.ts index 6d98372..99eeb0d 100644 --- a/client/src/utils/languageUtils.ts +++ b/client/src/utils/languageUtils.ts @@ -286,12 +286,12 @@ export const normalizeLanguage = (lang: string): string => { } } - return 'plaintext'; + return lang; }; export const getMonacoLanguage = (lang: string): string => { const normalized = normalizeLanguage(lang); - return LANGUAGE_MAPPING[normalized]?.monacoAlias || 'plaintext'; + return LANGUAGE_MAPPING[normalized]?.monacoAlias || lang; }; export const getLanguageLabel = (lang: string): string => { diff --git a/client/tsconfig.json b/client/tsconfig.json index cf92881..d201d01 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -18,8 +18,12 @@ "types": ["node"], "paths": { "@/*": ["./src/*"] - } + }, + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ] }, - "include": ["src"], + "include": ["src/**/*"], "references": [{ "path": "./tsconfig.node.json" }] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3e88ff9..0ef618d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,9 +37,11 @@ "framer-motion": "^11.11.9", "lucide-react": "^0.452.0", "monaco-editor": "^0.52.0", + "parse-duration": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-router-dom": "^6.28.0", "react-syntax-highlighter": "^15.6.1", "vite-plugin-monaco-editor": "^1.1.0" }, @@ -1742,6 +1744,15 @@ "node": ">=14" } }, + "node_modules/@remix-run/router": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", @@ -7882,6 +7893,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse-duration": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.1.0.tgz", + "integrity": "sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==", + "license": "MIT" + }, "node_modules/parse-entities": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", @@ -8529,6 +8546,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", + "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", + "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0", + "react-router": "6.28.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-syntax-highlighter": { "version": "15.6.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz", diff --git a/server/src/app.js b/server/src/app.js index 472e201..8218885 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -2,6 +2,7 @@ const express = require('express'); const { initializeDatabase } = require('./config/database'); const snippetRoutes = require('./routes/snippetRoutes'); const authRoutes = require('./routes/authRoutes'); +const shareRoutes = require('./routes/shareRoutes') const { authenticateToken } = require('./middleware/auth'); const { join } = require('path'); const fs = require('fs'); @@ -17,6 +18,7 @@ const assetsPath = join(buildPath, 'assets'); app.use(`${basePath}/api/auth`, authRoutes); app.use(`${basePath}/api/snippets`, authenticateToken, snippetRoutes); +app.use(`${basePath}/api/share`, shareRoutes); app.use(`${basePath}/assets`, express.static(assetsPath)); app.use(`${basePath}/monacoeditorwork`, express.static(join(buildPath, 'monacoeditorwork'))); diff --git a/server/src/config/database.js b/server/src/config/database.js index cd4e2f2..b570910 100644 --- a/server/src/config/database.js +++ b/server/src/config/database.js @@ -143,6 +143,17 @@ function initializeDatabase() { CREATE INDEX IF NOT EXISTS idx_categories_snippet_id ON categories(snippet_id); CREATE INDEX IF NOT EXISTS idx_fragments_snippet_id ON fragments(snippet_id); + + CREATE TABLE IF NOT EXISTS shared_snippets ( + id TEXT PRIMARY KEY, + snippet_id INTEGER NOT NULL, + requires_auth BOOLEAN NOT NULL DEFAULT false, + expires_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snippet_id) REFERENCES snippets(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_shared_snippets_snippet_id ON shared_snippets(snippet_id); `); console.log('Database initialized successfully'); diff --git a/server/src/repositories/shareRepository.js b/server/src/repositories/shareRepository.js new file mode 100644 index 0000000..24bc6fd --- /dev/null +++ b/server/src/repositories/shareRepository.js @@ -0,0 +1,156 @@ +const { getDb } = require('../config/database'); +const crypto = require('crypto'); + +class ShareRepository { + constructor() { + this.createShareStmt = null; + this.getShareStmt = null; + this.getSharesBySnippetIdStmt = null; + this.deleteShareStmt = null; + this.incrementViewCountStmt = null; + } + + #initializeStatements() { + const db = getDb(); + + if (!this.createShareStmt) { + this.createShareStmt = db.prepare(` + INSERT INTO shared_snippets ( + id, + snippet_id, + requires_auth, + expires_at + ) VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds')) + `); + + this.getFragmentsStmt = db.prepare(` + SELECT id, file_name, code, language, position + FROM fragments + WHERE snippet_id = ? + ORDER BY position + `); + + this.getShareStmt = db.prepare(` + SELECT + ss.id as share_id, + ss.requires_auth, + ss.expires_at, + ss.created_at, + datetime(ss.expires_at) < datetime('now') as expired, + s.id, + s.title, + s.description, + datetime(s.updated_at) || 'Z' as updated_at, + GROUP_CONCAT(DISTINCT c.name) as categories + FROM shared_snippets ss + JOIN snippets s ON s.id = ss.snippet_id + LEFT JOIN categories c ON s.id = c.snippet_id + WHERE ss.id = ? + GROUP BY s.id + `); + + this.getSharesBySnippetIdStmt = db.prepare(` + SELECT + ss.*, + datetime(ss.expires_at) < datetime('now') as expired + FROM shared_snippets ss + WHERE ss.snippet_id = ? + ORDER BY ss.created_at DESC + `); + + this.deleteShareStmt = db.prepare(` + DELETE FROM shared_snippets WHERE id = ? + `); + } + } + + #processShare(share) { + if (!share) return null; + + const fragments = this.getFragmentsStmt.all(share.id); + + return { + id: share.id, + title: share.title, + description: share.description, + updated_at: share.updated_at, + categories: share.categories ? share.categories.split(',') : [], + fragments: fragments.sort((a, b) => a.position - b.position), + share: { + id: share.share_id, + requiresAuth: !!share.requires_auth, + expiresAt: share.expires_at, + createdAt: share.created_at, + expired: !!share.expired, + } + }; + } + + async createShare({ snippetId, requiresAuth, expiresIn }) { + this.#initializeStatements(); + + const shareId = crypto.randomBytes(16).toString('hex'); + + try { + this.createShareStmt.run( + shareId, + snippetId, + requiresAuth ? 1 : 0, + expiresIn + ); + + return { + id: shareId, + snippetId, + requiresAuth, + viewCount: 0, + expiresIn + }; + } catch (error) { + console.error('Error in createShare:', error); + throw error; + } + } + + async getShare(id) { + this.#initializeStatements(); + try { + const share = this.getShareStmt.get(id); + return this.#processShare(share); + } catch (error) { + console.error('Error in getShare:', error); + throw error; + } + } + async getSharesBySnippetId(snippetId) { + this.#initializeStatements(); + try { + return this.getSharesBySnippetIdStmt.all(snippetId); + } catch (error) { + console.error('Error in getSharesBySnippetId:', error); + throw error; + } + } + + async deleteShare(id) { + this.#initializeStatements(); + try { + return this.deleteShareStmt.run(id); + } catch (error) { + console.error('Error in deleteShare:', error); + throw error; + } + } + + async incrementViewCount(id) { + this.#initializeStatements(); + try { + return this.incrementViewCountStmt.run(id); + } catch (error) { + console.error('Error in incrementViewCount:', error); + throw error; + } + } +} + +module.exports = new ShareRepository(); \ No newline at end of file diff --git a/server/src/routes/shareRoutes.js b/server/src/routes/shareRoutes.js new file mode 100644 index 0000000..e602cc4 --- /dev/null +++ b/server/src/routes/shareRoutes.js @@ -0,0 +1,79 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { JWT_SECRET, authenticateToken } = require('../middleware/auth'); +const shareRepository = require('../repositories/shareRepository'); +const router = express.Router(); + +router.post('/', authenticateToken, async (req, res) => { + try { + const { snippetId, requiresAuth, expiresIn } = req.body; + const share = await shareRepository.createShare({ + snippetId, + requiresAuth: !!requiresAuth, + expiresIn: expiresIn ? parseInt(expiresIn) : null + }); + res.status(201).json(share); + } catch (error) { + console.error('Error creating share:', error); + res.status(500).json({ error: 'Failed to create share' }); + } +}); + +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const share = await shareRepository.getShare(id); + + if (!share) { + return res.status(404).json({ error: 'Share not found' }); + } + + if (share.share?.requiresAuth) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Authentication required' }); + } + + try { + jwt.verify(token, JWT_SECRET); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + } + + if (share.share?.expired) { + return res.status(410).json({ error: 'Share has expired' }); + } + + res.json(share); + } catch (error) { + console.error('Error getting share:', error); + res.status(500).json({ error: 'Failed to get share' }); + } +}); + +router.get('/snippet/:snippetId', authenticateToken, async (req, res) => { + try { + const { snippetId } = req.params; + const shares = await shareRepository.getSharesBySnippetId(snippetId); + res.json(shares); + } catch (error) { + console.error('Error listing shares:', error); + res.status(500).json({ error: 'Failed to list shares' }); + } +}); + +router.delete('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + await shareRepository.deleteShare(id); + res.json({ success: true }); + } catch (error) { + console.error('Error deleting share:', error); + res.status(500).json({ error: 'Failed to delete share' }); + } +}); + +module.exports = router; \ No newline at end of file
{durationError}
No active share links
{snippet.description}