├── .env.example ├── .github └── workflows │ └── prettier-lint.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps ├── server │ ├── lib │ │ └── google-drive.ts │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── routes │ │ │ ├── auth │ │ │ └── index.ts │ │ │ ├── files │ │ │ └── index.ts │ │ │ └── waitlist │ │ │ └── index.ts │ └── tsconfig.json └── web │ ├── .gitignore │ ├── README.md │ ├── app │ ├── (app) │ │ └── app │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ ├── (auth) │ │ └── login │ │ │ └── page.tsx │ ├── api │ │ └── waitlist │ │ │ ├── count │ │ │ └── route.ts │ │ │ └── join │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx │ ├── components.json │ ├── components │ ├── error-message │ │ ├── index.tsx │ │ └── with-retry.tsx │ ├── file-browser │ │ ├── file-browser-data.tsx │ │ ├── file-preview.tsx │ │ ├── file-tabs.tsx │ │ └── index.tsx │ ├── header.tsx │ ├── home │ │ ├── header.tsx │ │ ├── hero.tsx │ │ └── waitlist.tsx │ ├── icons │ │ ├── discord.tsx │ │ ├── github.tsx │ │ ├── google.tsx │ │ └── x.tsx │ ├── loader.tsx │ ├── login-form.tsx │ ├── main-sidebar │ │ ├── app-sidebar.tsx │ │ ├── quick-access.tsx │ │ ├── sidebar-folders.tsx │ │ ├── sidebar-footer.tsx │ │ ├── sources.tsx │ │ ├── tag-menu.tsx │ │ └── upload.tsx │ ├── mode-toggle.tsx │ ├── providers │ │ ├── query-provider.tsx │ │ └── theme-provider.tsx │ ├── ui │ │ ├── animated-group.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── collapsible.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── progress.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── tabs.tsx │ │ └── tooltip.tsx │ └── upload-button.tsx │ ├── hooks │ ├── createRequest.ts │ ├── use-mobile.ts │ └── useRequest.ts │ ├── lib │ ├── types.ts │ └── utils.ts │ ├── middleware.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── aws.tsx │ ├── azure.tsx │ ├── gcp.tsx │ ├── googledrive.tsx │ ├── icloud.tsx │ ├── images │ │ ├── hero-dark.png │ │ ├── hero-light.png │ │ └── preview.png │ └── onedrive.tsx │ ├── tsconfig.json │ └── utils │ ├── error.ts │ └── url.ts ├── bun.lock ├── docker-compose.yml ├── eslint.config.js ├── package.json ├── packages ├── auth │ ├── package.json │ ├── src │ │ ├── auth-client.ts │ │ └── auth.ts │ └── tsconfig.json └── db │ ├── drizzle.config.ts │ ├── drizzle │ ├── 0000_small_maggott.sql │ ├── 0001_jazzy_mother_askani.sql │ ├── 0002_fast_the_enforcers.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0002_snapshot.json │ │ └── _journal.json │ ├── package.json │ ├── schema.ts │ ├── src │ └── index.ts │ └── tsconfig.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # For more information on how to get these values, see https://www.better-auth.com/docs/authentication/google 2 | # Authorised JavaScript origins: http://localhost:1284 3 | # Authorised redirect URIs: http://localhost:1284/api/auth/callback/google 4 | GOOGLE_CLIENT_ID= 5 | GOOGLE_CLIENT_SECRET= 6 | 7 | # To generate a secret, just run `openssl rand -base64 32` 8 | BETTER_AUTH_SECRET= 9 | BETTER_AUTH_URL=http://localhost:1284 10 | 11 | # URLs 12 | FRONTEND_URL=http://localhost:3000 13 | BACKEND_URL=http://localhost:1284 14 | 15 | # Database. Make sure the user, password, and database name match the ones in the url 16 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/nimbus 17 | POSTGRES_USER=postgres 18 | POSTGRES_PASSWORD=postgres 19 | POSTGRES_DB=nimbus 20 | 21 | NODE_ENV=development 22 | -------------------------------------------------------------------------------- /.github/workflows/prettier-lint.yml: -------------------------------------------------------------------------------- 1 | name: Prettier & Lint Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | prettier: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Bun 19 | uses: oven-sh/setup-bun@v2 20 | 21 | - name: Install dependencies 22 | run: bun install 23 | 24 | - name: Run ESLint 25 | run: bun lint 26 | 27 | - name: Check Prettier formatting 28 | run: bun check-format 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env 35 | .env.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bunx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | */node_modules/ 3 | */.next/ 4 | */dist/ 5 | */build/ 6 | *package-lock.json 7 | *bun.lock 8 | 9 | # Standard ignore files (often include ignored files themselves) 10 | .gitignore 11 | .prettierignore 12 | 13 | # Configuration files that might be automatically generated or have specific formatting 14 | .env 15 | .env.* 16 | */next.config.js 17 | tailwind.config.js 18 | 19 | # Generated files 20 | schema.ts 21 | drizzle/ 22 | 23 | # Cache directories 24 | .cache/ 25 | .vscode/ # VS Code specific settings/cache 26 | 27 | # Log files 28 | *.log 29 | 30 | # Dotfiles 31 | .* 32 | 33 | # If you have serverless function output directories (e.g., Vercel, Netlify) 34 | */.vercel/ 35 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxSingleQuote": false, 7 | "proseWrap": "always", 8 | "quoteProps": "as-needed", 9 | "requirePragma": false, 10 | "semi": true, 11 | "singleQuote": false, 12 | "tabWidth": 2, 13 | "trailingComma": "es5", 14 | "useTabs": true, 15 | "printWidth": 120 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.configPath": ".prettierrc", 3 | "prettier.ignorePath": ".prettierignore" 4 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Nimbus 2 | 3 | Thank you for your interest in contributing to Nimbus! This guide will help you set up the development environment. 4 | 5 | ## Prerequisites 6 | 7 | - [Bun](https://bun.sh/) (JavaScript runtime) 8 | - [Docker](https://www.docker.com/) (for running PostgreSQL) 9 | - Git 10 | 11 | ## Quickstart 12 | 13 | ### 1. Clone the Repository 14 | 15 | ```bash 16 | git clone https://github.com/nimbusdotstorage/Nimbus.git 17 | cd Nimbus 18 | ``` 19 | 20 | ### 2. Install Dependencies 21 | 22 | ```bash 23 | bun i 24 | ``` 25 | 26 | ### 3. Set Up Postgres with Docker 27 | 28 | We use Docker to run a PostgreSQL database for local development. Follow these steps to set it up: 29 | 30 | 1. **Start the database**: 31 | 32 | ```bash 33 | bun db:up 34 | ``` 35 | 36 | This will start a Postgres container with default credentials: 37 | 38 | - Host: `localhost` 39 | - Port: `5432` 40 | - Database: `nimbus` 41 | - Username: `postgres` 42 | - Password: `postgres` 43 | 44 | 2. **Verify the database is running if running a detatched container**: 45 | 46 | ```bash 47 | docker compose ps 48 | ``` 49 | 50 | You should see the `nimbus-db` container in the list with a status of "Up". 51 | 52 | 3. **Connect to the database** (optional): 53 | ```bash 54 | # Using psql client inside the container 55 | docker compose exec postgres psql -U postgres -d nimbus 56 | ``` 57 | 58 | ### 4. Environment Setup 59 | 60 | Copy the `.env.example` file to `.env` using this command, `cp .env.example .env` and fill in these values: 61 | 62 | ```bash 63 | GOOGLE_CLIENT_ID= 64 | GOOGLE_CLIENT_SECRET= 65 | 66 | # To generate a secret, just run `openssl rand -base64 32` 67 | BETTER_AUTH_SECRET= 68 | ``` 69 | 70 | ### 5. Run Database Migrations 71 | 72 | After setting up the database, run the migrations: 73 | 74 | ```bash 75 | bun db:migrate 76 | ``` 77 | 78 | ### 6. Start the Development Server 79 | 80 | In a new terminal, start the development server: 81 | 82 | > NOTE: this starts both the web and server development servers, to run just one, use `bun dev:web` or `bun dev:server`. 83 | > Both will need the db running to work. 84 | 85 | ```bash 86 | bun dev 87 | ``` 88 | 89 | The application should now be running at http://localhost:3000 90 | 91 | ## Making Changes 92 | 93 | ### Fork the repo 94 | 95 | - On GitHub, click the "Fork" button and make your own fork of the repo 96 | 97 | ### Clone your fork locally 98 | 99 | ```bash 100 | git clone https://github.com/\/nimbus.git 101 | cd nimbus 102 | ``` 103 | 104 | ### Create a feature branch 105 | 106 | ```bash 107 | git checkout -b feature/your-feature-name 108 | ``` 109 | 110 | Add the original repo as a remote: 111 | 112 | ```bash 113 | git remote add upstream https://github.com/nimbusdotstorage/nimbus.git 114 | ``` 115 | 116 | > Make sure to pull from the upstream repo to keep your fork up to date using `git pull upstream main` 117 | 118 | ### Commit your changes 119 | 120 | ```bash 121 | git add . 122 | git commit -m "Your commit message" 123 | ``` 124 | 125 | ### Push to the branch 126 | 127 | ```bash 128 | git push origin feature/your-feature-name 129 | ``` 130 | 131 | ### Open a pull request 132 | 133 | - Go to GitHub and open a pull request from your feature branch 134 | 135 | ## Useful Commands 136 | 137 | - **Stop the database**: 138 | 139 | ```bash 140 | bun db:down 141 | ``` 142 | 143 | - **Reset the database** (deletes all data): 144 | 145 | ```bash 146 | bun db:reset 147 | ``` 148 | 149 | ## Troubleshooting 150 | 151 | - **Port conflicts**: If port 5432 is already in use, just change the port mapping in `docker-compose.yml` 152 | - **Permission issues**: On Linux, you might need to run Docker commands with `sudo` or add your user to the `docker` 153 | group with the command `sudo usermod -aG docker $USER` 154 | - **Database connection issues**: Ensure the database is running and the connection string in your `.env` file is 155 | correct 156 | 157 | ## License 158 | 159 | By contributing to this project, you agree that your contributions will be licensed under its 160 | [Apache License 2.0](LICENSE). 161 | 162 | --- 163 | 164 | ## For new contributors 165 | 166 | We want everyone to be able to contribute something to Nimbus. So we set up a list of a few items that can get you 167 | started contributing to the project. You can also check out the [roadmap](https://nimbus.nolt.io/) for ideas. This will 168 | be updated as needed. 169 | 170 | ### 1. Storage source support 171 | 172 | If you have experience with the APIs or specs for S3, R2, OneDrive, or any other storage source, we would love it if you 173 | help us add support for it. Try to stay as close to the API spec as possible, especially for S3 storage so we can 174 | support S3 compatible storage sources like MinIO. 175 | 176 | ### 2. UI/UX improvements 177 | 178 | Some items to get started with: 179 | 180 | - Add a missing page or component 181 | - Add error or loading states to a page or component 182 | - Add custom file icons for specific file types 183 | - Create modals for file actions (add, delete, rename, move, etc.) 184 | - Create modals for adding new storage sources 185 | - Create modals for tag management (add, delete, edit, rename, etc.) 186 | - Create pop ups for uploading files & folders 187 | - Notification dropdown 188 | - A settings page that functions with the providers and user settings 189 | - Add folder tree navigation, breadcrumbs, or a file previewer 190 | 191 | We realize that many of these changes will not have total functionality hooked up yet. Thats fine, just make sure to add 192 | dummy data so we can see the UI and make sure it works as expected before adding real data. 193 | 194 | ### 3. Backend Improvements 195 | 196 | Some items to get started with: 197 | 198 | - Any security related changes 199 | - tRPC support with [Hono](https://hono.dev/docs/guides/rpc). Add the provider and migrate a few routes that haven't 200 | been migrated. 201 | - Add in storage support drivers like OneDrive, S3, etc. 202 | - Add account linking to the Better-Auth config if needed. 203 | - Add authentication to the API routes if needed. 204 | - Add rate limiting to the API routes if needed. 205 | - Add database tables and migrations if needed for new features. 206 | - Add or improve logging with a lightweight logger. 207 | - Improve error handling. 208 | 209 | ### 4. Design 210 | 211 | Some items to get started with: 212 | 213 | - We need a logo. 214 | - Tag color selection 215 | - Visual hierarchy improvements 216 | - Transitions and component design 217 | - Any errors in spacing, margin, sizing, mode toggling, or responsiveness that you can find. 218 | 219 | ### 5. General Improvements/Features 220 | 221 | Some items to get started with: 222 | 223 | - Update the README.md or CONTRIBUTING.md if they are out of date. 224 | - Improve error messages on both the frontend and backend. 225 | - Add tests to the backend using Vitest 226 | - Add tests to the frontend using Playwright 227 | - Help us build a public API for Nimbus 228 | - Build a CLI for that API to upload/download/manage files form the terminal 229 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nimbus cloud storage 2 | 3 | A better cloud 4 | 5 | ## Quickstart 6 | 7 | ### 1. Clone the Repository 8 | 9 | ```bash 10 | git clone https://github.com/nimbusdotstorage/Nimbus.git 11 | cd Nimbus 12 | ``` 13 | 14 | ### 2. Install Dependencies 15 | 16 | ```bash 17 | bun i 18 | ``` 19 | 20 | ### 3. Set Up Postgres with Docker 21 | 22 | We use Docker to run a PostgreSQL database for local development. Follow these steps to set it up: 23 | 24 | 1. **Start the database**: 25 | 26 | ```bash 27 | bun db:up 28 | ``` 29 | 30 | This will start a Postgres container with default credentials: 31 | 32 | - Host: `localhost` 33 | - Port: `5432` 34 | - Database: `nimbus` 35 | - Username: `postgres` 36 | - Password: `postgres` 37 | 38 | 2. **Verify the database is running if running a detatched container**: 39 | 40 | ```bash 41 | docker compose ps 42 | ``` 43 | 44 | You should see the `nimbus-db` container in the list with a status of "Up". 45 | 46 | 3. **Connect to the database** (optional): 47 | 48 | ```bash 49 | # Using psql client inside the container 50 | docker compose exec postgres psql -U postgres -d nimbus 51 | ``` 52 | 53 | ### 4. Environment Setup 54 | 55 | Copy the `.env.example` file to `.env` using this command, `cp .env.example .env` and fill in these values: 56 | 57 | ```bash 58 | # For more information on how to get these values, see https://www.better-auth.com/docs/authentication/google 59 | # Authorised JavaScript origins: http://localhost:1284 60 | # Authorised redirect URIs: http://localhost:1284/api/auth/callback/google 61 | GOOGLE_CLIENT_ID= 62 | GOOGLE_CLIENT_SECRET= 63 | 64 | # To generate a secret, just run `openssl rand -base64 32` 65 | BETTER_AUTH_SECRET= 66 | ``` 67 | 68 | ### 5. Run Database Migrations 69 | 70 | After setting up the database, run the migrations: 71 | 72 | ```bash 73 | bun db:migrate 74 | ``` 75 | 76 | ### 6. Start the Development Server 77 | 78 | In a new terminal, start the development server: 79 | 80 | > NOTE: this starts both the web and server development servers, to run just one, use `bun dev:web` or `bun dev:server`. 81 | > Both will need the db running to work. 82 | 83 | ```bash 84 | bun dev 85 | ``` 86 | 87 | The application should now be running at [http://localhost:3000](http://localhost:3000) 88 | -------------------------------------------------------------------------------- /apps/server/lib/google-drive.ts: -------------------------------------------------------------------------------- 1 | import { google } from "googleapis"; 2 | import { OAuth2Client } from "google-auth-library"; 3 | import { drive_v3 } from "googleapis/build/src/apis/drive/v3"; 4 | 5 | export interface DriveManagerConfig { 6 | auth?: { 7 | refreshToken: string; 8 | email?: string; 9 | }; 10 | } 11 | 12 | export interface DriveFile { 13 | id: string; 14 | name: string; 15 | mimeType: string; 16 | modifiedTime?: string; 17 | size?: string; 18 | webViewLink?: string; 19 | iconLink?: string; 20 | parents?: string[]; 21 | $raw?: any; 22 | } 23 | 24 | export class GoogleDriveManager { 25 | private auth: OAuth2Client; 26 | private drive: drive_v3.Drive; 27 | 28 | constructor(public config: DriveManagerConfig) { 29 | this.auth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET); 30 | 31 | // Should have to just call getAccessToken from betterauth and pass token to setCredentials 32 | if (config.auth) { 33 | this.auth.setCredentials({}); 34 | } 35 | 36 | this.drive = google.drive({ version: "v3", auth: this.auth }); 37 | } 38 | 39 | //Get the OAuth2 scope for Google Drive 40 | public getScope(): string { 41 | return [ 42 | "https://www.googleapis.com/auth/drive", 43 | "https://www.googleapis.com/auth/userinfo.profile", 44 | "https://www.googleapis.com/auth/userinfo.email", 45 | ].join(" "); 46 | } 47 | 48 | //Get access tokens from authorization code 49 | public async getTokens(code: string) { 50 | return this.withErrorHandler( 51 | "getTokens", 52 | async () => { 53 | const { tokens } = await this.auth.getToken(code); 54 | return { tokens }; 55 | }, 56 | { code } 57 | ); 58 | } 59 | 60 | //List files in Google Drive 61 | public async listFiles(params: { 62 | folderId?: string; 63 | query?: string; 64 | pageSize?: number; 65 | pageToken?: string; 66 | fields?: string; 67 | }) { 68 | const { folderId, query, pageSize = 100, pageToken, fields } = params; 69 | 70 | return this.withErrorHandler( 71 | "listFiles", 72 | async () => { 73 | let q = ""; 74 | 75 | // If folderId is provided, list files in that folder 76 | if (folderId) { 77 | q = `'${folderId}' in parents`; 78 | } 79 | 80 | // If additional query is provided, append it 81 | if (query) { 82 | q = q ? `${q} and ${query}` : query; 83 | } 84 | 85 | const res = await this.drive.files.list({ 86 | q, 87 | pageSize, 88 | pageToken: pageToken || undefined, 89 | fields: 90 | fields || "nextPageToken, files(id, name, mimeType, modifiedTime, size, webViewLink, iconLink, parents)", 91 | }); 92 | 93 | return { 94 | files: (res.data.files || []).map(file => ({ 95 | id: file.id || "", 96 | name: file.name || "", 97 | mimeType: file.mimeType || "", 98 | modifiedTime: file.modifiedTime, 99 | size: file.size, 100 | webViewLink: file.webViewLink, 101 | iconLink: file.iconLink, 102 | parents: file.parents, 103 | $raw: file, 104 | })), 105 | nextPageToken: res.data.nextPageToken || null, 106 | }; 107 | }, 108 | { folderId, query, pageSize, pageToken } 109 | ); 110 | } 111 | 112 | //Get a file from Google Drive by ID 113 | public async getFile(fileId: string, fields?: string) { 114 | return this.withErrorHandler( 115 | "getFile", 116 | async () => { 117 | const res = await this.drive.files.get({ 118 | fileId, 119 | fields: fields || "id, name, mimeType, modifiedTime, size, webViewLink, iconLink, parents", 120 | }); 121 | 122 | const file = res.data; 123 | return { 124 | id: file.id || "", 125 | name: file.name || "", 126 | mimeType: file.mimeType || "", 127 | modifiedTime: file.modifiedTime, 128 | size: file.size, 129 | webViewLink: file.webViewLink, 130 | iconLink: file.iconLink, 131 | parents: file.parents, 132 | $raw: file, 133 | }; 134 | }, 135 | { fileId } 136 | ); 137 | } 138 | 139 | //Download a file from Google Drive 140 | public async downloadFile(fileId: string) { 141 | return this.withErrorHandler( 142 | "downloadFile", 143 | async () => { 144 | const res = await this.drive.files.get( 145 | { 146 | fileId, 147 | alt: "media", 148 | }, 149 | { responseType: "arraybuffer" } 150 | ); 151 | 152 | return { 153 | data: res.data, 154 | contentType: res.headers["content-type"], 155 | }; 156 | }, 157 | { fileId } 158 | ); 159 | } 160 | 161 | //Get user info from Google API 162 | public async getUserInfo() { 163 | return this.withErrorHandler( 164 | "getUserInfo", 165 | async () => { 166 | const res = await google.people({ version: "v1", auth: this.auth }).people.get({ 167 | resourceName: "people/me", 168 | personFields: "names,photos,emailAddresses", 169 | }); 170 | 171 | return { 172 | email: res.data.emailAddresses?.[0]?.value || "", 173 | name: res.data.names?.[0]?.displayName || "", 174 | photo: res.data.photos?.[0]?.url || "", 175 | }; 176 | }, 177 | {} 178 | ); 179 | } 180 | 181 | //Error handler wrapper for async functions 182 | private async withErrorHandler( 183 | operation: string, 184 | fn: () => Promise, 185 | context?: Record 186 | ): Promise { 187 | try { 188 | return await fn(); 189 | } catch (error: any) { 190 | console.error(`Error in GoogleDriveManager.${operation}:`, error.message, context); 191 | throw error; 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nimbus/server", 3 | "module": "src/index.ts", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "dev": "bun --watch src/index.ts", 8 | "build": "bun build src/index.ts --target bun --outdir dist", 9 | "start": "bun dist/index.js", 10 | "lint": "eslint src/**/*.ts" 11 | }, 12 | "dependencies": { 13 | "@nimbus/auth": "workspace:../../packages/auth", 14 | "@nimbus/db": "workspace:../../packages/db", 15 | "@google-cloud/local-auth": "^2.1.0", 16 | "@hono/zod-validator": "^0.7.0", 17 | "better-auth": "^1.2.8", 18 | "dotenv": "^16.5.0", 19 | "drizzle-orm": "^0.43.1", 20 | "googleapis": "^105.0.0", 21 | "hono": "^4.7.10", 22 | "pg": "^8.16.0" 23 | }, 24 | "devDependencies": { 25 | "@types/bun": "latest", 26 | "@types/pg": "^8.15.2", 27 | "drizzle-kit": "^0.31.1", 28 | "eslint": "^9.0.0", 29 | "tsx": "^4.19.4", 30 | "typescript": "^5.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { cors } from "hono/cors"; 3 | import filesRoutes from "@/apps/server/src/routes/files"; 4 | import authRoutes from "@/apps/server/src/routes/auth"; 5 | import waitlistRoutes from "@/apps/server/src/routes/waitlist"; 6 | 7 | const app = new Hono(); 8 | 9 | app.use( 10 | cors({ 11 | origin: process.env.FRONTEND_URL!, 12 | credentials: true, 13 | allowHeaders: ["Content-Type", "Authorization"], 14 | allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], 15 | }) 16 | ); 17 | 18 | // Health check 19 | app.get("/kamehame", c => c.text("HAAAAAAAAAAAAAA")); 20 | 21 | app.route("/files", filesRoutes); 22 | app.route("/api/auth", authRoutes); 23 | app.route("/waitlist", waitlistRoutes); 24 | 25 | export default { 26 | port: 1284, 27 | fetch: app.fetch, 28 | }; 29 | -------------------------------------------------------------------------------- /apps/server/src/routes/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { auth } from "@/packages/auth/src/auth"; 3 | 4 | const app = new Hono(); 5 | 6 | app.on(["POST", "GET"], "/*", async c => { 7 | try { 8 | return await auth.handler(c.req.raw); 9 | } catch (error: any) { 10 | console.error("Auth handler error:", error); 11 | return c.json({ error: "Authentication failed" }, 500); 12 | } 13 | }); 14 | 15 | export default app; 16 | -------------------------------------------------------------------------------- /apps/server/src/routes/files/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | 3 | const app = new Hono(); 4 | 5 | const sampleData = [ 6 | { id: "1", name: "Documents", type: "folder", modified: "May 15, 2024" }, 7 | { id: "2", name: "Images", type: "folder", modified: "May 12, 2024" }, 8 | { id: "3", name: "Project Proposal", type: "document", size: "2.4 MB", modified: "May 10, 2024" }, 9 | { id: "4", name: "Quarterly Report", type: "document", size: "4.2 MB", modified: "May 8, 2024" }, 10 | { id: "5", name: "Meeting Notes", type: "document", size: "1.1 MB", modified: "May 5, 2024" }, 11 | { id: "6", name: "Videos", type: "folder", modified: "May 3, 2024" }, 12 | ]; 13 | 14 | app.get("/", c => { 15 | const type = c.req.query("type")?.toLowerCase() || ""; 16 | const filteredData = sampleData.filter(item => !type || item.type.toLowerCase().includes(type)); 17 | return c.json(filteredData); 18 | }); 19 | 20 | app.get("/:id", c => { 21 | const { id } = c.req.param(); 22 | const file = sampleData.find(item => item.id === id); 23 | 24 | if (!file) { 25 | return c.json({ message: "File not found" }, 404); 26 | } 27 | 28 | return c.json(file); 29 | }); 30 | 31 | export default app; 32 | -------------------------------------------------------------------------------- /apps/server/src/routes/waitlist/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: Get server running on cloudflare workers. Need to set up db connections so that they open and close in the request to prevent hangups with worker. Also work on importing ENV vars to project, likely through hono context. 2 | 3 | import { Hono } from "hono"; 4 | import { z } from "zod"; 5 | import { zValidator } from "@hono/zod-validator"; 6 | import { db } from "@/packages/db/src"; 7 | import { waitlist } from "@/packages/db/schema"; 8 | import { count } from "drizzle-orm"; 9 | import { nanoid } from "nanoid"; 10 | 11 | const app = new Hono(); 12 | 13 | // Email validation schema with Zod 14 | const emailSchema = z.object({ 15 | email: z.string().email("Invalid email format"), 16 | }); 17 | 18 | type EmailSchema = z.infer; 19 | 20 | // Route to add email to waitlist 21 | app.post("/join", zValidator("json", emailSchema), async c => { 22 | try { 23 | // Type-safe access to validated data using type assertion 24 | const data = c.req.valid("json") as EmailSchema; 25 | const { email } = data; 26 | 27 | // Insert email into waitlist table 28 | await db 29 | .insert(waitlist) 30 | .values({ 31 | id: nanoid(), 32 | email: email, 33 | }) 34 | .onConflictDoNothing(); 35 | 36 | return c.json({ success: true }, 201); 37 | } catch (error) { 38 | console.error("Error adding email to waitlist:", error); 39 | return c.json( 40 | { 41 | success: false, 42 | message: "Failed to add email to waitlist", 43 | }, 44 | 500 45 | ); 46 | } 47 | }); 48 | 49 | // Route to get waitlist count 50 | app.get("/count", async c => { 51 | try { 52 | const result: { count: number }[] = await db.select({ count: count() }).from(waitlist); 53 | return c.json({ count: result[0]?.count }); 54 | } catch (error) { 55 | console.error("Error getting waitlist count:", error); 56 | return c.json( 57 | { 58 | success: false, 59 | message: "Failed to get waitlist count", 60 | }, 61 | 500 62 | ); 63 | } 64 | }); 65 | 66 | export default app; 67 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["esnext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false, 27 | 28 | "baseUrl": ".", 29 | "paths": { 30 | "@/*": ["../../*"] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | .next 4 | 5 | # output 6 | out 7 | dist 8 | *.tgz 9 | 10 | # code coverage 11 | coverage 12 | *.lcov 13 | 14 | # logs 15 | logs 16 | _.log 17 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 18 | 19 | # dotenv environment variable files 20 | .env 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | .env.local 25 | 26 | # caches 27 | .eslintcache 28 | .cache 29 | *.tsbuildinfo 30 | 31 | # IntelliJ based IDEs 32 | .idea 33 | 34 | # Finder (MacOS) folder config 35 | .DS_Store 36 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | # Web 2 | 3 | ### This is the front end application 4 | 5 | To run the application: 6 | 7 | ```bash 8 | bun i 9 | ``` 10 | 11 | To run: 12 | 13 | ```bash 14 | bun dev 15 | ``` 16 | 17 | ### Tech stack 18 | 19 | - Next.js 20 | - TypeScript 21 | - Bun 22 | - TailwindCSS 23 | - Shadcn UI 24 | - Better Auth 25 | -------------------------------------------------------------------------------- /apps/web/app/(app)/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import "@/web/app/globals.css"; 3 | import { Inter } from "next/font/google"; 4 | 5 | import { ThemeProvider } from "@/components/providers/theme-provider"; 6 | import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; 7 | import { AppSidebar } from "@/components/main-sidebar/app-sidebar"; 8 | 9 | const inter = Inter({ subsets: ["latin"] }); 10 | 11 | export default function RootLayout({ children }: { children: ReactNode }) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 |
{children}
20 |
21 |
22 |
23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/app/(app)/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FileBrowser } from "components/file-browser"; 4 | import { Header } from "components/header"; 5 | import { UploadButton } from "components/upload-button"; 6 | import { Suspense } from "react"; 7 | 8 | export default function DrivePage() { 9 | return ( 10 | <> 11 |
12 |
13 |
14 |

My Files

15 | 16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "@/components/login-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/app/api/waitlist/count/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { db } from "@/packages/db/src/index"; 3 | import { waitlist } from "@/packages/db/schema"; 4 | import { count } from "drizzle-orm"; 5 | 6 | export async function GET() { 7 | try { 8 | const result = await db.select({ count: count() }).from(waitlist); 9 | return NextResponse.json({ count: result[0]?.count || 0 }); 10 | } catch (error) { 11 | console.error("Error getting waitlist count:", error); 12 | return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/api/waitlist/join/route.ts: -------------------------------------------------------------------------------- 1 | import { rateLimitAttempts, waitlist } from "@/packages/db/schema"; 2 | import { db } from "@/packages/db/src/index"; 3 | import { eq, sql } from "drizzle-orm"; 4 | import { nanoid } from "nanoid"; 5 | import { NextRequest, NextResponse } from "next/server"; 6 | import { z } from "zod"; 7 | 8 | // Database-backed rate limiting function 9 | async function checkRateLimitDB(ip: string, limit: number = 3, windowMs: number = 120000) { 10 | const now = new Date(); 11 | 12 | const attempts = await db.select().from(rateLimitAttempts).where(eq(rateLimitAttempts.identifier, ip)).limit(1); 13 | 14 | let currentAttempt = attempts[0]; 15 | 16 | if (!currentAttempt || currentAttempt.expiresAt < now) { 17 | // No record, or record expired, create/reset it 18 | const newExpiry = new Date(now.getTime() + windowMs); 19 | await db 20 | .insert(rateLimitAttempts) 21 | .values({ identifier: ip, count: 1, expiresAt: newExpiry }) 22 | .onConflictDoUpdate({ 23 | target: rateLimitAttempts.identifier, 24 | set: { count: 1, expiresAt: newExpiry }, 25 | }); 26 | return { allowed: true, remaining: limit - 1, resetTime: newExpiry }; 27 | } 28 | 29 | if (currentAttempt.count >= limit) { 30 | return { allowed: false, remaining: 0, resetTime: currentAttempt.expiresAt }; 31 | } 32 | 33 | // Increment counter 34 | await db 35 | .update(rateLimitAttempts) 36 | .set({ count: sql`${rateLimitAttempts.count} + 1` }) 37 | .where(eq(rateLimitAttempts.identifier, ip)); 38 | 39 | return { allowed: true, remaining: limit - (currentAttempt.count + 1), resetTime: currentAttempt.expiresAt }; 40 | } 41 | 42 | // List of allowed email domains 43 | const ALLOWED_DOMAINS = ["gmail.com", "outlook.com", "yahoo.com", "proton.me"]; 44 | 45 | // Email validation schema 46 | const emailSchema = z.object({ 47 | email: z 48 | .string() 49 | .email("Please enter a valid email address") 50 | .refine(email => { 51 | const [, domain] = email.split("@"); 52 | if (!domain) return false; 53 | 54 | // Allowed domains check 55 | const allowed = ALLOWED_DOMAINS.some(allowed => domain === allowed || domain.endsWith(`.${allowed}`)); 56 | if (!allowed) return false; 57 | 58 | // TLD and label checks 59 | const labels = domain.split("."); 60 | if (labels.length < 2 || labels.length > 3) return false; 61 | const tld = labels.at(-1)!; 62 | return /^[a-z]{2,63}$/i.test(tld); 63 | }, "Invalid email, please try again"), 64 | }); 65 | 66 | // POST /api/waitlist/join - Add email to waitlist 67 | export async function POST(request: NextRequest) { 68 | try { 69 | const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "anonymous"; 70 | 71 | // Check rate limit (3 requests per 2 minutes) 72 | const rateLimitResult = await checkRateLimitDB(ip, 3, 120000); 73 | 74 | if (!rateLimitResult.allowed) { 75 | return NextResponse.json( 76 | { 77 | success: false, 78 | error: "Too many requests. Please wait before trying again.", 79 | retryAfter: Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000), 80 | }, 81 | { 82 | status: 429, 83 | } 84 | ); 85 | } 86 | 87 | const body = await request.json(); 88 | const result = emailSchema.safeParse(body); 89 | 90 | if (!result.success) { 91 | // Handle Zod error 92 | const errorMessage = result.error.errors[0]?.message; 93 | return NextResponse.json({ success: false, error: errorMessage }, { status: 400 }); 94 | } 95 | 96 | const { email } = result.data; 97 | 98 | // Check if email already exists 99 | const existingEmail = await db 100 | .select() 101 | .from(waitlist) 102 | .where(eq(waitlist.email, email.toLowerCase().trim())) 103 | .limit(1) 104 | .then(rows => rows[0]); 105 | 106 | if (existingEmail) { 107 | return NextResponse.json({ success: false, error: "This email is already on the waitlist" }, { status: 400 }); 108 | } 109 | 110 | // Insert email into waitlist table 111 | await db.insert(waitlist).values({ 112 | id: nanoid(), 113 | email: email.toLowerCase().trim(), 114 | }); 115 | 116 | // Add rate limit headers to successful response 117 | const response = NextResponse.json({ success: true }, { status: 201 }); 118 | 119 | return response; 120 | } catch (error) { 121 | console.error("Error adding email to waitlist:", error); 122 | return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /apps/web/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nimbusdotstorage/Nimbus/63061ce07d598e6d19f51949b6882a4d5da02575/apps/web/app/favicon.ico -------------------------------------------------------------------------------- /apps/web/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | :root { 46 | --radius: 0.625rem; 47 | --background: oklch(1 0 0); 48 | --foreground: oklch(0.147 0.004 49.25); 49 | --card: oklch(1 0 0); 50 | --card-foreground: oklch(0.147 0.004 49.25); 51 | --popover: oklch(1 0 0); 52 | --popover-foreground: oklch(0.147 0.004 49.25); 53 | --primary: oklch(0.216 0.006 56.043); 54 | --primary-foreground: oklch(0.985 0.001 106.423); 55 | --secondary: oklch(0.97 0.001 106.424); 56 | --secondary-foreground: oklch(0.216 0.006 56.043); 57 | --muted: oklch(0.97 0.001 106.424); 58 | --muted-foreground: oklch(0.553 0.013 58.071); 59 | --accent: oklch(0.97 0.001 106.424); 60 | --accent-foreground: oklch(0.216 0.006 56.043); 61 | --destructive: oklch(0.577 0.245 27.325); 62 | --border: oklch(0.923 0.003 48.717); 63 | --input: oklch(0.923 0.003 48.717); 64 | --ring: oklch(0.709 0.01 56.259); 65 | --chart-1: oklch(0.646 0.222 41.116); 66 | --chart-2: oklch(0.6 0.118 184.704); 67 | --chart-3: oklch(0.398 0.07 227.392); 68 | --chart-4: oklch(0.828 0.189 84.429); 69 | --chart-5: oklch(0.769 0.188 70.08); 70 | --sidebar: oklch(0.985 0.001 106.423); 71 | --sidebar-foreground: oklch(0.147 0.004 49.25); 72 | --sidebar-primary: oklch(0.216 0.006 56.043); 73 | --sidebar-primary-foreground: oklch(0.985 0.001 106.423); 74 | --sidebar-accent: oklch(0.97 0.001 106.424); 75 | --sidebar-accent-foreground: oklch(0.216 0.006 56.043); 76 | --sidebar-border: oklch(0.923 0.003 48.717); 77 | --sidebar-ring: oklch(0.709 0.01 56.259); 78 | } 79 | 80 | .dark { 81 | --background: oklch(0.147 0.004 49.25); 82 | --foreground: oklch(0.985 0.001 106.423); 83 | --card: oklch(0.216 0.006 56.043); 84 | --card-foreground: oklch(0.985 0.001 106.423); 85 | --popover: oklch(0.216 0.006 56.043); 86 | --popover-foreground: oklch(0.985 0.001 106.423); 87 | --primary: oklch(0.923 0.003 48.717); 88 | --primary-foreground: oklch(0.216 0.006 56.043); 89 | --secondary: oklch(0.268 0.007 34.298); 90 | --secondary-foreground: oklch(0.985 0.001 106.423); 91 | --muted: oklch(0.268 0.007 34.298); 92 | --muted-foreground: oklch(0.709 0.01 56.259); 93 | --accent: oklch(0.268 0.007 34.298); 94 | --accent-foreground: oklch(0.985 0.001 106.423); 95 | --destructive: oklch(0.704 0.191 22.216); 96 | --border: oklch(1 0 0 / 10%); 97 | --input: oklch(1 0 0 / 15%); 98 | --ring: oklch(0.553 0.013 58.071); 99 | --chart-1: oklch(0.488 0.243 264.376); 100 | --chart-2: oklch(0.696 0.17 162.48); 101 | --chart-3: oklch(0.769 0.188 70.08); 102 | --chart-4: oklch(0.627 0.265 303.9); 103 | --chart-5: oklch(0.645 0.246 16.439); 104 | --sidebar: oklch(0.216 0.006 56.043); 105 | --sidebar-foreground: oklch(0.985 0.001 106.423); 106 | --sidebar-primary: oklch(0.488 0.243 264.376); 107 | --sidebar-primary-foreground: oklch(0.985 0.001 106.423); 108 | --sidebar-accent: oklch(0.268 0.007 34.298); 109 | --sidebar-accent-foreground: oklch(0.985 0.001 106.423); 110 | --sidebar-border: oklch(1 0 0 / 10%); 111 | --sidebar-ring: oklch(0.553 0.013 58.071); 112 | } 113 | 114 | @layer base { 115 | * { 116 | @apply border-border outline-ring/50; 117 | } 118 | body { 119 | @apply bg-background text-foreground; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import "@/web/app/globals.css"; 3 | 4 | import promoImage from "@/public/images/preview.png"; 5 | import { ThemeProvider } from "@/components/providers/theme-provider"; 6 | import { ReactQueryProvider } from "@/web/components/providers/query-provider"; 7 | import { Geist, Geist_Mono } from "next/font/google"; 8 | import { Analytics } from "@vercel/analytics/next"; 9 | import { Toaster } from "sonner"; 10 | 11 | export const metadata = { 12 | title: "Nimbus", 13 | description: "A better cloud storage solution.", 14 | openGraph: { 15 | title: "Nimbus", 16 | description: "A better cloud storage solution.", 17 | url: "https://nimbus.storage", 18 | siteName: "Nimbus", 19 | images: [ 20 | { 21 | url: promoImage.src, 22 | width: promoImage.width, 23 | height: promoImage.height, 24 | alt: "Nimbus", 25 | }, 26 | ], 27 | locale: "en_US", 28 | type: "website", 29 | }, 30 | twitter: { 31 | title: "Nimbus", 32 | description: "A better cloud storage solution.", 33 | site: "@nimbusdotcloud", 34 | card: "summary_large_image", 35 | images: [ 36 | { 37 | url: promoImage.src, 38 | width: promoImage.width, 39 | height: promoImage.height, 40 | alt: "Nimbus", 41 | }, 42 | ], 43 | }, 44 | }; 45 | 46 | const geistSans = Geist({ 47 | variable: "--font-geist-sans", 48 | subsets: ["latin"], 49 | }); 50 | 51 | const geistMono = Geist_Mono({ 52 | variable: "--font-geist-mono", 53 | subsets: ["latin"], 54 | }); 55 | 56 | export default function RootLayout({ children }: { children: ReactNode }) { 57 | return ( 58 | 59 | 60 | 61 | 62 |
63 |
64 | {children} 65 | 66 |
67 | 68 |
69 |
70 |
71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /apps/web/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Hero from "@/components/home/hero"; 4 | 5 | export default function Home() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/web/components", 15 | "utils": "@/web/lib/utils", 16 | "ui": "@/web/components/ui", 17 | "lib": "@/web/lib", 18 | "hooks": "@/web/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/components/error-message/index.tsx: -------------------------------------------------------------------------------- 1 | import { parseError } from "@/web/utils/error"; 2 | 3 | export function ErrorMessage({ error }: { error: unknown }) { 4 | return ( 5 |
6 |

{parseError(error)}

7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/components/error-message/with-retry.tsx: -------------------------------------------------------------------------------- 1 | import { parseError } from "@/web/utils/error"; 2 | import { Button } from "@/components/ui/button"; 3 | 4 | export function ErrorMessageWithRetry({ error, retryFn }: { error: unknown; retryFn: () => void }) { 5 | return ( 6 |
7 |

{parseError(error)}

8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/components/file-browser/file-browser-data.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardFooter } from "@/components/ui/card"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuSeparator, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/dropdown-menu"; 9 | import { FileText, Folder, MoreVertical } from "lucide-react"; 10 | import Link from "next/link"; 11 | import { useSearchParams } from "next/navigation"; 12 | import type { FileItem } from "@/web/lib/types"; 13 | import { Button } from "@/components/ui/button"; 14 | 15 | export function FileBrowserData({ viewMode, data }: { viewMode: "grid" | "list"; data: FileItem[] }) { 16 | return viewMode === "grid" ? : ; 17 | } 18 | 19 | function FilesGrid({ data }: { data: FileItem[] }) { 20 | const searchParams = useSearchParams(); 21 | 22 | return ( 23 |
24 | {data.map(file => { 25 | const params = new URLSearchParams(searchParams.toString()); 26 | params.append("id", file.id); 27 | 28 | return ( 29 | 30 | 31 | 32 |
33 | {file.type === "folder" ? ( 34 | 35 | ) : ( 36 | 37 | )} 38 |
39 |
40 | 41 |
42 |

{file.name}

43 |

{file.modified}

44 |
45 | 46 |
47 |
48 | 49 | ); 50 | })} 51 | {/* zero case */} 52 | {data.length === 0 && ( 53 |
Nothing here :(
54 | )} 55 |
56 | ); 57 | } 58 | 59 | function FilesList({ data }: { data: FileItem[] }) { 60 | const searchParams = useSearchParams(); 61 | 62 | return ( 63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | {data.map(file => { 75 | const params = new URLSearchParams(searchParams.toString()); 76 | params.append("id", file.id); 77 | 78 | return ( 79 | 80 | 89 | 90 | 91 | 94 | 95 | ); 96 | })} 97 | 98 |
NameModifiedSize
81 | 82 | {file.type === "folder" ? ( 83 | 84 | ) : ( 85 | 86 | )} 87 | {file.name} 88 | {file.modified}{file.size || "—"} 92 | 93 |
99 |
100 | ); 101 | } 102 | 103 | function FileActions() { 104 | return ( 105 | 106 | 107 | 111 | 112 | 113 | Open 114 | Share 115 | Download 116 | Rename 117 | 118 | Delete 119 | 120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /apps/web/components/file-browser/file-preview.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; 3 | import { createRequest } from "@/web/hooks/createRequest"; 4 | import { FileText, Folder, X, Image, Video } from "lucide-react"; 5 | import { useRouter, useSearchParams } from "next/navigation"; 6 | import { useEffect } from "react"; 7 | import { useRequest } from "@/web/hooks/useRequest"; 8 | import type { FileItem } from "@/web/lib/types"; 9 | import { parseError } from "@/web/utils/error"; 10 | import { Loader } from "@/components/loader"; 11 | 12 | interface FolderContentItem extends FileItem { 13 | path?: string; 14 | } 15 | 16 | export function FilePreview() { 17 | const router = useRouter(); 18 | const searchParams = useSearchParams(); 19 | const id = searchParams.get("id"); 20 | 21 | const fetchFile = createRequest({ 22 | path: "/files/:id", 23 | pathParams: { id }, 24 | }); 25 | 26 | const { data, refetch, isLoading, error } = useRequest({ 27 | request: fetchFile, 28 | triggers: [id], 29 | manual: true, 30 | }); 31 | 32 | const fetchFolderContents = createRequest({ 33 | path: "/files/:id/contents", 34 | pathParams: { id }, 35 | }); 36 | 37 | const { 38 | data: folderContents, 39 | isLoading: folderContentsLoading, 40 | refetch: refetchFolderContents, 41 | } = useRequest({ 42 | request: fetchFolderContents, 43 | triggers: [id], 44 | manual: true, 45 | }); 46 | 47 | useEffect(() => { 48 | if (id && id !== data?.id) { 49 | void refetch(); 50 | } 51 | }, [id, data?.id]); 52 | 53 | useEffect(() => { 54 | if (data?.type === "folder") { 55 | void refetchFolderContents(); 56 | } 57 | }, [data?.type]); 58 | 59 | const handleClose = () => { 60 | const params = new URLSearchParams(searchParams.toString()); 61 | params.delete("id"); 62 | router.replace(`?${params.toString()}`); 63 | }; 64 | 65 | const getFileIcon = (type: string) => { 66 | switch (type) { 67 | case "folder": 68 | return ; 69 | case "image": 70 | return ; 71 | case "video": 72 | return