├── server ├── .gitignore ├── src │ ├── logger.js │ ├── middleware │ │ ├── apiKeyAuth.js │ │ └── auth.js │ ├── config │ │ ├── migrations │ │ │ ├── 20250601-migration.js │ │ │ ├── 20241120-migration.js │ │ │ ├── 20241121-migration.js │ │ │ ├── 20241122-migration.js │ │ │ ├── 20241119-migration.js │ │ │ ├── 20250905-migration.js │ │ │ ├── 20241111-migration.js │ │ │ └── 20241117-migration.js │ │ ├── schema │ │ │ └── init.sql │ │ └── database.js │ ├── routes │ │ ├── embedRoutes.js │ │ ├── apiKeyRoutes.js │ │ ├── publicRoutes.js │ │ └── shareRoutes.js │ ├── services │ │ └── userService.js │ └── repositories │ │ └── apiKeyRepository.js └── package.json ├── client ├── src │ ├── types │ │ ├── global.d.ts │ │ ├── auth.ts │ │ ├── common.ts │ │ ├── apiKey.ts │ │ ├── user.ts │ │ └── snippets.ts │ ├── service │ │ ├── index.ts │ │ ├── authService.ts │ │ ├── shareService.ts │ │ └── snippetService.ts │ ├── utils │ │ ├── paths.ts │ │ ├── api │ │ │ ├── basePath.ts │ │ │ ├── apiKeys.ts │ │ │ ├── auth.ts │ │ │ ├── share.ts │ │ │ ├── apiClient.ts │ │ │ └── snippets.ts │ │ ├── markdownUtils.ts │ │ ├── helpers │ │ │ ├── apiUtils.ts │ │ │ ├── embedUtils.ts │ │ │ └── colourUtils.ts │ │ ├── oidcErrorHandler.ts │ │ └── downloadUtils.ts │ ├── components │ │ ├── snippets │ │ │ ├── view │ │ │ │ ├── SnippetPage.tsx │ │ │ │ ├── common │ │ │ │ │ ├── ViewSwitch.tsx │ │ │ │ │ └── StorageHeader.tsx │ │ │ │ ├── SnippetModal.tsx │ │ │ │ ├── recycle │ │ │ │ │ └── RecycleSnippetStorage.tsx │ │ │ │ └── public │ │ │ │ │ └── PublicSnippetStorage.tsx │ │ │ ├── list │ │ │ │ ├── SnippetRecycleCardMenu.tsx │ │ │ │ └── SnippetList.tsx │ │ │ ├── embed │ │ │ │ └── EmbedCopyButton.tsx │ │ │ └── share │ │ │ │ └── SharedSnippetView.tsx │ │ ├── common │ │ │ ├── layout │ │ │ │ └── PageContainer.tsx │ │ │ ├── switch │ │ │ │ └── Switch.tsx │ │ │ ├── buttons │ │ │ │ ├── RawButton.tsx │ │ │ │ ├── DownloadButton.tsx │ │ │ │ ├── CopyButton.tsx │ │ │ │ ├── IconButton.tsx │ │ │ │ ├── DownloadArchiveButton.tsx │ │ │ │ └── FileUploadButton.tsx │ │ │ └── modals │ │ │ │ ├── ConfirmationModal.tsx │ │ │ │ └── Modal.tsx │ │ ├── auth │ │ │ ├── oidc │ │ │ │ ├── OIDCLogoutCallback.tsx │ │ │ │ └── OIDCCallback.tsx │ │ │ └── UserDropdown.tsx │ │ ├── utils │ │ │ └── Admonition.tsx │ │ ├── categories │ │ │ ├── CategoryTag.tsx │ │ │ └── CategorySuggestions.tsx │ │ ├── search │ │ │ └── SearchBar.tsx │ │ └── editor │ │ │ └── CodeEditor.tsx │ ├── index.tsx │ ├── constants │ │ ├── api.ts │ │ ├── settings.ts │ │ ├── events.ts │ │ └── routes.ts │ ├── hooks │ │ ├── useToast.ts │ │ ├── useAuth.ts │ │ ├── useDebounce.ts │ │ ├── useOutsideClick.ts │ │ ├── useKeyboardShortcut.ts │ │ └── useSettings.ts │ ├── styles │ │ └── markdown.css │ ├── contexts │ │ ├── ThemeContext.tsx │ │ ├── AuthContext.tsx │ │ └── ToastContext.tsx │ ├── index.css │ └── App.tsx ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── github-mark-white.svg │ └── docker-mark-white.svg ├── postcss.config.js ├── tsconfig.node.json ├── .gitignore ├── index.html ├── vite.config.ts ├── tsconfig.json ├── tailwind.config.js └── package.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── pull-request.yml │ └── release.yml ├── media ├── logo.webp └── app-image.png ├── .gitignore ├── helm-charts └── bytestash │ ├── Chart.yaml │ ├── templates │ ├── serviceaccount.yaml │ ├── pvc.yaml │ ├── service.yaml │ ├── NOTES.txt │ ├── ingress.yaml │ └── _helpers.tpl │ ├── .helmignore │ ├── .example.yaml │ ├── README.md │ └── values.yaml ├── package.json ├── Dockerfile ├── docker-compose.yaml └── README.md /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /client/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | __BASE_PATH__?: string; 3 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [jordan-dalby] 2 | custom: ["https://ko-fi.com/zalosath"] -------------------------------------------------------------------------------- /media/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordan-dalby/ByteStash/HEAD/media/logo.webp -------------------------------------------------------------------------------- /media/app-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordan-dalby/ByteStash/HEAD/media/app-image.png -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordan-dalby/ByteStash/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordan-dalby/ByteStash/HEAD/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordan-dalby/ByteStash/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/src/types/auth.ts: -------------------------------------------------------------------------------- 1 | export interface OIDCConfig { 2 | enabled: boolean; 3 | displayName: string; 4 | logged_in: boolean; 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # db storage directory 2 | data 3 | 4 | node_modules 5 | dist 6 | 7 | kube.yml 8 | 9 | docker-compose-dev.yaml 10 | 11 | example-fragment.js -------------------------------------------------------------------------------- /client/src/service/index.ts: -------------------------------------------------------------------------------- 1 | export { snippetService } from './snippetService'; 2 | export { shareService } from './shareService'; 3 | export { authService } from './authService'; -------------------------------------------------------------------------------- /client/src/utils/paths.ts: -------------------------------------------------------------------------------- 1 | export const getAssetPath = (path: string) => { 2 | const basePath = (window as any).__BASE_PATH__ || ''; 3 | return `${basePath}${path}`; 4 | }; -------------------------------------------------------------------------------- /helm-charts/bytestash/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: bytestash 3 | description: A Helm chart for deploying ByteStash to Kubernetes 4 | 5 | type: application 6 | version: 0.1.1 7 | appVersion: "1.5.7" 8 | -------------------------------------------------------------------------------- /client/src/components/snippets/view/SnippetPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AuthAwareSnippetView from './common/AuthAwareSnippetPage'; 3 | 4 | const SnippetPage: React.FC = () => { 5 | return ; 6 | }; 7 | 8 | export default SnippetPage; -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } -------------------------------------------------------------------------------- /client/src/utils/api/basePath.ts: -------------------------------------------------------------------------------- 1 | interface WindowWithBasePath extends Window { 2 | __BASE_PATH__?: string; 3 | } 4 | 5 | const getBasePath = (): string => { 6 | const win = window as WindowWithBasePath; 7 | return win.__BASE_PATH__ || ''; 8 | }; 9 | 10 | export const basePath = getBasePath(); -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | import './styles/markdown.css'; 6 | 7 | ReactDOM.createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | , 11 | ) -------------------------------------------------------------------------------- /client/src/constants/api.ts: -------------------------------------------------------------------------------- 1 | export const API_ENDPOINTS = { 2 | AUTH: '/api/auth', 3 | SNIPPETS: '/api/snippets', 4 | SHARE: '/api/share', 5 | PUBLIC: '/api/public/snippets' 6 | } as const; 7 | 8 | export const API_METHODS = { 9 | GET: 'GET', 10 | POST: 'POST', 11 | PUT: 'PUT', 12 | DELETE: 'DELETE' 13 | } as const; -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /client/src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { ToastContext } from '../contexts/ToastContext'; 3 | 4 | export const useToast = () => { 5 | const context = useContext(ToastContext); 6 | if (!context) { 7 | throw new Error('useToast must be used within a ToastProvider'); 8 | } 9 | return context; 10 | }; -------------------------------------------------------------------------------- /client/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { AuthContext } from '../contexts/AuthContext'; 3 | 4 | export const useAuth = () => { 5 | const context = useContext(AuthContext); 6 | if (context === undefined) { 7 | throw new Error('useAuth must be used within an AuthProvider'); 8 | } 9 | return context; 10 | }; -------------------------------------------------------------------------------- /client/src/types/common.ts: -------------------------------------------------------------------------------- 1 | export interface BaseResponse { 2 | success: boolean; 3 | message?: string; 4 | } 5 | 6 | export interface PaginatedResponse extends BaseResponse { 7 | data: T[]; 8 | total: number; 9 | page: number; 10 | pageSize: number; 11 | } 12 | 13 | export interface ApiError extends Error { 14 | status?: number; 15 | code?: string; 16 | } -------------------------------------------------------------------------------- /client/src/constants/settings.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_SETTINGS = { 2 | viewMode: 'grid', 3 | compactView: false, 4 | showCodePreview: true, 5 | previewLines: 4, 6 | includeCodeInSearch: false, 7 | showCategories: true, 8 | expandCategories: false, 9 | showLineNumbers: true, 10 | theme: 'system', 11 | } as const; 12 | 13 | export const APP_VERSION = '1.5.9'; 14 | -------------------------------------------------------------------------------- /client/src/types/apiKey.ts: -------------------------------------------------------------------------------- 1 | export interface ApiKey { 2 | id: string; 3 | name: string; 4 | key: string; 5 | last_used?: string; 6 | created_at: string; 7 | } 8 | 9 | export interface CreateApiKeyRequest { 10 | name: string; 11 | } 12 | 13 | export interface CreateApiKeyResponse { 14 | id: string; 15 | name: string; 16 | key: string; 17 | created_at: string; 18 | } 19 | -------------------------------------------------------------------------------- /client/src/constants/events.ts: -------------------------------------------------------------------------------- 1 | export const EVENTS = { 2 | AUTH_ERROR: 'bytestash:auth_error', 3 | SNIPPET_UPDATED: 'bytestash:snippet_updated', 4 | SNIPPET_DELETED: 'bytestash:snippet_deleted', 5 | SHARE_CREATED: 'bytestash:share_created', 6 | SHARE_DELETED: 'bytestash:share_deleted', 7 | } as const; 8 | 9 | export const createCustomEvent = (eventName: string) => new CustomEvent(eventName); -------------------------------------------------------------------------------- /client/src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const ROUTES = { 2 | HOME: '/', 3 | SHARED_SNIPPET: '/s/:shareId', 4 | SNIPPET: '/snippets/:snippetId', 5 | LOGIN: '/login', 6 | REGISTER: '/register', 7 | PUBLIC_SNIPPETS: '/public/snippets', 8 | AUTH_CALLBACK: '/auth/callback', 9 | LOGOUT_CALLBACK: '/auth/logout_callback', 10 | EMBED: '/embed/:shareId', 11 | RECYCLE: '/recycle/snippets', 12 | } as const; 13 | -------------------------------------------------------------------------------- /helm-charts/bytestash/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "bytestash.serviceAccountName" . }} 6 | labels: 7 | {{- include "bytestash.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /client/src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(handler); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | } -------------------------------------------------------------------------------- /helm-charts/bytestash/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bytestash", 3 | "description": "A React and node.js app that stores code snippets", 4 | "author": "Jordan Dalby", 5 | "version": "1.0.0", 6 | "main": "server/app.js", 7 | "scripts": { 8 | "start": "cd client && npm start", 9 | "server": "cd server && npm start", 10 | "dev": "docker-compose up --build" 11 | }, 12 | "workspaces": [ 13 | "client", 14 | "server" 15 | ], 16 | "engines": { 17 | "node": ">=22" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/types/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: number; 3 | username: string; 4 | created_at: string; 5 | oidc_id?: string; 6 | } 7 | 8 | export interface AuthResponse { 9 | token: string; 10 | user: User; 11 | error?: string; 12 | } 13 | 14 | export interface AuthConfig { 15 | authRequired: boolean; 16 | allowNewAccounts: boolean; 17 | hasUsers: boolean; 18 | disableAccounts: boolean; 19 | disableInternalAccounts: boolean; 20 | allowPasswordChanges: boolean; 21 | } -------------------------------------------------------------------------------- /helm-charts/bytestash/.example.yaml: -------------------------------------------------------------------------------- 1 | persistence: 2 | enabled: true 3 | storageClassName: some-class 4 | size: 10Gi 5 | bytestash: 6 | baseUrl: /bytestash 7 | allowNewAccount: true 8 | existingJwtSecret: 9 | secretName: name-of-existing-secret 10 | jwtKey: key-a-in-secret 11 | expirityKey: key-b-in-secret 12 | resources: 13 | requests: 14 | cpu: 50m 15 | memory: 64Mi 16 | ingress: 17 | enabled: true 18 | className: nginx 19 | host: org.example.com 20 | path: /bytestash 21 | pathType: Prefix 22 | -------------------------------------------------------------------------------- /client/src/utils/markdownUtils.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export const flattenToText = (node: ReactNode): string => { 4 | if (!node) return ""; 5 | 6 | if (typeof node === "string" || typeof node === "number") { 7 | return String(node); 8 | } 9 | 10 | if (Array.isArray(node)) { 11 | return node.reduce((text, child) => text + flattenToText(child), ""); 12 | } 13 | 14 | if (typeof node === "object" && "props" in node) { 15 | return flattenToText(node.props?.children); 16 | } 17 | 18 | return ""; 19 | }; 20 | -------------------------------------------------------------------------------- /server/src/logger.js: -------------------------------------------------------------------------------- 1 | const DEBUG = process.env.DEBUG === 'true'; 2 | 3 | class Logger { 4 | static debug(...args) { 5 | if (DEBUG) { 6 | console.log('[DEBUG]', ...args); 7 | } 8 | } 9 | 10 | static error(...args) { 11 | if (DEBUG) { 12 | console.error('[ERROR]', ...args); 13 | } else { 14 | const messages = args.map(arg => 15 | arg instanceof Error ? arg.message : arg 16 | ); 17 | console.error('[ERROR]', ...messages); 18 | } 19 | } 20 | 21 | static info(...args) { 22 | console.log('[INFO]', ...args); 23 | } 24 | } 25 | 26 | export default Logger; -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "ByteStash", 3 | "name": "ByteStash is a code snippet storage solution", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Platform** 23 | Which platform are you using? 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /client/src/utils/api/apiKeys.ts: -------------------------------------------------------------------------------- 1 | import { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from '../../types/apiKey'; 2 | import { apiClient } from './apiClient'; 3 | 4 | export const getApiKeys = async (): Promise => { 5 | return apiClient.get('/api/keys', { requiresAuth: true }); 6 | }; 7 | 8 | export const createApiKey = async (request: CreateApiKeyRequest): Promise => { 9 | return apiClient.post('/api/keys', request, { requiresAuth: true }); 10 | }; 11 | 12 | export const deleteApiKey = async (id: string): Promise => { 13 | await apiClient.delete(`/api/keys/${id}`, { requiresAuth: true }); 14 | }; 15 | -------------------------------------------------------------------------------- /helm-charts/bytestash/templates/pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.persistence.enabled -}} 2 | kind: PersistentVolumeClaim 3 | apiVersion: v1 4 | metadata: 5 | name: {{ include "bytestash.fullname" . }}-data 6 | labels: 7 | {{- include "bytestash.labels" . | nindent 4 }} 8 | spec: 9 | accessModes: 10 | - ReadWriteOnce 11 | resources: 12 | requests: 13 | storage: {{ .Values.persistence.size | quote }} 14 | {{- if .Values.persistence.storageClassName }} 15 | {{- if (eq "-" .Values.persistence.storageClassName) }} 16 | storageClassName: "" 17 | {{- else }} 18 | storageClassName: "{{ .Values.persistence.storageClassName }}" 19 | {{- end }} 20 | {{- end }} 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /client/src/utils/helpers/apiUtils.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from '../../types/common'; 2 | 3 | export const createApiError = (message: string, status?: number, code?: string): ApiError => { 4 | const error = new Error(message) as ApiError; 5 | if (status) error.status = status; 6 | if (code) error.code = code; 7 | return error; 8 | }; 9 | 10 | export const handleApiResponse = async (response: Response): Promise => { 11 | if (!response.ok) { 12 | const error = await response.json().catch(() => ({})); 13 | throw createApiError( 14 | error.message || 'An error occurred', 15 | response.status, 16 | error.code 17 | ); 18 | } 19 | 20 | return response.json(); 21 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | ByteStash 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bytestash-server", 3 | "version": "1.0.0", 4 | "main": "src/app.js", 5 | "type": "module", 6 | "scripts": { 7 | "start": "node src/app.js", 8 | "test": "jest" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "", 14 | "dependencies": { 15 | "bcrypt": "^5.1.1", 16 | "better-sqlite3": "^11.5.0", 17 | "body-parser": "^1.20.3", 18 | "cookie-parser": "^1.4.7", 19 | "cors": "^2.8.5", 20 | "express": "^4.21.1", 21 | "jsonwebtoken": "^9.0.2", 22 | "multer": "^1.4.5-lts.1", 23 | "openid-client": "^6.1.3", 24 | "swagger-ui-express": "^5.0.1", 25 | "yamljs": "^0.3.0" 26 | }, 27 | "engines": { 28 | "node": ">=22" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/hooks/useOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, RefObject } from 'react'; 2 | 3 | export const useOutsideClick = ( 4 | ref: RefObject, 5 | handler: () => void, 6 | deps: any[] = [] 7 | ) => { 8 | useEffect(() => { 9 | const listener = (event: MouseEvent | TouchEvent) => { 10 | if (!ref.current || ref.current.contains(event.target as Node)) { 11 | return; 12 | } 13 | handler(); 14 | }; 15 | 16 | document.addEventListener('mousedown', listener); 17 | document.addEventListener('touchstart', listener); 18 | 19 | return () => { 20 | document.removeEventListener('mousedown', listener); 21 | document.removeEventListener('touchstart', listener); 22 | }; 23 | }, [ref, handler, ...deps]); 24 | }; -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import monacoEditorPlugin from 'vite-plugin-monaco-editor'; 4 | 5 | // Parse allowed hosts from environment variable 6 | const allowedHosts = process.env.ALLOWED_HOSTS 7 | ? process.env.ALLOWED_HOSTS.split(',').map(host => host.trim()).filter(Boolean) 8 | : undefined; 9 | 10 | export default defineConfig({ 11 | plugins: [ 12 | react(), 13 | monacoEditorPlugin({ globalAPI: true }), 14 | ], 15 | server: { 16 | allowedHosts: allowedHosts, 17 | proxy: { 18 | '/api': { 19 | target: 'http://localhost:5000', 20 | changeOrigin: true, 21 | }, 22 | }, 23 | port: 3000, 24 | }, 25 | build: { 26 | outDir: 'build' 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["DOM", "DOM.Iterable", "ES2020"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "noEmit": true, 12 | "jsx": "react-jsx", 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "baseUrl": ".", 18 | "types": ["node"], 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | }, 22 | "typeRoots": [ 23 | "./node_modules/@types", 24 | "./src/types" 25 | ] 26 | }, 27 | "include": ["src/**/*"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage for client 2 | FROM node:22-alpine AS client-build 3 | WORKDIR /app/client 4 | COPY client/package.json ./ 5 | RUN npm install --package-lock-only 6 | RUN npm ci 7 | COPY client/ ./ 8 | RUN npm run build 9 | 10 | # Production stage 11 | FROM node:22-alpine AS production 12 | WORKDIR /app 13 | 14 | # Copy server source and dependencies 15 | WORKDIR /app 16 | COPY server/package.json ./ 17 | RUN apk add --no-cache --virtual .build-deps python3 make g++ gcc && \ 18 | npm install --omit=dev && \ 19 | apk del .build-deps 20 | 21 | COPY server/src ./src 22 | COPY server/docs ./docs 23 | 24 | # Copy client build 25 | COPY --from=client-build /app/client/build /client/build 26 | 27 | # Create output directory 28 | RUN mkdir -p ./data/snippets 29 | 30 | EXPOSE 5000 31 | 32 | CMD ["node", "src/app.js"] 33 | -------------------------------------------------------------------------------- /client/src/utils/helpers/embedUtils.ts: -------------------------------------------------------------------------------- 1 | interface EmbedParams { 2 | shareId: string; 3 | showTitle: boolean; 4 | showDescription: boolean; 5 | showFileHeaders: boolean; 6 | showPoweredBy: boolean; 7 | theme: string; 8 | fragmentIndex?: number; 9 | } 10 | 11 | export const generateEmbedId = (params: EmbedParams): string => { 12 | const paramsString = `${params.shareId}-${params.showTitle}-${params.showDescription}-${params.showFileHeaders}-${params.showPoweredBy}-${params.fragmentIndex ?? 'all'}`; 13 | 14 | let hash = 0; 15 | for (let i = 0; i < paramsString.length; i++) { 16 | const char = paramsString.charCodeAt(i); 17 | hash = ((hash << 5) - hash) + char; 18 | hash = hash & hash; 19 | } 20 | 21 | const hashStr = Math.abs(hash).toString(16).padStart(16, '0').slice(0, 16); 22 | return hashStr; 23 | }; 24 | -------------------------------------------------------------------------------- /client/src/types/snippets.ts: -------------------------------------------------------------------------------- 1 | export interface CodeFragment { 2 | id?: string; 3 | file_name: string; 4 | code: string; 5 | language: string; 6 | position: number; 7 | } 8 | 9 | export interface Snippet { 10 | id: string; 11 | title: string; 12 | description: string; 13 | updated_at: string; 14 | expiry_date?: string; 15 | categories: string[]; 16 | fragments: CodeFragment[]; 17 | share_count?: number; 18 | is_public: number; 19 | is_pinned: number; 20 | is_favorite: number; 21 | username?: string; 22 | } 23 | 24 | export interface ShareSettings { 25 | requiresAuth: boolean; 26 | expiresIn?: number; 27 | } 28 | 29 | export interface Share { 30 | id: string; 31 | snippet_id: number; 32 | requires_auth: number; 33 | view_limit: number | null; 34 | view_count: number; 35 | expires_at: string; 36 | created_at: string; 37 | expired: number; 38 | } 39 | -------------------------------------------------------------------------------- /helm-charts/bytestash/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "bytestash.fullname" . }} 5 | labels: 6 | {{- include "bytestash.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | {{- if .Values.service.externalIPs }} 10 | externalIPs: 11 | {{- toYaml .Values.service.externalIPs | nindent 4 }} 12 | {{- end }} 13 | {{- if .Values.service.loadBalancerIP }} 14 | loadBalancerIP: "{{ .Values.service.loadBalancerIP }}" 15 | {{- end }} 16 | {{- if .Values.service.loadBalancerSourceRanges }} 17 | loadBalancerSourceRanges: 18 | {{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }} 19 | {{- end }} 20 | ports: 21 | - port: {{ .Values.service.port }} 22 | targetPort: http 23 | protocol: TCP 24 | name: http 25 | selector: 26 | {{- include "bytestash.selectorLabels" . | nindent 4 }} 27 | -------------------------------------------------------------------------------- /server/src/middleware/apiKeyAuth.js: -------------------------------------------------------------------------------- 1 | import Logger from '../logger.js'; 2 | import { validateApiKey } from '../repositories/apiKeyRepository.js'; 3 | 4 | export function authenticateApiKey(req, res, next) { 5 | const apiKey = req.headers['x-api-key']; 6 | 7 | if (!apiKey) { 8 | return next(); 9 | } 10 | 11 | try { 12 | const result = validateApiKey(apiKey); 13 | 14 | if (result) { 15 | req.user = { id: result.userId }; 16 | req.apiKey = { id: result.keyId }; 17 | Logger.debug(`Request authenticated via API key ${result.keyId}`); 18 | return next(); 19 | } 20 | 21 | // Invalid API key 22 | Logger.info('Invalid API key provided'); 23 | res.status(401).json({ error: 'Invalid API key' }); 24 | } catch (error) { 25 | Logger.error('Error validating API key:', error); 26 | res.status(500).json({ error: 'Internal server error' }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/utils/helpers/colourUtils.ts: -------------------------------------------------------------------------------- 1 | export const colorUtils = { 2 | getContrastColor: (hexcolor: string): string => { 3 | const r = parseInt(hexcolor.slice(1, 3), 16); 4 | const g = parseInt(hexcolor.slice(3, 5), 16); 5 | const b = parseInt(hexcolor.slice(5, 7), 16); 6 | const yiq = (r * 299 + g * 587 + b * 114) / 1000; 7 | return yiq >= 128 ? '#000000' : '#FFFFFF'; 8 | }, 9 | 10 | adjustBrightness: (hex: string, percent: number): string => { 11 | const num = parseInt(hex.replace('#', ''), 16); 12 | const amt = Math.round(2.55 * percent); 13 | const R = (num >> 16) + amt; 14 | const G = (num >> 8 & 0x00FF) + amt; 15 | const B = (num & 0x0000FF) + amt; 16 | return `#${( 17 | 0x1000000 + 18 | (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + 19 | (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + 20 | (B < 255 ? (B < 1 ? 0 : B) : 255) 21 | ).toString(16).slice(1)}`; 22 | } 23 | }; -------------------------------------------------------------------------------- /client/src/service/authService.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../utils/api/apiClient'; 2 | import { API_ENDPOINTS } from '../constants/api'; 3 | 4 | interface AuthConfig { 5 | authRequired: boolean; 6 | } 7 | 8 | interface LoginResponse { 9 | token: string; 10 | } 11 | 12 | export const authService = { 13 | async getConfig(): Promise { 14 | return apiClient.get(`${API_ENDPOINTS.AUTH}/config`); 15 | }, 16 | 17 | async verifyToken(): Promise { 18 | try { 19 | const response = await apiClient.get<{ valid: boolean }>(`${API_ENDPOINTS.AUTH}/verify`, { requiresAuth: true }); 20 | return response.valid; 21 | } catch { 22 | return false; 23 | } 24 | }, 25 | 26 | async login(username: string, password: string): Promise { 27 | const response = await apiClient.post(`${API_ENDPOINTS.AUTH}/login`, { username, password }); 28 | return response.token; 29 | } 30 | }; -------------------------------------------------------------------------------- /client/public/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/**/*.{js,jsx,ts,tsx}", 5 | "./public/index.html" 6 | ], 7 | darkMode: 'class', 8 | theme: { 9 | extend: { 10 | colors: { 11 | 'light-bg': '#f1f5f9', 12 | 'light-surface': '#e2e8f0', 13 | 'light-border': '#cbd5e1', 14 | 'light-text': '#1e293b', 15 | 'light-text-secondary': '#475569', 16 | 'light-primary': '#2563eb', 17 | 'light-hover': '#cbd5e1', 18 | 'light-hover-more': '#eff2f6', 19 | 20 | 'dark-bg': '#0f172a', 21 | 'dark-surface': '#1e293b', 22 | 'dark-border': '#334155', 23 | 'dark-text': '#e2e8f0', 24 | 'dark-text-secondary': '#94a3b8', 25 | 'dark-primary': '#2563eb', 26 | 'dark-hover': '#334155', 27 | 'dark-hover-more': '#4d6280', 28 | }, 29 | }, 30 | }, 31 | plugins: [], 32 | } 33 | -------------------------------------------------------------------------------- /client/src/components/common/layout/PageContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface PageContainerProps { 4 | children: React.ReactNode; 5 | className?: string; 6 | title?: string; 7 | actions?: React.ReactNode; 8 | } 9 | 10 | export const PageContainer: React.FC = ({ 11 | children, 12 | className = '', 13 | title, 14 | actions 15 | }) => { 16 | return ( 17 |
18 |
19 | {(title || actions) && ( 20 |
21 | {title &&

{title}

} 22 | {actions &&
{actions}
} 23 |
24 | )} 25 | {children} 26 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/service/shareService.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../utils/api/apiClient'; 2 | import { Share, ShareSettings, Snippet } from '../types/snippets'; 3 | import { API_ENDPOINTS } from '../constants/api'; 4 | 5 | export const shareService = { 6 | async createShare(snippetId: string, settings: ShareSettings): Promise { 7 | return apiClient.post(API_ENDPOINTS.SHARE, { snippetId, ...settings }, { requiresAuth: true }); 8 | }, 9 | 10 | async getSharesBySnippetId(snippetId: string): Promise { 11 | return apiClient.get(`${API_ENDPOINTS.SHARE}/snippet/${snippetId}`, { requiresAuth: true }); 12 | }, 13 | 14 | async deleteShare(shareId: string): Promise { 15 | return apiClient.delete(`${API_ENDPOINTS.SHARE}/${shareId}`, { requiresAuth: true }); 16 | }, 17 | 18 | async getSharedSnippet(shareId: string): Promise { 19 | return apiClient.get(`${API_ENDPOINTS.SHARE}/${shareId}`, { requiresAuth: true }); 20 | } 21 | }; -------------------------------------------------------------------------------- /client/src/components/auth/oidc/OIDCLogoutCallback.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { useAuth } from '../../../hooks/useAuth'; 4 | import { Loader2 } from 'lucide-react'; 5 | import { PageContainer } from '../../common/layout/PageContainer'; 6 | 7 | export const OIDCLogoutCallback: React.FC = () => { 8 | const navigate = useNavigate(); 9 | const { logout } = useAuth(); 10 | 11 | useEffect(() => { 12 | logout(); 13 | navigate('/', { replace: true }); 14 | }, [logout]); 15 | 16 | return ( 17 | 18 |
19 |
20 | 21 | Completing sign out... 22 |
23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/components/common/switch/Switch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Switch: React.FC<{ 4 | id: string; 5 | checked: boolean; 6 | onChange: (checked: boolean) => void; 7 | }> = ({ id, checked, onChange }) => ( 8 | 28 | ); 29 | -------------------------------------------------------------------------------- /client/public/docker-mark-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /server/src/config/migrations/20250601-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from '../../logger.js'; 2 | 3 | function needsMigration(db) { 4 | try { 5 | const hasExpiryColumn = db 6 | .prepare(` 7 | SELECT COUNT(*) as count 8 | FROM pragma_table_info('snippets') 9 | WHERE name = 'expiry_date' 10 | `) 11 | .get(); 12 | 13 | return hasExpiryColumn.count === 0; 14 | } catch (error) { 15 | Logger.error('v1.6.0-snippet-expiry - Error checking migration status:', error); 16 | throw error; 17 | } 18 | } 19 | 20 | async function up_v1_6_0_snippet_expiry(db) { 21 | if (!needsMigration(db)) { 22 | Logger.debug('v1.6.0-snippet-expiry - Migration not needed'); 23 | return; 24 | } 25 | 26 | Logger.debug('v1.6.0-snippet-expiry - Starting migration...'); 27 | 28 | try { 29 | db.exec(` 30 | ALTER TABLE snippets ADD COLUMN expiry_date DATETIME DEFAULT NULL; 31 | `); 32 | 33 | Logger.debug('v1.6.0-snippet-expiry - Migration completed successfully'); 34 | } catch (error) { 35 | Logger.error('v1.6.0-snippet-expiry - Migration failed:', error); 36 | throw error; 37 | } 38 | } 39 | 40 | export { up_v1_6_0_snippet_expiry }; 41 | -------------------------------------------------------------------------------- /client/src/styles/markdown.css: -------------------------------------------------------------------------------- 1 | .markdown-content { 2 | color: white; 3 | background-color: #1E1E1E; 4 | padding: 1rem; 5 | border-radius: 0.5rem; 6 | position: relative; 7 | } 8 | 9 | .markdown-content > :first-child { 10 | margin-top: 0 !important; 11 | } 12 | 13 | .markdown-content > :last-child { 14 | margin-bottom: 0 !important; 15 | } 16 | 17 | .markdown-content blockquote { 18 | border-left: 3px solid #4a5568; 19 | padding-left: 1rem; 20 | margin: 1rem 0; 21 | color: #a0aec0; 22 | } 23 | 24 | .markdown-content table { 25 | width: 100%; 26 | border-collapse: collapse; 27 | margin: 1rem 0; 28 | } 29 | 30 | .markdown-content th, 31 | .markdown-content td { 32 | border: 1px solid #4a5568; 33 | padding: 0.5rem; 34 | text-align: left; 35 | } 36 | 37 | .markdown-content th { 38 | background-color: #2d3748; 39 | } 40 | 41 | .markdown-content hr { 42 | border: 0; 43 | border-top: 1px solid #4a5568; 44 | margin: 1rem 0; 45 | } 46 | 47 | .markdown.prose > :first-child { 48 | margin-top: 0 !important; 49 | } 50 | 51 | .markdown.prose > * { 52 | margin-top: 1rem !important; 53 | margin-bottom: 1rem !important; 54 | } 55 | 56 | .markdown.prose > :last-child { 57 | margin-bottom: 0 !important; 58 | } -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bytestash-client", 3 | "version": "0.1.0", 4 | "license": "ISC", 5 | "dependencies": { 6 | "@monaco-editor/react": "^4.6.0", 7 | "date-fns": "^4.1.0", 8 | "framer-motion": "^11.11.9", 9 | "jszip": "^3.10.1", 10 | "lucide-react": "^0.513.0", 11 | "monaco-editor": "^0.52.0", 12 | "parse-duration": "^1.1.0", 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "react-markdown": "^9.0.1", 16 | "react-router-dom": "^6.28.0", 17 | "react-syntax-highlighter": "^15.6.1", 18 | "vite-plugin-monaco-editor": "^1.1.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20.11.28", 22 | "@types/prismjs": "^1.26.5", 23 | "@types/react": "^18.3.11", 24 | "@types/react-dom": "^18.3.1", 25 | "@types/react-syntax-highlighter": "^15.5.13", 26 | "@vitejs/plugin-react": "^4.5.1", 27 | "autoprefixer": "^10.4.20", 28 | "postcss": "^8.4.47", 29 | "prismjs": "^1.30.0", 30 | "tailwindcss": "^3.4.14", 31 | "typescript": "^4.9.5", 32 | "vite": "^6.3.5" 33 | }, 34 | "scripts": { 35 | "start": "vite", 36 | "build": "tsc && vite build", 37 | "preview": "vite preview" 38 | }, 39 | "engines": { 40 | "node": ">=22" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/utils/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from './apiClient'; 2 | import type { AuthResponse, AuthConfig } from '../../types/user'; 3 | import { API_ENDPOINTS } from '../../constants/api'; 4 | 5 | export const getAuthConfig = async () => { 6 | return apiClient.get(`${API_ENDPOINTS.AUTH}/config`); 7 | }; 8 | 9 | export const verifyToken = async () => { 10 | return apiClient.get<{ valid: boolean; user?: any }>(`${API_ENDPOINTS.AUTH}/verify`, { requiresAuth: true }); 11 | }; 12 | 13 | export const login = async (username: string, password: string): Promise => { 14 | return apiClient.post(`${API_ENDPOINTS.AUTH}/login`, { username, password }); 15 | }; 16 | 17 | export const register = async (username: string, password: string): Promise => { 18 | return apiClient.post(`${API_ENDPOINTS.AUTH}/register`, { username, password }); 19 | }; 20 | 21 | export const anonymous = async (): Promise => { 22 | return apiClient.post(`${API_ENDPOINTS.AUTH}/anonymous`, {}); 23 | } 24 | 25 | export const changePassword = async (currentPassword: string, newPassword: string): Promise<{ success: boolean; message: string }> => { 26 | return apiClient.post<{ success: boolean; message: string }>(`${API_ENDPOINTS.AUTH}/change-password`, { 27 | currentPassword, 28 | newPassword 29 | }, { requiresAuth: true }); 30 | }; 31 | -------------------------------------------------------------------------------- /server/src/config/migrations/20241120-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from '../../logger.js'; 2 | 3 | function needsMigration(db) { 4 | try { 5 | const hasOIDCColumns = db.prepare(` 6 | SELECT COUNT(*) as count 7 | FROM pragma_table_info('users') 8 | WHERE name IN ('oidc_id', 'oidc_provider', 'email', 'name') 9 | `).get(); 10 | 11 | return hasOIDCColumns.count !== 4; 12 | } catch (error) { 13 | Logger.error('v1.5.0-oidc - Error checking migration status:', error); 14 | throw error; 15 | } 16 | } 17 | 18 | async function up_v1_5_0_oidc(db) { 19 | if (!needsMigration(db)) { 20 | Logger.debug('v1.5.0-oidc - Migration not needed'); 21 | return; 22 | } 23 | 24 | Logger.debug('v1.5.0-oidc - Starting migration...'); 25 | 26 | try { 27 | db.exec(` 28 | ALTER TABLE users ADD COLUMN oidc_id TEXT; 29 | ALTER TABLE users ADD COLUMN oidc_provider TEXT; 30 | ALTER TABLE users ADD COLUMN email TEXT; 31 | ALTER TABLE users ADD COLUMN name TEXT; 32 | 33 | CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc 34 | ON users(oidc_id, oidc_provider) 35 | WHERE oidc_id IS NOT NULL AND oidc_provider IS NOT NULL; 36 | `); 37 | 38 | Logger.debug('v1.5.0-oidc - Migration completed successfully'); 39 | } catch (error) { 40 | Logger.error('v1.5.0-oidc - Migration failed:', error); 41 | throw error; 42 | } 43 | } 44 | 45 | export { up_v1_5_0_oidc }; -------------------------------------------------------------------------------- /client/src/components/snippets/list/SnippetRecycleCardMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Trash2,ArchiveRestore } from 'lucide-react'; 3 | import { IconButton } from '../../common/buttons/IconButton'; 4 | 5 | interface SnippetRecycleCardMenuProps { 6 | onRestore: (e: React.MouseEvent) => void; 7 | onDelete: (e: React.MouseEvent) => void; 8 | } 9 | 10 | const SnippetRecycleCardMenu: React.FC = ({ 11 | onDelete, 12 | onRestore, 13 | }) => { 14 | return ( 15 |
16 | } 18 | onClick={(e: React.MouseEvent) => { 19 | e.stopPropagation(); 20 | onRestore(e); 21 | }} 22 | variant="custom" 23 | size="sm" 24 | className="bg-light-hover dark:bg-dark-hover hover:bg-light-hover-more dark:hover:bg-dark-hover-more" 25 | label="Restore snippet" 26 | /> 27 | } 29 | onClick={(e: React.MouseEvent) => { 30 | e.stopPropagation(); 31 | onDelete(e); 32 | }} 33 | variant="custom" 34 | size="sm" 35 | className="bg-light-hover dark:bg-dark-hover hover:bg-light-hover-more dark:hover:bg-dark-hover-more" 36 | label="Delete snippet" 37 | /> 38 |
39 | ); 40 | }; 41 | 42 | export default SnippetRecycleCardMenu; 43 | -------------------------------------------------------------------------------- /server/src/routes/embedRoutes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import shareRepository from '../repositories/shareRepository.js'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/:shareId', async (req, res) => { 7 | try { 8 | const { shareId } = req.params; 9 | const { showTitle, showDescription, fragmentIndex } = req.query; 10 | 11 | const snippet = await shareRepository.getShare(shareId); 12 | if (!snippet) { 13 | return res.status(404).json({ error: 'Snippet not found' }); 14 | } 15 | 16 | if (snippet.share.expired) { 17 | return res.status(404).json({ error: 'Share link has expired' }); 18 | } 19 | 20 | if (snippet.share.requiresAuth && !req.user) { 21 | return res.status(401).json({ error: 'Authentication required' }); 22 | } 23 | 24 | const embedData = { 25 | id: snippet.id, 26 | title: showTitle === 'true' ? snippet.title : undefined, 27 | description: showDescription === 'true' ? snippet.description : undefined, 28 | language: snippet.language, 29 | fragments: fragmentIndex !== undefined ? 30 | [snippet.fragments[parseInt(fragmentIndex, 10)]] : 31 | snippet.fragments, 32 | created_at: snippet.created_at, 33 | updated_at: snippet.updated_at 34 | }; 35 | 36 | res.json(embedData); 37 | } catch (error) { 38 | console.error('Error in embed route:', error); 39 | res.status(500).json({ error: 'Internal server error' }); 40 | } 41 | }); 42 | 43 | export default router; 44 | -------------------------------------------------------------------------------- /client/src/utils/api/share.ts: -------------------------------------------------------------------------------- 1 | import { shareService } from '../../service/shareService'; 2 | import type { Share, ShareSettings, Snippet } from '../../types/snippets'; 3 | import { createCustomEvent, EVENTS } from '../../constants/events'; 4 | 5 | export const createShare = async ( 6 | snippetId: string, 7 | settings: ShareSettings 8 | ): Promise => { 9 | try { 10 | const share = await shareService.createShare(snippetId, settings); 11 | window.dispatchEvent(createCustomEvent(EVENTS.SHARE_CREATED)); 12 | return share; 13 | } catch (error) { 14 | console.error('Error creating share:', error); 15 | throw error; 16 | } 17 | }; 18 | 19 | export const getSharesBySnippetId = async (snippetId: string): Promise => { 20 | try { 21 | return await shareService.getSharesBySnippetId(snippetId); 22 | } catch (error) { 23 | console.error('Error fetching shares:', error); 24 | throw error; 25 | } 26 | }; 27 | 28 | export const deleteShare = async (shareId: string): Promise => { 29 | try { 30 | await shareService.deleteShare(shareId); 31 | window.dispatchEvent(createCustomEvent(EVENTS.SHARE_DELETED)); 32 | } catch (error) { 33 | console.error('Error deleting share:', error); 34 | throw error; 35 | } 36 | }; 37 | 38 | export const getSharedSnippet = async (shareId: string): Promise => { 39 | try { 40 | return await shareService.getSharedSnippet(shareId); 41 | } catch (error) { 42 | console.error('Error fetching shared snippet:', error); 43 | throw error; 44 | } 45 | }; -------------------------------------------------------------------------------- /client/src/components/common/buttons/RawButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Check, Code } from 'lucide-react'; 3 | import { basePath } from '../../../utils/api/basePath'; 4 | export interface RawButtonProps { 5 | isPublicView: boolean; 6 | snippetId: string; 7 | fragmentId: string; 8 | } 9 | 10 | const RawButton: React.FC = ({ isPublicView, snippetId, fragmentId }) => { 11 | const [isOpenRaw, setIsOpenRaw] = useState(false); 12 | 13 | const handleOpenRaw = async (e: React.MouseEvent) => { 14 | e.stopPropagation(); 15 | try { 16 | if (isPublicView) { 17 | window.open(`${basePath}/api/public/snippets/${snippetId}/${fragmentId}/raw`, '_blank'); 18 | } else { 19 | window.open(`${basePath}/api/snippets/${snippetId}/${fragmentId}/raw`, '_blank'); 20 | } 21 | } catch (err) { 22 | console.error('Failed to open raw: ', err); 23 | } 24 | 25 | setIsOpenRaw(true); 26 | setTimeout(() => setIsOpenRaw(false), 2000); 27 | }; 28 | 29 | return ( 30 | 42 | ); 43 | }; 44 | 45 | export default RawButton; -------------------------------------------------------------------------------- /server/src/config/migrations/20241121-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from '../../logger.js'; 2 | 3 | function needsMigration(db) { 4 | try { 5 | const hasNormalizedColumn = db.prepare(` 6 | SELECT COUNT(*) as count 7 | FROM pragma_table_info('users') 8 | WHERE name = 'username_normalized' 9 | `).get(); 10 | 11 | return hasNormalizedColumn.count === 0; 12 | } catch (error) { 13 | Logger.error('v1.5.0-usernames - Error checking migration status:', error); 14 | throw error; 15 | } 16 | } 17 | 18 | async function up_v1_5_0_usernames(db) { 19 | if (!needsMigration(db)) { 20 | Logger.debug('v1.5.0-usernames - Migration not needed'); 21 | return; 22 | } 23 | 24 | Logger.debug('v1.5.0-usernames - Starting migration...'); 25 | 26 | try { 27 | db.transaction(() => { 28 | db.exec(` 29 | ALTER TABLE users ADD COLUMN username_normalized TEXT; 30 | CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_normalized 31 | ON users(username_normalized COLLATE NOCASE); 32 | `); 33 | 34 | const users = db.prepare('SELECT id, username FROM users').all(); 35 | const updateStmt = db.prepare( 36 | 'UPDATE users SET username_normalized = ? WHERE id = ?' 37 | ); 38 | 39 | for (const user of users) { 40 | updateStmt.run(user.username.toLowerCase(), user.id); 41 | } 42 | })(); 43 | 44 | Logger.debug('v1.5.0-usernames - Migration completed successfully'); 45 | } catch (error) { 46 | Logger.error('v1.5.0-usernames - Migration failed:', error); 47 | throw error; 48 | } 49 | } 50 | 51 | export { up_v1_5_0_usernames }; -------------------------------------------------------------------------------- /server/src/config/migrations/20241122-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from '../../logger.js'; 2 | 3 | function needsMigration(db) { 4 | try { 5 | const tableExists = db.prepare(` 6 | SELECT COUNT(*) as count 7 | FROM sqlite_master 8 | WHERE type='table' AND name='api_keys' 9 | `).get(); 10 | 11 | return tableExists.count === 0; 12 | } catch (error) { 13 | Logger.error('v1.5.1-api-keys - Error checking migration status:', error); 14 | throw error; 15 | } 16 | } 17 | 18 | function up_v1_5_1_api_keys(db) { 19 | if (!needsMigration(db)) { 20 | Logger.debug('v1.5.1-api-keys - Migration not needed'); 21 | return; 22 | } 23 | 24 | Logger.debug('v1.5.1-api-keys - Starting migration...'); 25 | 26 | try { 27 | db.transaction(() => { 28 | db.exec(` 29 | CREATE TABLE api_keys ( 30 | id INTEGER PRIMARY KEY AUTOINCREMENT, 31 | user_id INTEGER NOT NULL, 32 | key TEXT NOT NULL UNIQUE, 33 | name TEXT NOT NULL, 34 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 35 | last_used_at DATETIME, 36 | is_active BOOLEAN DEFAULT TRUE, 37 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 38 | ); 39 | 40 | CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); 41 | CREATE INDEX idx_api_keys_key ON api_keys(key); 42 | `); 43 | })(); 44 | 45 | Logger.debug('v1.5.1-api-keys - Migration completed successfully'); 46 | } catch (error) { 47 | Logger.error('v1.5.1-api-keys - Migration failed:', error); 48 | throw error; 49 | } 50 | } 51 | 52 | export { up_v1_5_1_api_keys }; 53 | -------------------------------------------------------------------------------- /helm-charts/bytestash/README.md: -------------------------------------------------------------------------------- 1 | # ByteStash Helm Chart for Kubernetes 2 | 3 | ## Before you begin 4 | 5 | This [Helm](https://github.com/kubernetes/helm) chart supports installation of [ByteStash](https://github.com/jordan-dalby/ByteStash) - A code snippet storage solution written in React & node.js 6 | 7 | The prerequisites for this Helm chart is a working **Kubernetes Cluster** and **Helm** installed. 8 | 9 | If you don't have a Kubernetes Cluster, create one with [minikube](https://minikube.sigs.k8s.io/docs/start/). 10 | 11 | To install Helm, see [Helm Installation guide](https://helm.sh/docs/intro/install/). 12 | 13 |
14 | 15 | ## Installation and Configuration 16 | 17 | To add the ByteStash helm repository, run command: 18 | 19 | ```bash 20 | helm repo add bytestash https://jordan-dalby.github.io/ByteStash/ 21 | ``` 22 | 23 | To install the ByteStash helm chart with a release name `my-release` in `ns` namespace, run command: 24 | 25 | ```bash 26 | helm install -n ns --create-namespace my-release bytestash/bytestash 27 | ``` 28 | 29 | To update latest changes of the charts from the Helm repository, run commands: 30 | 31 | ```bash 32 | helm repo update 33 | 34 | helm -n ns upgrade my-release bytestash/bytestash 35 | 36 | ``` 37 | 38 | To configure the Helm chart deployment, the configurable parameters can be found in `values.yaml` values file. Those parameters can be set via `--set` flag during installation or configured by editing the `values.yaml` directly. An example configuration can be found at [example](./.example.yaml) 39 | 40 | To uninstall/delete the `my-release` deployment, run command: 41 | 42 | ```bash 43 | helm delete my-release 44 | ``` 45 | -------------------------------------------------------------------------------- /client/src/components/common/buttons/DownloadButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Download } from "lucide-react"; 3 | import { downloadFragment } from "../../../utils/downloadUtils"; 4 | import { useToast } from "../../../hooks/useToast"; 5 | 6 | interface DownloadButtonProps { 7 | code: string; 8 | fileName: string; 9 | language: string; 10 | className?: string; 11 | } 12 | 13 | const DownloadButton: React.FC = ({ 14 | code, 15 | fileName, 16 | language, 17 | className = "", 18 | }) => { 19 | const { addToast } = useToast(); 20 | 21 | const handleDownload = (e: React.MouseEvent) => { 22 | try { 23 | e.stopPropagation(); 24 | e.preventDefault(); 25 | 26 | if (!code || !fileName) { 27 | addToast("Nothing to download", "warning"); 28 | return; 29 | } 30 | 31 | downloadFragment(code, fileName, language); 32 | addToast(`"${fileName}" downloaded successfully`, "success"); 33 | } catch (error) { 34 | console.error("Download failed:", error); 35 | addToast("Failed to download file", "error"); 36 | } 37 | }; 38 | 39 | return ( 40 | 48 | ); 49 | }; 50 | 51 | export default DownloadButton; 52 | -------------------------------------------------------------------------------- /client/src/components/auth/oidc/OIDCCallback.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { useAuth } from '../../../hooks/useAuth'; 4 | import { Loader2 } from 'lucide-react'; 5 | import { PageContainer } from '../../common/layout/PageContainer'; 6 | import { useToast } from '../../../hooks/useToast'; 7 | import { handleOIDCError } from '../../../utils/oidcErrorHandler'; 8 | 9 | export const OIDCCallback: React.FC = () => { 10 | const navigate = useNavigate(); 11 | const { login } = useAuth(); 12 | const { addToast } = useToast(); 13 | 14 | useEffect(() => { 15 | const params = new URLSearchParams(window.location.search); 16 | const token = params.get('token'); 17 | const error = params.get('error'); 18 | const message = params.get('message'); 19 | 20 | if (token) { 21 | login(token, null); 22 | navigate('/', { replace: true }); 23 | } else if (error) { 24 | handleOIDCError(error, addToast, undefined, message || undefined); 25 | navigate('/login', { replace: true }); 26 | } else { 27 | handleOIDCError('auth_failed', addToast); 28 | navigate('/login', { replace: true }); 29 | } 30 | }, [login, navigate, addToast]); 31 | 32 | return ( 33 | 34 |
35 |
36 | 37 | Completing sign in... 38 |
39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image for Pull Request Testing 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: read 11 | packages: write 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Login to GitHub Container Registry 18 | uses: docker/login-action@v3 19 | with: 20 | registry: ghcr.io 21 | username: ${{ github.actor }} 22 | password: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | - name: Set ghcr repository name 25 | id: set-ghcr-repository 26 | run: | 27 | ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }') 28 | echo "ghcr-repository=${ghcr_name}" >> $GITHUB_OUTPUT 29 | 30 | - name: Set Docker image metadata 31 | id: docker-metadata 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: | 35 | ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }} 36 | tags: | 37 | type=ref,event=pr 38 | 39 | - name: Set up QEMU for cross-platform builds 40 | uses: docker/setup-qemu-action@v3 41 | 42 | - name: Set up Docker Buildx 43 | uses: docker/setup-buildx-action@v3 44 | with: 45 | install: true 46 | 47 | - name: Build and Push Docker image for Testing 48 | uses: docker/build-push-action@v5 49 | with: 50 | context: . 51 | push: ${{ github.event.pull_request.head.repo.full_name == github.repository }} 52 | platforms: linux/amd64 53 | tags: ${{ steps.docker-metadata.outputs.tags }} 54 | labels: ${{ steps.docker-metadata.outputs.labels }} -------------------------------------------------------------------------------- /server/src/config/migrations/20241119-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from '../../logger.js'; 2 | 3 | function needsMigration(db) { 4 | try { 5 | const hasPublicColumn = db.prepare(` 6 | SELECT COUNT(*) as count 7 | FROM pragma_table_info('snippets') 8 | WHERE name = 'is_public' 9 | `).get(); 10 | 11 | if (hasPublicColumn.count === 0) { 12 | Logger.debug('v1.5.0-public - Snippets table missing is_public column, migration needed'); 13 | return true; 14 | } 15 | 16 | const hasPublicIndex = db.prepare(` 17 | SELECT COUNT(*) as count 18 | FROM sqlite_master 19 | WHERE type='index' AND name='idx_snippets_is_public' 20 | `).get(); 21 | 22 | if (hasPublicIndex.count === 0) { 23 | Logger.debug('v1.5.0-public - Missing is_public index, migration needed'); 24 | return true; 25 | } 26 | 27 | Logger.debug('v1.5.0-public - Database schema is up to date, no migration needed'); 28 | return false; 29 | } catch (error) { 30 | Logger.error('v1.5.0-public - Error checking migration status:', error); 31 | throw error; 32 | } 33 | } 34 | 35 | async function up_v1_5_0_public(db) { 36 | if (!needsMigration(db)) { 37 | Logger.debug('v1.5.0-public - Migration is not needed, database is up to date'); 38 | return; 39 | } 40 | 41 | Logger.debug('v1.5.0-public - Starting migration: Adding public snippets support...'); 42 | 43 | try { 44 | db.exec(` 45 | ALTER TABLE snippets ADD COLUMN is_public BOOLEAN DEFAULT FALSE; 46 | CREATE INDEX idx_snippets_is_public ON snippets(is_public); 47 | `); 48 | 49 | Logger.debug('v1.5.0-public - Migration completed successfully'); 50 | } catch (error) { 51 | Logger.error('v1.5.0-public - Migration failed:', error); 52 | throw error; 53 | } 54 | } 55 | 56 | export { up_v1_5_0_public }; -------------------------------------------------------------------------------- /helm-charts/bytestash/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "bytestash.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "bytestash.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "bytestash.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "bytestash.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /client/src/components/common/modals/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Modal from './Modal'; 3 | 4 | export interface ConfirmationModalProps { 5 | isOpen: boolean; 6 | onClose: () => void; 7 | onConfirm: () => void; 8 | title: string; 9 | message: string; 10 | confirmLabel?: string; 11 | cancelLabel?: string; 12 | variant?: 'danger' | 'warning' | 'info'; 13 | } 14 | 15 | export const ConfirmationModal: React.FC = ({ 16 | isOpen, 17 | onClose, 18 | onConfirm, 19 | title, 20 | message, 21 | confirmLabel = 'Confirm', 22 | cancelLabel = 'Cancel', 23 | variant = 'danger' 24 | }) => { 25 | const variantClasses = { 26 | danger: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800', 27 | warning: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800', 28 | info: 'bg-light-primary hover:opacity-90 dark:bg-dark-primary dark:hover:opacity-90' 29 | }; 30 | 31 | return ( 32 | 33 |
34 |

{message}

35 |
36 | 42 | 48 |
49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /server/src/routes/apiKeyRoutes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { createApiKey, getApiKeys, deleteApiKey } from '../repositories/apiKeyRepository.js'; 3 | import Logger from '../logger.js'; 4 | 5 | const router = express.Router(); 6 | 7 | // List all API keys for the authenticated user 8 | router.get('/', async (req, res) => { 9 | try { 10 | const keys = getApiKeys(req.user.id); 11 | res.json(keys); 12 | } catch (error) { 13 | Logger.error('Error fetching API keys:', error); 14 | res.status(500).json({ error: 'Failed to fetch API keys' }); 15 | } 16 | }); 17 | 18 | // Create a new API key 19 | router.post('/', async (req, res) => { 20 | try { 21 | const { name } = req.body; 22 | 23 | if (!name) { 24 | return res.status(400).json({ error: 'Name is required' }); 25 | } 26 | 27 | const apiKey = createApiKey(req.user.id, name); 28 | 29 | if (!apiKey) { 30 | return res.status(500).json({ error: 'Failed to create API key' }); 31 | } 32 | 33 | Logger.debug(`User ${req.user.id} created new API key`); 34 | res.status(201).json(apiKey); 35 | } catch (error) { 36 | Logger.error('Error creating API key:', error); 37 | res.status(500).json({ error: 'Failed to create API key' }); 38 | } 39 | }); 40 | 41 | // Delete an API key 42 | router.delete('/:id', async (req, res) => { 43 | try { 44 | const success = deleteApiKey(req.user.id, req.params.id); 45 | 46 | if (!success) { 47 | return res.status(404).json({ error: 'API key not found' }); 48 | } 49 | 50 | Logger.debug(`User ${req.user.id} deleted API key ${req.params.id}`); 51 | res.json({ sucess: success }); 52 | } catch (error) { 53 | Logger.error('Error deleting API key:', error); 54 | res.status(500).json({ error: 'Failed to delete API key' }); 55 | } 56 | }); 57 | 58 | export default router; 59 | -------------------------------------------------------------------------------- /client/src/components/common/buttons/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Copy, Check } from 'lucide-react'; 3 | 4 | export interface CopyButtonProps { 5 | text: string; 6 | } 7 | 8 | const CopyButton: React.FC = ({ text }) => { 9 | const [isCopied, setIsCopied] = useState(false); 10 | 11 | const handleCopy = async (e: React.MouseEvent) => { 12 | e.stopPropagation(); 13 | try { 14 | if (navigator.clipboard && window.isSecureContext) { 15 | await navigator.clipboard.writeText(text); 16 | } else { 17 | const textArea = document.createElement('textarea'); 18 | textArea.value = text; 19 | textArea.style.position = 'fixed'; 20 | textArea.style.left = '-999999px'; 21 | textArea.style.top = '-999999px'; 22 | document.body.appendChild(textArea); 23 | textArea.focus(); 24 | textArea.select(); 25 | 26 | try { 27 | document.execCommand('copy'); 28 | } finally { 29 | textArea.remove(); 30 | } 31 | } 32 | 33 | setIsCopied(true); 34 | setTimeout(() => setIsCopied(false), 2000); 35 | } catch (err) { 36 | console.error('Failed to copy text: ', err); 37 | } 38 | }; 39 | 40 | return ( 41 | 53 | ); 54 | }; 55 | 56 | export default CopyButton; -------------------------------------------------------------------------------- /client/src/utils/oidcErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { ToastType } from '../contexts/ToastContext'; 2 | 3 | interface OIDCErrorConfig { 4 | message: string; 5 | type: ToastType; 6 | duration?: number | null; 7 | } 8 | 9 | const OIDC_ERROR_CONFIGS: Record = { 10 | auth_failed: { 11 | message: "Authentication failed. This could be due to a cancelled login attempt or an expired session. Please try again.", 12 | type: 'error', 13 | duration: 8000 14 | }, 15 | registration_disabled: { 16 | message: "New account registration is currently disabled on this ByteStash instance. Please contact your administrator.", 17 | type: 'error', 18 | duration: null 19 | }, 20 | provider_error: { 21 | message: "The identity provider encountered an error or is unavailable. Please try again later or contact your administrator.", 22 | type: 'error', 23 | duration: 8000 24 | }, 25 | config_error: { 26 | message: "There was an error with the SSO configuration. Please contact your administrator.", 27 | type: 'error', 28 | duration: null 29 | }, 30 | default: { 31 | message: "An unexpected error occurred during authentication. Please try again.", 32 | type: 'error', 33 | duration: 8000 34 | } 35 | }; 36 | 37 | export const handleOIDCError = ( 38 | error: string, 39 | addToast: (message: string, type: ToastType, duration?: number | null) => void, 40 | providerName?: string, 41 | additionalMessage?: string 42 | ) => { 43 | const config = OIDC_ERROR_CONFIGS[error] || OIDC_ERROR_CONFIGS.default; 44 | let message = config.message; 45 | 46 | if (providerName) { 47 | message = message.replace('identity provider', providerName); 48 | } 49 | 50 | if (additionalMessage) { 51 | message = `${message}\n\nError details: ${additionalMessage}`; 52 | } 53 | 54 | addToast(message, config.type, config.duration); 55 | }; -------------------------------------------------------------------------------- /helm-charts/bytestash/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "bytestash.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "bytestash.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | - host: {{ .Values.ingress.host | quote }} 41 | http: 42 | paths: 43 | - path: {{ .Values.ingress.path }} 44 | pathType: {{ .Values.ingress.pathType }} 45 | backend: 46 | service: 47 | name: {{ $fullName }} 48 | port: 49 | number: {{ $svcPort }} 50 | {{- end }} 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image on Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Login to GitHub Container Registry 20 | uses: docker/login-action@v3 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Set ghcr repository name 27 | id: set-ghcr-repository 28 | run: | 29 | ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }') 30 | echo "ghcr-repository=${ghcr_name}" >> $GITHUB_OUTPUT 31 | 32 | - name: Set Docker image metadata 33 | id: docker-metadata 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: | 37 | ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }} 38 | name=docker.io/${{ github.repository }},enable=false 39 | tags: | 40 | type=semver,pattern={{version}} 41 | type=semver,pattern={{major}}.{{minor}} 42 | 43 | - name: Set up QEMU for cross-platform builds 44 | uses: docker/setup-qemu-action@v3 45 | 46 | - name: Set up Docker Buildx 47 | uses: docker/setup-buildx-action@v3 48 | with: 49 | install: true 50 | 51 | - name: Build and Push Docker image 52 | uses: docker/build-push-action@v5 53 | with: 54 | context: . 55 | push: true 56 | platforms: linux/amd64,linux/arm64,linux/arm/v7 57 | tags: ${{ steps.docker-metadata.outputs.tags }} 58 | labels: ${{ steps.docker-metadata.outputs.labels }} 59 | -------------------------------------------------------------------------------- /server/src/routes/publicRoutes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import snippetService from '../services/snippetService.js'; 3 | import Logger from '../logger.js'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (req, res) => { 8 | try { 9 | const snippets = await snippetService.getAllPublicSnippets(); 10 | res.json(snippets); 11 | } catch (error) { 12 | Logger.error('Error in GET /public/snippets:', error); 13 | res.status(500).json({ error: 'Internal server error' }); 14 | } 15 | }); 16 | 17 | // Raw public snippet endpoint for plain text access 18 | router.get('/:id/:fragmentId/raw', async (req, res) => { 19 | try { 20 | const { id, fragmentId } = req.params; 21 | const snippet = await snippetService.findById(id); 22 | if (!snippet) { 23 | res.status(404).send('Snippet not found'); 24 | } else { 25 | const fragment = snippet.fragments.find(fragment => fragment.id === parseInt(fragmentId)); 26 | if (!fragment) { 27 | res.status(404).send('Fragment not found'); 28 | } else { 29 | res.set('Content-Type', 'text/plain; charset=utf-8'); 30 | // Remove carriage returns to fix bash script execution issues 31 | const normalizedCode = fragment.code.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); 32 | res.send(normalizedCode); 33 | } 34 | } 35 | } catch (error) { 36 | Logger.error('Error in GET /public/snippets/:id/raw:', error); 37 | res.status(500).send('Internal server error'); 38 | } 39 | }); 40 | 41 | router.get('/:id', async (req, res) => { 42 | try { 43 | const snippet = await snippetService.findById(req.params.id); 44 | if (!snippet) { 45 | res.status(404).json({ error: 'Snippet not found' }); 46 | } else { 47 | res.json(snippet); 48 | } 49 | } catch (error) { 50 | Logger.error('Error in GET /public/snippets/:id:', error); 51 | res.status(500).json({ error: 'Internal server error' }); 52 | } 53 | }); 54 | 55 | export default router; -------------------------------------------------------------------------------- /helm-charts/bytestash/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "bytestash.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "bytestash.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "bytestash.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "bytestash.labels" -}} 37 | helm.sh/chart: {{ include "bytestash.chart" . }} 38 | {{ include "bytestash.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "bytestash.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "bytestash.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "bytestash.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "bytestash.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "5000:5000" 8 | environment: 9 | # e.g. write /bytestash for a domain such as my.domain/bytestash, leave blank in every other case 10 | - BASE_PATH= 11 | # optionally include additional allowed hosts for reverse proxies 12 | # e.g. localhost,my.domain.com,my.domain.net 13 | - ALLOWED_HOSTS= 14 | # Either provide JWT_SECRET directly or use JWT_SECRET_FILE for Docker secrets 15 | #- JWT_SECRET_FILE=/run/secrets/jwt 16 | - JWT_SECRET=your-secret 17 | # how long the token lasts, examples: "2 days", "10h", "7d", "1m", "60s" 18 | - TOKEN_EXPIRY=24h 19 | # is this bytestash instance open to new accounts being created? 20 | - ALLOW_NEW_ACCOUNTS=true 21 | # Should debug mode be enabled? Essentially enables logging, in most cases leave this as false 22 | - DEBUG=false 23 | # Should we use accounts at all? When enabled, it will be like starting a fresh account so export your snippets, no login required 24 | - DISABLE_ACCOUNTS=false 25 | # Should internal accounts be disabled? 26 | - DISABLE_INTERNAL_ACCOUNTS=false 27 | # Allow password changes (false by default) 28 | - ALLOW_PASSWORD_CHANGES=true 29 | 30 | # Optional: Enable OIDC for Single Sign On 31 | - OIDC_ENABLED=true 32 | # Optional: Display name for users signing in with SSO, will default to Single Sign-on 33 | - OIDC_DISPLAY_NAME= 34 | # Your OIDC issuer url, e.g. https://authentik.mydomain.com/application/o/bytestash/ for authentik 35 | - OIDC_ISSUER_URL= 36 | # Your OIDC client ID, you can find it in your app provider 37 | - OIDC_CLIENT_ID= 38 | # Your OIDC client secret, again, found in the app provider 39 | - OIDC_CLIENT_SECRET= 40 | # The OIDC scopes to request, e.g. "openid profile email groups" 41 | - OIDC_SCOPES= 42 | volumes: 43 | - ./data:/data/snippets 44 | # Uncomment to use docker secrets 45 | # secrets: 46 | # - jwt 47 | 48 | #secrets: 49 | # jwt: 50 | # file: ./secrets/jwt.txt -------------------------------------------------------------------------------- /client/src/components/common/buttons/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | 3 | export interface IconButtonProps { 4 | icon: React.ReactNode; 5 | onClick: (e: React.MouseEvent) => void; 6 | label?: string; 7 | variant?: 'primary' | 'secondary' | 'danger' | 'action' | 'custom'; 8 | size?: 'sm' | 'md' | 'lg'; 9 | disabled?: boolean; 10 | className?: string; 11 | type?: 'button' | 'submit' | 'reset'; 12 | showLabel?: boolean; 13 | } 14 | 15 | export const IconButton = forwardRef(({ 16 | icon, 17 | onClick, 18 | label, 19 | variant = 'secondary', 20 | size = 'md', 21 | disabled = false, 22 | className = '', 23 | type = 'button', 24 | showLabel = false 25 | }, ref) => { 26 | const baseClasses = 'flex items-center justify-center gap-2 rounded-md transition-colors'; 27 | const variantClasses = { 28 | primary: 'bg-light-hover dark:bg-dark-hover hover:bg-light-hover dark:hover:bg-dark-hover text-light-text dark:text-dark-text', 29 | secondary: 'bg-light-surface dark:bg-dark-surface hover:bg-light-hover dark:hover:bg-dark-hover text-light-text dark:text-dark-text', 30 | danger: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white', 31 | action: 'bg-light-primary dark:bg-dark-primary hover:opacity-90 text-white', 32 | custom: '' 33 | }; 34 | const sizeClasses = { 35 | sm: label ? 'p-1.5 text-sm' : 'p-1.5', 36 | md: label ? 'p-2 text-base' : 'p-2', 37 | lg: label ? 'p-3 text-lg' : 'p-3' 38 | }; 39 | 40 | const handleClick = (e: React.MouseEvent) => { 41 | e.preventDefault(); 42 | onClick(e); 43 | }; 44 | 45 | return ( 46 | 60 | ); 61 | }); 62 | 63 | IconButton.displayName = 'IconButton'; 64 | -------------------------------------------------------------------------------- /client/src/hooks/useKeyboardShortcut.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | /** 4 | * Options for the useKeyboardShortcut hook 5 | */ 6 | interface UseKeyboardShortcutOptions { 7 | /** The keyboard key to listen for (e.g., '/', 'Escape', 'Enter') */ 8 | key: string; 9 | /** Function to call when the key is pressed */ 10 | callback: () => void; 11 | /** Whether the shortcut is enabled (default: true) */ 12 | enabled?: boolean; 13 | /** Whether to prevent the default browser behavior (default: true) */ 14 | preventDefault?: boolean; 15 | } 16 | 17 | /** 18 | * Custom hook for handling global keyboard shortcuts 19 | * 20 | * @param options - Configuration options for the keyboard shortcut 21 | * @returns void 22 | * 23 | * @example 24 | * ```tsx 25 | * useKeyboardShortcut({ 26 | * key: '/', 27 | * callback: () => focusSearchInput(), 28 | * enabled: true, 29 | * preventDefault: true 30 | * }); 31 | * ``` 32 | */ 33 | export const useKeyboardShortcut = ({ 34 | key, 35 | callback, 36 | enabled = true, 37 | preventDefault = true, 38 | }: UseKeyboardShortcutOptions) => { 39 | const callbackRef = useRef(callback); 40 | 41 | // Keep callback ref up to date 42 | useEffect(() => { 43 | callbackRef.current = callback; 44 | }, [callback]); 45 | 46 | useEffect(() => { 47 | if (!enabled) return; 48 | 49 | const handleKeyDown = (event: KeyboardEvent) => { 50 | // Check if the pressed key matches our target key 51 | if (event.key === key) { 52 | // Don't trigger if user is typing in an input, textarea, or contenteditable 53 | const target = event.target as HTMLElement; 54 | const isTyping = 55 | target.tagName === 'INPUT' || 56 | target.tagName === 'TEXTAREA' || 57 | target.contentEditable === 'true'; 58 | 59 | if (!isTyping) { 60 | if (preventDefault) { 61 | event.preventDefault(); 62 | } 63 | callbackRef.current(); 64 | } 65 | } 66 | }; 67 | 68 | document.addEventListener('keydown', handleKeyDown); 69 | return () => document.removeEventListener('keydown', handleKeyDown); 70 | }, [key, enabled, preventDefault]); 71 | }; 72 | 73 | export default useKeyboardShortcut; 74 | -------------------------------------------------------------------------------- /server/src/config/migrations/20250905-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from "../../logger.js"; 2 | 3 | function needsMigration(db) { 4 | try { 5 | const hasPinnedColumn = db 6 | .prepare( 7 | `SELECT COUNT(*) as count FROM pragma_table_info('snippets') WHERE name = 'is_pinned'` 8 | ) 9 | .get(); 10 | const hasFavoriteColumn = db 11 | .prepare( 12 | `SELECT COUNT(*) as count FROM pragma_table_info('snippets') WHERE name = 'is_favorite'` 13 | ) 14 | .get(); 15 | return hasPinnedColumn.count === 0 || hasFavoriteColumn.count === 0; 16 | } catch (error) { 17 | Logger.error( 18 | "v1.7.0-snippet-pin-favorite - Error checking migration status:", 19 | error 20 | ); 21 | throw error; 22 | } 23 | } 24 | 25 | async function up_v1_7_0_snippet_pin_favorite(db) { 26 | if (!needsMigration(db)) { 27 | Logger.debug("v1.7.0-snippet-pin-favorite - Migration not needed"); 28 | return; 29 | } 30 | 31 | Logger.debug("v1.7.0-snippet-pin-favorite - Starting migration..."); 32 | 33 | try { 34 | // Add is_pinned column if not exists 35 | const hasPinnedColumn = db 36 | .prepare( 37 | `SELECT COUNT(*) as count FROM pragma_table_info('snippets') WHERE name = 'is_pinned'` 38 | ) 39 | .get(); 40 | if (hasPinnedColumn.count === 0) { 41 | db.exec(`ALTER TABLE snippets ADD COLUMN is_pinned INTEGER DEFAULT 0;`); 42 | Logger.debug("v1.7.0-snippet-pin-favorite - Added is_pinned column"); 43 | } 44 | 45 | // Add is_favorite column if not exists 46 | const hasFavoriteColumn = db 47 | .prepare( 48 | `SELECT COUNT(*) as count FROM pragma_table_info('snippets') WHERE name = 'is_favorite'` 49 | ) 50 | .get(); 51 | if (hasFavoriteColumn.count === 0) { 52 | db.exec(`ALTER TABLE snippets ADD COLUMN is_favorite INTEGER DEFAULT 0;`); 53 | Logger.debug("v1.7.0-snippet-pin-favorite - Added is_favorite column"); 54 | } 55 | 56 | Logger.debug( 57 | "v1.7.0-snippet-pin-favorite - Migration completed successfully" 58 | ); 59 | } catch (error) { 60 | Logger.error("v1.7.0-snippet-pin-favorite - Migration failed:", error); 61 | throw error; 62 | } 63 | } 64 | 65 | export { up_v1_7_0_snippet_pin_favorite }; 66 | -------------------------------------------------------------------------------- /client/src/components/snippets/view/common/ViewSwitch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Globe, Lock } from 'lucide-react'; 3 | 4 | interface ViewSwitchProps { 5 | checked: boolean; 6 | onChange: (checked: boolean) => void; 7 | } 8 | 9 | const ViewSwitch: React.FC = ({ checked, onChange }) => { 10 | return ( 11 |
12 |
16 | 36 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default ViewSwitch; 62 | -------------------------------------------------------------------------------- /client/src/service/snippetService.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "../utils/api/apiClient"; 2 | import { Snippet } from "../types/snippets"; 3 | import { API_ENDPOINTS } from "../constants/api"; 4 | 5 | export const snippetService = { 6 | async getAllSnippets(): Promise { 7 | return apiClient.get(API_ENDPOINTS.SNIPPETS, { 8 | requiresAuth: true, 9 | }); 10 | }, 11 | 12 | async getSnippetById(id: string): Promise { 13 | return apiClient.get(`${API_ENDPOINTS.SNIPPETS}/${id}`, { 14 | requiresAuth: true, 15 | }); 16 | }, 17 | 18 | async createSnippet( 19 | snippet: Omit 20 | ): Promise { 21 | return apiClient.post(API_ENDPOINTS.SNIPPETS, snippet, { 22 | requiresAuth: true, 23 | }); 24 | }, 25 | 26 | async updateSnippet( 27 | id: string, 28 | snippet: Omit 29 | ): Promise { 30 | return apiClient.put(`${API_ENDPOINTS.SNIPPETS}/${id}`, snippet, { 31 | requiresAuth: true, 32 | }); 33 | }, 34 | 35 | async deleteSnippet(id: string): Promise { 36 | return apiClient.delete(`${API_ENDPOINTS.SNIPPETS}/${id}`, { 37 | requiresAuth: true, 38 | }); 39 | }, 40 | 41 | async getRecycleSnippets(): Promise { 42 | return apiClient.get(`${API_ENDPOINTS.SNIPPETS}/recycled`, { 43 | requiresAuth: true, 44 | }); 45 | }, 46 | 47 | async restoreSnippet(id: string): Promise { 48 | return apiClient.patch( 49 | `${API_ENDPOINTS.SNIPPETS}/${id}/restore`, 50 | {}, 51 | { requiresAuth: true } 52 | ); 53 | }, 54 | 55 | async moveToRecycleBin(id: string): Promise { 56 | return apiClient.patch( 57 | `${API_ENDPOINTS.SNIPPETS}/${id}/recycle`, 58 | {}, 59 | { requiresAuth: true } 60 | ); 61 | }, 62 | 63 | async setPinned(id: string, is_pinned: boolean): Promise { 64 | return apiClient.patch( 65 | `${API_ENDPOINTS.SNIPPETS}/${id}/pin`, 66 | { is_pinned }, 67 | { requiresAuth: true } 68 | ); 69 | }, 70 | 71 | async setFavorite(id: string, is_favorite: boolean): Promise { 72 | return apiClient.patch( 73 | `${API_ENDPOINTS.SNIPPETS}/${id}/favorite`, 74 | { is_favorite }, 75 | { requiresAuth: true } 76 | ); 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /client/src/contexts/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from 'react'; 2 | 3 | type Theme = 'light' | 'dark' | 'system'; 4 | 5 | interface ThemeContextType { 6 | theme: Theme; 7 | toggleTheme: () => void; 8 | setTheme: (theme: Theme) => void; 9 | } 10 | 11 | const ThemeContext = createContext(undefined); 12 | 13 | export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 14 | const [theme, setThemeState] = useState(() => { 15 | const savedTheme = localStorage.getItem('theme'); 16 | if (savedTheme === 'light' || savedTheme === 'dark' || savedTheme === 'system') { 17 | return savedTheme; 18 | } 19 | return 'system'; 20 | }); 21 | 22 | useEffect(() => { 23 | const root = window.document.documentElement; 24 | const effectiveTheme = theme === 'system' 25 | ? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 26 | : theme; 27 | 28 | if (effectiveTheme === 'dark') { 29 | root.classList.add('dark'); 30 | } else { 31 | root.classList.remove('dark'); 32 | } 33 | localStorage.setItem('theme', theme); 34 | }, [theme]); 35 | 36 | useEffect(() => { 37 | if (theme === 'system') { 38 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 39 | const handleChange = () => { 40 | const root = window.document.documentElement; 41 | if (mediaQuery.matches) { 42 | root.classList.add('dark'); 43 | } else { 44 | root.classList.remove('dark'); 45 | } 46 | }; 47 | 48 | mediaQuery.addEventListener('change', handleChange); 49 | return () => mediaQuery.removeEventListener('change', handleChange); 50 | } 51 | }, [theme]); 52 | 53 | const toggleTheme = () => { 54 | setThemeState(prev => prev === 'light' ? 'dark' : 'light'); 55 | }; 56 | 57 | const setTheme = (newTheme: Theme) => { 58 | setThemeState(newTheme); 59 | }; 60 | 61 | return ( 62 | 63 | {children} 64 | 65 | ); 66 | }; 67 | 68 | export const useTheme = () => { 69 | const context = useContext(ThemeContext); 70 | if (context === undefined) { 71 | throw new Error('useTheme must be used within a ThemeProvider'); 72 | } 73 | return context; 74 | }; 75 | -------------------------------------------------------------------------------- /client/src/components/snippets/view/common/StorageHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { APP_VERSION } from '../../../../constants/settings'; 4 | import ViewSwitch from './ViewSwitch'; 5 | import { ROUTES } from '../../../../constants/routes'; 6 | import { getAssetPath } from '../../../../utils/paths'; 7 | 8 | interface StorageHeaderProps { 9 | isPublicView: boolean; 10 | } 11 | 12 | const StorageHeader: React.FC = ({ isPublicView }) => { 13 | const [isTooltipVisible, setIsTooltipVisible] = useState(false); 14 | const navigate = useNavigate(); 15 | 16 | const tooltipText = isPublicView 17 | ? "You're viewing publicly shared snippets. These snippets are read-only and visible to everyone." 18 | : "You're viewing your private snippets. Only you can see and modify these snippets."; 19 | 20 | const handleViewToggle = (checked: boolean) => { 21 | navigate(checked ? ROUTES.PUBLIC_SNIPPETS : ROUTES.HOME); 22 | }; 23 | 24 | return ( 25 |
26 |

27 | ByteStash Logo 28 | ByteStash 29 | v{APP_VERSION} 30 |

31 | 32 |
setIsTooltipVisible(true)} 35 | onMouseLeave={() => setIsTooltipVisible(false)} 36 | > 37 | 38 | 39 | {isTooltipVisible && ( 40 |
48 | {tooltipText} 49 |
50 | )} 51 |
52 |
53 | ); 54 | }; 55 | 56 | export default StorageHeader; 57 | -------------------------------------------------------------------------------- /client/src/components/common/buttons/DownloadArchiveButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ArrowDownToLine } from "lucide-react"; 3 | import { downloadSnippetArchive } from "../../../utils/downloadUtils"; 4 | import { useToast } from "../../../hooks/useToast"; 5 | 6 | interface DownloadArchiveButtonProps { 7 | snippetTitle: string; 8 | fragments: Array<{ 9 | code: string; 10 | file_name: string; 11 | language: string; 12 | }>; 13 | className?: string; 14 | size?: "sm" | "md" | "lg"; 15 | variant?: "primary" | "secondary"; 16 | } 17 | 18 | export const DownloadArchiveButton: React.FC = ({ 19 | snippetTitle, 20 | fragments, 21 | className = "", 22 | size = "md", 23 | variant = "primary", 24 | }) => { 25 | const { addToast } = useToast(); 26 | 27 | const handleDownload = async () => { 28 | if (fragments.length === 0) { 29 | addToast("No fragments to download", "warning"); 30 | return; 31 | } 32 | 33 | try { 34 | await downloadSnippetArchive(snippetTitle, fragments); 35 | addToast("Downloaded all code fragments", "success"); 36 | } catch (error) { 37 | console.error("Failed to download archive:", error); 38 | addToast("Failed to download archive", "error"); 39 | } 40 | }; 41 | 42 | const sizeClasses = { 43 | sm: "px-2 py-1 text-xs", 44 | md: "px-3 py-1.5 text-sm", 45 | lg: "px-4 py-2 text-base", 46 | }; 47 | 48 | const variantClasses = { 49 | primary: "bg-blue-600 hover:bg-blue-700 text-white", 50 | secondary: 51 | "bg-light-hover dark:bg-dark-hover text-light-text dark:text-dark-text hover:bg-light-hover/80 dark:hover:bg-dark-hover/80", 52 | }; 53 | 54 | const iconSize = { 55 | sm: 12, 56 | md: 14, 57 | lg: 16, 58 | }; 59 | 60 | return ( 61 | 79 | ); 80 | }; 81 | 82 | export default DownloadArchiveButton; 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ByteStash 2 |

3 | 4 |

5 | 6 | ByteStash is a self-hosted web application designed to store, organise, and manage your code snippets efficiently. With support for creating, editing, and filtering snippets, ByteStash helps you keep track of your code in one secure place. 7 | 8 | ![ByteStash App](https://raw.githubusercontent.com/jordan-dalby/ByteStash/refs/heads/main/media/app-image.png) 9 | 10 | ## Demo 11 | Check out the [ByteStash demo](https://bytestash-demo.pikapod.net/) powered by PikaPods! 12 | Username: demo 13 | Password: demodemo 14 | 15 | ## Features 16 | - Create and Edit Snippets: Easily add new code snippets or update existing ones with an intuitive interface. 17 | - Filter by Language and Content: Quickly find the right snippet by filtering based on programming language or keywords in the content. 18 | - Secure Storage: All snippets are securely stored in a sqlite database, ensuring your code remains safe and accessible only to you. 19 | 20 | ## Howto 21 | ### Unraid 22 | ByteStash is now on the Unraid App Store! Install it from [there](https://unraid.net/community/apps). 23 | 24 | ### PikaPods 25 | Also available on [PikaPods](https://www.pikapods.com/) for [1-click install](https://www.pikapods.com/pods?run=bytestash) from $1/month. 26 | 27 | ### Docker 28 | ByteStash can also be hosted manually via the docker-compose file: 29 | ```yaml 30 | services: 31 | bytestash: 32 | image: "ghcr.io/jordan-dalby/bytestash:latest" 33 | restart: always 34 | volumes: 35 | - /your/snippet/path:/data/snippets 36 | ports: 37 | - "5000:5000" 38 | environment: 39 | # See https://github.com/jordan-dalby/ByteStash/wiki/FAQ#environment-variables 40 | #ALLOWED_HOSTS: localhost,my.domain.com,my.domain.net 41 | BASE_PATH: "" 42 | JWT_SECRET: your-secret 43 | TOKEN_EXPIRY: 24h 44 | ALLOW_NEW_ACCOUNTS: "true" 45 | DEBUG: "true" 46 | DISABLE_ACCOUNTS: "false" 47 | DISABLE_INTERNAL_ACCOUNTS: "false" 48 | 49 | # See https://github.com/jordan-dalby/ByteStash/wiki/Single-Sign%E2%80%90on-Setup for more info 50 | OIDC_ENABLED: "false" 51 | OIDC_DISPLAY_NAME: "" 52 | OIDC_ISSUER_URL: "" 53 | OIDC_CLIENT_ID: "" 54 | OIDC_CLIENT_SECRET: "" 55 | OIDC_SCOPES: "" 56 | ``` 57 | 58 | ## Tech Stack 59 | - Frontend: React, Tailwind CSS 60 | - Backend: Node.js, Express 61 | - Containerisation: Docker 62 | 63 | ## API Documentation 64 | Once the server is running you can explore the API via Swagger UI. Open 65 | `/api-docs` in your browser to view the documentation for all endpoints. 66 | 67 | ## Contributing 68 | Contributions are welcome! Please submit a pull request or open an issue for any improvements or bug fixes. 69 | -------------------------------------------------------------------------------- /server/src/middleware/auth.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import jwt from 'jsonwebtoken'; 3 | import crypto from 'crypto'; 4 | import Logger from '../logger.js'; 5 | import userRepository from '../repositories/userRepository.js'; 6 | 7 | function getJwtSecret() { 8 | if (process.env.JWT_SECRET_FILE) { 9 | try { 10 | return fs.readFileSync(process.env.JWT_SECRET_FILE, 'utf8').trim(); 11 | } catch (error) { 12 | console.error('Error reading JWT secret file:', error); 13 | process.exit(1); 14 | } 15 | } 16 | return process.env.JWT_SECRET || 'your-secret-key'; 17 | } 18 | 19 | const JWT_SECRET = getJwtSecret(); 20 | const ALLOW_NEW_ACCOUNTS = process.env.ALLOW_NEW_ACCOUNTS === 'true'; 21 | const TOKEN_EXPIRY = process.env.TOKEN_EXPIRY || '24h'; 22 | const DISABLE_ACCOUNTS = process.env.DISABLE_ACCOUNTS === 'true'; 23 | const DISABLE_INTERNAL_ACCOUNTS = process.env.DISABLE_INTERNAL_ACCOUNTS === 'true'; 24 | const ALLOW_PASSWORD_CHANGES = process.env.ALLOW_PASSWORD_CHANGES === 'true'; 25 | 26 | function generateAnonymousUsername() { 27 | return `anon-${crypto.randomBytes(8).toString('hex')}`; 28 | } 29 | 30 | async function getOrCreateAnonymousUser() { 31 | try { 32 | let existingUser = await userRepository.findById(0); 33 | 34 | if (existingUser) { 35 | return existingUser; 36 | } 37 | 38 | return await userRepository.createAnonymousUser(generateAnonymousUsername()); 39 | } catch (error) { 40 | Logger.error('Error getting/creating anonymous user:', error); 41 | throw error; 42 | } 43 | } 44 | 45 | const authenticateToken = async (req, res, next) => { 46 | if (DISABLE_ACCOUNTS) { 47 | try { 48 | const anonymousUser = await getOrCreateAnonymousUser(); 49 | req.user = anonymousUser; 50 | return next(); 51 | } catch (error) { 52 | Logger.error('Error in anonymous authentication:', error); 53 | return res.status(500).json({ error: 'Internal server error' }); 54 | } 55 | } 56 | 57 | // Try to get token from header first (for API calls) 58 | const authHeader = req.headers['bytestashauth']; 59 | let token = authHeader && authHeader.split(' ')[1]; 60 | 61 | // If no header token, try to get from cookie (for browser access) 62 | if (!token && req.cookies) { 63 | token = req.cookies.bytestash_token; 64 | } 65 | 66 | if (!token) { 67 | return res.status(401).json({ error: 'Authentication required' }); 68 | } 69 | 70 | jwt.verify(token, JWT_SECRET, (err, user) => { 71 | if (err) { 72 | return res.status(403).json({ error: 'Invalid token' }); 73 | } 74 | req.user = user; 75 | next(); 76 | }); 77 | }; 78 | 79 | export { 80 | authenticateToken, 81 | JWT_SECRET, 82 | TOKEN_EXPIRY, 83 | ALLOW_NEW_ACCOUNTS, 84 | DISABLE_ACCOUNTS, 85 | DISABLE_INTERNAL_ACCOUNTS, 86 | ALLOW_PASSWORD_CHANGES, 87 | getOrCreateAnonymousUser, 88 | }; -------------------------------------------------------------------------------- /server/src/services/userService.js: -------------------------------------------------------------------------------- 1 | import Logger from '../logger.js'; 2 | import userRepository from '../repositories/userRepository.js'; 3 | 4 | class UserService { 5 | async createUser(username, password) { 6 | try { 7 | if (!username || username.length < 3 || username.length > 30) { 8 | throw new Error('Username must be between 3 and 30 characters'); 9 | } 10 | 11 | if (!password || password.length < 8) { 12 | throw new Error('Password must be at least 8 characters'); 13 | } 14 | 15 | if (!/^[a-zA-Z0-9_-]+$/.test(username)) { 16 | throw new Error('Username can only contain letters, numbers, underscores, and hyphens'); 17 | } 18 | 19 | const existing = await userRepository.findByUsername(username); 20 | if (existing) { 21 | throw new Error('Username already exists'); 22 | } 23 | 24 | return await userRepository.create(username, password); 25 | } catch (error) { 26 | Logger.error('Service Error - createUser:', error); 27 | throw error; 28 | } 29 | } 30 | 31 | async validateUser(username, password) { 32 | try { 33 | const user = await userRepository.findByUsername(username); 34 | if (!user) { 35 | return null; 36 | } 37 | 38 | const isValid = await userRepository.verifyPassword(user, password); 39 | if (!isValid) { 40 | return null; 41 | } 42 | 43 | const { password_hash, ...userWithoutPassword } = user; 44 | return userWithoutPassword; 45 | } catch (error) { 46 | Logger.error('Service Error - validateUser:', error); 47 | throw error; 48 | } 49 | } 50 | 51 | async findById(id) { 52 | try { 53 | return await userRepository.findById(id); 54 | } catch (error) { 55 | Logger.error('Service Error - findById:', error); 56 | throw error; 57 | } 58 | } 59 | 60 | async changePassword(userId, currentPassword, newPassword) { 61 | try { 62 | // Validate new password 63 | if (!newPassword || newPassword.length < 8) { 64 | throw new Error('New password must be at least 8 characters'); 65 | } 66 | 67 | // Get user to verify current password 68 | const user = await userRepository.findByIdWithPassword(userId); 69 | if (!user) { 70 | throw new Error('User not found'); 71 | } 72 | 73 | // Verify current password 74 | const isCurrentPasswordValid = await userRepository.verifyPassword(user, currentPassword); 75 | if (!isCurrentPasswordValid) { 76 | throw new Error('Current password is incorrect'); 77 | } 78 | 79 | // Update password 80 | return await userRepository.updatePassword(userId, newPassword); 81 | } catch (error) { 82 | Logger.error('Service Error - changePassword:', error); 83 | throw error; 84 | } 85 | } 86 | } 87 | 88 | export default new UserService(); -------------------------------------------------------------------------------- /server/src/config/migrations/20241111-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from '../../logger.js'; 2 | 3 | function needsMigration(db) { 4 | const hasCodeColumn = db.prepare(` 5 | SELECT COUNT(*) as count 6 | FROM pragma_table_info('snippets') 7 | WHERE name = 'code' 8 | `).get().count > 0; 9 | 10 | return hasCodeColumn; 11 | } 12 | 13 | async function up_v1_4_0(db) { 14 | if (!needsMigration(db)) { 15 | Logger.debug('v1.4.0 - Migration not necessary'); 16 | return; 17 | } 18 | 19 | Logger.debug('v1.4.0 - Starting migration to fragments...'); 20 | 21 | db.pragma('foreign_keys = OFF'); 22 | 23 | try { 24 | db.transaction(() => { 25 | db.exec(` 26 | CREATE TABLE IF NOT EXISTS fragments ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, 28 | snippet_id INTEGER NOT NULL, 29 | file_name TEXT NOT NULL, 30 | code TEXT NOT NULL, 31 | language TEXT NOT NULL, 32 | position INTEGER NOT NULL, 33 | FOREIGN KEY (snippet_id) REFERENCES snippets(id) ON DELETE CASCADE 34 | ); 35 | 36 | CREATE INDEX IF NOT EXISTS idx_fragments_snippet_id ON fragments(snippet_id); 37 | 38 | CREATE TABLE IF NOT EXISTS shared_snippets ( 39 | id TEXT PRIMARY KEY, 40 | snippet_id INTEGER NOT NULL, 41 | requires_auth BOOLEAN NOT NULL DEFAULT false, 42 | expires_at DATETIME, 43 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 44 | FOREIGN KEY (snippet_id) REFERENCES snippets(id) ON DELETE CASCADE 45 | ); 46 | 47 | CREATE INDEX IF NOT EXISTS idx_shared_snippets_snippet_id ON shared_snippets(snippet_id); 48 | `); 49 | 50 | const snippets = db.prepare('SELECT id, code, language FROM snippets').all(); 51 | const insertFragment = db.prepare( 52 | 'INSERT INTO fragments (snippet_id, file_name, code, language, position) VALUES (?, ?, ?, ?, ?)' 53 | ); 54 | 55 | for (const snippet of snippets) { 56 | insertFragment.run(snippet.id, 'main', snippet.code || '', snippet.language || 'plaintext', 0); 57 | } 58 | 59 | db.exec(` 60 | CREATE TABLE snippets_new ( 61 | id INTEGER PRIMARY KEY AUTOINCREMENT, 62 | title TEXT NOT NULL, 63 | description TEXT, 64 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 65 | ); 66 | 67 | INSERT INTO snippets_new (id, title, description, updated_at) 68 | SELECT id, title, description, updated_at FROM snippets; 69 | 70 | DROP TABLE snippets; 71 | ALTER TABLE snippets_new RENAME TO snippets; 72 | `); 73 | })(); 74 | 75 | Logger.debug('v1.4.0 - Migration completed successfully'); 76 | } catch (error) { 77 | Logger.error('v1.4.0 - Migration failed:', error); 78 | throw error; 79 | } finally { 80 | db.pragma('foreign_keys = ON'); 81 | } 82 | } 83 | 84 | export { up_v1_4_0 }; 85 | -------------------------------------------------------------------------------- /client/src/components/utils/Admonition.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Info, 4 | Lightbulb, 5 | MessageSquareWarning, 6 | AlertTriangle, 7 | AlertOctagon, 8 | } from "lucide-react"; 9 | 10 | type AdmonitionType = "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION"; 11 | 12 | const CONFIG: Record< 13 | AdmonitionType, 14 | { 15 | title: string; 16 | container: string; 17 | bar: string; 18 | titleColor: string; 19 | iconColor: string; 20 | Icon: React.ComponentType<{ className?: string }>; 21 | } 22 | > = { 23 | NOTE: { 24 | title: "Note", 25 | container: "bg-blue-500/10 dark:bg-blue-500/10", 26 | bar: "bg-blue-500", 27 | titleColor: "text-blue-600 dark:text-blue-400", 28 | iconColor: "text-blue-500", 29 | Icon: Info, 30 | }, 31 | TIP: { 32 | title: "Tip", 33 | container: "bg-green-500/10 dark:bg-green-500/10", 34 | bar: "bg-green-500", 35 | titleColor: "text-green-600 dark:text-green-400", 36 | iconColor: "text-green-500", 37 | Icon: Lightbulb, 38 | }, 39 | IMPORTANT: { 40 | title: "Important", 41 | container: "bg-purple-500/10 dark:bg-purple-500/10", 42 | bar: "bg-purple-500", 43 | titleColor: "text-purple-600 dark:text-purple-400", 44 | iconColor: "text-purple-500", 45 | Icon: MessageSquareWarning, 46 | }, 47 | WARNING: { 48 | title: "Warning", 49 | container: "bg-amber-500/10 dark:bg-amber-500/10", 50 | bar: "bg-amber-500", 51 | titleColor: "text-amber-600 dark:text-amber-400", 52 | iconColor: "text-amber-500", 53 | Icon: AlertTriangle, 54 | }, 55 | CAUTION: { 56 | title: "Caution", 57 | container: "bg-red-500/10 dark:bg-red-500/10", 58 | bar: "bg-red-500", 59 | titleColor: "text-red-600 dark:text-red-400", 60 | iconColor: "text-red-500", 61 | Icon: AlertOctagon, 62 | }, 63 | }; 64 | 65 | const Admonition: React.FC<{ type: string; children: React.ReactNode }> = ({ 66 | type, 67 | children, 68 | }) => { 69 | const key = (type?.toUpperCase() as AdmonitionType) || "NOTE"; 70 | const cfg = CONFIG[key] || CONFIG.NOTE; 71 | 72 | return ( 73 |
74 |
75 | 80 |
81 | 82 |
83 |
{cfg.title}
84 | {children && ( 85 |
{children}
86 | )} 87 |
88 |
89 |
90 |
91 | ); 92 | }; 93 | 94 | export default Admonition; 95 | -------------------------------------------------------------------------------- /client/src/components/snippets/embed/EmbedCopyButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Copy, Check } from 'lucide-react'; 3 | 4 | export interface EmbedCopyButtonProps { 5 | text: string; 6 | theme: 'light' | 'dark' | 'blue' | 'system'; 7 | } 8 | 9 | const EmbedCopyButton: React.FC = ({ text, theme }) => { 10 | const [isCopied, setIsCopied] = useState(false); 11 | 12 | const isDark = theme === 'dark' || theme === 'blue' || 13 | (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); 14 | 15 | const handleCopy = async (e: React.MouseEvent) => { 16 | e.stopPropagation(); 17 | try { 18 | const textArea = document.createElement('textarea'); 19 | textArea.value = text; 20 | textArea.style.position = 'fixed'; 21 | textArea.style.left = '-999999px'; 22 | textArea.style.top = '-999999px'; 23 | document.body.appendChild(textArea); 24 | textArea.focus(); 25 | textArea.select(); 26 | 27 | try { 28 | const successful = document.execCommand('copy'); 29 | if (!successful) { 30 | throw new Error('Copy command failed'); 31 | } 32 | } finally { 33 | textArea.remove(); 34 | } 35 | 36 | setIsCopied(true); 37 | setTimeout(() => setIsCopied(false), 2000); 38 | } catch (err) { 39 | console.error('Failed to copy text: ', err); 40 | setIsCopied(false); 41 | } 42 | }; 43 | 44 | const getBackgroundColor = () => { 45 | switch (theme) { 46 | case 'blue': 47 | return 'bg-dark-surface hover:bg-dark-hover'; 48 | case 'dark': 49 | return 'bg-neutral-700 hover:bg-neutral-600'; 50 | case 'light': 51 | return 'bg-light-surface hover:bg-light-hover'; 52 | case 'system': 53 | return isDark 54 | ? 'bg-neutral-700 hover:bg-neutral-600' 55 | : 'bg-light-surface hover:bg-light-hover'; 56 | } 57 | }; 58 | 59 | const getTextColor = () => { 60 | if (theme === 'blue' || theme === 'dark' || (theme === 'system' && isDark)) { 61 | return 'text-dark-text'; 62 | } 63 | return 'text-light-text'; 64 | }; 65 | 66 | const getIconColor = () => { 67 | if (isCopied) { 68 | return isDark ? 'text-dark-primary' : 'text-light-primary'; 69 | } 70 | if (theme === 'blue' || theme === 'dark' || (theme === 'system' && isDark)) { 71 | return 'text-dark-text'; 72 | } 73 | return 'text-light-text'; 74 | }; 75 | 76 | return ( 77 | 88 | ); 89 | }; 90 | 91 | export default EmbedCopyButton; 92 | -------------------------------------------------------------------------------- /server/src/repositories/apiKeyRepository.js: -------------------------------------------------------------------------------- 1 | import { getDb } from '../config/database.js'; 2 | import crypto from 'crypto'; 3 | import Logger from '../logger.js'; 4 | 5 | function generateApiKey() { 6 | return crypto.randomBytes(32).toString('hex'); 7 | } 8 | 9 | export function createApiKey(userId, name) { 10 | const db = getDb(); 11 | const key = generateApiKey(); 12 | 13 | try { 14 | const stmt = db.prepare(` 15 | INSERT INTO api_keys (user_id, key, name) 16 | VALUES (?, ?, ?) 17 | `); 18 | 19 | const result = stmt.run(userId, key, name); 20 | 21 | if (result.changes === 1) { 22 | Logger.debug(`Created new API key for user ${userId}`); 23 | return { 24 | id: result.lastInsertRowid, 25 | key, 26 | name, 27 | created_at: new Date().toISOString(), 28 | is_active: true 29 | }; 30 | } 31 | return null; 32 | } catch (error) { 33 | Logger.error('Error creating API key:', error); 34 | throw error; 35 | } 36 | } 37 | 38 | export function getApiKeys(userId) { 39 | const db = getDb(); 40 | try { 41 | const stmt = db.prepare(` 42 | SELECT id, name, created_at, last_used_at, is_active 43 | FROM api_keys 44 | WHERE user_id = ? 45 | ORDER BY created_at DESC 46 | `); 47 | 48 | return stmt.all(userId); 49 | } catch (error) { 50 | Logger.error('Error fetching API keys:', error); 51 | throw error; 52 | } 53 | } 54 | 55 | export function deleteApiKey(userId, keyId) { 56 | const db = getDb(); 57 | try { 58 | const stmt = db.prepare(` 59 | DELETE FROM api_keys 60 | WHERE id = ? AND user_id = ? 61 | `); 62 | 63 | const result = stmt.run(keyId, userId); 64 | if (result.changes === 1) { 65 | Logger.debug(`Deleted API key ${keyId} for user ${userId}`); 66 | } 67 | return result.changes === 1; 68 | } catch (error) { 69 | Logger.error('Error deleting API key:', error); 70 | throw error; 71 | } 72 | } 73 | 74 | export function validateApiKey(key) { 75 | const db = getDb(); 76 | try { 77 | const stmt = db.prepare(` 78 | SELECT ak.*, u.id as user_id 79 | FROM api_keys ak 80 | JOIN users u ON ak.user_id = u.id 81 | WHERE ak.key = ? AND ak.is_active = TRUE 82 | `); 83 | 84 | const apiKey = stmt.get(key); 85 | 86 | if (apiKey) { 87 | // Update last_used_at 88 | db.prepare(` 89 | UPDATE api_keys 90 | SET last_used_at = CURRENT_TIMESTAMP 91 | WHERE id = ? 92 | `).run(apiKey.id); 93 | 94 | Logger.debug(`Validated API key ${apiKey.id} for user ${apiKey.user_id}`); 95 | return { 96 | userId: apiKey.user_id, 97 | keyId: apiKey.id 98 | }; 99 | } 100 | 101 | return null; 102 | } catch (error) { 103 | Logger.error('Error validating API key:', error); 104 | throw error; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /server/src/routes/shareRoutes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | import { JWT_SECRET, authenticateToken } from '../middleware/auth.js'; 4 | import shareRepository from '../repositories/shareRepository.js'; 5 | import Logger from '../logger.js'; 6 | 7 | const router = express.Router(); 8 | 9 | router.post('/', authenticateToken, async (req, res) => { 10 | try { 11 | const { snippetId, requiresAuth, expiresIn } = req.body; 12 | const share = await shareRepository.createShare({ 13 | snippetId, 14 | requiresAuth: !!requiresAuth, 15 | expiresIn: expiresIn ? parseInt(expiresIn) : null 16 | }, req.user.id); 17 | res.status(201).json(share); 18 | } catch (error) { 19 | Logger.error('Error creating share:', error); 20 | if (error.message === 'Unauthorized') { 21 | res.status(403).json({ error: 'You do not have permission to share this snippet' }); 22 | } else if (error.message === 'Invalid snippet ID') { 23 | res.status(400).json({ error: 'Invalid snippet ID provided' }); 24 | } else { 25 | res.status(500).json({ error: 'Failed to create share' }); 26 | } 27 | } 28 | }); 29 | 30 | router.get('/:id', async (req, res) => { 31 | try { 32 | const { id } = req.params; 33 | const share = await shareRepository.getShare(id); 34 | 35 | if (!share) { 36 | return res.status(404).json({ error: 'Share not found' }); 37 | } 38 | 39 | if (share.share?.requiresAuth) { 40 | const authHeader = req.headers['bytestashauth']; 41 | const token = authHeader && authHeader.split(' ')[1]; 42 | 43 | if (!token) { 44 | return res.status(401).json({ error: 'Authentication required' }); 45 | } 46 | 47 | try { 48 | jwt.verify(token, JWT_SECRET); 49 | } catch (err) { 50 | return res.status(401).json({ error: 'Invalid or expired token' }); 51 | } 52 | } 53 | 54 | if (share.share?.expired) { 55 | return res.status(410).json({ error: 'Share has expired' }); 56 | } 57 | 58 | res.json(share); 59 | } catch (error) { 60 | Logger.error('Error getting share:', error); 61 | res.status(500).json({ error: 'Failed to get share' }); 62 | } 63 | }); 64 | 65 | router.get('/snippet/:snippetId', authenticateToken, async (req, res) => { 66 | try { 67 | const { snippetId } = req.params; 68 | const shares = await shareRepository.getSharesBySnippetId(snippetId, req.user.id); 69 | res.json(shares); 70 | } catch (error) { 71 | Logger.error('Error listing shares:', error); 72 | res.status(500).json({ error: 'Failed to list shares' }); 73 | } 74 | }); 75 | 76 | router.delete('/:id', authenticateToken, async (req, res) => { 77 | try { 78 | const { id } = req.params; 79 | await shareRepository.deleteShare(id, req.user.id); 80 | res.json({ success: true }); 81 | } catch (error) { 82 | Logger.error('Error deleting share:', error); 83 | res.status(500).json({ error: 'Failed to delete share' }); 84 | } 85 | }); 86 | 87 | export default router; -------------------------------------------------------------------------------- /server/src/config/schema/init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | username TEXT UNIQUE NOT NULL, 4 | username_normalized TEXT, 5 | password_hash TEXT NOT NULL, 6 | oidc_id TEXT, 7 | oidc_provider TEXT, 8 | email TEXT, 9 | name TEXT, 10 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | CREATE TABLE IF NOT EXISTS snippets ( 14 | id INTEGER PRIMARY KEY AUTOINCREMENT, 15 | title TEXT NOT NULL, 16 | description TEXT, 17 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 18 | expiry_date DATETIME DEFAULT NULL, 19 | user_id INTEGER REFERENCES users (id), 20 | is_public BOOLEAN DEFAULT FALSE, 21 | is_pinned BOOLEAN DEFAULT FALSE, 22 | is_favorite BOOLEAN DEFAULT FALSE 23 | ); 24 | 25 | CREATE TABLE IF NOT EXISTS categories ( 26 | id INTEGER PRIMARY KEY AUTOINCREMENT, 27 | snippet_id INTEGER, 28 | name TEXT NOT NULL, 29 | FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE 30 | ); 31 | 32 | CREATE TABLE IF NOT EXISTS fragments ( 33 | id INTEGER PRIMARY KEY AUTOINCREMENT, 34 | snippet_id INTEGER NOT NULL, 35 | file_name TEXT NOT NULL, 36 | code TEXT NOT NULL, 37 | language TEXT NOT NULL, 38 | position INTEGER NOT NULL, 39 | FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE 40 | ); 41 | 42 | CREATE TABLE IF NOT EXISTS shared_snippets ( 43 | id TEXT PRIMARY KEY, 44 | snippet_id INTEGER NOT NULL, 45 | requires_auth BOOLEAN NOT NULL DEFAULT false, 46 | expires_at DATETIME, 47 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 48 | FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE 49 | ); 50 | 51 | CREATE TABLE IF NOT EXISTS api_keys ( 52 | id INTEGER PRIMARY KEY AUTOINCREMENT, 53 | user_id INTEGER NOT NULL, 54 | key TEXT NOT NULL UNIQUE, 55 | name TEXT NOT NULL, 56 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 57 | last_used_at DATETIME, 58 | is_active BOOLEAN DEFAULT TRUE, 59 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE 60 | ); 61 | 62 | CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); 63 | 64 | CREATE INDEX IF NOT EXISTS idx_snippets_user_id ON snippets (user_id); 65 | 66 | CREATE INDEX IF NOT EXISTS idx_categories_snippet_id ON categories (snippet_id); 67 | 68 | CREATE INDEX IF NOT EXISTS idx_fragments_snippet_id ON fragments (snippet_id); 69 | 70 | CREATE INDEX IF NOT EXISTS idx_shared_snippets_snippet_id ON shared_snippets (snippet_id); 71 | 72 | CREATE INDEX idx_snippets_is_public ON snippets (is_public); 73 | 74 | CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_normalized ON users ( 75 | username_normalized COLLATE NOCASE 76 | ); 77 | 78 | CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON users (oidc_id, oidc_provider) 79 | WHERE 80 | oidc_id IS NOT NULL 81 | AND oidc_provider IS NOT NULL; 82 | 83 | CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys (user_id); 84 | 85 | CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys (key); -------------------------------------------------------------------------------- /server/src/config/migrations/20241117-migration.js: -------------------------------------------------------------------------------- 1 | import Logger from '../../logger.js'; 2 | 3 | function needsMigration(db) { 4 | try { 5 | const hasUsersTable = db.prepare(` 6 | SELECT name 7 | FROM sqlite_master 8 | WHERE type='table' AND name='users' 9 | `).get(); 10 | 11 | if (!hasUsersTable) { 12 | Logger.debug('v1.5.0 - Users table does not exist, migration needed'); 13 | return true; 14 | } 15 | 16 | const hasUserIdColumn = db.prepare(` 17 | SELECT COUNT(*) as count 18 | FROM pragma_table_info('snippets') 19 | WHERE name = 'user_id' 20 | `).get(); 21 | 22 | if (hasUserIdColumn.count === 0) { 23 | Logger.debug('v1.5.0 - Snippets table missing user_id column, migration needed'); 24 | return true; 25 | } 26 | 27 | const hasUserIdIndex = db.prepare(` 28 | SELECT COUNT(*) as count 29 | FROM sqlite_master 30 | WHERE type='index' AND name='idx_snippets_user_id' 31 | `).get(); 32 | 33 | if (hasUserIdIndex.count === 0) { 34 | Logger.debug('v1.5.0 - Missing user_id index, migration needed'); 35 | return true; 36 | } 37 | 38 | Logger.debug('v1.5.0 - Database schema is up to date, no migration needed'); 39 | return false; 40 | } catch (error) { 41 | Logger.error('v1.5.0 - Error checking migration status:', error); 42 | throw error; 43 | } 44 | } 45 | 46 | async function up_v1_5_0(db) { 47 | if (!needsMigration(db)) { 48 | Logger.debug('v1.5.0 - Migration is not needed, database is up to date'); 49 | return; 50 | } 51 | 52 | Logger.debug('v1.5.0 - Starting migration: Adding users table and updating snippets...'); 53 | 54 | try { 55 | db.exec(` 56 | CREATE TABLE IF NOT EXISTS users ( 57 | id INTEGER PRIMARY KEY AUTOINCREMENT, 58 | username TEXT UNIQUE NOT NULL, 59 | password_hash TEXT NOT NULL, 60 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 61 | ); 62 | 63 | CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); 64 | `); 65 | 66 | db.exec(` 67 | ALTER TABLE snippets ADD COLUMN user_id INTEGER REFERENCES users(id); 68 | CREATE INDEX idx_snippets_user_id ON snippets(user_id); 69 | `); 70 | 71 | Logger.debug('v1.5.0 - Migration completed successfully'); 72 | } catch (error) { 73 | Logger.error('v1.5.0 - Migration failed:', error); 74 | throw error; 75 | } 76 | } 77 | 78 | async function up_v1_5_0_snippets(db, userId) { 79 | try { 80 | Logger.debug(`v1.5.0 - Migrating orphaned snippets to user ${userId}...`); 81 | 82 | const updateSnippets = db.prepare(` 83 | UPDATE snippets SET user_id = ? WHERE user_id IS NULL 84 | `); 85 | 86 | const result = updateSnippets.run(userId); 87 | Logger.debug(`v1.5.0 - Successfully migrated ${result.changes} snippets to user ${userId}`); 88 | 89 | return result.changes; 90 | } catch (error) { 91 | Logger.error('v1.5.0 - Snippet migration failed:', error); 92 | throw error; 93 | } 94 | } 95 | 96 | export { up_v1_5_0, up_v1_5_0_snippets }; -------------------------------------------------------------------------------- /client/src/utils/api/apiClient.ts: -------------------------------------------------------------------------------- 1 | import { EVENTS } from '../../constants/events'; 2 | 3 | interface RequestOptions extends RequestInit { 4 | requiresAuth?: boolean; 5 | } 6 | 7 | export class ApiClient { 8 | private static instance: ApiClient; 9 | private basePath: string; 10 | 11 | private constructor() { 12 | this.basePath = (window as any).__BASE_PATH__ || ''; 13 | } 14 | 15 | static getInstance(): ApiClient { 16 | if (!ApiClient.instance) { 17 | ApiClient.instance = new ApiClient(); 18 | } 19 | return ApiClient.instance; 20 | } 21 | 22 | private getHeaders(options: RequestOptions = {}): Headers { 23 | const headers = new Headers(options.headers); 24 | headers.set('Content-Type', 'application/json'); 25 | 26 | if (options.requiresAuth) { 27 | const token = localStorage.getItem('token'); 28 | if (token) { 29 | headers.set('bytestashauth', `Bearer ${token}`); 30 | } 31 | } 32 | 33 | return headers; 34 | } 35 | 36 | private handleError(error: any): never { 37 | if (error instanceof Response) { 38 | if (error.status === 401 || error.status === 403) { 39 | window.dispatchEvent(new CustomEvent(EVENTS.AUTH_ERROR)); 40 | } 41 | } 42 | throw error; 43 | } 44 | 45 | async request(endpoint: string, options: RequestOptions = {}): Promise { 46 | try { 47 | const response = await fetch(`${this.basePath}${endpoint}`, { 48 | ...options, 49 | headers: this.getHeaders(options), 50 | }); 51 | 52 | if (!response.ok) { 53 | if (response.status === 401 || response.status === 403) { 54 | window.dispatchEvent(new CustomEvent(EVENTS.AUTH_ERROR)); 55 | } 56 | const error = await response.json().catch(() => ({})); 57 | error.status = response.status; 58 | throw error; 59 | } 60 | 61 | return response.json(); 62 | } catch (error) { 63 | this.handleError(error); 64 | } 65 | } 66 | 67 | async get(endpoint: string, options: RequestOptions = {}): Promise { 68 | return this.request(endpoint, { ...options, method: 'GET' }); 69 | } 70 | 71 | async post(endpoint: string, data: any, options: RequestOptions = {}): Promise { 72 | return this.request(endpoint, { 73 | ...options, 74 | method: 'POST', 75 | body: JSON.stringify(data), 76 | }); 77 | } 78 | 79 | async put(endpoint: string, data: any, options: RequestOptions = {}): Promise { 80 | return this.request(endpoint, { 81 | ...options, 82 | method: 'PUT', 83 | body: JSON.stringify(data), 84 | }); 85 | } 86 | async patch(endpoint: string, data: any, options: RequestOptions = {}): Promise { 87 | return this.request(endpoint, { 88 | ...options, 89 | method: 'PATCH', 90 | body: JSON.stringify(data), 91 | }); 92 | } 93 | 94 | async delete(endpoint: string, options: RequestOptions = {}): Promise { 95 | return this.request(endpoint, { ...options, method: 'DELETE' }); 96 | } 97 | } 98 | 99 | export const apiClient = ApiClient.getInstance(); -------------------------------------------------------------------------------- /client/src/components/snippets/list/SnippetList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { SnippetCard } from "./SnippetCard"; 3 | import { Snippet } from "../../../types/snippets"; 4 | 5 | export interface SnippetListProps { 6 | snippets: Snippet[]; 7 | viewMode: "grid" | "list"; 8 | onOpen: (snippet: Snippet) => void; 9 | onDelete: (id: string) => void; 10 | onRestore: (id: string) => void; 11 | onEdit: (snippet: Snippet) => void; 12 | onShare: (snippet: Snippet) => void; 13 | onDuplicate: (snippet: Snippet) => void; 14 | onCategoryClick: (category: string) => void; 15 | compactView: boolean; 16 | showCodePreview: boolean; 17 | previewLines: number; 18 | showCategories: boolean; 19 | expandCategories: boolean; 20 | showLineNumbers: boolean; 21 | isPublicView: boolean; 22 | isRecycleView: boolean; 23 | isAuthenticated: boolean; 24 | pinSnippet?: (id: string, isPinned: boolean) => Promise; 25 | favoriteSnippet?: ( 26 | id: string, 27 | isFavorite: boolean 28 | ) => Promise; 29 | } 30 | 31 | const SnippetList: React.FC = ({ 32 | snippets, 33 | viewMode, 34 | onOpen, 35 | onDelete, 36 | onRestore, 37 | onEdit, 38 | onShare, 39 | onDuplicate, 40 | onCategoryClick, 41 | compactView, 42 | showCodePreview, 43 | previewLines, 44 | showCategories, 45 | expandCategories, 46 | showLineNumbers, 47 | isPublicView, 48 | isRecycleView, 49 | isAuthenticated, 50 | pinSnippet, 51 | favoriteSnippet, 52 | }) => { 53 | if (snippets.length === 0) { 54 | return ( 55 |
56 |

57 | No snippets match your search criteria. 58 |

59 |
60 | ); 61 | } 62 | return ( 63 |
70 | {snippets.map((snippet) => ( 71 | 94 | ))} 95 |
96 | ); 97 | }; 98 | 99 | export default SnippetList; 100 | -------------------------------------------------------------------------------- /client/src/components/snippets/view/SnippetModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from "react"; 2 | import { Snippet } from "../../../types/snippets"; 3 | import Modal from "../../common/modals/Modal"; 4 | import { FullCodeView } from "./FullCodeView"; 5 | import { ConfirmationModal } from "../../common/modals/ConfirmationModal"; 6 | 7 | export interface SnippetModalProps { 8 | snippet: Snippet | null; 9 | isOpen: boolean; 10 | onClose: () => void; 11 | onEdit?: (snippet: Snippet) => void; 12 | onDelete?: (id: string) => Promise; 13 | onCategoryClick: (category: string) => void; 14 | showLineNumbers: boolean; 15 | isPublicView: boolean; 16 | isRecycleView?: boolean; 17 | } 18 | 19 | const SnippetModal: React.FC = ({ 20 | snippet, 21 | isOpen, 22 | onClose, 23 | onEdit, 24 | onDelete, 25 | onCategoryClick, 26 | showLineNumbers, 27 | isPublicView, 28 | isRecycleView 29 | }) => { 30 | if (!snippet) return null; 31 | 32 | const handleCategoryClick = (e: React.MouseEvent, category: string) => { 33 | e.preventDefault(); 34 | onCategoryClick(category); 35 | }; 36 | 37 | const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); 38 | const [snippetToDelete, setSnippetToDelete] = useState(null); 39 | 40 | const handleDeleteSnippet = useCallback(() => { 41 | setSnippetToDelete(snippet); 42 | setIsDeleteModalOpen(true); 43 | }, [snippet]); 44 | 45 | const confirmDeleteSnippet = useCallback(async () => { 46 | if (snippetToDelete && onDelete) { 47 | await onDelete(snippetToDelete.id); 48 | onClose(); 49 | } 50 | setSnippetToDelete(null); 51 | setIsDeleteModalOpen(false); 52 | }, [snippetToDelete, onDelete, onClose]); 53 | 54 | const cancelDeleteSnippet = useCallback(() => { 55 | setIsDeleteModalOpen(false); 56 | }, []); 57 | 58 | const handleEditSnippet = useCallback(() => { 59 | if (snippet && onEdit) { 60 | onEdit(snippet); 61 | onClose(); 62 | } 63 | }, [snippet, onEdit, onClose]); 64 | 65 | return ( 66 | <> 67 | {snippet.title} 74 | } 75 | expandable={true} 76 | > 77 | handleCategoryClick} 82 | isModal={true} 83 | isPublicView={isPublicView} 84 | /> 85 | 86 | 100 | 101 | ); 102 | }; 103 | 104 | export default SnippetModal; 105 | -------------------------------------------------------------------------------- /client/src/components/categories/CategoryTag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type CategoryTagVariant = 'removable' | 'clickable'; 4 | 5 | interface CategoryTagProps { 6 | category: string; 7 | onClick: (e: React.MouseEvent, category: string) => void; 8 | variant: CategoryTagVariant; 9 | className?: string; 10 | } 11 | 12 | const CategoryTag: React.FC = ({ 13 | category, 14 | onClick, 15 | variant, 16 | className = "" 17 | }) => { 18 | const handleClick = (e: React.MouseEvent) => { 19 | e.stopPropagation(); 20 | onClick(e, category); 21 | }; 22 | 23 | if (variant === 'removable') { 24 | return ( 25 | 34 | ); 35 | } 36 | 37 | return ( 38 | 46 | ); 47 | }; 48 | 49 | const getCategoryColor = (name: string) => { 50 | const colorSchemes = [ 51 | { 52 | bg: 'bg-blue-500/20 dark:bg-blue-500/30', 53 | text: 'text-blue-700 dark:text-blue-200', 54 | hover: 'hover:bg-blue-500/30 dark:hover:bg-blue-500/40' 55 | }, 56 | { 57 | bg: 'bg-emerald-500/20 dark:bg-emerald-500/30', 58 | text: 'text-emerald-700 dark:text-emerald-200', 59 | hover: 'hover:bg-emerald-500/30 dark:hover:bg-emerald-500/40' 60 | }, 61 | { 62 | bg: 'bg-purple-500/20 dark:bg-purple-500/30', 63 | text: 'text-purple-700 dark:text-purple-200', 64 | hover: 'hover:bg-purple-500/30 dark:hover:bg-purple-500/40' 65 | }, 66 | { 67 | bg: 'bg-amber-500/20 dark:bg-amber-500/30', 68 | text: 'text-amber-700 dark:text-amber-200', 69 | hover: 'hover:bg-amber-500/30 dark:hover:bg-amber-500/40' 70 | }, 71 | { 72 | bg: 'bg-rose-500/20 dark:bg-rose-500/30', 73 | text: 'text-rose-700 dark:text-rose-200', 74 | hover: 'hover:bg-rose-500/30 dark:hover:bg-rose-500/40' 75 | }, 76 | { 77 | bg: 'bg-cyan-500/20 dark:bg-cyan-500/30', 78 | text: 'text-cyan-700 dark:text-cyan-200', 79 | hover: 'hover:bg-cyan-500/30 dark:hover:bg-cyan-500/40' 80 | }, 81 | { 82 | bg: 'bg-indigo-500/20 dark:bg-indigo-500/30', 83 | text: 'text-indigo-700 dark:text-indigo-200', 84 | hover: 'hover:bg-indigo-500/30 dark:hover:bg-indigo-500/40' 85 | }, 86 | { 87 | bg: 'bg-teal-500/20 dark:bg-teal-500/30', 88 | text: 'text-teal-700 dark:text-teal-200', 89 | hover: 'hover:bg-teal-500/30 dark:hover:bg-teal-500/40' 90 | } 91 | ]; 92 | 93 | const hash = name.split('').reduce((acc, char, i) => { 94 | return char.charCodeAt(0) + ((acc << 5) - acc) + i; 95 | }, 0); 96 | 97 | const scheme = colorSchemes[Math.abs(hash) % colorSchemes.length]; 98 | return `${scheme.bg} ${scheme.text} ${scheme.hover}`; 99 | }; 100 | 101 | export default CategoryTag; 102 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | 19 | .markdown > * { 20 | all: revert; 21 | } 22 | 23 | .markdown p { 24 | margin: revert; 25 | } 26 | 27 | .markdown a { 28 | @apply text-blue-600 dark:text-blue-400 no-underline hover:underline; 29 | } 30 | 31 | .markdown ul { 32 | list-style-type: disc; 33 | padding-left: 1.5rem; 34 | } 35 | 36 | .markdown ul ul { 37 | list-style-type: disc; 38 | padding-left: 1.5rem; 39 | } 40 | 41 | .markdown ol { 42 | list-style-type: decimal; 43 | padding-left: 1.5rem; 44 | } 45 | 46 | .markdown ol ol { 47 | list-style-type: lower-alpha; 48 | padding-left: 1.5rem; 49 | } 50 | 51 | /* Markdown styles */ 52 | .markdown pre { 53 | @apply bg-[#ebebeb] dark:bg-[#2d2d2d]; 54 | padding: 0.2rem 0.4rem; 55 | border-radius: 0.25rem; 56 | @apply text-light-text dark:text-dark-text; 57 | } 58 | .markdown code { 59 | @apply bg-[#ebebeb] dark:bg-[#2d2d2d]; 60 | padding: 0.2rem 0.4rem; 61 | border-radius: 0.25rem; 62 | @apply text-light-text dark:text-dark-text; 63 | } 64 | 65 | .markdown pre code { 66 | padding: 0; 67 | background-color: transparent; 68 | border: none; 69 | box-shadow: none; 70 | } 71 | 72 | /* Light theme scrollbars */ 73 | .light ::-webkit-scrollbar { 74 | width: 8px; 75 | height: 8px; 76 | } 77 | 78 | .light ::-webkit-scrollbar-track { 79 | @apply bg-light-surface; 80 | border-radius: 4px; 81 | } 82 | 83 | .light ::-webkit-scrollbar-thumb { 84 | @apply bg-slate-400; 85 | border-radius: 4px; 86 | transition: background 0.2s ease; 87 | } 88 | 89 | .light ::-webkit-scrollbar-thumb:hover { 90 | @apply bg-slate-500; 91 | } 92 | 93 | .light ::-webkit-scrollbar-corner { 94 | background: transparent; 95 | } 96 | 97 | /* Dark theme scrollbars */ 98 | .dark ::-webkit-scrollbar { 99 | width: 8px; 100 | height: 8px; 101 | } 102 | 103 | .dark ::-webkit-scrollbar-track { 104 | @apply bg-dark-surface; 105 | border-radius: 4px; 106 | } 107 | 108 | .dark ::-webkit-scrollbar-thumb { 109 | @apply bg-slate-600; 110 | border-radius: 4px; 111 | transition: background 0.2s ease; 112 | } 113 | 114 | .dark ::-webkit-scrollbar-thumb:hover { 115 | @apply bg-slate-500; 116 | } 117 | 118 | .dark ::-webkit-scrollbar-corner { 119 | background: transparent; 120 | } 121 | 122 | /* Firefox scrollbars */ 123 | .light * { 124 | scrollbar-width: thin; 125 | scrollbar-color: var(--light-scrollbar) var(--light-scrollbar-track); 126 | } 127 | 128 | .dark * { 129 | scrollbar-width: thin; 130 | scrollbar-color: var(--dark-scrollbar) var(--dark-scrollbar-track); 131 | } 132 | 133 | .light .modal-content, 134 | .light [role="dialog"], 135 | .light [role="complementary"], 136 | .light .overflow-auto, 137 | .light .overflow-y-auto, 138 | .light .overflow-x-auto { 139 | scrollbar-width: thin; 140 | scrollbar-color: var(--light-scrollbar) var(--light-scrollbar-track); 141 | } 142 | 143 | .dark .modal-content, 144 | .dark [role="dialog"], 145 | .dark [role="complementary"], 146 | .dark .overflow-auto, 147 | .dark .overflow-y-auto, 148 | .dark .overflow-x-auto { 149 | scrollbar-width: thin; 150 | scrollbar-color: var(--dark-scrollbar) var(--dark-scrollbar-track); 151 | } 152 | 153 | :root { 154 | --dark-scrollbar-track: rgb(30, 41, 59); /* slate-800 */ 155 | } 156 | -------------------------------------------------------------------------------- /client/src/components/snippets/view/recycle/RecycleSnippetStorage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useSettings } from "../../../../hooks/useSettings"; 3 | import { useToast } from "../../../../hooks/useToast"; 4 | import { useAuth } from "../../../../hooks/useAuth"; 5 | import { initializeMonaco } from "../../../../utils/language/languageUtils"; 6 | import SettingsModal from "../../../settings/SettingsModal"; 7 | import BaseSnippetStorage from "../common/BaseSnippetStorage"; 8 | import { getRecycleSnippets } from "../../../../utils/api/snippets"; 9 | import { useSnippets } from "../../../../hooks/useSnippets"; 10 | import { Snippet } from "../../../../types/snippets"; 11 | import { UserDropdown } from "../../../auth/UserDropdown"; 12 | 13 | const RecycleSnippetStorage: React.FC = () => { 14 | const { 15 | viewMode, 16 | setViewMode, 17 | compactView, 18 | showCodePreview, 19 | previewLines, 20 | includeCodeInSearch, 21 | updateSettings, 22 | showCategories, 23 | expandCategories, 24 | showLineNumbers, 25 | theme, 26 | } = useSettings(); 27 | 28 | const { isAuthenticated } = useAuth(); 29 | const { addToast } = useToast(); 30 | const [snippets, setSnippets] = useState([]); 31 | const [isLoading, setIsLoading] = useState(true); 32 | const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); 33 | const { permanentDeleteSnippet, restoreSnippet, permanentDeleteAllSnippets } = 34 | useSnippets(); 35 | 36 | useEffect(() => { 37 | initializeMonaco(); 38 | loadSnippets(); 39 | }, []); 40 | 41 | const loadSnippets = async () => { 42 | try { 43 | const fetchedSnippets = await getRecycleSnippets(); 44 | setSnippets(fetchedSnippets); 45 | } catch (error) { 46 | console.error("Failed to load recycled snippets:", error); 47 | addToast("Failed to load recycled snippets", "error"); 48 | } finally { 49 | setIsLoading(false); 50 | } 51 | }; 52 | 53 | return ( 54 | <> 55 | { 66 | await permanentDeleteSnippet(id); 67 | loadSnippets(); // refresh after delete 68 | }} 69 | onPermanentDeleteAll={async () => { 70 | await permanentDeleteAllSnippets(snippets); 71 | loadSnippets(); // refresh after delete all 72 | }} 73 | onRestore={restoreSnippet} 74 | expandCategories={expandCategories} 75 | showLineNumbers={showLineNumbers} 76 | onSettingsOpen={() => setIsSettingsModalOpen(true)} 77 | onNewSnippet={() => null} 78 | headerRight={} 79 | isPublicView={false} 80 | isRecycleView={true} 81 | isAuthenticated={isAuthenticated} 82 | /> 83 | 84 | setIsSettingsModalOpen(false)} 87 | settings={{ 88 | compactView, 89 | showCodePreview, 90 | previewLines, 91 | includeCodeInSearch, 92 | showCategories, 93 | expandCategories, 94 | showLineNumbers, 95 | theme, 96 | }} 97 | onSettingsChange={updateSettings} 98 | snippets={[]} 99 | addSnippet={() => Promise.resolve({} as Snippet)} 100 | reloadSnippets={() => {}} 101 | isPublicView={true} 102 | /> 103 | 104 | ); 105 | }; 106 | 107 | export default RecycleSnippetStorage; 108 | -------------------------------------------------------------------------------- /client/src/components/categories/CategorySuggestions.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import BaseDropdown from '../common/dropdowns/BaseDropdown'; 3 | 4 | export interface CategorySuggestionsProps { 5 | inputValue: string; 6 | onInputChange: (value: string) => void; 7 | onCategorySelect: (category: string) => void; 8 | existingCategories: string[]; 9 | selectedCategories: string[]; 10 | placeholder?: string; 11 | disabled?: boolean; 12 | className?: string; 13 | showAddText?: boolean; 14 | maxCategories?: number; 15 | handleHashtag: boolean; 16 | } 17 | 18 | const CategorySuggestions: React.FC = ({ 19 | inputValue, 20 | onInputChange, 21 | onCategorySelect, 22 | existingCategories, 23 | selectedCategories, 24 | placeholder = "Type to search categories...", 25 | disabled = false, 26 | className = "", 27 | maxCategories, 28 | handleHashtag = false 29 | }) => { 30 | const [internalValue, setInternalValue] = useState(inputValue); 31 | 32 | useEffect(() => { 33 | setInternalValue(inputValue); 34 | }, [inputValue]); 35 | 36 | const getSections = (searchTerm: string) => { 37 | const term = handleHashtag 38 | ? searchTerm.slice(searchTerm.lastIndexOf('#') + 1).trim().toLowerCase() 39 | : searchTerm.trim().toLowerCase(); 40 | 41 | if (handleHashtag && !searchTerm.includes('#')) { 42 | return []; 43 | } 44 | 45 | const sections = []; 46 | 47 | const availableCategories = existingCategories.filter( 48 | cat => !selectedCategories.includes(cat.toLowerCase()) 49 | ); 50 | 51 | const filtered = term 52 | ? availableCategories.filter(cat => 53 | cat.toLowerCase().includes(term) 54 | ) 55 | : availableCategories; 56 | 57 | if (filtered.length > 0) { 58 | sections.push({ 59 | title: 'Categories', 60 | items: filtered 61 | }); 62 | } 63 | 64 | if (term && term.length > 0 && 65 | !existingCategories.some(cat => cat.toLowerCase() === term)) { 66 | sections.push({ 67 | title: 'Add New', 68 | items: [`Add new: ${term}`] 69 | }); 70 | } 71 | 72 | return sections; 73 | }; 74 | 75 | const handleKeyDown = (e: React.KeyboardEvent) => { 76 | if (e.key === ',') { 77 | e.preventDefault(); 78 | const term = handleHashtag 79 | ? internalValue.slice(internalValue.lastIndexOf('#') + 1).trim() 80 | : internalValue.trim(); 81 | 82 | if (term) { 83 | handleSelect(`Add new: ${term}`); 84 | } 85 | } 86 | }; 87 | 88 | const handleChange = (newValue: string) => { 89 | setInternalValue(newValue); 90 | onInputChange(newValue); 91 | }; 92 | 93 | const handleSelect = (option: string) => { 94 | let newCategory; 95 | if (option.startsWith('Add new:')) { 96 | newCategory = option.slice(9).trim(); 97 | } else { 98 | newCategory = option; 99 | } 100 | 101 | if (handleHashtag) { 102 | const hashtagIndex = internalValue.lastIndexOf('#'); 103 | if (hashtagIndex !== -1) { 104 | const newValue = internalValue.substring(0, hashtagIndex).trim(); 105 | setInternalValue(newValue); 106 | onInputChange(newValue); 107 | } 108 | } else { 109 | setInternalValue(''); 110 | onInputChange(''); 111 | } 112 | 113 | onCategorySelect(newCategory.toLowerCase()); 114 | }; 115 | 116 | return ( 117 | = maxCategories)} 126 | showChevron={false} 127 | /> 128 | ); 129 | }; 130 | 131 | export default CategorySuggestions; -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'react-router-dom'; 3 | import { AuthProvider } from './contexts/AuthContext'; 4 | import { ThemeProvider } from './contexts/ThemeContext'; 5 | import { useAuth } from './hooks/useAuth'; 6 | import { LoginPage } from './components/auth/LoginPage'; 7 | import { RegisterPage } from './components/auth/RegisterPage'; 8 | import { OIDCCallback } from './components/auth/oidc/OIDCCallback'; 9 | import { ROUTES } from './constants/routes'; 10 | import { PageContainer } from './components/common/layout/PageContainer'; 11 | import { ToastProvider } from './contexts/ToastContext'; 12 | import SnippetStorage from './components/snippets/view/SnippetStorage'; 13 | import SharedSnippetView from './components/snippets/share/SharedSnippetView'; 14 | import SnippetPage from './components/snippets/view/SnippetPage'; 15 | import PublicSnippetStorage from './components/snippets/view/public/PublicSnippetStorage'; 16 | import EmbedView from './components/snippets/embed/EmbedView'; 17 | import RecycleSnippetStorage from './components/snippets/view/recycle/RecycleSnippetStorage'; 18 | import { OIDCLogoutCallback } from './components/auth/oidc/OIDCLogoutCallback'; 19 | 20 | const AuthenticatedApp: React.FC = () => { 21 | const { isAuthenticated, isLoading } = useAuth(); 22 | 23 | if (isLoading) { 24 | return ( 25 | 26 |
27 |
Loading...
28 |
29 |
30 | ); 31 | } 32 | 33 | if (!isAuthenticated) { 34 | return ; 35 | } 36 | 37 | return ; 38 | }; 39 | 40 | const EmbedViewWrapper: React.FC = () => { 41 | const { shareId } = useParams(); 42 | const searchParams = new URLSearchParams(window.location.search); 43 | 44 | if (!shareId) { 45 | return
Invalid share ID
; 46 | } 47 | 48 | const theme = searchParams.get('theme') as 'light' | 'dark' | 'system' | null; 49 | 50 | return ( 51 | 60 | ); 61 | }; 62 | 63 | const App: React.FC = () => { 64 | return ( 65 | 66 | 67 |
68 | 69 | 70 | 71 | } /> 72 | } /> 73 | } /> 74 | } /> 75 | } /> 76 | } /> 77 | } /> 78 | } /> 79 | } /> 80 | } /> 81 | 82 | 83 | 84 |
85 |
86 |
87 | ); 88 | }; 89 | 90 | export default App; 91 | -------------------------------------------------------------------------------- /client/src/components/snippets/share/SharedSnippetView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams, useNavigate, Link } from 'react-router-dom'; 3 | import { Loader2 } from 'lucide-react'; 4 | import { Snippet } from '../../../types/snippets'; 5 | import { useAuth } from '../../../hooks/useAuth'; 6 | import { getSharedSnippet } from '../../../utils/api/share'; 7 | import { FullCodeView } from '../view/FullCodeView'; 8 | import { ROUTES } from '../../../constants/routes'; 9 | 10 | const SharedSnippetView: React.FC = () => { 11 | const { shareId } = useParams<{ shareId: string }>(); 12 | const [snippet, setSnippet] = useState(null); 13 | const [error, setError] = useState(null); 14 | const [errorCode, setErrorCode] = useState(null); 15 | const [isLoading, setIsLoading] = useState(true); 16 | const { isAuthenticated } = useAuth(); 17 | const navigate = useNavigate(); 18 | 19 | useEffect(() => { 20 | loadSharedSnippet(); 21 | }, [shareId, isAuthenticated]); 22 | 23 | const loadSharedSnippet = async () => { 24 | if (!shareId) return; 25 | 26 | try { 27 | setIsLoading(true); 28 | const shared = await getSharedSnippet(shareId); 29 | setSnippet(shared); 30 | setError(null); 31 | setErrorCode(null); 32 | } catch (err: any) { 33 | setErrorCode(err.status); 34 | setError(err.error); 35 | 36 | if (err.status === 401 && !isAuthenticated) { 37 | navigate(`${ROUTES.LOGIN}`, { replace: true }); 38 | return; 39 | } 40 | } finally { 41 | setIsLoading(false); 42 | } 43 | }; 44 | 45 | if (isLoading) { 46 | return ( 47 |
48 |
49 | 50 | Loading snippet... 51 |
52 |
53 | ); 54 | } 55 | 56 | if (error) { 57 | return ( 58 |
59 |
{error}
60 | 64 | Browse public snippets 65 | 66 |
67 | ); 68 | } 69 | 70 | if (errorCode === 410) { 71 | return ( 72 |
73 |
This shared snippet has expired
74 | 78 | Browse public snippets 79 | 80 |
81 | ); 82 | } 83 | 84 | if (!snippet) { 85 | return ( 86 |
87 |
Snippet not found
88 | 92 | Browse public snippets 93 | 94 |
95 | ); 96 | } 97 | 98 | return ( 99 |
100 |
101 | 102 |
103 |
104 | ); 105 | }; 106 | 107 | export default SharedSnippetView; 108 | -------------------------------------------------------------------------------- /client/src/components/search/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { Search, X } from 'lucide-react'; 3 | import BaseDropdown, { BaseDropdownRef } from '../common/dropdowns/BaseDropdown'; 4 | import { IconButton } from '../common/buttons/IconButton'; 5 | import { useKeyboardShortcut } from '../../hooks/useKeyboardShortcut'; 6 | 7 | interface SearchBarProps { 8 | value: string; 9 | onChange: (value: string) => void; 10 | onCategorySelect: (category: string) => void; 11 | existingCategories: string[]; 12 | selectedCategories: string[]; 13 | placeholder?: string; 14 | } 15 | 16 | export const SearchBar: React.FC = ({ 17 | value, 18 | onChange, 19 | onCategorySelect, 20 | existingCategories, 21 | selectedCategories, 22 | placeholder = "Search snippets... (Type # to see all available categories)" 23 | }) => { 24 | const [inputValue, setInputValue] = useState(value); 25 | const lastValueRef = useRef(value); 26 | const inputRef = useRef(null); 27 | 28 | useEffect(() => { 29 | if (value !== lastValueRef.current) { 30 | setInputValue(value); 31 | lastValueRef.current = value; 32 | } 33 | }, [value]); 34 | 35 | // Focus the search input when "/" key is pressed 36 | useKeyboardShortcut({ 37 | key: '/', 38 | callback: () => { 39 | if (inputRef.current) { 40 | inputRef.current.focus(); 41 | } 42 | }, 43 | }); 44 | 45 | const getSections = (searchTerm: string) => { 46 | if (!searchTerm.includes('#')) return []; 47 | 48 | const term = searchTerm.slice(searchTerm.lastIndexOf('#') + 1).trim().toLowerCase(); 49 | const sections = []; 50 | 51 | const availableCategories = existingCategories.filter( 52 | cat => !selectedCategories.includes(cat.toLowerCase()) 53 | ); 54 | 55 | const filtered = term 56 | ? availableCategories.filter(cat => cat.toLowerCase().includes(term)) 57 | : availableCategories; 58 | 59 | if (filtered.length > 0) { 60 | sections.push({ 61 | title: 'Categories', 62 | items: filtered 63 | }); 64 | } 65 | 66 | if (term && !existingCategories.some(cat => cat.toLowerCase() === term)) { 67 | sections.push({ 68 | title: 'Add New', 69 | items: [`Add new: ${term}`] 70 | }); 71 | } 72 | 73 | return sections; 74 | }; 75 | 76 | const handleSelect = (option: string) => { 77 | const newCategory = option.startsWith('Add new:') 78 | ? option.slice(9).trim() 79 | : option; 80 | 81 | const hashtagIndex = inputValue.lastIndexOf('#'); 82 | if (hashtagIndex !== -1) { 83 | const newValue = inputValue.substring(0, hashtagIndex).trim(); 84 | setInputValue(newValue); 85 | onChange(newValue); 86 | } 87 | 88 | onCategorySelect(newCategory.toLowerCase()); 89 | }; 90 | 91 | return ( 92 |
93 | { 97 | setInputValue(value); 98 | onChange(value); 99 | }} 100 | onSelect={handleSelect} 101 | getSections={getSections} 102 | placeholder={placeholder} 103 | className="h-10 mt-0 bg-light-surface dark:bg-dark-surface" 104 | showChevron={false} 105 | /> 106 | {inputValue && ( 107 | } 109 | onClick={() => { 110 | setInputValue(''); 111 | onChange(''); 112 | }} 113 | variant="secondary" 114 | className="absolute right-3 top-1/2 -translate-y-1/2 mr-4 text-light-text-secondary dark:text-dark-text-secondary" 115 | label="Clear search" 116 | /> 117 | )} 118 | 122 |
123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /client/src/utils/downloadUtils.ts: -------------------------------------------------------------------------------- 1 | import JSZip from "jszip"; 2 | 3 | // Get file extension based on language 4 | export const getFileExtension = (language: string): string => { 5 | const extensionMap: Record = { 6 | javascript: "js", 7 | typescript: "ts", 8 | python: "py", 9 | java: "java", 10 | cpp: "cpp", 11 | "c++": "cpp", 12 | c: "c", 13 | csharp: "cs", 14 | "c#": "cs", 15 | php: "php", 16 | ruby: "rb", 17 | go: "go", 18 | rust: "rs", 19 | swift: "swift", 20 | kotlin: "kt", 21 | scala: "scala", 22 | perl: "pl", 23 | lua: "lua", 24 | r: "r", 25 | matlab: "m", 26 | shell: "sh", 27 | bash: "sh", 28 | powershell: "ps1", 29 | html: "html", 30 | css: "css", 31 | scss: "scss", 32 | sass: "sass", 33 | less: "less", 34 | xml: "xml", 35 | json: "json", 36 | yaml: "yml", 37 | yml: "yml", 38 | markdown: "md", 39 | sql: "sql", 40 | dockerfile: "dockerfile", 41 | vim: "vim", 42 | ini: "ini", 43 | toml: "toml", 44 | makefile: "makefile", 45 | gitignore: "gitignore", 46 | plaintext: "txt", 47 | text: "txt", 48 | }; 49 | const normalizedLanguage = language.toLowerCase().replace(/[^a-z0-9]/g, ""); 50 | return extensionMap[normalizedLanguage] || "txt"; 51 | }; 52 | 53 | // Download a file with given content and filename 54 | export const downloadFile = ( 55 | content: string | Blob, 56 | filename: string, 57 | mimeType: string = "text/plain" 58 | ): void => { 59 | const blob = 60 | content instanceof Blob ? content : new Blob([content], { type: mimeType }); 61 | const url = URL.createObjectURL(blob); 62 | const link = document.createElement("a"); 63 | link.href = url; 64 | link.download = filename; 65 | link.style.display = "none"; 66 | document.body.appendChild(link); 67 | link.click(); 68 | document.body.removeChild(link); 69 | URL.revokeObjectURL(url); 70 | }; 71 | 72 | // Download a code fragment with appropriate filename and extension 73 | export const downloadFragment = ( 74 | code: string, 75 | fileName: string, 76 | language: string 77 | ): void => { 78 | const hasExtension = fileName.includes("."); 79 | const finalFileName = hasExtension 80 | ? fileName 81 | : `${fileName}.${getFileExtension(language)}`; 82 | downloadFile(code, finalFileName); 83 | }; 84 | 85 | // Create and download a zip file containing multiple code fragments 86 | export const downloadSnippetArchive = async ( 87 | snippetTitle: string, 88 | fragments: Array<{ 89 | code: string; 90 | file_name: string; 91 | language: string; 92 | }> 93 | ): Promise => { 94 | try { 95 | const zip = new JSZip(); 96 | const folderName = 97 | snippetTitle.replace(/[^a-zA-Z0-9-_\s]/g, "").trim() || "snippet"; 98 | const folder = zip.folder(folderName); 99 | const usedFilenames = new Set(); 100 | fragments.forEach((fragment) => { 101 | const hasExtension = fragment.file_name.includes("."); 102 | const baseFileName = hasExtension 103 | ? fragment.file_name 104 | : `${fragment.file_name}.${getFileExtension(fragment.language)}`; 105 | let uniqueFileName = baseFileName; 106 | let counter = 1; 107 | while (usedFilenames.has(uniqueFileName)) { 108 | const nameWithoutExt = baseFileName.replace(/\.[^/.]+$/, ""); 109 | const ext = baseFileName.includes(".") 110 | ? baseFileName.split(".").pop() 111 | : ""; 112 | uniqueFileName = ext 113 | ? `${nameWithoutExt}_${counter}.${ext}` 114 | : `${nameWithoutExt}_${counter}`; 115 | counter++; 116 | } 117 | usedFilenames.add(uniqueFileName); 118 | folder?.file(uniqueFileName, fragment.code); 119 | }); 120 | const content = await zip.generateAsync({ type: "blob" }); 121 | const zipFileName = `${folderName}.zip`; 122 | downloadFile(content, zipFileName, "application/zip"); 123 | } catch (error) { 124 | console.error("Error creating zip file:", error); 125 | throw new Error( 126 | "Failed to create archive. Please try downloading files individually." 127 | ); 128 | } 129 | }; 130 | -------------------------------------------------------------------------------- /client/src/components/common/buttons/FileUploadButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { Upload } from "lucide-react"; 3 | import { 4 | processUploadedFile, 5 | ACCEPTED_FILE_EXTENSIONS, 6 | } from "../../../utils/fileUploadUtils"; 7 | import { useToast } from "../../../hooks/useToast"; 8 | 9 | interface FileUploadButtonProps { 10 | onFileProcessed: (fileData: { 11 | file_name: string; 12 | code: string; 13 | language: string; 14 | position: number; 15 | }) => void; 16 | onError: (error: string) => void; 17 | className?: string; 18 | multiple?: boolean; 19 | existingFragments?: Array<{ 20 | file_name: string; 21 | language: string; 22 | }>; 23 | } 24 | 25 | export const FileUploadButton: React.FC = ({ 26 | onFileProcessed, 27 | onError, 28 | className = "", 29 | multiple = true, 30 | existingFragments = [], 31 | }) => { 32 | const fileInputRef = useRef(null); 33 | const { addToast } = useToast(); 34 | 35 | const handleFileSelect = () => { 36 | fileInputRef.current?.click(); 37 | }; 38 | 39 | const handleFileChange = async ( 40 | event: React.ChangeEvent 41 | ) => { 42 | const files = event.target.files; 43 | if (!files || files.length === 0) return; 44 | 45 | let successCount = 0; 46 | let duplicateCount = 0; 47 | 48 | for (let i = 0; i < files.length; i++) { 49 | const file = files[i]; 50 | try { 51 | const fileData = await processUploadedFile(file); 52 | // Check for duplicates 53 | const isDuplicate = existingFragments.some( 54 | (existing) => 55 | existing.file_name === fileData.file_name && 56 | existing.language === fileData.language 57 | ); 58 | 59 | if (isDuplicate) { 60 | duplicateCount++; 61 | continue; 62 | } 63 | 64 | onFileProcessed(fileData); 65 | successCount++; 66 | } catch (error) { 67 | const errorMessage = `${ 68 | error instanceof Error ? error.message : "Unknown error" 69 | }`; 70 | onError(errorMessage); 71 | addToast(errorMessage, "error"); 72 | } 73 | } 74 | 75 | // Show success toast for successfully processed files 76 | if (successCount > 0) { 77 | if (successCount === 1 && files.length === 1) { 78 | addToast(`"${files[0].name}" uploaded successfully`, "success"); 79 | } else if (successCount === files.length) { 80 | addToast(`All ${successCount} files uploaded successfully`, "success"); 81 | } else { 82 | addToast( 83 | `${successCount} of ${files.length} files uploaded successfully`, 84 | "success" 85 | ); 86 | } 87 | } 88 | 89 | // Show summary toast if there were duplicates 90 | if (duplicateCount > 0) { 91 | if (duplicateCount === 1) { 92 | addToast(`Duplicate file detected`, "info"); 93 | } else { 94 | addToast(`${duplicateCount} duplicate files detected`, "info"); 95 | } 96 | } 97 | 98 | // Reset the input 99 | if (fileInputRef.current) { 100 | fileInputRef.current.value = ""; 101 | } 102 | }; 103 | 104 | return ( 105 | <> 106 | 115 | 128 | 129 | ); 130 | }; 131 | 132 | export default FileUploadButton; 133 | -------------------------------------------------------------------------------- /client/src/contexts/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useEffect } from 'react'; 2 | import { useToast } from '../hooks/useToast'; 3 | import { EVENTS } from '../constants/events'; 4 | import { anonymous, getAuthConfig, verifyToken } from '../utils/api/auth'; 5 | import type { User, AuthConfig } from '../types/user'; 6 | 7 | interface AuthContextType { 8 | isAuthenticated: boolean; 9 | isLoading: boolean; 10 | user: User | null; 11 | authConfig: AuthConfig | null; 12 | login: (token: string, user: User | null) => void; 13 | logout: () => void; 14 | refreshAuthConfig: () => Promise; 15 | } 16 | 17 | export const AuthContext = createContext(undefined); 18 | 19 | export interface AuthProviderProps { 20 | children: React.ReactNode; 21 | } 22 | 23 | export const AuthProvider: React.FC = ({ children }) => { 24 | const [isAuthenticated, setIsAuthenticated] = useState(false); 25 | const [user, setUser] = useState(null); 26 | const [authConfig, setAuthConfig] = useState(null); 27 | const [isLoading, setIsLoading] = useState(true); 28 | const { addToast } = useToast(); 29 | 30 | useEffect(() => { 31 | const handleAuthError = () => { 32 | localStorage.removeItem('token'); 33 | document.cookie = 'bytestash_token=; path=/; max-age=0'; 34 | setIsAuthenticated(false); 35 | setUser(null); 36 | }; 37 | 38 | window.addEventListener(EVENTS.AUTH_ERROR, handleAuthError); 39 | return () => window.removeEventListener(EVENTS.AUTH_ERROR, handleAuthError); 40 | }, [addToast]); 41 | 42 | useEffect(() => { 43 | const initializeAuth = async () => { 44 | try { 45 | const config = await getAuthConfig(); 46 | setAuthConfig(config); 47 | 48 | if (config.disableAccounts) { 49 | try { 50 | const response = await anonymous(); 51 | if (response.token && response.user) { 52 | login(response.token, response.user); 53 | } 54 | } catch (error) { 55 | console.error('Failed to create anonymous session:', error); 56 | addToast('Failed to initialize anonymous session', 'error'); 57 | } 58 | } else { 59 | const token = localStorage.getItem('token'); 60 | if (token) { 61 | const response = await verifyToken(); 62 | if (response.valid && response.user) { 63 | setIsAuthenticated(true); 64 | setUser(response.user); 65 | } else { 66 | localStorage.removeItem('token'); 67 | document.cookie = 'bytestash_token=; path=/; max-age=0'; 68 | } 69 | } 70 | } 71 | } catch (error) { 72 | console.error('Auth initialization error:', error); 73 | localStorage.removeItem('token'); 74 | document.cookie = 'bytestash_token=; path=/; max-age=0'; 75 | } finally { 76 | setIsLoading(false); 77 | } 78 | }; 79 | 80 | initializeAuth(); 81 | }, []); 82 | 83 | const login = (token: string, userData: User | null) => { 84 | localStorage.setItem('token', token); 85 | // Also set as httpOnly cookie for direct browser API access 86 | document.cookie = `bytestash_token=${token}; path=/; max-age=86400; SameSite=Lax`; 87 | setIsAuthenticated(true); 88 | setUser(userData); 89 | }; 90 | 91 | const logout = () => { 92 | localStorage.removeItem('token'); 93 | // Clear the cookie as well 94 | document.cookie = 'bytestash_token=; path=/; max-age=0'; 95 | setIsAuthenticated(false); 96 | setUser(null); 97 | addToast('Successfully logged out.', 'info'); 98 | }; 99 | 100 | const refreshAuthConfig = async () => { 101 | try { 102 | const config = await getAuthConfig(); 103 | setAuthConfig(config); 104 | } catch (error) { 105 | console.error('Error refreshing auth config:', error); 106 | } 107 | }; 108 | 109 | return ( 110 | 121 | {children} 122 | 123 | ); 124 | }; -------------------------------------------------------------------------------- /client/src/hooks/useSettings.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useTheme } from "../contexts/ThemeContext"; 3 | 4 | type Theme = "light" | "dark" | "system"; 5 | 6 | interface Settings { 7 | compactView: boolean; 8 | showCodePreview: boolean; 9 | previewLines: number; 10 | includeCodeInSearch: boolean; 11 | showCategories: boolean; 12 | expandCategories: boolean; 13 | showLineNumbers: boolean; 14 | theme: Theme; 15 | showFavorites?: boolean; 16 | } 17 | 18 | export const useSettings = () => { 19 | const { setTheme: setThemeContext } = useTheme(); 20 | const [viewMode, setViewMode] = useState<"grid" | "list">( 21 | () => (localStorage.getItem("viewMode") as "grid" | "list") || "grid" 22 | ); 23 | const [compactView, setCompactView] = useState( 24 | () => localStorage.getItem("compactView") === "true" 25 | ); 26 | const [showCodePreview, setShowCodePreview] = useState( 27 | () => localStorage.getItem("showCodePreview") !== "false" 28 | ); 29 | const [previewLines, setPreviewLines] = useState(() => 30 | parseInt(localStorage.getItem("previewLines") || "4", 10) 31 | ); 32 | const [includeCodeInSearch, setIncludeCodeInSearch] = useState( 33 | () => localStorage.getItem("includeCodeInSearch") === "true" 34 | ); 35 | const [showCategories, setShowCategories] = useState( 36 | () => localStorage.getItem("showCategories") !== "false" 37 | ); 38 | const [expandCategories, setExpandCategories] = useState( 39 | () => localStorage.getItem("expandCategories") === "true" 40 | ); 41 | const [showLineNumbers, setShowLineNumbers] = useState( 42 | () => localStorage.getItem("showLineNumbers") === "true" 43 | ); 44 | const [showFavorites, setShowFavorites] = useState( 45 | () => localStorage.getItem("showFavorites") === "true" 46 | ); 47 | const [theme, setThemeState] = useState(() => { 48 | const savedTheme = localStorage.getItem("theme"); 49 | return savedTheme === "light" || 50 | savedTheme === "dark" || 51 | savedTheme === "system" 52 | ? savedTheme 53 | : "system"; 54 | }); 55 | 56 | useEffect(() => { 57 | localStorage.setItem("viewMode", viewMode); 58 | }, [viewMode]); 59 | 60 | useEffect(() => { 61 | localStorage.setItem("compactView", compactView.toString()); 62 | }, [compactView]); 63 | 64 | useEffect(() => { 65 | localStorage.setItem("showCodePreview", showCodePreview.toString()); 66 | }, [showCodePreview]); 67 | 68 | useEffect(() => { 69 | localStorage.setItem("previewLines", previewLines.toString()); 70 | }, [previewLines]); 71 | 72 | useEffect(() => { 73 | localStorage.setItem("includeCodeInSearch", includeCodeInSearch.toString()); 74 | }, [includeCodeInSearch]); 75 | 76 | useEffect(() => { 77 | localStorage.setItem("showCategories", showCategories.toString()); 78 | }, [showCategories]); 79 | 80 | useEffect(() => { 81 | localStorage.setItem("expandCategories", expandCategories.toString()); 82 | }, [expandCategories]); 83 | 84 | useEffect(() => { 85 | localStorage.setItem("showLineNumbers", showLineNumbers.toString()); 86 | }, [showLineNumbers]); 87 | 88 | useEffect(() => { 89 | localStorage.setItem("showFavorites", showFavorites.toString()); 90 | }, [showFavorites]); 91 | 92 | useEffect(() => { 93 | localStorage.setItem("theme", theme); 94 | setThemeContext(theme); 95 | }, [theme, setThemeContext]); 96 | 97 | const updateSettings = (newSettings: Settings) => { 98 | setCompactView(newSettings.compactView); 99 | setShowCodePreview(newSettings.showCodePreview); 100 | setPreviewLines(newSettings.previewLines); 101 | setIncludeCodeInSearch(newSettings.includeCodeInSearch); 102 | setShowCategories(newSettings.showCategories); 103 | setExpandCategories(newSettings.expandCategories); 104 | setShowLineNumbers(newSettings.showLineNumbers); 105 | if (newSettings.showFavorites) { 106 | setShowFavorites(newSettings.showFavorites); 107 | } 108 | setThemeState(newSettings.theme); 109 | }; 110 | 111 | return { 112 | viewMode, 113 | setViewMode, 114 | compactView, 115 | showCodePreview, 116 | previewLines, 117 | includeCodeInSearch, 118 | showCategories, 119 | expandCategories, 120 | updateSettings, 121 | showLineNumbers, 122 | showFavorites, 123 | setShowFavorites, 124 | theme, 125 | }; 126 | }; 127 | -------------------------------------------------------------------------------- /client/src/utils/api/snippets.ts: -------------------------------------------------------------------------------- 1 | import { snippetService } from "../../service/snippetService"; 2 | import type { Snippet } from "../../types/snippets"; 3 | import { apiClient } from "./apiClient"; 4 | import { API_ENDPOINTS } from "../../constants/api"; 5 | import { createCustomEvent, EVENTS } from "../../constants/events"; 6 | 7 | export const fetchSnippets = async (): Promise => { 8 | try { 9 | return await snippetService.getAllSnippets(); 10 | } catch (error) { 11 | console.error("Error fetching snippets:", error); 12 | throw error; 13 | } 14 | }; 15 | 16 | export const fetchPublicSnippets = async (): Promise => { 17 | try { 18 | return await apiClient.get(`${API_ENDPOINTS.PUBLIC}`); 19 | } catch (error) { 20 | console.error("Error fetching public snippets:", error); 21 | throw error; 22 | } 23 | }; 24 | 25 | export const createSnippet = async ( 26 | snippet: Omit 27 | ): Promise => { 28 | try { 29 | const newSnippet = await snippetService.createSnippet(snippet); 30 | window.dispatchEvent(createCustomEvent(EVENTS.SNIPPET_UPDATED)); 31 | return newSnippet; 32 | } catch (error) { 33 | console.error("Error creating snippet:", error); 34 | throw error; 35 | } 36 | }; 37 | 38 | export const deleteSnippet = async (id: string): Promise => { 39 | try { 40 | await snippetService.deleteSnippet(id); 41 | window.dispatchEvent(createCustomEvent(EVENTS.SNIPPET_DELETED)); 42 | } catch (error) { 43 | console.error("Error deleting snippet:", error); 44 | throw error; 45 | } 46 | }; 47 | 48 | export const editSnippet = async ( 49 | id: string, 50 | snippet: Omit 51 | ): Promise => { 52 | try { 53 | const updatedSnippet = await snippetService.updateSnippet(id, snippet); 54 | window.dispatchEvent(createCustomEvent(EVENTS.SNIPPET_UPDATED)); 55 | return updatedSnippet; 56 | } catch (error) { 57 | console.error("Error updating snippet:", error); 58 | throw error; 59 | } 60 | }; 61 | 62 | export const getSnippetById = async (id: string): Promise => { 63 | try { 64 | return await snippetService.getSnippetById(id); 65 | } catch (error) { 66 | console.error("Error fetching snippet:", error); 67 | throw error; 68 | } 69 | }; 70 | 71 | export const getPublicSnippetById = async (id: string): Promise => { 72 | try { 73 | return await apiClient.get(`${API_ENDPOINTS.PUBLIC}/${id}`); 74 | } catch (error) { 75 | console.error("Error fetching public snippet:", error); 76 | throw error; 77 | } 78 | }; 79 | 80 | export const getRecycleSnippets = async (): Promise => { 81 | try { 82 | return await snippetService.getRecycleSnippets(); 83 | } catch (error) { 84 | console.error("Error fetching recycled snippets:", error); 85 | throw error; 86 | } 87 | }; 88 | 89 | export const restoreSnippetById = async (id: string): Promise => { 90 | try { 91 | await snippetService.restoreSnippet(id); 92 | } catch (error) { 93 | console.error("Error restoring snippet:", error); 94 | throw error; 95 | } 96 | }; 97 | 98 | export const moveToRecycleBin = async (id: string): Promise => { 99 | try { 100 | await snippetService.moveToRecycleBin(id); 101 | } catch (error) { 102 | console.error("Error moving snippet to recycle bin:", error); 103 | throw error; 104 | } 105 | }; 106 | 107 | export const setPinnedSnippet = async ( 108 | id: string, 109 | is_pinned: boolean 110 | ): Promise => { 111 | try { 112 | const updatedSnippet = await snippetService.setPinned(id, is_pinned); 113 | window.dispatchEvent(createCustomEvent(EVENTS.SNIPPET_UPDATED)); 114 | return updatedSnippet; 115 | } catch (error) { 116 | console.error("Error setting snippet pinned status:", error); 117 | throw error; 118 | } 119 | }; 120 | 121 | export const setFavoriteSnippet = async ( 122 | id: string, 123 | is_favorite: boolean 124 | ): Promise => { 125 | try { 126 | const updatedSnippet = await snippetService.setFavorite(id, is_favorite); 127 | window.dispatchEvent(createCustomEvent(EVENTS.SNIPPET_UPDATED)); 128 | return updatedSnippet; 129 | } catch (error) { 130 | console.error("Error setting snippet favorite status:", error); 131 | throw error; 132 | } 133 | }; 134 | -------------------------------------------------------------------------------- /client/src/components/snippets/view/public/PublicSnippetStorage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { useSettings } from "../../../../hooks/useSettings"; 4 | import { useToast } from "../../../../hooks/useToast"; 5 | import { useAuth } from "../../../../hooks/useAuth"; 6 | import { initializeMonaco } from "../../../../utils/language/languageUtils"; 7 | import SettingsModal from "../../../settings/SettingsModal"; 8 | import BaseSnippetStorage from "../common/BaseSnippetStorage"; 9 | import { fetchPublicSnippets } from "../../../../utils/api/snippets"; 10 | import { Snippet } from "../../../../types/snippets"; 11 | import { UserDropdown } from "../../../auth/UserDropdown"; 12 | import { ROUTES } from "../../../../constants/routes"; 13 | 14 | const PublicSnippetStorage: React.FC = () => { 15 | const { 16 | viewMode, 17 | setViewMode, 18 | compactView, 19 | showCodePreview, 20 | previewLines, 21 | includeCodeInSearch, 22 | updateSettings, 23 | showCategories, 24 | expandCategories, 25 | showLineNumbers, 26 | theme, 27 | } = useSettings(); 28 | 29 | const { isAuthenticated } = useAuth(); 30 | const { addToast } = useToast(); 31 | const navigate = useNavigate(); 32 | const [snippets, setSnippets] = useState([]); 33 | const [isLoading, setIsLoading] = useState(true); 34 | const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); 35 | 36 | useEffect(() => { 37 | initializeMonaco(); 38 | loadSnippets(); 39 | }, []); 40 | 41 | const loadSnippets = async () => { 42 | try { 43 | const fetchedSnippets = await fetchPublicSnippets(); 44 | setSnippets(fetchedSnippets); 45 | } catch (error) { 46 | console.error("Failed to load public snippets:", error); 47 | addToast("Failed to load public snippets", "error"); 48 | } finally { 49 | setIsLoading(false); 50 | } 51 | }; 52 | 53 | const handleDuplicate = async (snippet: Snippet) => { 54 | if (!isAuthenticated) { 55 | addToast("Please sign in to add this snippet to your collection", "info"); 56 | navigate(ROUTES.LOGIN); 57 | return; 58 | } 59 | 60 | try { 61 | const duplicatedSnippet: Omit< 62 | Snippet, 63 | "id" | "updated_at" | "share_count" | "username" 64 | > = { 65 | title: `${snippet.title}`, 66 | description: snippet.description, 67 | categories: [...snippet.categories], 68 | fragments: snippet.fragments.map((f) => ({ ...f })), 69 | is_public: 0, 70 | is_pinned: 0, 71 | is_favorite: 0, 72 | }; 73 | 74 | const { createSnippet } = await import("../../../../utils/api/snippets"); 75 | await createSnippet(duplicatedSnippet); 76 | addToast("Snippet added to your collection", "success"); 77 | } catch (error) { 78 | console.error("Failed to duplicate snippet:", error); 79 | addToast("Failed to add snippet to your collection", "error"); 80 | } 81 | }; 82 | 83 | return ( 84 | <> 85 | setIsSettingsModalOpen(true)} 98 | onNewSnippet={() => null} 99 | onDuplicate={handleDuplicate} 100 | headerRight={} 101 | isPublicView={true} 102 | isRecycleView={false} 103 | isAuthenticated={isAuthenticated} 104 | /> 105 | 106 | setIsSettingsModalOpen(false)} 109 | settings={{ 110 | compactView, 111 | showCodePreview, 112 | previewLines, 113 | includeCodeInSearch, 114 | showCategories, 115 | expandCategories, 116 | showLineNumbers, 117 | theme, 118 | }} 119 | onSettingsChange={updateSettings} 120 | snippets={[]} 121 | addSnippet={() => Promise.resolve({} as Snippet)} 122 | reloadSnippets={() => {}} 123 | isPublicView={true} 124 | /> 125 | 126 | ); 127 | }; 128 | 129 | export default PublicSnippetStorage; 130 | -------------------------------------------------------------------------------- /client/src/contexts/ToastContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useCallback } from 'react'; 2 | import { X, Info, CheckCircle, AlertTriangle, AlertCircle } from 'lucide-react'; 3 | 4 | export type ToastType = 'info' | 'success' | 'error' | 'warning'; 5 | 6 | export interface Toast { 7 | id: number; 8 | message: string; 9 | type: ToastType; 10 | duration: number | null; 11 | } 12 | 13 | export interface ToastContextType { 14 | addToast: (message: string, type?: ToastType, duration?: number | null) => void; 15 | removeToast: (id: number) => void; 16 | } 17 | 18 | export const ToastContext = createContext(undefined); 19 | 20 | interface ToastProps extends Toast { 21 | onClose: () => void; 22 | } 23 | 24 | const toastConfig = { 25 | info: { 26 | icon: Info, 27 | bgColor: 'bg-light-primary dark:bg-dark-primary', 28 | borderColor: 'border-light-primary dark:border-dark-primary', 29 | textColor: 'text-white', 30 | hoverColor: 'hover:bg-light-hover dark:hover:bg-dark-hover' 31 | }, 32 | success: { 33 | icon: CheckCircle, 34 | bgColor: 'bg-green-500 dark:bg-green-600', 35 | borderColor: 'border-green-600 dark:border-green-700', 36 | textColor: 'text-white', 37 | hoverColor: 'hover:bg-green-600 dark:hover:bg-green-700' 38 | }, 39 | error: { 40 | icon: AlertCircle, 41 | bgColor: 'bg-red-500 dark:bg-red-600', 42 | borderColor: 'border-red-600 dark:border-red-700', 43 | textColor: 'text-white', 44 | hoverColor: 'hover:bg-red-600 dark:hover:bg-red-700' 45 | }, 46 | warning: { 47 | icon: AlertTriangle, 48 | bgColor: 'bg-yellow-500 dark:bg-yellow-600', 49 | borderColor: 'border-yellow-600 dark:border-yellow-700', 50 | textColor: 'text-white', 51 | hoverColor: 'hover:bg-yellow-600 dark:hover:bg-yellow-700' 52 | }, 53 | } as const; 54 | 55 | const ToastComponent: React.FC = ({ 56 | message, 57 | type, 58 | duration, 59 | onClose 60 | }) => { 61 | const [progress, setProgress] = useState(100); 62 | const config = toastConfig[type]; 63 | const Icon = config.icon; 64 | 65 | React.useEffect(() => { 66 | const interval = setInterval(() => { 67 | setProgress(prev => { 68 | if (prev <= 0) { 69 | clearInterval(interval); 70 | return 0; 71 | } 72 | return prev - (100 / ((duration || 0) / 100)); 73 | }); 74 | }, 100); 75 | 76 | return () => clearInterval(interval); 77 | }, [duration]); 78 | 79 | return ( 80 |
83 |
84 | 85 |
86 |
87 |

{message}

88 |
89 | 96 |
100 |
101 | ); 102 | }; 103 | 104 | export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 105 | const [toasts, setToasts] = useState([]); 106 | 107 | const removeToast = useCallback((id: number) => { 108 | setToasts(prev => prev.filter(toast => toast.id !== id)); 109 | }, []); 110 | 111 | const addToast = useCallback(( 112 | message: string, 113 | type: ToastType = 'info', 114 | duration: number | null = 3000 115 | ) => { 116 | const id = Date.now(); 117 | setToasts(prev => [...prev, { id, message, type, duration }]); 118 | if (duration !== null) { 119 | setTimeout(() => removeToast(id), duration); 120 | } 121 | }, [removeToast]); 122 | 123 | return ( 124 | 125 | {children} 126 |
127 | {toasts.map(toast => ( 128 | removeToast(toast.id)} 132 | /> 133 | ))} 134 |
135 |
136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /helm-charts/bytestash/values.yaml: -------------------------------------------------------------------------------- 1 | ### ByteStash Helm Chart for Kubernetes 2 | ### Maintainer: Tin Trung Ngo - trungtinth1011@gmail.com 3 | 4 | ## Strings for naming overrides 5 | ## 6 | nameOverride: "" 7 | fullnameOverride: "" 8 | 9 | ## ServiceAccount configuration 10 | ## 11 | serviceAccount: 12 | # Specifies whether a service account should be created 13 | create: false 14 | # Annotations to add to the service account 15 | annotations: {} 16 | # The name of the service account to use. 17 | # If not set and create is true, a name is generated using the fullname template 18 | name: "" 19 | 20 | ### ByteStash configs 21 | ### ref: https://github.com/jordan-dalby/ByteStash/wiki 22 | ### 23 | replicaCount: 1 24 | 25 | ### Deployment strategy (default: RollingUpdate) 26 | ### Set to Recreate if using persistence with ReadWriteOnce volumes 27 | strategy: 28 | type: Recreate 29 | 30 | ### Enabling this will persist the `/data` directory with a Persistent Volume 31 | persistence: 32 | enabled: false 33 | storageClassName: "" 34 | size: 10Gi 35 | 36 | ### Basic configs 37 | bytestash: 38 | baseUrl: "" 39 | debug: false 40 | disableAccount: false 41 | disableAllAccount: false 42 | allowNewAccount: false 43 | jwtSecret: "" 44 | jwtExpirity: "" 45 | 46 | ### Existing Kubernetes Secret that contains JWT secret and JWT expiration time 47 | ### This will override the `bytestash.jwtSecret` and `bytestash.jwtExpirity` 48 | existingJwtSecret: 49 | secretName: "" 50 | jwtKey: "" 51 | expirityKey: "" 52 | 53 | ### Ref: https://github.com/jordan-dalby/ByteStash/wiki/Single-Sign%E2%80%90on-Setup 54 | oidc: 55 | enabled: false 56 | name: "Single Sign-on" 57 | issuerUrl: "" # Authentik, Authelia, or Keycloak 58 | clientId: "" 59 | clientSecret: "" 60 | scopes: "" 61 | 62 | imagePullSecrets: [] 63 | image: 64 | repository: ghcr.io/jordan-dalby/bytestash 65 | pullPolicy: IfNotPresent 66 | # Overrides the image tag whose default is the chart appVersion. 67 | tag: 1.5.7 68 | 69 | ## Array with extra environment variables to add to the pod. For example: 70 | ## extraEnv: 71 | ## - name: ENV01 72 | ## value: "value01" 73 | ## 74 | extraEnv: [] 75 | 76 | ## Map with extra environment variables fetched from existing secrets. For example: 77 | ## extraEnvSecrets: 78 | ## ENV02: 79 | ## name: extra-secret 80 | ## key: username 81 | extraEnvSecrets: {} 82 | 83 | ## Configure resource requests and limits 84 | ## ref: http://kubernetes.io/docs/user-guide/compute-resources/ 85 | ## 86 | resources: {} 87 | 88 | ## Configure liveness and readiness probes 89 | ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/ 90 | ## 91 | livenessProbe: 92 | httpGet: 93 | port: http 94 | initialDelaySeconds: 10 95 | periodSeconds: 10 96 | timeoutSeconds: 10 97 | failureThreshold: 3 98 | successThreshold: 1 99 | 100 | readinessProbe: 101 | httpGet: 102 | port: http 103 | initialDelaySeconds: 10 104 | periodSeconds: 10 105 | timeoutSeconds: 10 106 | failureThreshold: 3 107 | successThreshold: 1 108 | 109 | ## Extra annotations for pods 110 | ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ 111 | ## 112 | podAnnotations: {} 113 | 114 | ## Extra labels for pods 115 | ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ 116 | ## 117 | podLabels: {} 118 | 119 | ## Configure Pods Security Context 120 | ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-pod 121 | ## 122 | podSecurityContext: 123 | {} 124 | # fsGroup: 1000 125 | # runAsGroup: 1000 126 | # runAsNonRoot: true 127 | # runAsUser: 1000 128 | 129 | ## Configure Container Security Context 130 | ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-container 131 | ## 132 | containerSecurityContext: 133 | {} 134 | # capabilities: 135 | # drop: 136 | # - ALL 137 | # readOnlyRootFilesystem: true 138 | # runAsNonRoot: true 139 | # runAsUser: 1000 140 | 141 | ## ByteStash service parameters 142 | ## 143 | service: 144 | type: ClusterIP 145 | port: 5000 146 | annotations: {} 147 | externalIPs: [] 148 | loadBalancerIP: "" 149 | loadBalancerSourceRanges: [] 150 | 151 | ## ByteStash ingress parameters 152 | ## 153 | ingress: 154 | enabled: false 155 | className: "" 156 | annotations: 157 | {} 158 | # kubernetes.io/tls-acme: "true" 159 | host: chart-example.local 160 | path: / 161 | pathType: ImplementationSpecific 162 | tls: [] 163 | 164 | ## Node labels selector for pods assignment 165 | ## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ 166 | ## 167 | nodeSelector: {} 168 | 169 | ## Tolerations for pods assignment 170 | ## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ 171 | ## 172 | tolerations: [] 173 | 174 | ## Affinity for pods assignment 175 | ## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#affinity-and-anti-affinity 176 | ## 177 | affinity: {} 178 | -------------------------------------------------------------------------------- /client/src/components/common/modals/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { X, Maximize2, Minimize2, Trash2, Pencil } from "lucide-react"; 3 | import { IconButton } from "../buttons/IconButton"; 4 | 5 | export interface ModalProps { 6 | isOpen: boolean; 7 | onClose: () => void; 8 | title?: React.ReactNode; 9 | children: React.ReactNode; 10 | width?: string; 11 | expandable?: boolean; 12 | defaultExpanded?: boolean; 13 | onEdit?: () => void; 14 | onDelete?: () => void; 15 | contentRef?: React.Ref; 16 | } 17 | 18 | const Modal: React.FC = ({ 19 | isOpen, 20 | onClose, 21 | title, 22 | children, 23 | width = "max-w-3xl", 24 | expandable = false, 25 | defaultExpanded = false, 26 | onEdit, 27 | onDelete, 28 | contentRef, 29 | }) => { 30 | const [isExpanded, setIsExpanded] = useState(() => { 31 | if (expandable) { 32 | const savedState = localStorage.getItem("modalExpandedState"); 33 | return savedState !== null ? savedState === "true" : defaultExpanded; 34 | } 35 | return defaultExpanded; 36 | }); 37 | 38 | useEffect(() => { 39 | if (expandable) { 40 | localStorage.setItem("modalExpandedState", isExpanded.toString()); 41 | } 42 | }, [isExpanded, expandable]); 43 | 44 | const modalRef = useRef(null); 45 | 46 | // Handle outside click + escape 47 | useEffect(() => { 48 | const handleClickOutside = (event: MouseEvent) => { 49 | const isBackdropClick = (event.target as HTMLElement).classList.contains( 50 | "modal-backdrop" 51 | ); 52 | if (isBackdropClick && modalRef.current?.parentElement === event.target) { 53 | onClose(); 54 | } 55 | }; 56 | 57 | const handleEscapeKey = (event: KeyboardEvent) => { 58 | if (event.key === "Escape" && isOpen) { 59 | onClose(); 60 | } 61 | }; 62 | 63 | if (isOpen) { 64 | document.body.style.overflow = "hidden"; 65 | document.addEventListener("mousedown", handleClickOutside); 66 | document.addEventListener("keydown", handleEscapeKey); 67 | } 68 | 69 | return () => { 70 | document.body.style.overflow = "unset"; 71 | document.removeEventListener("mousedown", handleClickOutside); 72 | document.removeEventListener("keydown", handleEscapeKey); 73 | }; 74 | }, [isOpen, onClose]); 75 | 76 | const modalWidth = isExpanded ? "max-w-[90vw]" : width; 77 | 78 | return ( 79 |
83 |
92 | {/* Header */} 93 |
94 |
95 | {title} 96 |
97 |
98 | {expandable && ( 99 | : 102 | } 103 | onClick={() => setIsExpanded(!isExpanded)} 104 | variant="secondary" 105 | size="sm" 106 | label={isExpanded ? "Minimize" : "Maximize"} 107 | /> 108 | )} 109 | {onEdit && ( 110 | } 112 | onClick={onEdit} 113 | variant="secondary" 114 | size="sm" 115 | label="Edit" 116 | /> 117 | )} 118 | {onDelete && ( 119 | } 121 | onClick={onDelete} 122 | variant="secondary" 123 | size="sm" 124 | label="Delete" 125 | /> 126 | )} 127 | 133 |
134 |
135 | 136 | {/* Single scrollable area connected to external ref */} 137 |
138 | {children} 139 |
140 |
141 |
142 | ); 143 | }; 144 | 145 | export default Modal; 146 | -------------------------------------------------------------------------------- /server/src/config/database.js: -------------------------------------------------------------------------------- 1 | import Database from "better-sqlite3"; 2 | import { dirname, join } from "path"; 3 | import fs from "fs"; 4 | import { up_v1_4_0 } from "./migrations/20241111-migration.js"; 5 | import { up_v1_5_0 } from "./migrations/20241117-migration.js"; 6 | import Logger from "../logger.js"; 7 | import { up_v1_5_0_public } from "./migrations/20241119-migration.js"; 8 | import { up_v1_5_0_oidc } from "./migrations/20241120-migration.js"; 9 | import { fileURLToPath } from "url"; 10 | import { up_v1_5_0_usernames } from "./migrations/20241121-migration.js"; 11 | import { up_v1_5_1_api_keys } from "./migrations/20241122-migration.js"; 12 | import { up_v1_6_0_snippet_expiry } from "./migrations/20250601-migration.js"; 13 | import { up_v1_7_0_snippet_pin_favorite } from "./migrations/20250905-migration.js"; 14 | import path from "path"; 15 | let db = null; 16 | let checkpointInterval = null; 17 | 18 | const __filename = fileURLToPath(import.meta.url); 19 | const __dirname = dirname(__filename); 20 | 21 | function getDatabasePath() { 22 | const dbPath = join(__dirname, "../../../data/snippets"); 23 | if (!fs.existsSync(dbPath)) { 24 | fs.mkdirSync(dbPath, { recursive: true }); 25 | } 26 | return join(dbPath, "snippets.db"); 27 | } 28 | 29 | function checkpointDatabase() { 30 | if (!db) return; 31 | 32 | try { 33 | Logger.debug("Starting database checkpoint..."); 34 | const start = Date.now(); 35 | 36 | db.pragma("wal_checkpoint(PASSIVE)"); 37 | 38 | const duration = Date.now() - start; 39 | Logger.debug(`Database checkpoint completed in ${duration}ms`); 40 | } catch (error) { 41 | Logger.error("Error during database checkpoint:", error); 42 | } 43 | } 44 | 45 | function startCheckpointInterval() { 46 | const CHECKPOINT_INTERVAL = 5 * 60 * 1000; 47 | 48 | if (checkpointInterval) { 49 | clearInterval(checkpointInterval); 50 | } 51 | 52 | checkpointInterval = setInterval(checkpointDatabase, CHECKPOINT_INTERVAL); 53 | } 54 | 55 | function stopCheckpointInterval() { 56 | if (checkpointInterval) { 57 | clearInterval(checkpointInterval); 58 | checkpointInterval = null; 59 | } 60 | } 61 | 62 | function backupDatabase(dbPath) { 63 | const baseBackupPath = `${dbPath}.backup`; 64 | checkpointDatabase(); 65 | 66 | try { 67 | if (fs.existsSync(dbPath)) { 68 | const dbBackupPath = `${baseBackupPath}.db`; 69 | fs.copyFileSync(dbPath, dbBackupPath); 70 | Logger.debug(`Database backed up to: ${dbBackupPath}`); 71 | } else { 72 | Logger.error(`Database file not found: ${dbPath}`); 73 | return false; 74 | } 75 | return true; 76 | } catch (error) { 77 | Logger.error("Failed to create database backup:", error); 78 | throw error; 79 | } 80 | } 81 | 82 | function createInitialSchema(db) { 83 | const initSQL = fs.readFileSync( 84 | path.join(__dirname, "schema/init.sql"), 85 | "utf8" 86 | ); 87 | Logger.debug("Init SQL Path:", path.join(__dirname, "schema/init.sql")); 88 | db.exec(initSQL); 89 | Logger.debug("✅ Initial schema executed"); 90 | } 91 | 92 | function initializeDatabase() { 93 | try { 94 | const dbPath = getDatabasePath(); 95 | Logger.debug(`Initializing SQLite database at: ${dbPath}`); 96 | 97 | const dbExists = fs.existsSync(dbPath); 98 | 99 | db = new Database(dbPath, { 100 | verbose: Logger.debug, 101 | fileMustExist: false, 102 | }); 103 | 104 | db.pragma("foreign_keys = ON"); 105 | db.pragma("journal_mode = WAL"); 106 | 107 | backupDatabase(dbPath); 108 | 109 | if (!dbExists) { 110 | Logger.debug("Creating new database with initial schema..."); 111 | createInitialSchema(db); 112 | } else { 113 | Logger.debug("Database file exists, checking for needed migrations..."); 114 | up_v1_4_0(db); 115 | up_v1_5_0(db); 116 | up_v1_5_0_public(db); 117 | up_v1_5_0_oidc(db); 118 | up_v1_5_0_usernames(db); 119 | up_v1_5_1_api_keys(db); 120 | up_v1_6_0_snippet_expiry(db); 121 | up_v1_7_0_snippet_pin_favorite(db); 122 | Logger.debug("All migrations applied successfully"); 123 | } 124 | 125 | startCheckpointInterval(); 126 | 127 | Logger.debug("Database initialization completed successfully"); 128 | return db; 129 | } catch (error) { 130 | Logger.error("Database initialization error:", error); 131 | throw error; 132 | } 133 | } 134 | 135 | function getDb() { 136 | if (!db) { 137 | throw new Error( 138 | "Database not initialized. Call initializeDatabase() first." 139 | ); 140 | } 141 | return db; 142 | } 143 | 144 | function shutdownDatabase() { 145 | if (db) { 146 | try { 147 | Logger.debug("Performing final database checkpoint..."); 148 | db.pragma("wal_checkpoint(TRUNCATE)"); 149 | 150 | stopCheckpointInterval(); 151 | db.close(); 152 | db = null; 153 | 154 | Logger.debug("Database shutdown completed successfully"); 155 | } catch (error) { 156 | Logger.error("Error during database shutdown:", error); 157 | throw error; 158 | } 159 | } 160 | } 161 | 162 | export { initializeDatabase, getDb, shutdownDatabase, checkpointDatabase }; 163 | -------------------------------------------------------------------------------- /client/src/components/auth/UserDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react'; 2 | import { LogOut, User, Key, Lock } from 'lucide-react'; 3 | import { useAuth } from '../../hooks/useAuth'; 4 | import { useOutsideClick } from '../../hooks/useOutsideClick'; 5 | import { Link } from 'react-router-dom'; 6 | import { ApiKeysModal } from './ApiKeysModal'; 7 | import { ChangePasswordModal } from './ChangePasswordModal'; 8 | import { apiClient } from '../../utils/api/apiClient'; 9 | import { OIDCConfig } from '../../types/auth'; 10 | 11 | export const UserDropdown: React.FC = () => { 12 | const [isOpen, setIsOpen] = useState(false); 13 | const [isApiKeysModalOpen, setIsApiKeysModalOpen] = useState(false); 14 | const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); 15 | const dropdownRef = useRef(null); 16 | const { user, logout, authConfig } = useAuth(); 17 | const [oidcConfig, setOIDCConfig] = useState(null); 18 | 19 | 20 | useEffect(() => { 21 | const fetchOIDCConfig = async () => { 22 | try { 23 | const response = await apiClient.get('/api/auth/oidc/config'); 24 | setOIDCConfig(response); 25 | } catch (error) { 26 | console.error('Failed to fetch OIDC config:', error); 27 | } 28 | }; 29 | 30 | fetchOIDCConfig(); 31 | }, []); 32 | 33 | if (user?.id === 0) { 34 | return (<>) 35 | } 36 | 37 | useOutsideClick(dropdownRef, () => setIsOpen(false)); 38 | 39 | const handlePasswordChanged = () => { 40 | // Log out the user after password change to force re-login 41 | oidcConfig?.enabled && oidcConfig?.logged_in ? handleOIDCLogout() : logout(); 42 | }; 43 | 44 | const handleOIDCLogout = async () => { 45 | window.location.href = `${window.__BASE_PATH__ || ''}/api/auth/oidc/logout`; 46 | }; 47 | 48 | if (user) { 49 | return ( 50 |
51 | 59 | 60 | {isOpen && ( 61 |
65 | 76 | {!user.oidc_id && authConfig?.allowPasswordChanges && ( 77 | 88 | )} 89 | 100 |
101 | )} 102 | 103 | setIsApiKeysModalOpen(false)} 106 | /> 107 | 108 | setIsChangePasswordModalOpen(false)} 111 | onPasswordChanged={handlePasswordChanged} 112 | /> 113 |
114 | ); 115 | } 116 | 117 | return ( 118 |
119 | 124 | 125 | Sign in 126 | 127 |
128 | ); 129 | }; 130 | -------------------------------------------------------------------------------- /client/src/components/editor/CodeEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState } from 'react'; 2 | import Editor, { OnMount } from '@monaco-editor/react'; 3 | import * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; 4 | import { getMonacoLanguage } from '../../utils/language/languageUtils'; 5 | import { useTheme } from '../../contexts/ThemeContext'; 6 | 7 | export interface CodeEditorProps { 8 | code: string; 9 | language?: string; 10 | onValueChange: (value?: string) => void; 11 | showLineNumbers: boolean; 12 | minHeight?: string; 13 | maxHeight?: string; 14 | } 15 | 16 | export const CodeEditor: React.FC = ({ 17 | code, 18 | language = 'plaintext', 19 | onValueChange, 20 | showLineNumbers = true, 21 | minHeight = "100px", 22 | maxHeight = "500px" 23 | }) => { 24 | const editorRef = useRef(null); 25 | const containerRef = useRef(null); 26 | const monacoLanguage = getMonacoLanguage(language); 27 | const [editorHeight, setEditorHeight] = useState(minHeight); 28 | const { theme } = useTheme(); 29 | const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>( 30 | theme === 'system' 31 | ? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 32 | : theme 33 | ); 34 | 35 | useEffect(() => { 36 | const updateEffectiveTheme = () => { 37 | if (theme === 'system') { 38 | setEffectiveTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); 39 | } else { 40 | setEffectiveTheme(theme); 41 | } 42 | }; 43 | 44 | updateEffectiveTheme(); 45 | 46 | if (theme === 'system') { 47 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 48 | mediaQuery.addEventListener('change', updateEffectiveTheme); 49 | return () => mediaQuery.removeEventListener('change', updateEffectiveTheme); 50 | } 51 | }, [theme]); 52 | 53 | const isDark = effectiveTheme === 'dark'; 54 | 55 | useEffect(() => { 56 | if (editorRef.current) { 57 | const currentValue = editorRef.current.getValue(); 58 | if (currentValue !== code) { 59 | editorRef.current.setValue(code); 60 | } 61 | } 62 | }, [code]); 63 | 64 | const updateEditorHeight = () => { 65 | if (!editorRef.current) return; 66 | 67 | const editor = editorRef.current; 68 | const contentHeight = editor.getContentHeight(); 69 | const minHeightPx = parseInt(minHeight); 70 | const maxHeightPx = parseInt(maxHeight); 71 | 72 | const newHeight = Math.min(maxHeightPx, Math.max(minHeightPx, contentHeight)); 73 | 74 | setEditorHeight(`${newHeight}px`); 75 | 76 | const shouldShowScrollbar = contentHeight > maxHeightPx; 77 | editor.updateOptions({ 78 | scrollbar: { 79 | vertical: shouldShowScrollbar ? 'visible' : 'hidden', 80 | horizontal: 'visible', 81 | verticalScrollbarSize: 12, 82 | horizontalScrollbarSize: 12, 83 | } 84 | }); 85 | 86 | editor.layout(); 87 | }; 88 | 89 | const handleEditorDidMount: OnMount = (editor) => { 90 | editorRef.current = editor; 91 | 92 | editor.onDidContentSizeChange(() => { 93 | window.requestAnimationFrame(updateEditorHeight); 94 | }); 95 | 96 | updateEditorHeight(); 97 | }; 98 | 99 | useEffect(() => { 100 | if (editorRef.current) { 101 | const model = editorRef.current.getModel(); 102 | if (model) { 103 | Monaco.editor.setModelLanguage(model, monacoLanguage); 104 | updateEditorHeight(); 105 | } 106 | } 107 | }, [monacoLanguage]); 108 | 109 | return ( 110 |
111 | { 116 | onValueChange?.(value); 117 | setTimeout(updateEditorHeight, 10); 118 | }} 119 | onMount={handleEditorDidMount} 120 | theme={isDark ? "vs-dark" : "light"} 121 | options={{ 122 | minimap: { enabled: false }, 123 | scrollBeyondLastLine: false, 124 | fontSize: 13, 125 | lineNumbers: showLineNumbers ? 'on' : 'off', 126 | renderLineHighlight: 'all', 127 | wordWrap: 'on', 128 | wrappingIndent: 'indent', 129 | automaticLayout: true, 130 | folding: false, 131 | tabSize: 4, 132 | formatOnPaste: true, 133 | formatOnType: true, 134 | padding: { top: 12, bottom: 12 }, 135 | lineDecorationsWidth: showLineNumbers ? 24 : 50, 136 | overviewRulerBorder: false, 137 | scrollbar: { 138 | alwaysConsumeMouseWheel: false, // Fixes an issue where scrolling to end of code block did not allow further scrolling 139 | vertical: 'visible', 140 | horizontal: 'visible', 141 | verticalScrollbarSize: 12, 142 | horizontalScrollbarSize: 12, 143 | useShadows: false 144 | } 145 | }} 146 | /> 147 |
148 | ); 149 | }; 150 | --------------------------------------------------------------------------------