├── .cursorrules ├── .dockerignore ├── .env.example ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── nodemon.json ├── package-lock.json ├── package.json ├── public ├── Assets │ ├── dumbpad.png │ ├── dumbpad.svg │ └── styles.css ├── app.js ├── index.html ├── login.html ├── managers │ ├── collaboration.js │ ├── confirmation.js │ ├── cursor-manager.js │ ├── operations.js │ ├── search.js │ ├── settings.js │ ├── storage.js │ └── toaster.js └── service-worker.js ├── scripts ├── cors.js ├── generate-png.js └── pwa-manifest-generator.js └── server.js /.cursorrules: -------------------------------------------------------------------------------- 1 | /** 2 | * Cursor rules for maintaining code quality and consistency 3 | */ 4 | 5 | { 6 | "rules": { 7 | "file-header-docs": { 8 | "description": "All source files must have a header comment explaining their purpose", 9 | "pattern": "src/**/*.js", 10 | "check": { 11 | "type": "regex", 12 | "value": "^/\\*\\*\\n \\* [^\\n]+\\n \\* [^\\n]+\\n \\* [^\\n]+\\n \\*/\\n", 13 | "message": "File must start with a header comment block (3 lines) explaining its purpose" 14 | } 15 | } 16 | } 17 | } 18 | 19 | # Project Principles 20 | 21 | # Code Philosophy 22 | - Keep code simple, smart, and follow best practices 23 | - Don't over-engineer for the sake of engineering 24 | - Use standard conventions and patterns 25 | - Write human-readable code 26 | - Keep it simple so the app just works 27 | - Follow the principle: "Make it work, make it right, make it fast" 28 | - Comments should explain "why" behind the code in more complex functions 29 | - Overcommented code is better than undercommented code 30 | 31 | # Commit Conventions 32 | - Use Conventional Commits format: 33 | - feat: new features 34 | - fix: bug fixes 35 | - docs: documentation changes 36 | - style: formatting, missing semi colons, etc. 37 | - refactor: code changes that neither fix bugs nor add features 38 | - test: adding or modifying tests 39 | - chore: updating build tasks, package manager configs, etc. 40 | - Each commit should be atomic and focused 41 | - Write clear, descriptive commit messages 42 | 43 | # Project Structure 44 | 45 | # Root Directory 46 | - Keep root directory clean with only essential files 47 | - Production configuration files in root: 48 | - docker-compose.yml 49 | - Dockerfile 50 | - .env.example 51 | - package.json 52 | - README.md 53 | 54 | # Source Code (/src) 55 | - All application source code in /src directory 56 | - app.js: Application setup and configuration 57 | - server.js: Server entry point 58 | - routes/: Route handlers 59 | - middleware/: Custom middleware 60 | - utils/: Helper functions and utilities 61 | - models/: Data models (if applicable) 62 | - services/: Business logic 63 | 64 | # Development 65 | - All development configurations in /dev directory 66 | - Development specific files: 67 | - /dev/docker-compose.dev.yml 68 | - /dev/.env.dev.example 69 | - /dev/README.md (development setup instructions) 70 | 71 | # Static Assets and Uploads 72 | - Static assets in /public directory 73 | - Upload directories: 74 | - /uploads (production) 75 | - /local_uploads (local development) 76 | 77 | # Documentation 78 | - Main README.md in root focuses on production deployment 79 | - Development documentation in /dev/README.md 80 | - Code must be self-documenting with clear naming 81 | - Complex logic must include comments explaining "why" not "what" 82 | - JSDoc comments for public functions and APIs 83 | 84 | # Docker Configuration 85 | - Use environment-specific .dockerignore files: 86 | - .dockerignore: Production defaults (most restrictive) 87 | - dev/.dockerignore: Development-specific (allows test/dev files) 88 | - Production .dockerignore should exclude: 89 | - All test files and configurations 90 | - Development-only dependencies 91 | - Documentation and non-essential files 92 | - Local development configurations 93 | - Development .dockerignore should: 94 | - Allow test files and configurations 95 | - Allow development dependencies 96 | - Still exclude node_modules and sensitive files 97 | - Keep Docker-specific files excluded 98 | - Docker Compose configurations: 99 | - Production: docker-compose.yml in root 100 | - Development: docker-compose.dev.yml in /dev 101 | - Use BuildKit features when needed 102 | - Document any special build arguments 103 | - Multi-stage builds: 104 | - Use appropriate base images 105 | - Minimize final image size 106 | - Separate development and production stages 107 | - Use specific version tags for base images 108 | 109 | # Code Style 110 | - Follow ESLint and Prettier configurations 111 | - Use meaningful variable and function names 112 | - Keep functions small and focused 113 | - Maximum line length: 100 characters 114 | - Use modern JavaScript features appropriately 115 | - Prefer clarity over cleverness 116 | - Add logging when appropriate and environment variable DEBUG is set to true 117 | 118 | # Code Organization and Modularity 119 | - Break complex functionality into separate modules 120 | - Each module should have a single responsibility 121 | - Modules should be self-contained with clear interfaces 122 | - Avoid circular dependencies between modules 123 | - Keep module files under 300 lines when possible 124 | - Export only what is necessary from each module 125 | - Group related functionality in the same directory 126 | 127 | # Theme and Styling 128 | - Maintain consistent theme colors across the application: 129 | - Light theme colors: 130 | - Background: #ffffff 131 | - Text: #1a1a1a 132 | - Primary: #2563eb 133 | - Secondary: #64748b 134 | - Dark theme colors: 135 | - Background: #1a1a1a 136 | - Text: #ffffff 137 | - Primary: #3b82f6 138 | - Secondary: #94a3b8 139 | - Theme toggle must be present on all pages 140 | - Theme preference must persist in localStorage 141 | - System theme preference should be respected by default 142 | 143 | # Security and Authentication 144 | - PIN authentication logic in login.html must not be modified without verification and override from the owner 145 | - PIN input fields must: 146 | - Use type="password" 147 | - Have numeric validation 148 | - Support paste functionality 149 | - Auto-advance on input 150 | - Support backspace navigation 151 | - Maintain brute force protection with: 152 | - Maximum attempt limits 153 | - Lockout periods 154 | - Constant-time PIN comparison 155 | - Session management must use secure cookies -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | data 4 | .git 5 | .gitignore 6 | .dockerignore 7 | Dockerfile 8 | README.md 9 | .env 10 | .env.* 11 | !.env.example -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | PORT=3000 3 | 4 | # Security 5 | # Set a PIN (4-10 digits) for access protection, remove or leave empty for no protection 6 | DUMBPAD_PIN= 7 | 8 | # Customization 9 | # Set a custom site title, defaults to "DumbPad" if not set 10 | SITE_TITLE= 11 | 12 | # Base URL Configuration 13 | # Set the base URL for the application, defaults to http://localhost:PORT if not set 14 | BASE_URL= 15 | 16 | # Node Environment Configuration 17 | # Defaults to production if empty (development allows all cors origins by default) - development | production 18 | NODE_ENV= 19 | 20 | # Use ALLOWED_ORIGINS below to allow all origins or specify a list 21 | # Usage: '*' to allow all OR Comma-separated list of urls: 'http://internalip:port,https://base.proxy.tld,https://authprovider.domain.tld' 22 | # ALLOWED_ORIGINS: '*' 23 | 24 | # Customize pin lockout time (if empty, defaults to 15 in minutes) 25 | # LOCKOUT_TIME: 15 26 | 27 | # Customize pin max attempts (if empty, defaults to 5) 28 | # MAX_ATTEMPTS: 5 29 | 30 | # # Default 15 (in minutes) 31 | # LOCKOUT_TIME=15 32 | 33 | # Customize maximum age of cookies primarily used for pin verification (default 24) in hours 34 | # COOKIE_MAX_AGE=24 35 | 36 | # Customize age of cookie to show the last notepad opened (default 365 | max 400) in days - shows default notepad on load if expired 37 | # PAGE_HISTORY_COOKIE_AGE=365 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: Your Informative Title Here 5 | labels: '' 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 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | 30 | **Smartphone (please complete the following information):** 31 | - Device: [e.g. iPhone6] 32 | - OS: [e.g. iOS8.1] 33 | - Browser [e.g. stock browser, safari] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 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 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Trigger the workflow on pushes to the main branch 7 | 8 | env: 9 | DOCKER_IMAGE: dumbwareio/dumbpad 10 | PLATFORMS: linux/amd64,linux/arm64 11 | 12 | jobs: 13 | build-and-push: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | with: 24 | platforms: 'arm64,amd64' 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v3 28 | with: 29 | driver-opts: | 30 | image=moby/buildkit:latest 31 | network=host 32 | 33 | - name: Cache npm dependencies 34 | uses: actions/cache@v3 35 | with: 36 | path: ~/.npm 37 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-node- 40 | 41 | - name: Log in to Docker Hub 42 | uses: docker/login-action@v3 43 | with: 44 | username: ${{ secrets.DOCKER_USERNAME }} 45 | password: ${{ secrets.DOCKER_PASSWORD }} 46 | 47 | - name: Extract version from package.json 48 | id: package_version 49 | run: | 50 | VERSION=$(node -p "require('./package.json').version") 51 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 52 | echo "Extracted version: $VERSION" 53 | 54 | - name: Set Docker tags 55 | id: docker_meta 56 | run: | 57 | # Always add the version tag 58 | echo "VERSION_TAG=${{ env.DOCKER_IMAGE }}:${{ steps.package_version.outputs.VERSION }}" >> $GITHUB_OUTPUT 59 | 60 | # Add branch-specific tags 61 | if [ "${{ github.ref }}" = "refs/heads/main" ]; then 62 | echo "ADDITIONAL_TAG=${{ env.DOCKER_IMAGE }}:latest" >> $GITHUB_OUTPUT 63 | echo "Using tags: ${{ steps.package_version.outputs.VERSION_TAG }}, ${{ env.DOCKER_IMAGE }}:latest" 64 | elif [ "${{ github.ref }}" = "refs/heads/testing" ]; then 65 | echo "ADDITIONAL_TAG=${{ env.DOCKER_IMAGE }}:testing" >> $GITHUB_OUTPUT 66 | echo "Using tags: ${{ env.DOCKER_IMAGE }}:${{ steps.package_version.outputs.VERSION }}, ${{ env.DOCKER_IMAGE }}:testing" 67 | else 68 | echo "Using tag: ${{ env.DOCKER_IMAGE }}:${{ steps.package_version.outputs.VERSION }}" 69 | fi 70 | 71 | - name: Build and Push Multi-Platform Image 72 | uses: docker/build-push-action@v5 73 | with: 74 | context: . 75 | platforms: ${{ env.PLATFORMS }} 76 | push: true 77 | tags: | 78 | ${{ steps.docker_meta.outputs.VERSION_TAG}} 79 | ${{ steps.docker_meta.outputs.ADDITIONAL_TAG }} 80 | cache-from: type=gha 81 | cache-to: type=gha,mode=max 82 | provenance: false 83 | build-args: | 84 | BUILDKIT_INLINE_CACHE=1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # Environment files 76 | .env 77 | .env.local 78 | .env.*.local 79 | 80 | # Keep example file 81 | !.env.example 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | .cache 107 | 108 | # Docusaurus cache and generated files 109 | .docusaurus 110 | 111 | # Serverless directories 112 | .serverless/ 113 | 114 | # FuseBox cache 115 | .fusebox/ 116 | 117 | # DynamoDB Local files 118 | .dynamodb/ 119 | 120 | # TernJS port file 121 | .tern-port 122 | 123 | # Stores VSCode versions used for testing VSCode extensions 124 | .vscode-test 125 | 126 | # yarn v2 127 | .yarn/cache 128 | .yarn/unplugged 129 | .yarn/build-state.yml 130 | .yarn/install-state.gz 131 | .pnp.* 132 | 133 | # Data directory 134 | data/ 135 | !data/.gitkeep 136 | 137 | # IDE files 138 | .vscode/ 139 | .idea/ 140 | 141 | # OS files 142 | .DS_Store 143 | Thumbs.db 144 | 145 | # Generated PWA Files 146 | /public/*manifest.json 147 | /public/Assets/*manifest.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --production 8 | 9 | COPY . . 10 | 11 | # Create data directory and ensure it's a volume 12 | VOLUME /app/data 13 | 14 | EXPOSE 3000 15 | 16 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DumbPad 2 | 3 | A stupid simple, no auth (unless you want it!), modern notepad application with auto-save functionality and dark mode support. 4 | 5 | ![image](https://github.com/user-attachments/assets/c7138bc4-3a9f-456a-a049-67a03a2f45a5) 6 | 7 | ## Table of Contents 8 | - [Features](#features) 9 | - [Quick Start](#quick-start) 10 | - [Prerequisites](#prerequisites) 11 | - [Option 1: Docker](#option-1-docker-for-dummies) 12 | - [Option 2: Docker Compose](#option-2-docker-compose-for-dummies-who-like-customizing) 13 | - [Option 3: Running Locally](#option-3-running-locally-for-developers) 14 | - [Configuration](#configuration) 15 | - [Security](#security) 16 | - [Technical Details](#technical-details) 17 | - [Links](#links) 18 | - [Contributing](#contributing) 19 | - [Future Features](#future-features) 20 | 21 | ## Features 22 | 23 | - Simple, clean interface 24 | - Auto-saving 25 | - Dark mode support 26 | - Responsive design 27 | - Docker support 28 | - Optional PIN protection (4-10 digits) 29 | - File-based storage 30 | - Data persistence across updates 31 | - Markdown Formatting 32 | - Fuzzy Search (by filename and file contents) 33 | - PWA Support 34 | 35 | ## Quick Start 36 | 37 | ### Prerequisites 38 | 39 | * Docker (recommended) 40 | * Node.js >=20.0.0 (for local development) 41 | 42 | ### Option 1: Docker (For Dummies) 43 | 44 | ```bash 45 | # Pull and run with one command 46 | docker run -p 3000:3000 \ 47 | -v ./data:/app/data \ 48 | dumbwareio/dumbpad:latest 49 | ``` 50 | 51 | 1. Go to http://localhost:3000 52 | 2. Start typing - Your notes auto-save 53 | 3. Marvel at how dumb easy this was 54 | 55 | ### Option 2: Docker Compose (For Dummies who like customizing) 56 | 57 | Create a `docker-compose.yml` file: 58 | 59 | ```yaml 60 | services: 61 | dumbpad: 62 | image: dumbwareio/dumbpad:latest 63 | container_name: dumbpad 64 | restart: unless-stopped 65 | ports: 66 | - ${DUMBPAD_PORT:-3000}:3000 67 | volumes: 68 | - ${DUMBPAD_DATA_PATH:-./data}:/app/data 69 | environment: 70 | # The title shown in the web interface 71 | SITE_TITLE: ${DUMBPAD_SITE_TITLE:-DumbPad} 72 | # Optional PIN protection (leave empty to disable) 73 | DUMBPAD_PIN: ${DUMBPAD_PIN:-} 74 | # The base URL for the application 75 | BASE_URL: ${DUMBPAD_BASE_URL:-http://localhost:3000} # Use ALLOWED_ORIGINS below to restrict cors to specific origins 76 | # (OPTIONAL) 77 | # Usage: Comma-separated list of urls: http://localhost:port,http://internalip:port,https://base.proxy.tld,https://authprovider.domain.tld 78 | # ALLOWED_ORIGINS: ${DUMBPAD_ALLOWED_ORIGINS:-http://localhost:3000} # Comment out to allow all origins (*) 79 | # LOCKOUT_TIME: ${DUMBPAD_LOCK_TIME:-15} # Customize pin lockout time (if empty, defaults to 15 in minutes) 80 | # MAX_ATTEMPTS: ${DUMBPAD_MAX_ATTEMPTS:-5} # Customize pin max attempts (if empty, defaults to 5) 81 | # COOKIE_MAX_AGE: ${DUMBPAD_COOKIE_MAX_AGE:-24} # Customize maximum age of cookies primarily used for pin verification (default 24) in hours 82 | # PAGE_HISTORY_COOKIE_AGE: ${DUMBPAD_PAGE_HISTORY_COOKIE_AGE:-365} # Customize age of cookie to show the last notepad opened (default 365 | max 400) in days - shows default notepad on load if expired 83 | ``` 84 | 85 | Then run: 86 | ```bash 87 | docker compose up -d 88 | ``` 89 | 90 | 1. Go to http://localhost:3000 91 | 2. Start typing - Your notes auto-save 92 | 3. Rejoice in the glory of your dumb notes 93 | 94 | ### Option 3: Running Locally (For Developers) 95 | 96 | 1. Install dependencies: 97 | ```bash 98 | npm install 99 | ``` 100 | 101 | 2. Set environment variables in `.env` or `cp .env.example .env`: 102 | ```bash 103 | PORT=3000 # Port to run the server on 104 | DUMBPAD_PIN=1234 # Optional PIN protection 105 | SITE_TITLE=DumbPad # Custom site title 106 | BASE_URL=http://localhost:3000 # Base URL for the application 107 | ``` 108 | 109 | 3. Start the server: 110 | ```bash 111 | npm start 112 | ``` 113 | 114 | #### Windows Users 115 | 116 | If you're using Windows PowerShell with Docker, use this format for paths: 117 | ```powershell 118 | docker run -p 3000:3000 -v "${PWD}\data:/app/data" dumbwareio/dumbpad:latest 119 | ``` 120 | 121 | ## Features 122 | 123 | * 📝 Auto-saving notes 124 | * 🌓 Dark/Light mode support 125 | * 🔒 Optional PIN protection 126 | * 📱 Mobile-friendly interface / PWA Support 127 | * 🗂️ Multiple notepads 128 | * 📄 Markdown Formatting 129 | * ⬇️ Download notes as text or markdown files 130 | * 🔍 Fuzzy Search by name or contents 131 | * 🖨️ Print functionality 132 | * 🔄 Real-time saving 133 | * 💽 Add .txt files into data folder to import (requires page refresh) 134 | * ⚡ Zero dependencies on client-side 135 | * 🛡️ Built-in security features 136 | * 🎨 Clean, modern interface 137 | * 📦 Docker support with easy configuration 138 | * 🌐 Optional CORS support 139 | * ⚙️ Customizable settings 140 | 141 | ## Configuration 142 | 143 | ### Environment Variables 144 | 145 | | Variable | Description | Default | Required | 146 | |---------------------------|------------------------------------------------------------------------------------|-----------------------------|----------| 147 | | PORT | Server port | 3000 | No | 148 | | BASE_URL | Base URL for the application | http://localhost:PORT | Yes | 149 | | DUMBPAD_PIN | PIN protection (4-10 digits) | None | No | 150 | | SITE_TITLE | Site title displayed in header | DumbPad | No | 151 | | NODE_ENV | Node environment mode (development or production) | production | No | 152 | | ALLOWED_ORIGINS | Allowed CORS origins (`*` for all or comma-separated list) | * | No | 153 | | LOCKOUT_TIME | Lockout time after max PIN attempts (in minutes) | 15 | No | 154 | | MAX_ATTEMPTS | Maximum PIN entry attempts before lockout | 5 | No | 155 | | COOKIE_MAX_AGE | Maximum age of authentication cookies (in hours) | 24 | No | 156 | | PAGE_HISTORY_COOKIE_AGE | Age of cookie storing last opened notepad (in days, max 400) | 365 | No | 157 | 158 | ## Security 159 | 160 | ### Features 161 | 162 | * Variable-length PIN support (4-10 digits) 163 | * Constant-time PIN comparison 164 | * Brute force protection: 165 | * 5 attempts maximum 166 | * 15-minute lockout after failed attempts 167 | * IP-based tracking 168 | * Secure cookie handling 169 | * No client-side PIN storage 170 | * Rate limiting 171 | * Collaborative editing 172 | * CORS support for origin restrictions (optional) 173 | 174 | ## Technical Details 175 | 176 | ### Stack 177 | 178 | * **Backend**: Node.js (>=20.0.0) with Express 179 | * **Frontend**: Vanilla JavaScript (ES6+) 180 | * **Container**: Docker with multi-stage builds 181 | * **Security**: Express security middleware 182 | * **Storage**: File-based with auto-save 183 | * **Theme**: Dynamic dark/light mode with system preference support 184 | 185 | ### Dependencies 186 | 187 | * express: Web framework 188 | * cors: Cross-origin resource sharing 189 | * dotenv: Environment configuration 190 | * cookie-parser: Cookie handling 191 | * express-rate-limit: Rate limiting 192 | * marked: Markdown formatting 193 | * fuse.js: Fuzzy searching 194 | 195 | The `data` directory contains: 196 | - `notepads.json`: List of all notepads 197 | - Individual `.txt` files for each notepad's content 198 | - Drop in .txt files to import notes (requires page refresh) 199 | 200 | ⚠️ Important: Never delete the `data` directory when updating! This is where all your notes are stored. 201 | 202 | ## Usage 203 | 204 | - Just start typing! Your notes will be automatically saved. 205 | - Use the theme toggle in the top-right corner to switch between light and dark mode. 206 | - Press `Ctrl+S` (or `Cmd+S` on Mac) to force save. 207 | - Auto-saves every 10 seconds while typing. 208 | - Create multiple notepads with the + button. 209 | - Download notepads as .txt or .md files. 210 | - Hover over notepad controls to view tooltips of keyboard shortcuts 211 | - Press `Ctrl+K` (or `Cmd+K`) to open fuzzy search 212 | - If PIN protection is enabled, you'll need to enter the PIN to access the app. 213 | 214 | ## Technical Details 215 | 216 | - Backend: Node.js with Express 217 | - Frontend: Vanilla JavaScript 218 | - Storage: File-based storage in `data` directory 219 | - Styling: Modern CSS with CSS variables for theming 220 | - Security: Constant-time PIN comparison, brute force protection 221 | 222 | ## Links 223 | 224 | - GitHub: [github.com/dumbwareio/dumbpad](https://github.com/dumbwareio/dumbpad) 225 | - Docker Hub: [hub.docker.com/r/dumbwareio/dumbpad](https://hub.docker.com/r/dumbwareio/dumbpad) 226 | 227 | ## Contributing 228 | 229 | 1. Fork the repository 230 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 231 | 3. Commit your changes using conventional commits 232 | 4. Push to the branch (`git push origin feature/amazing-feature`) 233 | 5. Open a Pull Request 234 | 235 | See Development Guide for local setup and guidelines. 236 | 237 | --- 238 | 239 | Made with ❤️ by DumbWare.io 240 | 241 | ## 🌐 Check Us Out 242 | - **Website:** [dumbware.io](https://www.dumbware.io/) 243 | - **Buy Us a Coffee:** [buymeacoffee.com/dumbware](https://buymeacoffee.com/dumbware) ☕ 244 | - **Join the Chaos:** [Discord](https://discord.gg/zJutzxWyq2) 💬 245 | 246 | ## Future Features 247 | 248 | * File attachments 249 | 250 | > Got an idea? Open an issue or submit a PR 251 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dumbpad: 3 | image: dumbwareio/dumbpad:latest 4 | # build: . 5 | container_name: dumbpad 6 | restart: unless-stopped 7 | ports: 8 | - ${DUMBPAD_PORT:-3000}:3000 9 | volumes: 10 | - ${DUMBPAD_DATA_PATH:-./data}:/app/data 11 | environment: 12 | # The title shown in the web interface 13 | SITE_TITLE: ${DUMBPAD_SITE_TITLE:-DumbPad} 14 | # Optional PIN protection (leave empty to disable) 15 | DUMBPAD_PIN: ${DUMBPAD_PIN:-} 16 | # The base URL for the application 17 | BASE_URL: ${DUMBPAD_BASE_URL:-http://localhost:3000} # Use ALLOWED_ORIGINS below to restrict cors to specific origins 18 | # (OPTIONAL) 19 | # Usage: Comma-separated list of urls: http://localhost:port,http://internalip:port,https://base.proxy.tld,https://authprovider.domain.tld 20 | # ALLOWED_ORIGINS: ${DUMBPAD_ALLOWED_ORIGINS:-http://localhost:3000} # Comment out to allow all origins (*) 21 | # LOCKOUT_TIME: ${DUMBPAD_LOCK_TIME:-15} # Customize pin lockout time (if empty, defaults to 15 in minutes) 22 | # MAX_ATTEMPTS: ${DUMBPAD_MAX_ATTEMPTS:-5} # Customize pin max attempts (if empty, defaults to 5) 23 | # COOKIE_MAX_AGE: ${DUMBPAD_COOKIE_MAX_AGE:-24} # Customize maximum age of cookies primarily used for pin verification (default 24) in hours 24 | # PAGE_HISTORY_COOKIE_AGE: ${DUMBPAD_PAGE_HISTORY_COOKIE_AGE:-365} # Customize age of cookie to show the last notepad opened (default 365 | max 400) in days - shows default notepad on load if expired 25 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["asset-manifest.json", "manifest.json"] 3 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dumbpad", 3 | "version": "1.0.0", 4 | "description": "A simple notepad application", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "dev": "nodemon server.js", 9 | "generate-icons": "node scripts/generate-png.js" 10 | }, 11 | "dependencies": { 12 | "cookie-parser": "^1.4.7", 13 | "cors": "^2.8.5", 14 | "dotenv": "^16.4.1", 15 | "express": "^5.1.0", 16 | "fuse.js": "^7.1.0", 17 | "marked": "^15.0.11", 18 | "ws": "^8.18.2" 19 | }, 20 | "devDependencies": { 21 | "nodemon": "^3.0.3", 22 | "sharp": "^0.33.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/Assets/dumbpad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbPad/3cf6291e76c3c467cae0047decccfc65f6b0c80e/public/Assets/dumbpad.png -------------------------------------------------------------------------------- /public/Assets/dumbpad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 14 | 16 | 17 | 19 | 21 | 23 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/Assets/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #ffffff; 3 | --text-color: #333333; 4 | --primary-color: #2563eb; 5 | --secondary-color: #e5e7eb; 6 | --header-bg: #f8fafc; 7 | --textarea-bg: #ffffff; 8 | --success-status-bg: rgba(37, 99, 235, 0.5); 9 | --danger-status-bg:rgba(220, 38, 38, 0.5); 10 | --modal-overlay: rgba(0, 0, 0, 0.5); 11 | --modal-bg: #ffffff; 12 | --button-hover: #f3f4f6; 13 | --danger-color: #dc2626; 14 | --danger-hover: #ef4444; 15 | --code-markdown: rgba(37, 100, 235, 0.2); 16 | --transition: background-color 0.3s, color 0.3s; 17 | --input-element-transition: background-color 0.2s ease; 18 | } 19 | 20 | [data-theme="dark"] { 21 | --bg-color: #1a1a1a; 22 | --text-color: #e5e7eb; 23 | --primary-color: #60a5fa; 24 | --secondary-color: #374151; 25 | --header-bg: #111111; 26 | --textarea-bg: #242424; 27 | --success-status-bg: rgba(96, 165, 250, 0.5); 28 | --danger-status-bg:rgba(220, 38, 38, 0.5); 29 | --modal-bg: #242424; 30 | --button-hover: #374151; 31 | --danger-color: #dc2626; 32 | --danger-hover: #b91c1c; 33 | --code-markdown: rgba(96, 165, 250, 0.2); 34 | } 35 | 36 | * { 37 | margin: 0; 38 | padding: 0; 39 | box-sizing: border-box; 40 | } 41 | 42 | body { 43 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 44 | background-color: var(--bg-color); 45 | color: var(--text-color); 46 | transition: var(--transition); 47 | } 48 | 49 | .container { 50 | max-width: 75%; 51 | margin: 0 auto; 52 | padding: 1rem; 53 | display: flex; 54 | flex-direction: column; 55 | transition: var(--transition); 56 | } 57 | 58 | header { 59 | display: flex; 60 | justify-content: center; 61 | align-items: center; 62 | padding: 1rem; 63 | background-color: var(--header-bg); 64 | border-radius: 12px; 65 | margin-bottom: 1rem; 66 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 67 | position: relative; 68 | } 69 | 70 | #header-title { 71 | font-size: 1.5rem; 72 | font-weight: 600; 73 | color: var(--text-color); 74 | } 75 | 76 | .header-top { 77 | display: flex; 78 | align-items: center; 79 | } 80 | 81 | .header-right { 82 | display: flex; 83 | justify-content: center; 84 | align-items: center; 85 | position: absolute; 86 | right: 1rem; 87 | background: none; 88 | border: none; 89 | cursor: pointer; 90 | padding: 0.5rem; 91 | color: var(--text-color); 92 | border-radius: 8px; 93 | transition: var(--input-element-transition); 94 | font-size: 1.5rem; 95 | } 96 | 97 | #theme-toggle span { 98 | font-size: 1.5rem; 99 | line-height: 1; 100 | display: block; 101 | } 102 | 103 | #theme-toggle:hover { 104 | background-color: var(--secondary-color); 105 | } 106 | 107 | [data-theme="light"] .moon { 108 | display: block; 109 | } 110 | 111 | [data-theme="light"] .sun { 112 | display: none; 113 | } 114 | 115 | [data-theme="dark"] .moon { 116 | display: none; 117 | } 118 | 119 | [data-theme="dark"] .sun { 120 | display: block; 121 | } 122 | 123 | main { 124 | flex: 1; 125 | } 126 | 127 | /* Custom Scrollbars */ 128 | /* Webkit browsers */ 129 | ::-webkit-scrollbar { 130 | width: 10px; 131 | background: transparent; 132 | } 133 | ::-webkit-scrollbar-thumb { 134 | background: transparent; 135 | border-radius: 8px; 136 | border: 2px solid var(--bg-color); 137 | min-height: 40px; 138 | transition: background 0.2s; 139 | } 140 | ::-webkit-scrollbar-thumb:hover { 141 | background: var(--primary-color); 142 | } 143 | ::-webkit-scrollbar-corner { 144 | background: transparent; 145 | } 146 | 147 | /* Firefox */ 148 | * { 149 | scrollbar-width: thin; 150 | scrollbar-color: var(--secondary-color) transparent; 151 | } 152 | 153 | /* Scrollbars for specific scrollable containers */ 154 | #editor, #preview-pane { 155 | scrollbar-width: thin; 156 | scrollbar-color: var(--secondary-color) transparent; 157 | } 158 | 159 | #editor, #preview-pane { 160 | width: 100%; 161 | height: 100%; 162 | padding: 1.5rem; 163 | font-size: 1rem; 164 | line-height: 1.6; 165 | border: none; 166 | outline: none; 167 | resize: none; 168 | background-color: transparent; 169 | color: var(--text-color); 170 | transition: var(--transition); 171 | position: relative; 172 | z-index: 2; 173 | font-family: monospace; 174 | } 175 | 176 | #editor::placeholder { 177 | color: #9ca3af; 178 | } 179 | 180 | .editor-container, .preview-container { 181 | height: calc(100vh - 9rem); 182 | border-radius: 12px; 183 | overflow: hidden; 184 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 185 | position: relative; 186 | z-index: 1; 187 | background-color: var(--textarea-bg); 188 | } 189 | 190 | /* Preview pane markdown styles */ 191 | .preview-container { 192 | overflow: auto; 193 | } 194 | #preview-pane { 195 | white-space: pre-wrap; 196 | font-family: sans-serif; 197 | } 198 | 199 | code { 200 | background-color: var(--code-markdown); 201 | padding: 2px 4px; 202 | border-radius: 5px; 203 | } 204 | 205 | .toast-container { 206 | position: fixed; 207 | bottom: 1rem; 208 | left: 50%; 209 | transform: translateX(-50%); 210 | padding: 0.5rem 1rem; 211 | display: flex; 212 | flex-direction: column; 213 | gap: 10px; 214 | z-index: 2000; 215 | } 216 | 217 | .toast { 218 | color: #ffffff; 219 | padding: 0.5rem 1rem; 220 | border-radius: 20px; 221 | opacity: 0; 222 | transition: opacity 0.3s ease-in-out; 223 | max-width: 300px; 224 | box-sizing: border-box; 225 | word-wrap: break-word; 226 | font-size: 0.875rem; 227 | text-align: center; 228 | } 229 | 230 | .toast.show { 231 | opacity: 1; 232 | } 233 | 234 | .toast.success { 235 | background-color: var(--success-status-bg); 236 | } 237 | 238 | .toast.error { 239 | background-color: var(--danger-status-bg); 240 | } 241 | 242 | .notepad-controls { 243 | position: absolute; 244 | left: 1rem; 245 | display: flex; 246 | align-items: center; 247 | gap: 1rem; 248 | } 249 | 250 | .select-wrapper, .notepad-controls-wrapper { 251 | display: flex; 252 | align-items: center; 253 | gap: 0.5rem; 254 | margin: 1rem, auto; 255 | } 256 | 257 | #notepad-selector { 258 | padding: 0.5rem; 259 | border-radius: 6px; 260 | border: 1px solid var(--secondary-color); 261 | background-color: var(--textarea-bg); 262 | color: var(--text-color); 263 | font-size: 0.875rem; 264 | min-width: 5rem; 265 | max-width: 8rem; 266 | overflow: hidden; 267 | text-overflow: ellipsis; 268 | white-space: nowrap; 269 | } 270 | 271 | .icon-button { 272 | background: none; 273 | border: none; 274 | cursor: pointer; 275 | padding: 0.5rem; 276 | color: var(--text-color); 277 | border-radius: 8px; 278 | display: flex; 279 | align-items: center; 280 | justify-content: center; 281 | transition: var(--input-element-transition); 282 | } 283 | 284 | .icon-button:hover { 285 | background-color: var(--button-hover); 286 | } 287 | 288 | #preview-markdown.active svg, #search-open.active svg { 289 | stroke: var(--primary-color); 290 | } 291 | 292 | .modal { 293 | display: none; 294 | position: fixed; 295 | top: 0; 296 | left: 0; 297 | width: 100%; 298 | height: 100%; 299 | background-color: var(--modal-overlay); 300 | z-index: 1000; 301 | align-items: center; 302 | justify-content: center; 303 | } 304 | 305 | .modal.visible { 306 | display: flex; 307 | } 308 | 309 | .modal-content { 310 | background-color: var(--modal-bg); 311 | padding: 2rem; 312 | border-radius: 12px; 313 | width: 90%; 314 | max-width: 400px; 315 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 316 | } 317 | 318 | .modal-content h2 { 319 | margin-bottom: 1rem; 320 | color: var(--text-color); 321 | } 322 | 323 | .modal-input { 324 | width: 100%; 325 | padding: 0.5rem; 326 | border: 1px solid var(--secondary-color); 327 | border-radius: 6px; 328 | background-color: var(--textarea-bg); 329 | color: var(--text-color); 330 | font-size: 1rem; 331 | } 332 | 333 | .modal-buttons { 334 | display: flex; 335 | justify-content: flex-end; 336 | gap: 1rem; 337 | margin-top: 1rem; 338 | } 339 | 340 | .modal-buttons button { 341 | padding: 0.5rem 1rem; 342 | border: none; 343 | border-radius: 6px; 344 | cursor: pointer; 345 | font-size: 0.875rem; 346 | transition: var(--input-element-transition); 347 | } 348 | 349 | #rename-cancel, #download-cancel, #settings-cancel { 350 | background-color: var(--secondary-color); 351 | color: var(--text-color); 352 | } 353 | 354 | #rename-confirm, #download-txt, #download-md, #settings-save { 355 | background-color: var(--primary-color); 356 | color: white; 357 | } 358 | 359 | .modal-message { 360 | color: var(--text-color); 361 | margin-bottom: 1.5rem; 362 | line-height: 1.5; 363 | } 364 | 365 | .modal-buttons button.danger { 366 | background-color: var(--danger-color); 367 | color: white; 368 | } 369 | 370 | .modal-buttons button.danger:hover { 371 | background-color: var(--danger-hover); 372 | } 373 | 374 | #delete-notepad { 375 | color: var(--danger-color); 376 | } 377 | 378 | #delete-notepad:hover { 379 | background-color: rgba(220, 38, 38, 0.1); 380 | } 381 | 382 | #pin-modal { 383 | background-color: var(--bg-color); 384 | } 385 | 386 | #pin-modal .modal-content { 387 | text-align: center; 388 | padding: 3rem 2rem; 389 | } 390 | 391 | .pin-header { 392 | color: var(--text-color); 393 | font-size: 1.2rem; 394 | margin-bottom: 1rem; 395 | font-weight: 700; 396 | text-shadow: none; 397 | } 398 | 399 | .pin-header h2 { 400 | color: var(--text-color); 401 | opacity: 0.8; 402 | font-size: 1.25rem; 403 | font-weight: 500; 404 | margin-top: 1rem; 405 | } 406 | 407 | .pin-container { 408 | display: flex; 409 | gap: 0.75rem; 410 | justify-content: center; 411 | margin: 2rem 0; 412 | } 413 | 414 | .pin-digit { 415 | width: 35px; 416 | height: 45px; 417 | text-align: center; 418 | font-size: 1.25rem; 419 | border: 2px solid var(--secondary-color); 420 | border-radius: 8px; 421 | background-color: var(--textarea-bg); 422 | color: var(--text-color); 423 | transition: all 0.2s ease; 424 | flex: none; 425 | max-width: 30px; 426 | padding: 0; 427 | } 428 | 429 | .pin-digit:disabled, 430 | .pin-digit.disabled { 431 | opacity: 0.5; 432 | cursor: not-allowed; 433 | border-color: var(--secondary-color) !important; 434 | background-color: var(--textarea-bg) !important; 435 | color: var(--text-color) !important; 436 | box-shadow: none !important; 437 | } 438 | 439 | .pin-digit.disabled:focus { 440 | outline: none; 441 | border-color: var(--secondary-color); 442 | box-shadow: none; 443 | } 444 | 445 | .pin-digit:focus { 446 | border-color: var(--primary-color); 447 | box-shadow: 0 0 0 2px var(--primary-color); 448 | outline: none; 449 | } 450 | 451 | .pin-digit.filled { 452 | border-color: var(--primary-color); 453 | background-color: var(--primary-color); 454 | color: white; 455 | } 456 | 457 | .error-message { 458 | color: var(--danger-color); 459 | margin-top: 1rem; 460 | font-size: 0.875rem; 461 | min-height: 1.25rem; 462 | } 463 | 464 | .modal-buttons button.primary { 465 | background-color: var(--primary-color); 466 | color: white; 467 | padding: 0.75rem 2rem; 468 | font-size: 1rem; 469 | margin-top: 1rem; 470 | min-width: 120px; 471 | border-radius: 8px; 472 | transition: var(--input-element-transition); 473 | } 474 | 475 | .modal-buttons button.primary:hover { 476 | background-color: var(--primary-color); 477 | opacity: 0.9; 478 | } 479 | 480 | #pin-modal .modal-buttons { 481 | display: flex; 482 | justify-content: center; 483 | margin-top: 2rem; 484 | } 485 | 486 | #pin-modal .modal-buttons button.primary { 487 | background-color: var(--primary-color); 488 | color: white; 489 | padding: 0.75rem 2rem; 490 | font-size: 1rem; 491 | min-width: 120px; 492 | border-radius: 8px; 493 | transition: var(--input-element-transition); 494 | } 495 | 496 | #pin-modal .modal-buttons button.primary:hover { 497 | background-color: var(--primary-color); 498 | opacity: 0.9; 499 | transform: translateY(-1px); 500 | } 501 | 502 | #pin-modal .modal-buttons button.primary:active { 503 | transform: translateY(0); 504 | } 505 | 506 | .login-container { 507 | display: flex; 508 | align-items: center; 509 | justify-content: center; 510 | min-height: 100vh; 511 | background-color: var(--bg-color); 512 | position: relative; 513 | } 514 | 515 | #login-content { 516 | text-align: center; 517 | padding: 2rem; 518 | border-radius: 12px; 519 | max-width: 400px; 520 | width: 90%; 521 | } 522 | 523 | /* Theme toggle for login page */ 524 | .login-container #theme-toggle { 525 | position: absolute; 526 | top: 1rem; 527 | right: 1rem; 528 | background: none; 529 | border: none; 530 | cursor: pointer; 531 | padding: 0.5rem; 532 | color: var(--text-color); 533 | border-radius: 8px; 534 | transition: var(--input-element-transition); 535 | } 536 | 537 | .login-container #theme-toggle:hover { 538 | background-color: var(--secondary-color); 539 | } 540 | 541 | /* Remote cursor styling */ 542 | .remote-cursor { 543 | position: absolute; 544 | pointer-events: none; 545 | z-index: 3; 546 | width: 2px; 547 | height: 1.6em; 548 | background-color: currentColor; 549 | will-change: transform; 550 | transform-origin: left top; 551 | transition: transform 0.1s ease; 552 | opacity: 0.8; 553 | display: block; 554 | top: 0; 555 | left: 0; 556 | } 557 | 558 | .remote-cursor::before { 559 | content: ''; 560 | position: absolute; 561 | top: -6px; 562 | left: -4px; 563 | width: 10px; 564 | height: 10px; 565 | border-radius: 50%; 566 | background-color: currentColor; 567 | opacity: 0.9; 568 | } 569 | 570 | /* Remove the old label styles */ 571 | .remote-cursor-label { 572 | display: none; 573 | } 574 | 575 | .tooltip { 576 | position: absolute; 577 | background-color: var(--secondary-color); 578 | color: var(--text-color); 579 | padding: 5px 10px; 580 | border-radius: 4px; 581 | font-size: 12px; 582 | visibility: hidden; 583 | opacity: 0; 584 | transition: opacity 0.3s; 585 | z-index: 1000; /* Ensure it's above other elements */ 586 | } 587 | 588 | .tooltip.show { 589 | visibility: visible; 590 | opacity: 1; 591 | } 592 | 593 | /* Search styles */ 594 | /* Scoped Search Styles */ 595 | #search-modal.modal { 596 | display: none; 597 | position: fixed; 598 | top: 0; 599 | left: 0; 600 | width: 100%; 601 | height: 100%; 602 | background: rgba(0, 0, 0, 0.35); 603 | display: flex; 604 | justify-content: center; 605 | align-items: center; 606 | z-index: 1000; 607 | } 608 | #search-modal.hidden { 609 | display: none; 610 | } 611 | .search-modal-content { 612 | background: var(--header-bg); 613 | padding: 20px; 614 | min-width: 30%; 615 | max-width: 50%; 616 | border-radius: 8px; 617 | box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.3); 618 | } 619 | #search-modal input { 620 | width: 100%; 621 | padding: 8px; 622 | margin-bottom: 10px; 623 | border: 1px solid var(--primary-color); 624 | border-radius: 4px; 625 | background-color: var(--textarea-bg); 626 | color: var(--text-color); 627 | font-family: monospace; 628 | font-size: 1rem; 629 | } 630 | #search-results { 631 | list-style-type: none; 632 | padding: 0; 633 | margin: 0; 634 | max-height: 30rem; 635 | overflow-y: auto; 636 | font-family: monospace; 637 | font-size: 1rem; 638 | } 639 | #search-results li { 640 | list-style-type: none; 641 | padding: 5px; 642 | cursor: pointer; 643 | border-bottom: 0.1px solid var(--secondary-color); 644 | transition: var(--input-element-transition); 645 | } 646 | #search-results li:last-child { 647 | border-bottom: none; 648 | } 649 | #search-results li:hover, 650 | #search-results .selected { 651 | background-color: var(--button-hover); 652 | border-radius: 6px; 653 | padding: 4px 8px; /* Adds spacing to make the rounded corners more visible */ 654 | } 655 | #search-results mark { 656 | background: var(--primary-color); 657 | font-weight: bold; 658 | } 659 | 660 | .settings-form { 661 | display: flex; 662 | flex-direction: column; 663 | gap: 1rem; 664 | margin-top: 1rem; 665 | /* margin-bottom: 1.5rem; */ 666 | } 667 | 668 | .settings-label { 669 | display: flex; 670 | flex-direction: column; 671 | align-items: flex-start; 672 | } 673 | .settings-label input { 674 | margin-top: 0.5rem; 675 | } 676 | 677 | input[type="checkbox"] { 678 | appearance: none; 679 | -webkit-appearance: none; 680 | width: 40px; 681 | height: 20px; 682 | background-color: #ccc; 683 | border-radius: 10px; 684 | position: relative; 685 | cursor: pointer; 686 | transition: background-color 0.3s; 687 | } 688 | 689 | input[type="checkbox"]:after { 690 | content: ""; 691 | position: absolute; 692 | left: 2px; 693 | top: 2px; 694 | width: 16px; 695 | height: 16px; 696 | border-radius: 50%; 697 | background-color: white; 698 | transition: transform 0.3s; 699 | } 700 | 701 | input[type="checkbox"]:checked { 702 | background-color: var(--primary-color); 703 | } 704 | 705 | input[type="checkbox"]:checked:after { 706 | transform: translateX(20px); 707 | } 708 | 709 | @media(max-width: 1400px) { 710 | #header-title { 711 | font-size: 1.2rem; 712 | } 713 | } 714 | @media (max-width: 1300px) { 715 | .container { 716 | max-width: 95%; 717 | padding: 0.3rem; 718 | } 719 | 720 | header { 721 | flex-direction: column; 722 | gap: 0.5rem; 723 | padding: 0, auto; 724 | margin: 0, auto; 725 | } 726 | 727 | .header-title { 728 | justify-content: center; 729 | } 730 | 731 | .header-right { 732 | position: absolute; 733 | float: right; 734 | padding: 0; 735 | margin: 0; 736 | } 737 | 738 | .notepad-controls { 739 | position: static; 740 | width: 100%; 741 | align-items: center; 742 | justify-content: center; 743 | } 744 | 745 | #theme-toggle { 746 | position: static; 747 | } 748 | 749 | .editor-container, .preview-container { 750 | max-height: 83vh; 751 | } 752 | 753 | .notepad-controls-wrapper { 754 | gap: 2rem; 755 | } 756 | } 757 | 758 | 759 | /* MOBILE */ 760 | @media (max-width: 585px) { 761 | .container { 762 | max-width: 100%; 763 | padding: 0, auto; 764 | margin: 0, auto; 765 | } 766 | 767 | .pin-container { 768 | gap: 0.5rem; 769 | } 770 | 771 | .pin-digit { 772 | width: 32px; 773 | height: 40px; 774 | font-size: 1.125rem; 775 | max-width: 28px; 776 | } 777 | 778 | .header-top { 779 | flex-direction: column; 780 | } 781 | 782 | .header-right { 783 | position: static; 784 | gap: 2rem; 785 | } 786 | 787 | #header-title { 788 | margin-bottom: 0.25rem; 789 | } 790 | 791 | .notepad-controls { 792 | flex-direction: column; 793 | margin: 0, auto; 794 | padding: 0, auto; 795 | margin-bottom: 0; 796 | row-gap: 0.1rem; 797 | } 798 | 799 | #notepad-selector { 800 | margin: 0, auto; 801 | padding: 0, auto; 802 | width: auto; 803 | max-width: 15rem; 804 | } 805 | 806 | .select-wrapper { 807 | width: 100%; 808 | justify-content: center; 809 | margin: 0, auto; 810 | padding: 0, auto; 811 | } 812 | 813 | .editor-container, .preview-container { 814 | max-height: 71vh; 815 | margin: 0, auto; 816 | padding: 0, auto; 817 | } 818 | #editor { 819 | padding: 0.3rem; 820 | } 821 | } 822 | 823 | @media (max-width: 375px) { 824 | .editor-container, .preview-container { 825 | max-height: 65vh; 826 | } 827 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DumbPad - Simple Notes 7 | 8 | 9 | 10 | 11 | 12 | 22 | 23 | 24 |
25 |
26 |
27 |
28 |

DumbPad

29 |
30 |
31 | 36 | 47 | 65 |
66 |
67 |
68 |
69 | 75 | 77 |
78 |
79 | 84 | 91 | 98 | 107 | 114 |
115 |
116 |
117 |
118 |
119 | 120 |
121 | 124 |
125 |
126 | 136 | 146 | 157 | 158 | 164 | 165 | 189 |
190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /public/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DumbPad - Login 7 | 8 | 9 | 10 | 11 | 21 | 22 | 23 | 53 | 248 | 249 | -------------------------------------------------------------------------------- /public/managers/collaboration.js: -------------------------------------------------------------------------------- 1 | import { marked } from '/js/marked/marked.esm.js'; 2 | 3 | export class CollaborationManager { 4 | constructor({ userId, userColor, currentNotepadId, operationsManager, editor, onNotepadChange, onUserDisconnect, onCursorUpdate, 5 | settingsManager, toaster, confirmationManager, saveNotes, renameNotepad 6 | }) 7 | { 8 | this.userId = userId; 9 | this.userColor = userColor; 10 | this.currentNotepadId = currentNotepadId; 11 | this.operationsManager = operationsManager; 12 | this.editor = editor; 13 | this.onNotepadChange = onNotepadChange; 14 | this.onUserDisconnect = onUserDisconnect; 15 | this.onCursorUpdate = onCursorUpdate; 16 | this.previewPane = document.getElementById('preview-pane'); 17 | this.settingsManager = settingsManager; 18 | this.toaster = toaster; 19 | this.wsCount = 1; 20 | this.confirmationManager = confirmationManager; 21 | this.saveNotes = saveNotes; 22 | this.renameNotepad = renameNotepad; 23 | 24 | this.ws = null; 25 | this.isReceivingUpdate = false; 26 | this.lastCursorUpdate = 0; 27 | this.CURSOR_UPDATE_INTERVAL = 50; // More frequent cursor updates 28 | this.DEBUG = false; 29 | 30 | // For cursor update debouncing 31 | this.cursorUpdateTimeout = null; 32 | 33 | // Websocket message queue to send messages when the connection is not open 34 | this.messageQueue = []; 35 | this.messageQueueTimer = null; 36 | this.debounceDelay = 200; // 200ms debounce delay 37 | } 38 | 39 | // Initialize WebSocket connection 40 | setupWebSocket() { 41 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 42 | const wsUrl = `${protocol}//${window.location.host}`; 43 | 44 | if (this.DEBUG) { 45 | console.log('Attempting WebSocket connection to:', wsUrl); 46 | } 47 | 48 | this.ws = new WebSocket(wsUrl); 49 | this.setupWebSocketHandlers(); 50 | } 51 | 52 | // Set up WebSocket event handlers 53 | setupWebSocketHandlers() { 54 | this.ws.onmessage = this.handleWebSocketMessage.bind(this); 55 | 56 | this.ws.onclose = () => { 57 | if (this.DEBUG) { 58 | console.log('WebSocket connection closed'); 59 | } 60 | clearTimeout(this.messageQueueTimer); 61 | setTimeout(() => this.setupWebSocket(), 5000); 62 | }; 63 | 64 | this.ws.onopen = () => { 65 | if (this.DEBUG) { 66 | console.log('WebSocket connection established'); 67 | } 68 | this.updateLocalCursor(); 69 | 70 | // Send any queued messages 71 | this.debounceSendQueue(); 72 | }; 73 | 74 | this.ws.onerror = (error) => { 75 | console.error('WebSocket error:', error); 76 | clearTimeout(this.messageQueueTimer); 77 | this.toaster.show(`Websocket connection error: ${error}`, 'error'); 78 | }; 79 | } 80 | 81 | // Handle incoming WebSocket messages 82 | handleWebSocketMessage(event) { 83 | try { 84 | const data = JSON.parse(event.data); 85 | if (this.DEBUG) { 86 | console.log('Received WebSocket message:', data); 87 | } 88 | 89 | if (data.type === 'user_connected') { 90 | if (data.count > 1) { 91 | this.toastRemoteConnection(true, data); 92 | this.wsCount = data.count; 93 | } 94 | } 95 | 96 | if (data.type === 'cursor' && data.notepadId === this.currentNotepadId) { 97 | this.handleCursorUpdate(data); 98 | } 99 | else if (data.type === 'ack') { 100 | this.handleOperationAck(data); 101 | } 102 | else if (data.type === 'operation' && data.notepadId === this.currentNotepadId) { 103 | this.handleRemoteOperation(data); 104 | } 105 | else if (data.type === 'notepad_rename') { 106 | // Handle remote notepad rename 107 | this.handleNotepadRename(data); 108 | } 109 | else if (data.type === 'notepad_change') { 110 | this.onNotepadChange(); 111 | } 112 | else if (data.type === 'notepad_delete') { 113 | // notify other users on the same notepad of the deletion 114 | this.handleConfirmDeleteNotepad(data); 115 | } 116 | else if (data.type === 'notify_delete_revert') { 117 | const message = `${data.notepadName} delete has been reverted by a remote user`; 118 | this.toaster.show(message, 'error', false, 5000); 119 | this.onNotepadChange(); 120 | } 121 | else if (data.type === 'user_disconnected') { 122 | this.onUserDisconnect(data.userId); 123 | this.toastRemoteConnection(false, data); 124 | this.wsCount = data.count; 125 | } 126 | } catch (error) { 127 | console.error('WebSocket message error:', error); 128 | } 129 | } 130 | 131 | // Handle cursor updates from other users 132 | handleCursorUpdate(data) { 133 | // Enhanced logging for cursor updates 134 | if (this.DEBUG) { 135 | console.log('Cursor update debug:', { 136 | receivedUserId: data.userId, 137 | ourUserId: this.userId, 138 | receivedType: typeof data.userId, 139 | ourType: typeof this.userId, 140 | isOurs: data.userId === this.userId 141 | }); 142 | } 143 | 144 | // Ignore cursor updates from our own user ID 145 | if (data.userId === this.userId) { 146 | if (this.DEBUG) { 147 | console.log('Ignoring our own cursor update'); 148 | } 149 | return; 150 | } 151 | 152 | if (this.DEBUG) { 153 | console.log('Updating cursor for user:', data.userId, 'at position:', data.position); 154 | } 155 | 156 | this.onCursorUpdate(data.userId, data.position, data.color); 157 | } 158 | 159 | // Handle operation acknowledgments from the server 160 | handleOperationAck(data) { 161 | if (this.DEBUG) { 162 | console.log('Operation acknowledged:', data.operationId); 163 | } 164 | this.operationsManager.handleOperationAck(data.operationId, data.serverVersion); 165 | } 166 | 167 | // Handle operations from other users 168 | handleRemoteOperation(data) { 169 | if (data.userId !== this.userId) { 170 | if (this.DEBUG) { 171 | console.log('Applying remote operation:', data.operation); 172 | } 173 | this.isReceivingUpdate = true; 174 | 175 | // Transform operation against pending operations 176 | let operation = data.operation; 177 | const pendingOps = this.operationsManager.getPendingOperations(); 178 | 179 | for (const pending of pendingOps) { 180 | const transformed = this.operationsManager.transformOperation(operation, pending); 181 | if (transformed) { 182 | operation = transformed; 183 | } else { 184 | if (this.DEBUG) { 185 | console.log('Operation was nullified by transformation'); 186 | } 187 | this.isReceivingUpdate = false; 188 | return; 189 | } 190 | } 191 | 192 | // Save current cursor position 193 | const currentPos = this.editor.selectionStart; 194 | const currentEnd = this.editor.selectionEnd; 195 | 196 | // Apply the operation 197 | this.editor.value = this.operationsManager.applyOperation(operation, this.editor.value); 198 | 199 | // Update the preview pane in markdown 200 | this.previewPane.innerHTML = marked.parse(this.editor.value); 201 | 202 | // Adjust cursor position based on operation type and position 203 | let newPos = currentPos; 204 | let newEnd = currentEnd; 205 | 206 | if (operation.type === 'insert') { 207 | if (operation.position < currentPos) { 208 | newPos += operation.text.length; 209 | newEnd += operation.text.length; 210 | } 211 | } else if (operation.type === 'delete') { 212 | if (operation.position < currentPos) { 213 | newPos = Math.max(operation.position, 214 | currentPos - operation.text.length); 215 | newEnd = Math.max(operation.position, 216 | currentEnd - operation.text.length); 217 | } 218 | } 219 | 220 | // Restore adjusted cursor position 221 | this.editor.setSelectionRange(newPos, newEnd); 222 | 223 | this.isReceivingUpdate = false; 224 | this.updateLocalCursor(); 225 | } 226 | } 227 | 228 | // Send an operation to the server 229 | sendOperation(operation) { 230 | if (this.ws && this.ws.readyState === WebSocket.OPEN && !this.isReceivingUpdate) { 231 | if (this.DEBUG) { 232 | console.log('Sending operation:', operation); 233 | } 234 | 235 | this.ws.send(JSON.stringify({ 236 | type: 'operation', 237 | operation, 238 | notepadId: this.currentNotepadId, 239 | userId: this.userId 240 | })); 241 | 242 | // Set up retry if no acknowledgment received 243 | const retryTimeout = setTimeout(() => { 244 | if (this.operationsManager.pendingOperations.has(operation.id)) { 245 | if (this.DEBUG) { 246 | console.log('Operation not acknowledged, retrying:', operation.id); 247 | } 248 | this.sendOperation(operation); 249 | } 250 | }, 3000); // Retry after 3 seconds 251 | 252 | // Store retry timeout with the operation 253 | operation.retryTimeout = retryTimeout; 254 | } 255 | } 256 | 257 | // Update local cursor position 258 | updateLocalCursor() { 259 | clearTimeout(this.cursorUpdateTimeout); 260 | this.cursorUpdateTimeout = setTimeout(() => { 261 | const now = Date.now(); 262 | if (now - this.lastCursorUpdate < this.CURSOR_UPDATE_INTERVAL) return; 263 | 264 | this.lastCursorUpdate = now; 265 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 266 | const position = this.editor.selectionStart; 267 | if (this.DEBUG) { 268 | console.log('Sending cursor update, position:', position); 269 | } 270 | this.ws.send(JSON.stringify({ 271 | type: 'cursor', 272 | userId: this.userId, 273 | color: this.userColor, 274 | position: position, 275 | notepadId: this.currentNotepadId 276 | })); 277 | } 278 | }, 50); // 50ms debounce 279 | } 280 | 281 | // Clean up resources 282 | cleanup() { 283 | if (this.ws) { 284 | this.ws.close(); 285 | this.ws = null; 286 | } 287 | clearTimeout(this.cursorUpdateTimeout); 288 | } 289 | 290 | // Handle remote notepad rename 291 | handleNotepadRename(data) { 292 | const option = document.querySelector(`#notepad-selector option[value="${data.notepadId}"]`); 293 | if (option) { 294 | option.textContent = data.newName; 295 | } else { 296 | // If we can't find the option, refresh the entire list 297 | this.onNotepadChange(); 298 | } 299 | } 300 | 301 | async handleConfirmDeleteNotepad(data) { 302 | if (data.notepadId && data.notepadId === this.currentNotepadId) { 303 | const message = `${data.notepadName} has been deleted by a remote user.\n\nConfirm to accept or cancel to reject.`; 304 | const confirmed = await this.confirmationManager.show( 305 | message, 306 | [ // onConfirm 307 | this.onNotepadChange, 308 | () => this.toaster.show(`${data.notepadName} deleted`) 309 | ], 310 | [ // onCancel 311 | () => this.saveNotes(this.editor.value, true, false), 312 | this.onNotepadChange, 313 | () => this.renameNotepad(data.notepadName, false), 314 | this.onNotepadChange, 315 | () => this.toaster.show('Notepad delete reverted'), 316 | ]); 317 | 318 | if (!confirmed) { 319 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 320 | try { 321 | this.ws.send(JSON.stringify({ 322 | type: 'notify_delete_revert', 323 | notepadId: data.notepadId, 324 | notepadName: data.notepadName, 325 | userId: this.userId 326 | })); 327 | } catch (error) { 328 | console.error('Error sending WebSocket message:', error); 329 | } 330 | } 331 | else { 332 | // queue message to notify remote users of the delete revert 333 | // called in this.ws.onopen 334 | this.messageQueue.push({ 335 | type: 'notify_delete_revert', 336 | notepadId: data.notepadId, 337 | notepadName: data.notepadName, 338 | userId: this.userId 339 | }); 340 | } 341 | } 342 | } 343 | else this.onNotepadChange(); 344 | } 345 | 346 | toastRemoteConnection(isConnect, data) { 347 | const appSettings = this.settingsManager.getSettings(); 348 | if (appSettings.enableRemoteConnectionMessages) { 349 | let message = isConnect ? 'Remote User Connected' : 'Remote User Disconnected'; 350 | if (data.count > 1) message += ` - (${data.count} Online)`; 351 | this.toaster.show(message); 352 | } 353 | } 354 | 355 | debounceSendQueue() { 356 | clearTimeout(this.messageQueueTimer); 357 | this.messageQueueTimer = setTimeout(() => { 358 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 359 | while (this.messageQueue.length > 0) { 360 | const message = this.messageQueue.shift(); 361 | try { 362 | this.ws.send(JSON.stringify(message)); 363 | } catch(error) { 364 | console.error("error sending queued message", error); 365 | } 366 | } 367 | } else { 368 | console.error('WebSocket connection is not open.'); 369 | } 370 | }, this.debounceDelay); 371 | } 372 | 373 | getWSCount() { 374 | return this.wsCount; 375 | } 376 | } -------------------------------------------------------------------------------- /public/managers/confirmation.js: -------------------------------------------------------------------------------- 1 | export default class ConfirmationManager { 2 | constructor() {} 3 | 4 | // Use to show confirmations when a modal is already open 5 | async show(message, onConfirm, onCancel) { 6 | const confirmed = window.confirm(message); 7 | 8 | if (confirmed) { 9 | if (Array.isArray(onConfirm)) { // Check if onConfirm is an array 10 | for (const func of onConfirm) { 11 | if (typeof func === 'function') { 12 | await func(); // Await each function call 13 | } else { 14 | console.log('Invalid function in onConfirm array.'); 15 | } 16 | } 17 | } else if (typeof onConfirm === 'function') { 18 | await onConfirm(); // Await single function call 19 | } else { 20 | console.log('Confirmed, but no onConfirm function or array provided.'); 21 | } 22 | } else { 23 | if (Array.isArray(onCancel)) { 24 | for (const func of onCancel) { 25 | if (typeof func === 'function') { 26 | await func(); // Await each function call 27 | } else { 28 | console.log('Invalid function in onCancel array.'); 29 | } 30 | } 31 | } else if (typeof onCancel === 'function') { 32 | await onCancel(); // Await single function call 33 | } else { 34 | console.log('Cancelled, but no onCancel function or array provided.'); 35 | } 36 | } 37 | 38 | return confirmed; 39 | } 40 | } -------------------------------------------------------------------------------- /public/managers/cursor-manager.js: -------------------------------------------------------------------------------- 1 | export class CursorManager { 2 | constructor({ editor }) { 3 | this.editor = editor; 4 | this.remoteUsers = new Map(); // Store other users' colors and cursors 5 | this.DEBUG = false; 6 | 7 | // Cache for text measurements 8 | this.textMetrics = { 9 | lineHeight: 0, 10 | charWidth: 0, 11 | lastUpdate: 0, 12 | measurementDiv: null 13 | }; 14 | 15 | this.initializeTextMetrics(); 16 | } 17 | 18 | // Initialize text measurements with debug logging 19 | initializeTextMetrics() { 20 | const style = getComputedStyle(this.editor); 21 | this.textMetrics.measurementDiv = document.createElement('div'); 22 | Object.assign(this.textMetrics.measurementDiv.style, { 23 | position: 'absolute', 24 | visibility: 'hidden', 25 | whiteSpace: 'pre', 26 | font: style.font, 27 | fontSize: style.fontSize, 28 | lineHeight: style.lineHeight, 29 | letterSpacing: style.letterSpacing, 30 | padding: '0', 31 | border: 'none', 32 | margin: '0' 33 | }); 34 | document.body.appendChild(this.textMetrics.measurementDiv); 35 | this.updateTextMetrics(); 36 | 37 | if (this.DEBUG) { 38 | console.log('Text metrics initialized:', { 39 | font: style.font, 40 | fontSize: style.fontSize, 41 | lineHeight: style.lineHeight, 42 | letterSpacing: style.letterSpacing, 43 | editorStyle: { 44 | font: style.font, 45 | lineHeight: style.lineHeight, 46 | padding: style.padding 47 | } 48 | }); 49 | } 50 | } 51 | 52 | // Update text measurements periodically 53 | updateTextMetrics() { 54 | const now = Date.now(); 55 | if (now - this.textMetrics.lastUpdate > 5000) { // Update every 5 seconds 56 | const style = getComputedStyle(this.editor); 57 | this.textMetrics.lineHeight = parseFloat(style.lineHeight); 58 | if (isNaN(this.textMetrics.lineHeight)) { 59 | this.textMetrics.lineHeight = parseFloat(style.fontSize) * 1.2; 60 | } 61 | this.textMetrics.measurementDiv.textContent = 'X'; 62 | this.textMetrics.charWidth = this.textMetrics.measurementDiv.offsetWidth; 63 | this.textMetrics.lastUpdate = now; 64 | } 65 | } 66 | 67 | // Get cursor coordinates using Range API 68 | getCursorCoordinates(position) { 69 | // Create a temporary div with the same styling as the editor 70 | const tempDiv = document.createElement('div'); 71 | const editorStyle = getComputedStyle(this.editor); 72 | 73 | Object.assign(tempDiv.style, { 74 | position: 'absolute', 75 | visibility: 'hidden', 76 | whiteSpace: this.editor.style.whiteSpace || 'pre-wrap', 77 | wordWrap: this.editor.style.wordWrap || 'break-word', 78 | width: `${this.editor.clientWidth}px`, 79 | font: editorStyle.font, 80 | lineHeight: editorStyle.lineHeight, 81 | letterSpacing: editorStyle.letterSpacing, 82 | padding: editorStyle.padding, 83 | boxSizing: 'border-box', 84 | top: '0', 85 | left: '0', 86 | border: editorStyle.border, 87 | margin: editorStyle.margin 88 | }); 89 | 90 | // Create text nodes for before and at cursor 91 | const textBeforeCursor = document.createTextNode(this.editor.value.substring(0, position)); 92 | const cursorNode = document.createTextNode('\u200B'); // Zero-width space for cursor position 93 | 94 | tempDiv.appendChild(textBeforeCursor); 95 | tempDiv.appendChild(cursorNode); 96 | 97 | // Add the temp div to the editor container for proper positioning context 98 | const container = document.querySelector('.editor-container'); 99 | if (!container) { 100 | console.error('Editor container not found'); 101 | return null; 102 | } 103 | container.appendChild(tempDiv); 104 | 105 | // Create and position the range 106 | const range = document.createRange(); 107 | range.setStart(cursorNode, 0); 108 | range.setEnd(cursorNode, 1); 109 | 110 | // Get the rectangle for the cursor position 111 | const rects = range.getClientRects(); 112 | const rect = rects[0]; // Use the first rect for the cursor position 113 | 114 | // Clean up 115 | container.removeChild(tempDiv); 116 | 117 | if (!rect) { 118 | if (this.DEBUG) { 119 | console.warn('Could not get cursor coordinates, falling back to editor position'); 120 | } 121 | return { 122 | top: parseFloat(editorStyle.paddingTop) || 0, 123 | left: parseFloat(editorStyle.paddingLeft) || 0, 124 | height: parseFloat(editorStyle.lineHeight) || parseFloat(editorStyle.fontSize) * 1.6 125 | }; 126 | } 127 | 128 | // Get editor's padding and margins 129 | const paddingLeft = parseFloat(editorStyle.paddingLeft) || 0; 130 | const paddingTop = parseFloat(editorStyle.paddingTop) || 0; 131 | const marginLeft = parseFloat(editorStyle.marginLeft) || 0; 132 | const marginTop = parseFloat(editorStyle.marginTop) || 0; 133 | 134 | // Calculate position relative to the editor's content area 135 | const editorRect = this.editor.getBoundingClientRect(); 136 | const containerRect = container.getBoundingClientRect(); 137 | 138 | // Get the line height for proper cursor height 139 | const lineHeight = parseFloat(editorStyle.lineHeight) || parseFloat(editorStyle.fontSize) * 1.6; 140 | 141 | return { 142 | top: rect.top - containerRect.top + this.editor.scrollTop - (lineHeight * 0.25), // Move up by quarter line height 143 | left: rect.left - containerRect.left, 144 | height: lineHeight 145 | }; 146 | } 147 | 148 | // Create and update remote cursors 149 | createRemoteCursor(remoteUserId, color) { 150 | // Double check we never create our own cursor 151 | if (remoteUserId === window.userId) { 152 | if (this.DEBUG) { 153 | console.warn('Attempted to create cursor for our own userId:', remoteUserId); 154 | } 155 | return null; 156 | } 157 | 158 | const cursor = document.createElement('div'); 159 | cursor.className = 'remote-cursor'; 160 | cursor.style.color = color; 161 | 162 | // Ensure the editor container exists 163 | const container = document.querySelector('.editor-container'); 164 | if (!container) { 165 | console.error('Editor container not found'); 166 | return null; 167 | } 168 | 169 | container.appendChild(cursor); 170 | if (this.DEBUG) { 171 | console.log('Created remote cursor for user:', remoteUserId, 'color:', color); 172 | } 173 | 174 | // Store user information 175 | this.remoteUsers.set(remoteUserId, { color, cursor }); 176 | return cursor; 177 | } 178 | 179 | // Update cursor position with improved measurements 180 | updateCursorPosition(remoteUserId, position, color) { 181 | // Don't create or update cursor for our own user ID 182 | if (remoteUserId === window.userId) { 183 | return; 184 | } 185 | 186 | let userInfo = this.remoteUsers.get(remoteUserId); 187 | let cursor; 188 | 189 | if (!userInfo) { 190 | cursor = this.createRemoteCursor(remoteUserId, color); 191 | if (!cursor) return; // Exit if cursor creation failed 192 | } else { 193 | cursor = userInfo.cursor; 194 | if (color !== userInfo.color) { 195 | cursor.style.color = color; 196 | userInfo.color = color; 197 | } 198 | } 199 | 200 | // Get cursor coordinates using Range API 201 | const coords = this.getCursorCoordinates(position); 202 | if (!coords) return; // Exit if we couldn't get coordinates 203 | 204 | if (this.DEBUG) { 205 | console.log('Cursor coordinates:', coords); 206 | } 207 | 208 | // Store position for scroll updates 209 | cursor.dataset.position = position; 210 | 211 | // Apply position with smooth transition 212 | cursor.style.transform = `translate3d(${coords.left}px, ${coords.top}px, 0)`; 213 | cursor.style.height = `${coords.height}px`; // Use full line height 214 | cursor.style.display = 'block'; // Ensure cursor is visible 215 | } 216 | 217 | // Handle user disconnection 218 | handleUserDisconnection(userId) { 219 | if (this.DEBUG) { 220 | console.log('User disconnected:', userId); 221 | } 222 | const userInfo = this.remoteUsers.get(userId); 223 | if (userInfo) { 224 | userInfo.cursor.remove(); 225 | this.remoteUsers.delete(userId); 226 | } 227 | } 228 | 229 | // Update all cursors (e.g., on scroll) 230 | updateAllCursors() { 231 | this.remoteUsers.forEach((userInfo, userId) => { 232 | const position = parseInt(userInfo.cursor.dataset.position); 233 | if (!isNaN(position)) { 234 | this.updateCursorPosition(userId, position, userInfo.color); 235 | } 236 | }); 237 | } 238 | 239 | // Clean up resources 240 | cleanup() { 241 | if (this.textMetrics.measurementDiv) { 242 | this.textMetrics.measurementDiv.remove(); 243 | } 244 | this.remoteUsers.forEach(userInfo => userInfo.cursor.remove()); 245 | this.remoteUsers.clear(); 246 | } 247 | } -------------------------------------------------------------------------------- /public/managers/operations.js: -------------------------------------------------------------------------------- 1 | // Operation Types 2 | export const OperationType = { 3 | INSERT: 'insert', 4 | DELETE: 'delete' 5 | }; 6 | 7 | export class OperationsManager { 8 | constructor() { 9 | this.DEBUG = false; 10 | this.localVersion = 0; // Local operation counter 11 | this.serverVersion = 0; // Last acknowledged server version 12 | this.pendingOperations = new Map(); // Map of operation ID to operation 13 | this.nextOperationId = 0; 14 | } 15 | 16 | // Create an operation object with unique ID 17 | createOperation(type, position, text = '', userId) { 18 | const operationId = this.nextOperationId++; 19 | const operation = { 20 | id: operationId, 21 | type, 22 | position, 23 | text, 24 | userId, 25 | localVersion: this.localVersion++, 26 | serverVersion: this.serverVersion, 27 | timestamp: Date.now() 28 | }; 29 | this.pendingOperations.set(operationId, operation); 30 | 31 | if (this.DEBUG) { 32 | console.log('Created operation:', operation); 33 | } 34 | 35 | return operation; 36 | } 37 | 38 | // Apply an operation to the text 39 | applyOperation(operation, text) { 40 | let result; 41 | switch (operation.type) { 42 | case OperationType.INSERT: 43 | result = text.slice(0, operation.position) + operation.text + text.slice(operation.position); 44 | break; 45 | case OperationType.DELETE: 46 | result = text.slice(0, operation.position) + text.slice(operation.position + operation.text.length); 47 | break; 48 | default: 49 | result = text; 50 | } 51 | 52 | if (this.DEBUG) { 53 | console.log('Applied operation:', operation, 'Result:', result); 54 | } 55 | 56 | return result; 57 | } 58 | 59 | // Handle operation acknowledgment 60 | handleOperationAck(operationId, serverVer) { 61 | if (this.pendingOperations.has(operationId)) { 62 | if (this.DEBUG) { 63 | console.log('Operation acknowledged:', operationId, 'server version:', serverVer); 64 | } 65 | const operation = this.pendingOperations.get(operationId); 66 | operation.serverVersion = serverVer; 67 | this.pendingOperations.delete(operationId); 68 | this.serverVersion = Math.max(this.serverVersion, serverVer); 69 | } 70 | } 71 | 72 | // Transform operation against another operation with improved handling 73 | transformOperation(operation, against) { 74 | if (operation.timestamp < against.timestamp) { 75 | return operation; 76 | } 77 | 78 | let newOperation = { ...operation }; 79 | 80 | if (against.type === OperationType.INSERT) { 81 | if (operation.position > against.position) { 82 | newOperation.position += against.text.length; 83 | } else if (operation.position === against.position) { 84 | // For concurrent insertions at the same position, 85 | // order by user ID to ensure consistency 86 | if (operation.userId > against.userId) { 87 | newOperation.position += against.text.length; 88 | } 89 | } 90 | } else if (against.type === OperationType.DELETE) { 91 | if (operation.type === OperationType.INSERT) { 92 | // Handle insert against delete 93 | if (operation.position >= against.position + against.text.length) { 94 | newOperation.position -= against.text.length; 95 | } else if (operation.position > against.position) { 96 | newOperation.position = against.position; 97 | } 98 | } else if (operation.type === OperationType.DELETE) { 99 | // Handle delete against delete 100 | if (operation.position >= against.position + against.text.length) { 101 | newOperation.position -= against.text.length; 102 | } else if (operation.position + operation.text.length <= against.position) { 103 | // No change needed 104 | } else { 105 | // Handle overlapping deletions 106 | const overlapStart = Math.max(operation.position, against.position); 107 | const overlapEnd = Math.min( 108 | operation.position + operation.text.length, 109 | against.position + against.text.length 110 | ); 111 | const overlap = overlapEnd - overlapStart; 112 | 113 | if (operation.position < against.position) { 114 | // Our deletion starts before the other deletion 115 | newOperation.text = operation.text.slice(0, against.position - operation.position); 116 | } else { 117 | // Our deletion starts within or after the other deletion 118 | newOperation.position = against.position; 119 | newOperation.text = operation.text.slice(overlap); 120 | } 121 | 122 | if (newOperation.text.length === 0) { 123 | return null; // Operation is no longer needed 124 | } 125 | } 126 | } 127 | } 128 | 129 | if (this.DEBUG) { 130 | console.log('Transformed operation:', operation, 'against:', against, 'result:', newOperation); 131 | } 132 | 133 | return newOperation; 134 | } 135 | 136 | // Get all pending operations 137 | getPendingOperations() { 138 | return Array.from(this.pendingOperations.values()) 139 | .sort((a, b) => a.timestamp - b.timestamp); 140 | } 141 | 142 | // Clear all pending operations 143 | clearPendingOperations() { 144 | this.pendingOperations.clear(); 145 | } 146 | } -------------------------------------------------------------------------------- /public/managers/search.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default class SearchManager { 4 | constructor(fetchWithPin, selectNotepad, closeModals) { 5 | this.fetchWithPin = fetchWithPin; 6 | this.selectNotepad = selectNotepad; 7 | this.closeModals = closeModals; 8 | this.cache = {}; 9 | this.page = 1; 10 | this.query = ""; 11 | this.selectedIndex = -1; 12 | this.searchModal = document.getElementById("search-modal"); 13 | this.searchBox = document.getElementById("search-box"); 14 | this.searchResults = document.getElementById("search-results"); 15 | this.resultItems = this.searchResults.querySelectorAll("li"); 16 | this.openSearchBtn = document.getElementById("search-open"); 17 | } 18 | 19 | debounce(func, delay = 300) { 20 | let timer; 21 | return (...args) => { 22 | clearTimeout(timer); 23 | timer = setTimeout(() => func(...args), delay); 24 | } 25 | } 26 | 27 | async search(newQuery, newPage = 1) { 28 | if (newQuery !== this.query) { 29 | this.page = 1; 30 | this.selectedIndex = -1; 31 | this.cache = {}; // resets the cache for new queries 32 | this.clearResults(); 33 | } 34 | 35 | this.query = newQuery; 36 | 37 | if(this.cache[`${this.query}-${this.page}}`]) 38 | return displayResults(this.cache[this.query]); 39 | 40 | const response = await this.fetchWithPin(`/api/search?query=${this.query}&page=${newPage}`); 41 | const results = await response.json(); 42 | this.cache[`${this.query}-${this.page}`] = results; 43 | this.displayResults(results); 44 | } 45 | 46 | highlightMatch(text) { 47 | return text.replace(new RegExp(this.query, "gi"), (match) => `${match}`); 48 | } 49 | 50 | handleKeydown(e) { 51 | if (this.searchModal.classList.contains("hidden")) return; // Prevent running when modal is closed 52 | if (this.resultItems.length === 0) return; 53 | 54 | if (e.key ==="ArrowDown") this.selectedIndex = Math.min(this.selectedIndex + 1, this.resultItems.length - 1); 55 | else if (e.key === "ArrowUp") this.selectedIndex = Math.max(this.selectedIndex - 1, 0); 56 | else if (e.key === "Enter" && this.selectedIndex >= 0) this.openNotepad(this.resultItems[this.selectedIndex].dataset.id); 57 | else if (e.key === "Enter" && this.selectedIndex === -1) this.openNotepad(this.resultItems[0].dataset.id); // opens the first item by default 58 | 59 | this.displayResults(this.cache[`${this.query}-${this.page}`]); 60 | } 61 | 62 | displayResults(data) { 63 | const results = data.results; 64 | if(!results || results?.length === 0) { 65 | return; 66 | } 67 | 68 | let html = results 69 | .map((result, i) => 70 | `
  • 71 | ${this.highlightMatch(result.name)} [${result.match}] 72 |
  • ` 73 | ).join(""); 74 | 75 | this.searchResults.innerHTML = html; 76 | 77 | // Update resultItems after the DOM has been updated 78 | this.resultItems = this.searchResults.querySelectorAll("li"); 79 | this.resultItems.forEach(item => { 80 | item.addEventListener("click", async () => { 81 | const id = item.getAttribute("data-id"); 82 | await this.openNotepad(id); 83 | }); 84 | }); 85 | } 86 | 87 | clearResults() { 88 | this.searchResults.innerHTML = ""; 89 | this.selectedIndex = -1; 90 | this.resultItems = this.searchResults.querySelectorAll("li"); 91 | } 92 | 93 | async openNotepad(id) { 94 | await this.selectNotepad(id); 95 | this.closeModal(); 96 | } 97 | 98 | openModal() { 99 | this.closeModals(); // close any open modals 100 | this.clearResults(); 101 | this.openSearchBtn.classList.add('active'); 102 | this.searchModal.classList.remove('hidden'); 103 | this.searchBox.focus(); 104 | } 105 | 106 | closeModal() { 107 | this.searchModal.classList.add('hidden'); 108 | this.openSearchBtn.classList.remove('active'); 109 | this.searchBox.value = ""; 110 | this.clearResults(); 111 | } 112 | 113 | addEventListeners() { 114 | this.openSearchBtn.addEventListener("click", this.openModal.bind(this)); 115 | this.searchBox.addEventListener("input", this.debounce((e) => { 116 | if (!e.target.value) { 117 | this.searchResults.innerHTML = ""; 118 | return; 119 | } 120 | 121 | this.search(e.target.value); 122 | })); 123 | 124 | document.addEventListener("keydown", (e) => { 125 | if (e.key === "Escape") 126 | this.closeModal(); 127 | 128 | this.handleKeydown(e); 129 | }); 130 | 131 | this.searchModal.addEventListener("click", (e) => { 132 | if (e.target.id === "search-modal") this.closeModal(); 133 | }) 134 | } 135 | } -------------------------------------------------------------------------------- /public/managers/settings.js: -------------------------------------------------------------------------------- 1 | export default class SettingsManager { 2 | constructor(storageManager, applySettings) { 3 | this.storageManager = storageManager; 4 | this.SETTINGS_KEY = 'dumbpad_settings'; 5 | this.applySettings = applySettings 6 | this.settingsInputAutoSaveStatusInterval = document.getElementById('autosave-status-interval-input'); 7 | this.settingsEnableRemoteConnectionMessages = document.getElementById('settings-remote-connection-messages'); 8 | this.settingsDefaultMarkdownPreview = document.getElementById('settings-default-markdown-preview'); 9 | } 10 | 11 | defaultSettings() { 12 | return { // Add additional default settings in here: 13 | saveStatusMessageInterval: 500, 14 | enableRemoteConnectionMessages: false, 15 | defaultMarkdownPreview: false, 16 | } 17 | } 18 | 19 | getSettings() { 20 | try { 21 | let currentSettings = this.storageManager.load(this.SETTINGS_KEY); 22 | if (!currentSettings) currentSettings = this.defaultSettings(); 23 | // console.log("Current Settings:", currentSettings); 24 | return currentSettings; 25 | } catch (err) { 26 | console.error(err); 27 | return false; 28 | } 29 | } 30 | 31 | saveSettings(reset) { 32 | try { 33 | const settingsToSave = reset ? this.defaultSettings() : this.getInputValues(); 34 | this.storageManager.save(this.SETTINGS_KEY, settingsToSave); 35 | // console.log("Saved new settings:", newSettings); 36 | this.applySettings(settingsToSave); 37 | return settingsToSave; 38 | } 39 | catch (err) { 40 | console.error(err); 41 | } 42 | } 43 | 44 | loadSettings(reset) { 45 | try { 46 | const appSettings = this.defaultSettings(); 47 | let currentSettings = this.getSettings(); 48 | 49 | // saves default values to local storage 50 | if (reset || !currentSettings) currentSettings = this.saveSettings(true); 51 | 52 | // initialize/update values and inputs in app.js below: 53 | appSettings.saveStatusMessageInterval = currentSettings.saveStatusMessageInterval; 54 | this.settingsInputAutoSaveStatusInterval.value = currentSettings.saveStatusMessageInterval; 55 | 56 | appSettings.enableRemoteConnectionMessages = currentSettings.enableRemoteConnectionMessages; 57 | this.settingsEnableRemoteConnectionMessages.checked = currentSettings.enableRemoteConnectionMessages; 58 | 59 | appSettings.defaultMarkdownPreview = currentSettings.defaultMarkdownPreview; 60 | this.settingsDefaultMarkdownPreview.checked = currentSettings.defaultMarkdownPreview; 61 | 62 | return currentSettings; 63 | } 64 | catch (err) { 65 | console.error(err); 66 | } 67 | } 68 | 69 | getInputValues() { 70 | const appSettings = this.defaultSettings(); 71 | 72 | // Get and set values from inputs to appSettings 73 | let newInterval = parseInt(this.settingsInputAutoSaveStatusInterval.value.trim()); 74 | if (isNaN(newInterval) || newInterval <= 0) newInterval = null; 75 | appSettings.saveStatusMessageInterval = newInterval; 76 | 77 | appSettings.enableRemoteConnectionMessages = this.settingsEnableRemoteConnectionMessages.checked; 78 | 79 | appSettings.defaultMarkdownPreview = this.settingsDefaultMarkdownPreview.checked; 80 | 81 | return appSettings; 82 | } 83 | } -------------------------------------------------------------------------------- /public/managers/storage.js: -------------------------------------------------------------------------------- 1 | export default class StorageManager { 2 | constructor() { 3 | if (!window.localStorage) { 4 | throw new Error("Local Storage is not supported in this environment."); 5 | } 6 | } 7 | 8 | /** 9 | * Saves data to localStorage 10 | * @param {string} key - The key under which data is stored 11 | * @param {any} value - The value to be stored (will be stringified) 12 | * @returns {boolean} - Returns true if successful, false otherwise 13 | */ 14 | save(key, value) { 15 | try { 16 | if (!key) throw new Error("Key cannot be empty."); 17 | const serializedValue = JSON.stringify(value); 18 | localStorage.setItem(key, serializedValue); 19 | return true; 20 | } catch (error) { 21 | console.error(`Error saving to localStorage: ${error.message}`); 22 | return false; 23 | } 24 | } 25 | 26 | /** 27 | * Loads data from localStorage 28 | * @param {string} key - The key of the data to retrieve 29 | * @returns {any|null} - The retrieved value or null if not found 30 | */ 31 | load(key) { 32 | try { 33 | if (!key) throw new Error("Key cannot be empty."); 34 | const data = localStorage.getItem(key); 35 | const parsedData = data ? JSON.parse(data) : null; 36 | return parsedData; 37 | } catch (error) { 38 | console.error(`Error loading from localStorage: ${error.message}`); 39 | return null; 40 | } 41 | } 42 | 43 | /** 44 | * Removes an item from localStorage 45 | * @param {string} key - The key of the item to remove 46 | * @returns {boolean} - Returns true if successful, false otherwise 47 | */ 48 | remove(key) { 49 | try { 50 | if (!key) throw new Error("Key cannot be empty."); 51 | localStorage.removeItem(key); 52 | return true; 53 | } catch (error) { 54 | console.error(`Error removing from localStorage: ${error.message}`); 55 | return false; 56 | } 57 | } 58 | 59 | /** 60 | * Clears all localStorage data 61 | * @returns {boolean} - Returns true if successful, false otherwise 62 | */ 63 | clearAll() { 64 | try { 65 | localStorage.clear(); 66 | return true; 67 | } catch (error) { 68 | console.error(`Error clearing localStorage: ${error.message}`); 69 | return false; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /public/managers/toaster.js: -------------------------------------------------------------------------------- 1 | export class ToastManager { 2 | constructor(containerElement) { 3 | this.container = containerElement; 4 | this.isError = 'error'; 5 | this.isSuccess = 'success'; 6 | } 7 | 8 | show(message, type = 'success', isStatic = false, timeoutMs = 1000) { 9 | if (!timeoutMs || timeoutMs < 1) return; 10 | 11 | const toast = document.createElement('div'); 12 | toast.classList.add('toast'); 13 | toast.textContent = message; 14 | 15 | if (type === this.isSuccess) toast.classList.add('success'); 16 | else toast.classList.add('error'); 17 | 18 | this.container.appendChild(toast); 19 | 20 | setTimeout(() => { 21 | toast.addEventListener('click', () => this.hide(toast)); 22 | toast.classList.add('show'); 23 | }, 10); 24 | 25 | if (!isStatic) { 26 | setTimeout(() => { 27 | toast.classList.remove('show'); 28 | setTimeout(() => { 29 | this.hide(toast); 30 | }, 300); // Match transition duration 31 | }, timeoutMs); 32 | } 33 | } 34 | 35 | hide(toast) { 36 | toast.classList.remove('show'); 37 | setTimeout(() => { 38 | this.container.removeChild(toast); 39 | }, 300); 40 | } 41 | 42 | clear() { 43 | // use to clear static toast messages 44 | while (this.container.firstChild) { 45 | this.container.removeChild(this.container.firstChild); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = "DUMBPAD_PWA_CACHE_V1"; 2 | const ASSETS_TO_CACHE = []; 3 | 4 | const preload = async () => { 5 | console.log("Installing web app"); 6 | return await caches.open(CACHE_NAME) 7 | .then(async (cache) => { 8 | console.log("caching index and important routes"); 9 | const response = await fetch("/asset-manifest.json"); 10 | const assets = await response.json(); 11 | ASSETS_TO_CACHE.push(...assets, "/js/marked/marked.esm.js"); 12 | console.log("Assets Cached:", ASSETS_TO_CACHE); 13 | return cache.addAll(ASSETS_TO_CACHE); 14 | }); 15 | } 16 | 17 | // Fetch asset manifest dynamically 18 | globalThis.addEventListener("install", (event) => { 19 | event.waitUntil(preload()); 20 | }); 21 | 22 | globalThis.addEventListener("activate", (event) => { 23 | event.waitUntil(clients.claim()); 24 | }); 25 | 26 | globalThis.addEventListener("fetch", (event) => { 27 | event.respondWith( 28 | caches.match(event.request).then((cachedResponse) => { 29 | return cachedResponse || fetch(event.request); 30 | }) 31 | ); 32 | }); -------------------------------------------------------------------------------- /scripts/cors.js: -------------------------------------------------------------------------------- 1 | const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*'; 2 | const NODE_ENV = process.env.NODE_ENV || 'production'; 3 | let allowedOrigins = []; 4 | 5 | function setupOrigins(baseUrl) { 6 | allowedOrigins = [ baseUrl ]; 7 | 8 | if (NODE_ENV === 'development' || ALLOWED_ORIGINS === '*') allowedOrigins = '*'; 9 | else if (ALLOWED_ORIGINS && typeof ALLOWED_ORIGINS === 'string') { 10 | try { 11 | const allowed = ALLOWED_ORIGINS.split(',').map(origin => origin.trim()); 12 | allowed.forEach(origin => { 13 | const normalizedOrigin = normalizeOrigin(origin); 14 | if (normalizedOrigin !== baseUrl) allowedOrigins.push(normalizedOrigin); 15 | }); 16 | } 17 | catch (error) { 18 | console.error(`Error setting up ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}:`, error); 19 | } 20 | } 21 | console.log("ALLOWED ORIGINS:", allowedOrigins); 22 | return allowedOrigins; 23 | } 24 | 25 | function normalizeOrigin(origin) { 26 | if (origin) { 27 | try { 28 | const normalizedOrigin = new URL(origin).origin; 29 | return normalizedOrigin; 30 | } catch (error) { 31 | console.error("Error parsing referer URL:", error); 32 | throw new Error("Error parsing referer URL:", error); 33 | } 34 | } 35 | } 36 | 37 | function validateOrigin(origin) { 38 | if (NODE_ENV === 'development' || allowedOrigins === '*') return true; 39 | 40 | try { 41 | if (origin) origin = normalizeOrigin(origin); 42 | else { 43 | console.warn("No origin to validate."); 44 | return false; 45 | } 46 | 47 | console.log("Validating Origin:", origin); 48 | 49 | if (allowedOrigins.includes(origin)) { 50 | console.log("Allowed request from origin:", origin); 51 | return true; 52 | } 53 | else { 54 | console.warn("Blocked request from origin:", origin); 55 | return false; 56 | } 57 | } 58 | catch (error) { 59 | console.error(error); 60 | } 61 | } 62 | 63 | function originValidationMiddleware(req, res, next) { 64 | const origin = req.headers.referer || `${req.protocol}://${req.headers.host}`; 65 | const isOriginValid = validateOrigin(origin); 66 | 67 | if (isOriginValid) { 68 | next(); 69 | } else { 70 | res.status(403).json({ error: 'Forbidden' }); 71 | } 72 | } 73 | 74 | 75 | function getCorsOptions(baseUrl) { 76 | const allowedOrigins = setupOrigins(baseUrl); 77 | const corsOptions = { 78 | origin: allowedOrigins, 79 | credentials: true, 80 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 81 | allowedHeaders: ['Content-Type', 'Authorization'], 82 | }; 83 | 84 | return corsOptions; 85 | } 86 | 87 | module.exports = { getCorsOptions, originValidationMiddleware, validateOrigin }; -------------------------------------------------------------------------------- /scripts/generate-png.js: -------------------------------------------------------------------------------- 1 | const sharp = require('sharp'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Sizes to generate 6 | const sizes = [16, 32, 64, 128, 256]; 7 | 8 | async function convertToPng() { 9 | const svgBuffer = fs.readFileSync(path.join(__dirname, '../public/favicon.svg')); 10 | 11 | for (const size of sizes) { 12 | await sharp(svgBuffer) 13 | .resize(size, size) 14 | .png() 15 | .toFile(path.join(__dirname, `../public/favicon-${size}.png`)); 16 | 17 | console.log(`Generated ${size}x${size} PNG`); 18 | } 19 | } 20 | 21 | convertToPng().catch(console.error); -------------------------------------------------------------------------------- /scripts/pwa-manifest-generator.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const PUBLIC_DIR = path.join(__dirname, "..", "public"); 4 | const ASSETS_DIR = path.join(PUBLIC_DIR, "Assets"); 5 | 6 | function getFiles(dir, basePath = "/") { 7 | let fileList = []; 8 | const files = fs.readdirSync(dir); 9 | const excludeList = [".DS_Store"]; // Add files or patterns to exclude here 10 | 11 | files.forEach((file) => { 12 | const filePath = path.join(dir, file); 13 | const fileUrl = path.join(basePath, file).replace(/\\/g, "/"); 14 | 15 | if (fs.statSync(filePath).isDirectory()) { 16 | fileList = fileList.concat(getFiles(filePath, fileUrl)); 17 | } else { 18 | if (!excludeList.includes(file)){ 19 | fileList.push(fileUrl); 20 | } 21 | } 22 | }); 23 | 24 | return fileList; 25 | } 26 | 27 | function generateAssetManifest() { 28 | const assets = getFiles(PUBLIC_DIR); 29 | fs.writeFileSync(path.join(ASSETS_DIR, "asset-manifest.json"), JSON.stringify(assets, null, 2)); 30 | console.log("Asset manifest generated!", assets); 31 | } 32 | 33 | function generatePWAManifest(siteTitle) { 34 | generateAssetManifest(); // fetched later in service-worker 35 | 36 | const pwaManifest = { 37 | name: siteTitle, 38 | short_name: siteTitle, 39 | description: "A simple notepad application", 40 | start_url: "/", 41 | display: "standalone", 42 | background_color: "#ffffff", 43 | theme_color: "#000000", 44 | icons: [ 45 | { 46 | src: "dumbpad.png", 47 | type: "image/png", 48 | sizes: "192x192" 49 | }, 50 | { 51 | src: "dumbpad.png", 52 | type: "image/png", 53 | sizes: "512x512" 54 | } 55 | ], 56 | orientation: "any" 57 | }; 58 | 59 | fs.writeFileSync(path.join(ASSETS_DIR, "manifest.json"), JSON.stringify(pwaManifest, null, 2)); 60 | console.log("PWA manifest generated!", pwaManifest); 61 | } 62 | 63 | module.exports = { generatePWAManifest }; -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const fs = require('fs').promises; 5 | const path = require('path'); 6 | const crypto = require('crypto'); 7 | const cookieParser = require('cookie-parser'); 8 | const WebSocket = require('ws'); 9 | const Fuse = require('fuse.js'); 10 | const { generatePWAManifest } = require("./scripts/pwa-manifest-generator") 11 | const { originValidationMiddleware, getCorsOptions, validateOrigin } = require('./scripts/cors'); 12 | 13 | const app = express(); 14 | const PORT = process.env.PORT || 3000; 15 | const NODE_ENV = process.env.NODE_ENV || 'development' 16 | const DATA_DIR = path.join(__dirname, 'data'); 17 | const PUBLIC_DIR = path.join(__dirname, "public"); 18 | const ASSETS_DIR = path.join(PUBLIC_DIR, "Assets"); 19 | const NOTEPADS_FILE = path.join(DATA_DIR, 'notepads.json'); 20 | const SITE_TITLE = process.env.SITE_TITLE || 'DumbPad'; 21 | const PIN = process.env.DUMBPAD_PIN; 22 | const COOKIE_NAME = 'dumbpad_auth'; 23 | const COOKIE_MAX_AGE = process.env.COOKIE_MAX_AGE || 24; // default 24 in hours 24 | const cookieMaxAge = COOKIE_MAX_AGE * 60 * 60 * 1000; // in hours 25 | const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; 26 | const PAGE_HISTORY_COOKIE = 'dumbpad_page_history'; 27 | const PAGE_HISTORY_COOKIE_AGE = process.env.PAGE_HISTORY_COOKIE_AGE || 365; // defaults to 1 Year in days 28 | const pageHistoryCookieAge = PAGE_HISTORY_COOKIE_AGE * 24 * 60 * 60 * 1000; 29 | let notepads_cache = { 30 | notepads: [], 31 | index: null, 32 | }; 33 | 34 | const server = app.listen(PORT, () => { 35 | console.log(`Server is running on port ${PORT}`); 36 | console.log(`Base URL: ${BASE_URL}`); 37 | console.log(`Environment: ${NODE_ENV}`); 38 | }); 39 | 40 | // Trust proxy - required for secure cookies behind a reverse proxy 41 | app.set('trust proxy', 1); 42 | 43 | // CORS setup 44 | const corsOptions = getCorsOptions(BASE_URL); 45 | 46 | // Middleware setup 47 | app.use(cors(corsOptions)); 48 | app.use(express.json()); 49 | app.use(cookieParser()); 50 | // Serve the marked library to clients 51 | app.use('/js/marked', express.static( 52 | path.join(__dirname, 'node_modules/marked/lib') 53 | )); 54 | 55 | generatePWAManifest(SITE_TITLE); 56 | 57 | app.use(express.static(path.join(__dirname, 'public'), { 58 | index: false 59 | })); 60 | 61 | // Set up WebSocket server 62 | const wss = new WebSocket.Server({ server, verifyClient: (info, done) => { 63 | const origin = info.req.headers.origin; 64 | const isOriginValid = validateOrigin(origin); 65 | if (isOriginValid) done(true); // allow the connection 66 | else { 67 | console.warn("Blocked connection from origin:", {origin}); 68 | done(false, 403, 'Forbidden'); // reject the connection 69 | } 70 | }}); 71 | 72 | // Store all active connections with their user IDs 73 | const clients = new Map(); 74 | 75 | // Store operations history for each notepad 76 | const operationsHistory = new Map(); 77 | 78 | // WebSocket connection handling 79 | wss.on('connection', (ws) => { 80 | console.log('New WebSocket connection established'); 81 | let userId = null; 82 | 83 | // Handle incoming messages 84 | ws.on('message', (message) => { 85 | try { 86 | const data = JSON.parse(message); 87 | console.log('Received WebSocket message:', data); 88 | 89 | // Store userId when first received 90 | if (data.userId && !userId) { 91 | userId = data.userId; 92 | clients.set(userId, ws); 93 | console.log('User connected:', userId); 94 | 95 | if (clients.size > 1) { 96 | console.log('Notifying other clients about new user:', userId); 97 | clients.forEach((client, clientId) => { 98 | if (client.readyState === WebSocket.OPEN) { 99 | client.send(JSON.stringify({ 100 | type: 'user_connected', 101 | userId: userId, 102 | notepadId: data.notepadId, 103 | count: clients.size 104 | })); 105 | } 106 | }) 107 | } 108 | } 109 | 110 | // Handle different message types 111 | if (data.type === 'operation' && data.notepadId) { 112 | console.log('Operation received from user:', userId); 113 | // Store operation in history 114 | if (!operationsHistory.has(data.notepadId)) { 115 | operationsHistory.set(data.notepadId, []); 116 | } 117 | 118 | // Add server version to operation 119 | const history = operationsHistory.get(data.notepadId); 120 | const serverVersion = history.length; 121 | const operation = { 122 | ...data.operation, 123 | serverVersion 124 | }; 125 | history.push(operation); 126 | 127 | // Keep only last 1000 operations 128 | if (history.length > 1000) { 129 | history.splice(0, history.length - 1000); 130 | } 131 | 132 | // Send acknowledgment to the sender 133 | ws.send(JSON.stringify({ 134 | type: 'ack', 135 | operationId: data.operation.id, 136 | serverVersion 137 | })); 138 | 139 | // Broadcast to other clients 140 | clients.forEach((client, clientId) => { 141 | if (client !== ws && client.readyState === WebSocket.OPEN) { 142 | console.log('Broadcasting operation to user:', clientId); 143 | client.send(JSON.stringify({ 144 | type: 'operation', 145 | operation, 146 | notepadId: data.notepadId, 147 | userId: data.userId 148 | })); 149 | } 150 | }); 151 | } 152 | else if (data.type === 'cursor' && data.notepadId) { 153 | console.log('Cursor update from user:', userId, 'position:', data.position); 154 | // Broadcast cursor updates 155 | clients.forEach((client, clientId) => { 156 | if (client !== ws && client.readyState === WebSocket.OPEN) { 157 | console.log('Broadcasting cursor update to user:', clientId); 158 | client.send(JSON.stringify({ 159 | type: 'cursor', 160 | userId: data.userId, 161 | color: data.color, 162 | position: data.position, 163 | notepadId: data.notepadId 164 | })); 165 | } 166 | }); 167 | } 168 | else if (data.type === 'notepad_rename') { 169 | console.log('Notepad rename from user:', userId, 'notepad:', data.notepadId); 170 | // Broadcast rename to all clients 171 | clients.forEach((client, clientId) => { 172 | if (client !== ws && client.readyState === WebSocket.OPEN) { 173 | console.log('Broadcasting notepad rename to user:', clientId); 174 | client.send(JSON.stringify({ 175 | type: 'notepad_rename', 176 | notepadId: data.notepadId, 177 | newName: data.newName 178 | })); 179 | } 180 | }); 181 | } 182 | else if (data.type === 'sync_request') { 183 | console.log('Sync request from user:', userId); 184 | // Send operation history for catch-up 185 | const history = operationsHistory.get(data.notepadId) || []; 186 | ws.send(JSON.stringify({ 187 | type: 'sync_response', 188 | operations: history, 189 | notepadId: data.notepadId 190 | })); 191 | } 192 | else { 193 | // Broadcast other types of messages 194 | console.log('Broadcasting other message type:', data.type); 195 | clients.forEach((client, clientId) => { 196 | if (client !== ws && client.readyState === WebSocket.OPEN) { 197 | client.send(JSON.stringify(data)); 198 | } 199 | }); 200 | } 201 | } catch (error) { 202 | console.error('WebSocket message error:', error); 203 | } 204 | }); 205 | 206 | // Handle client disconnection 207 | ws.on('close', () => { 208 | if (userId) { 209 | console.log('User disconnected:', userId); 210 | clients.delete(userId); 211 | // Notify other clients about disconnection 212 | clients.forEach((client) => { 213 | if (client.readyState === WebSocket.OPEN) { 214 | client.send(JSON.stringify({ 215 | type: 'user_disconnected', 216 | userId: userId, 217 | count: clients.size 218 | })); 219 | } 220 | }); 221 | } 222 | }); 223 | }); 224 | 225 | // Brute force protection 226 | const loginAttempts = new Map(); 227 | const MAX_ATTEMPTS = process.env.MAX_ATTEMPTS || 5; // default to 5 228 | const LOCKOUT_TIME = process.env.LOCKOUT_TIME || 15; // default 15 minutes 229 | const lockOutTime = LOCKOUT_TIME * 60 * 1000; // in milliseconds 230 | 231 | // Reset attempts for an IP 232 | function resetAttempts(ip) { 233 | loginAttempts.delete(ip); 234 | } 235 | 236 | // Check if an IP is locked out 237 | function isLockedOut(ip) { 238 | const attempts = loginAttempts.get(ip); 239 | if (!attempts) return false; 240 | 241 | if (attempts.count >= MAX_ATTEMPTS) { 242 | const timeElapsed = Date.now() - attempts.lastAttempt; 243 | if (timeElapsed < lockOutTime) { 244 | return true; 245 | } 246 | resetAttempts(ip); 247 | } 248 | return false; 249 | } 250 | 251 | // Record an attempt for an IP 252 | function recordAttempt(ip) { 253 | const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; 254 | attempts.count += 1; 255 | attempts.lastAttempt = Date.now(); 256 | loginAttempts.set(ip, attempts); 257 | } 258 | 259 | // Cleanup old lockouts periodically 260 | setInterval(() => { 261 | const now = Date.now(); 262 | for (const [ip, attempts] of loginAttempts.entries()) { 263 | if (now - attempts.lastAttempt >= lockOutTime) { 264 | loginAttempts.delete(ip); 265 | } 266 | } 267 | }, 60000); // Clean up every minute 268 | 269 | // Validate PIN format 270 | function isValidPin(pin) { 271 | return typeof pin === 'string' && /^\d{4,10}$/.test(pin); 272 | } 273 | 274 | // Constant-time string comparison to prevent timing attacks 275 | function secureCompare(a, b) { 276 | if (typeof a !== 'string' || typeof b !== 'string') { 277 | return false; 278 | } 279 | 280 | // Use Node's built-in constant-time comparison 281 | try { 282 | return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); 283 | } catch (err) { 284 | return false; 285 | } 286 | } 287 | 288 | // Main app route with PIN & CORS check 289 | app.get('/', originValidationMiddleware, (req, res) => { 290 | const pin = process.env.DUMBPAD_PIN; 291 | 292 | // Skip PIN if not configured 293 | if (!pin || !isValidPin(pin)) { 294 | return res.sendFile(path.join(__dirname, 'public', 'index.html')); 295 | } 296 | 297 | // Check PIN cookie 298 | const authCookie = req.cookies[COOKIE_NAME]; 299 | if (!authCookie || !secureCompare(authCookie, pin)) { 300 | return res.redirect('/login'); 301 | } 302 | 303 | res.sendFile(path.join(__dirname, 'public', 'index.html')); 304 | }); 305 | 306 | // Serve the pwa/asset manifest 307 | app.get("/asset-manifest.json", (req, res) => { 308 | // generated in pwa-manifest-generator and fetched from service-worker.js 309 | res.sendFile(path.join(ASSETS_DIR, "asset-manifest.json")); 310 | }); 311 | app.get("/manifest.json", (req, res) => { 312 | res.sendFile(path.join(ASSETS_DIR, "manifest.json")); 313 | }); 314 | 315 | // Login page route 316 | app.get('/login', (req, res) => { 317 | // If no PIN is required or user is already authenticated, redirect to main app 318 | const pin = process.env.DUMBPAD_PIN; 319 | if (!pin || !isValidPin(pin) || (req.cookies[COOKIE_NAME] && secureCompare(req.cookies[COOKIE_NAME], pin))) { 320 | return res.redirect('/'); 321 | } 322 | 323 | res.sendFile(path.join(__dirname, 'public', 'login.html')); 324 | }); 325 | 326 | // Pin verification endpoint 327 | app.post('/api/verify-pin', (req, res) => { 328 | const { pin } = req.body; 329 | 330 | // If no PIN is set in env, always return success 331 | if (!PIN) { 332 | return res.json({ success: true }); 333 | } 334 | 335 | const ip = req.ip; 336 | 337 | // Check if IP is locked out 338 | if (isLockedOut(ip)) { 339 | const attempts = loginAttempts.get(ip); 340 | const timeLeft = Math.ceil((lockOutTime - (Date.now() - attempts.lastAttempt)) / 1000 / 60); 341 | return res.status(429).json({ 342 | error: `Too many attempts. Please try again in ${timeLeft} minute(s).` 343 | }); 344 | } 345 | 346 | // Validate PIN format 347 | if (!isValidPin(pin)) { 348 | recordAttempt(ip); 349 | return res.status(400).json({ success: false, error: 'Invalid PIN format' }); 350 | } 351 | 352 | // Verify the PIN using constant-time comparison 353 | if (pin && secureCompare(pin, PIN)) { 354 | // Reset attempts on successful login 355 | resetAttempts(ip); 356 | 357 | // Set secure HTTP-only cookie 358 | res.cookie(COOKIE_NAME, pin, { 359 | httpOnly: true, 360 | secure: req.secure || (BASE_URL.startsWith("https") && NODE_ENV === 'production'), 361 | sameSite: 'strict', 362 | maxAge: cookieMaxAge 363 | }); 364 | res.json({ success: true }); 365 | } else { 366 | // Record failed attempt 367 | recordAttempt(ip); 368 | 369 | const attempts = loginAttempts.get(ip); 370 | const attemptsLeft = MAX_ATTEMPTS - attempts.count; 371 | 372 | res.status(401).json({ 373 | success: false, 374 | error: 'Invalid PIN', 375 | attemptsLeft: Math.max(0, attemptsLeft) 376 | }); 377 | } 378 | }); 379 | 380 | // Check if PIN is required 381 | app.get('/api/pin-required', (req, res) => { 382 | res.json({ 383 | required: !!PIN && isValidPin(PIN), 384 | length: PIN ? PIN.length : 0, 385 | locked: isLockedOut(req.ip) 386 | }); 387 | }); 388 | 389 | // Get site configuration 390 | app.get('/api/config', (req, res) => { 391 | res.json({ 392 | siteTitle: SITE_TITLE, 393 | baseUrl: process.env.BASE_URL, 394 | }); 395 | }); 396 | 397 | // Pin protection middleware 398 | const requirePin = (req, res, next) => { 399 | if (!PIN || !isValidPin(PIN)) { 400 | return next(); 401 | } 402 | 403 | const authCookie = req.cookies[COOKIE_NAME]; 404 | if (!authCookie || !secureCompare(authCookie, PIN)) { 405 | return res.status(401).json({ error: 'Unauthorized' }); 406 | } 407 | next(); 408 | }; 409 | 410 | // Apply pin protection to all /api routes except pin verification 411 | app.use('/api', (req, res, next) => { 412 | if (req.path === '/verify-pin' || req.path === '/pin-required' || req.path === '/config') { 413 | return next(); 414 | } 415 | requirePin(req, res, next); 416 | }); 417 | 418 | // Ensure data directory exists 419 | async function ensureDataDir() { 420 | try { 421 | // Create data directory if it doesn't exist 422 | await fs.mkdir(DATA_DIR, { recursive: true }); 423 | 424 | // Create notepads.json if it doesn't exist 425 | try { 426 | await fs.access(NOTEPADS_FILE); 427 | // If file exists, validate its structure 428 | const content = await fs.readFile(NOTEPADS_FILE, 'utf8'); 429 | try { 430 | const data = JSON.parse(content); 431 | if (!data.notepads || !Array.isArray(data.notepads)) { 432 | throw new Error('Invalid notepads structure'); 433 | } 434 | } catch (err) { 435 | console.error('Invalid notepads.json, recreating:', err); 436 | await fs.writeFile(NOTEPADS_FILE, JSON.stringify({ 437 | notepads: [{ id: 'default', name: 'Default Notepad' }] 438 | }, null, 2)); 439 | } 440 | } catch (err) { 441 | // File doesn't exist or can't be accessed, create it 442 | console.log('Creating new notepads.json'); 443 | await fs.writeFile(NOTEPADS_FILE, JSON.stringify({ 444 | notepads: [{ id: 'default', name: 'Default Notepad' }] 445 | }, null, 2)); 446 | } 447 | 448 | // Ensure default notepad file exists 449 | const defaultNotePath = path.join(DATA_DIR, 'default.txt'); 450 | try { 451 | await fs.access(defaultNotePath); 452 | } catch { 453 | await fs.writeFile(defaultNotePath, ''); 454 | } 455 | } catch (err) { 456 | console.error('Error initializing data directory:', err); 457 | throw err; 458 | } 459 | } 460 | 461 | async function loadNotepadsList() { 462 | const notepadsList = await getNotepadsFromDir(); 463 | return notepadsList || []; 464 | } 465 | 466 | async function getNotepadsFromDir() { 467 | await ensureDataDir(); 468 | let notepadsData = { notepads: [] }; 469 | try { 470 | const fileContent = await fs.readFile(NOTEPADS_FILE, 'utf8'); 471 | notepadsData = JSON.parse(fileContent); 472 | } catch (readError) { 473 | // If notepads.json doesn't exist or is invalid, start with an empty array 474 | if (readError.code !== 'ENOENT') { 475 | console.error('Error reading notepads.json:', readError); 476 | } 477 | } 478 | 479 | const notepads = notepadsData.notepads || []; 480 | 481 | const dataFiles = await fs.readdir(DATA_DIR); 482 | const txtFiles = dataFiles 483 | .filter(file => file.endsWith('.txt')) 484 | .map(file => path.parse(file).name); // Extract filename without extension 485 | 486 | const newNotepads = txtFiles.filter(txtFile => !notepads.some(notepad => notepad.id === txtFile)) 487 | .map(txtFile => ({ id: txtFile, name: txtFile })); 488 | 489 | if (newNotepads.length > 0) { 490 | notepadsData.notepads = [...notepads, ...newNotepads]; 491 | await fs.writeFile(NOTEPADS_FILE, JSON.stringify(notepadsData, null, 2), 'utf8'); 492 | console.log(`Added new notepads: ${newNotepads.map(n => n.id).join(', ')}`); 493 | } 494 | 495 | return notepadsData.notepads; 496 | } 497 | 498 | /* Notepad Search Functionality */ 499 | // Load and index text files 500 | async function indexNotepads() { 501 | console.log("Indexing notepads..."); 502 | notepads_cache.notepads = await loadNotepadsList(); 503 | 504 | let items = await Promise.all(notepads_cache.notepads.map(async ({ id, name }) => { 505 | let content = ""; 506 | // console.log("id: ", id, "name:", name); 507 | let filePath = path.join(DATA_DIR, `${id}.txt`); 508 | try { 509 | await fs.access(filePath); // Ensure file exists 510 | content = await fs.readFile(filePath, 'utf8'); 511 | } catch (error) { 512 | console.warn(`Could not read file: ${filePath}`); 513 | } 514 | 515 | return { id, name, content }; 516 | })); 517 | 518 | notepads_cache.index = new Fuse(items, { 519 | keys: ["name", "content"], 520 | threshold: 0.38, // lower thresholds mean stricter matching 521 | minMatchCharLength: 3, // Ensures partial words can be matched 522 | ignoreLocation: true, // Allows searching across larger texts 523 | includeScore: true, // Useful for debugging relevance 524 | includeMatches: true 525 | }); 526 | 527 | // console.log(notepads_cache); // uncomment to debug 528 | } 529 | 530 | // Search function using cache 531 | function searchNotepads(query) { 532 | if (!notepads_cache.index) indexNotepads(); 533 | 534 | const results = notepads_cache.index.search(query).map(({ item }) => { 535 | const isFilenameMatch = item.name.toLowerCase().includes(query.toLowerCase()); 536 | let truncatedContent = item.content; 537 | 538 | if (!isFilenameMatch) { 539 | const lowerContent = item.content.toLowerCase(); 540 | const matchIndex = lowerContent.indexOf(query.toLowerCase()); 541 | 542 | if (matchIndex !== -1) { 543 | let start = matchIndex; 544 | let end = matchIndex + query.length; 545 | 546 | // Move start back up to 3 spaces before 547 | let spaceCount = 0; 548 | while (start > 0 && spaceCount < 3) { 549 | if (lowerContent[start] === ' ') spaceCount++; 550 | start--; 551 | } 552 | start = Math.max(0, start); // Ensure start doesn't go negative 553 | 554 | // Move end forward until at least 25 characters are reached 555 | while (end < lowerContent.length && (end - start) < 25) { 556 | end++; 557 | } 558 | 559 | // Extract snippet 560 | truncatedContent = item.content.substring(start, end).trim(); 561 | // Add ellipsis to beginning if we truncated from somewhere 562 | if (start > 0) truncatedContent = `...${truncatedContent}`; 563 | // Add ellipsis to end if there is more content after the snippet 564 | if (end < item.content.length) truncatedContent = `${truncatedContent}...`; 565 | } else { 566 | truncatedContent = item.content.substring(0, 20).trim() + "..."; // Fallback if no match is found 567 | } 568 | } 569 | 570 | let truncatedName = item.name.substring(0, 20).trim(); 571 | if(item.name.length >= 20) { 572 | truncatedName += "..."; 573 | } 574 | 575 | return { 576 | id: item.id, 577 | name: isFilenameMatch ? truncatedName : truncatedContent, 578 | match: isFilenameMatch ? "notepad" : `content in ${truncatedName}` 579 | }; 580 | }); 581 | 582 | return results; 583 | } 584 | 585 | // Watch for changes in notepads.json or .txt files 586 | fs.watch(DATA_DIR, (eventType, filename) => { 587 | if (filename.endsWith(".txt")) indexNotepads(); 588 | }); 589 | fs.watch(NOTEPADS_FILE, () => indexNotepads()); 590 | 591 | // Initial indexing 592 | indexNotepads(); 593 | 594 | /* API Endpoints */ 595 | // Get list of notepads 596 | app.get('/api/notepads', async (req, res) => { 597 | try { 598 | const notepadsList = await loadNotepadsList(); 599 | // Return the existing cookie value along with notes 600 | const note_history = req.cookies.dumbpad_page_history || 'default'; 601 | res.json({'notepads_list': notepadsList, 'note_history': note_history}); 602 | } catch (err) { 603 | res.status(500).json({ error: 'Error reading notepads list' }); 604 | } 605 | }); 606 | 607 | // Create new notepad 608 | app.post('/api/notepads', async (req, res) => { 609 | try { 610 | const data = JSON.parse(await fs.readFile(NOTEPADS_FILE, 'utf8')); 611 | const id = Date.now().toString(); 612 | const newNotepad = { 613 | id, 614 | name: `Notepad ${data.notepads.length + 1}` 615 | }; 616 | data.notepads.push(newNotepad); 617 | 618 | // Set new notes as the current page in cookies. 619 | res.cookie(PAGE_HISTORY_COOKIE, id, { 620 | httpOnly: true, 621 | secure: req.secure || (BASE_URL.startsWith("https") && NODE_ENV === 'production'), 622 | sameSite: 'strict', 623 | maxAge: pageHistoryCookieAge 624 | }); 625 | 626 | await fs.writeFile(NOTEPADS_FILE, JSON.stringify(data)); 627 | await fs.writeFile(path.join(DATA_DIR, `${id}.txt`), ''); 628 | indexNotepads(); // update searching index 629 | res.json(newNotepad); 630 | } catch (err) { 631 | res.status(500).json({ error: 'Error creating new notepad' }); 632 | } 633 | }); 634 | 635 | // Rename notepad 636 | app.put('/api/notepads/:id', async (req, res) => { 637 | try { 638 | const { id } = req.params; 639 | const { name } = req.body; 640 | const data = JSON.parse(await fs.readFile(NOTEPADS_FILE, 'utf8')); 641 | const notepad = data.notepads.find(n => n.id === id); 642 | if (!notepad) { 643 | return res.status(404).json({ error: 'Notepad not found' }); 644 | } 645 | notepad.name = name; 646 | await fs.writeFile(NOTEPADS_FILE, JSON.stringify(data)); 647 | indexNotepads(); // update searching index 648 | res.json(notepad); 649 | } catch (err) { 650 | res.status(500).json({ error: 'Error renaming notepad' }); 651 | } 652 | }); 653 | 654 | // Get notes for a specific notepad 655 | app.get('/api/notes/:id', async (req, res) => { 656 | try { 657 | const { id } = req.params; 658 | const notePath = path.join(DATA_DIR, `${id}.txt`); 659 | const notes = await fs.readFile(notePath, 'utf8').catch(() => ''); 660 | 661 | // Set loaded notes as the current page in cookies. 662 | res.cookie(PAGE_HISTORY_COOKIE, id, { 663 | httpOnly: true, 664 | secure: req.secure || (BASE_URL.startsWith("https") && NODE_ENV === 'production'), 665 | sameSite: 'strict', 666 | maxAge: pageHistoryCookieAge 667 | }); 668 | 669 | res.json({ content: notes }); 670 | } catch (err) { 671 | res.status(500).json({ error: 'Error reading notes' }); 672 | } 673 | }); 674 | 675 | // Save notes for a specific notepad 676 | app.post('/api/notes/:id', async (req, res) => { 677 | try { 678 | const { id } = req.params; 679 | await ensureDataDir(); 680 | await fs.writeFile(path.join(DATA_DIR, `${id}.txt`), req.body.content); 681 | indexNotepads(); // update searching index 682 | res.json({ success: true }); 683 | } catch (err) { 684 | res.status(500).json({ error: 'Error saving notes' }); 685 | } 686 | }); 687 | 688 | // Delete notepad 689 | app.delete('/api/notepads/:id', async (req, res) => { 690 | try { 691 | const { id } = req.params; 692 | console.log(`Attempting to delete notepad with id: ${id}`); 693 | 694 | // Don't allow deletion of default notepad 695 | if (id === 'default') { 696 | console.log('Attempted to delete default notepad'); 697 | return res.status(400).json({ error: 'Cannot delete default notepad' }); 698 | } 699 | 700 | const data = JSON.parse(await fs.readFile(NOTEPADS_FILE, 'utf8')); 701 | console.log('Current notepads:', data.notepads); 702 | 703 | const notepadIndex = data.notepads.findIndex(n => n.id === id); 704 | 705 | if (notepadIndex === -1) { 706 | console.log(`Notepad with id ${id} not found`); 707 | return res.status(404).json({ error: 'Notepad not found' }); 708 | } 709 | 710 | // Remove from notepads list 711 | const removedNotepad = data.notepads.splice(notepadIndex, 1)[0]; 712 | console.log(`Removed notepad:`, removedNotepad); 713 | 714 | // Save updated notepads list 715 | await fs.writeFile(NOTEPADS_FILE, JSON.stringify(data, null, 2)); 716 | console.log('Updated notepads list saved'); 717 | 718 | // Delete the notepad file 719 | const notePath = path.join(DATA_DIR, `${id}.txt`); 720 | try { 721 | await fs.access(notePath); 722 | await fs.unlink(notePath); 723 | console.log(`Deleted notepad file: ${notePath}`); 724 | } catch (err) { 725 | console.error(`Error accessing or deleting notepad file: ${notePath}`, err); 726 | // Continue even if file deletion fails 727 | } 728 | 729 | indexNotepads(); // update searching index 730 | res.json({ success: true, message: 'Notepad deleted successfully' }); 731 | } catch (err) { 732 | console.error('Error in delete notepad endpoint:', err); 733 | res.status(500).json({ error: 'Error deleting notepad' }); 734 | } 735 | }); 736 | 737 | // Health check endpoint 738 | app.get('/health', (req, res) => { 739 | res.json({ status: 'ok', timestamp: new Date().toISOString() }); 740 | }); 741 | 742 | /* Search API Endpoints */ 743 | // Search 744 | app.get('/api/search', (req, res) => { 745 | const query = req.query.query || ''; 746 | const results = searchNotepads(query); 747 | 748 | // set up for pagination 749 | const page = parseInt(req.query.page) || 1; 750 | const pageSize = results.length; // defaults to all results for now 751 | const paginatedResults = results.slice((page - 1) * pageSize, page * pageSize); 752 | res.json({ 753 | results: paginatedResults, 754 | totalPages: Math.ceil(results.length / pageSize), 755 | currentPage: page 756 | }); 757 | }); --------------------------------------------------------------------------------