├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── jest.config.js ├── nest-cli.json ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── sql └── ddl_1.0.0.sql ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── common │ └── filters │ │ └── throttler-exception.filter.ts ├── filters │ └── http-exception.filter.ts ├── github │ ├── github.module.ts │ ├── github.service.spec.ts │ └── github.service.ts ├── logger │ └── logger.service.ts ├── main.ts ├── shared │ ├── dto │ │ ├── create-shared.dto.ts │ │ └── update-shared.dto.ts │ ├── rices.controller.ts │ ├── shared.module.ts │ ├── shared.service.ts │ ├── spaces.controller.ts │ └── utils │ │ └── slug.util.ts └── supabase │ └── supabase.service.ts ├── test ├── app.e2e-spec.ts ├── files │ ├── example.zenrice │ └── example_update.zenrice ├── jest-e2e.json ├── restclient │ ├── rice │ │ ├── 01_create_rice.http │ │ ├── 01b_create_rice_same_token.http │ │ ├── 02_download_rice.http │ │ ├── 03_update_rice.http │ │ └── 04_delete_rice.http │ └── spaces │ │ └── 01_create_space.http └── setup.ts ├── tsconfig.build.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | GITHUB_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX 2 | GITHUB_REPO_OWNER=zen-browser 3 | GITHUB_REPO_NAME=rices-store 4 | 5 | SUPABASE_URL=https://xxxxxxxxxxxxx.supabase.co 6 | SUPABASE_KEY=XXXXXXXXXXXXXXXXXXX 7 | 8 | # We might have to change this if we want some sort of "pro-plan" 9 | MAX_RICES_BY_TOKEN=5 10 | 11 | MODERATION_SECRET=superSecret123 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | #node 16 | *package-lock.json 17 | 18 | # OS 19 | .DS_Store 20 | 21 | # Tests 22 | /coverage 23 | /.nyc_output 24 | 25 | # IDEs and editors 26 | /.idea 27 | .project 28 | .classpath 29 | .c9/ 30 | *.launch 31 | .settings/ 32 | *.sublime-workspace 33 | 34 | # IDE - VSCode 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json 40 | 41 | # dotenv environment variable files 42 | .env 43 | .env.development.local 44 | .env.test.local 45 | .env.production.local 46 | .env.local 47 | 48 | # temp directory 49 | .temp 50 | .tmp 51 | 52 | # Runtime data 53 | pids 54 | *.pid 55 | *.seed 56 | *.pid.lock 57 | 58 | # Diagnostic reports (https://nodejs.org/api/report.html) 59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 60 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug NestJS", 8 | "runtimeExecutable": "pnpm", 9 | "runtimeArgs": [ 10 | "start:debug" 11 | ], 12 | "console": "integratedTerminal", 13 | "restart": true, 14 | "autoAttachChildProcesses": true, 15 | "sourceMaps": true, 16 | "envFile": "${workspaceFolder}/.env", 17 | "skipFiles": [ 18 | "/**" 19 | ] 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Zen Rices API 4 | Based on [Nest](https://github.com/nestjs/nest) framework 5 | 6 | ## Project setup (install NVM) 7 | 8 | ```bash 9 | $ nvm use 22 10 | $ npm install 11 | ``` 12 | 13 | ## Compile and run the project 14 | 15 | ```bash 16 | # development 17 | $ npm run start 18 | 19 | # watch mode 20 | $ npm run start:dev 21 | 22 | # production mode 23 | $ npm run start:prod 24 | ``` 25 | 26 | ## Run tests 27 | 28 | ```bash 29 | # e2e tests 30 | $ npm run test:e2e 31 | ``` 32 | 33 | ## Deployment 34 | 35 | When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. 36 | 37 | If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: 38 | 39 | ```bash 40 | $ npm install -g mau 41 | $ mau deploy 42 | ``` 43 | 44 | With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. 45 | 46 | ## Resources 47 | 48 | Check out a few resources that may come in handy when working with NestJS: 49 | 50 | - Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. 51 | - For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). 52 | - To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). 53 | - Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. 54 | - Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). 55 | - Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). 56 | - To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). 57 | - Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). 58 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | 3 | module.exports = { 4 | preset: 'ts-jest/presets/default-esm', 5 | testEnvironment: 'node', 6 | moduleFileExtensions: ['js', 'json', 'ts'], 7 | maxWorkers: 1, 8 | rootDir: '.', 9 | testRegex: '.e2e-spec.ts$', 10 | transform: { 11 | '^.+\\.(t|j)s$': ['ts-jest', { useESM: true }], 12 | }, 13 | transformIgnorePatterns: [ 14 | '/node_modules/(?!(\\@octokit)/)', 15 | ], 16 | globals: { 17 | 'ts-jest': { 18 | tsconfig: 'tsconfig.json', 19 | useESM: true, 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zen-rices-backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:cov": "jest --coverage", 18 | "test:watch": "jest --watch", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/cache-manager": "^3.0.0", 24 | "@nestjs/platform-express": "^10.0.0", 25 | "@nestjs/swagger": "^8.1.0", 26 | "@octokit/rest": "^18.12.0", 27 | "@types/multer": "^1.4.12", 28 | "cache-manager": "^6.4.0", 29 | "class-transformer": "^0.5.1", 30 | "class-validator": "^0.14.1", 31 | "dotenv": "^16.4.7", 32 | "helmet": "^8.0.0", 33 | "reflect-metadata": "^0.2.0", 34 | "rxjs": "^7.8.1", 35 | "simple-git": "^3.27.0", 36 | "swagger-ui-express": "^5.0.1", 37 | "uuid": "^9.0.0", 38 | "winston": "^3.17.0", 39 | "winston-daily-rotate-file": "^5.0.0" 40 | }, 41 | "devDependencies": { 42 | "@nestjs/cli": "^10.0.0", 43 | "@nestjs/common": "^10.4.15", 44 | "@nestjs/config": "^3.3.0", 45 | "@nestjs/core": "^10.4.15", 46 | "@nestjs/schematics": "^10.0.0", 47 | "@nestjs/testing": "^10.0.0", 48 | "@nestjs/throttler": "^6.3.0", 49 | "@supabase/supabase-js": "^2.47.10", 50 | "@types/csso": "^5.0.4", 51 | "@types/express": "^5.0.0", 52 | "@types/jest": "^29.5.14", 53 | "@types/leo-profanity": "^1.5.4", 54 | "@types/node": "^20.3.1", 55 | "@types/supertest": "^6.0.2", 56 | "@types/uuid": "^10.0.0", 57 | "@typescript-eslint/eslint-plugin": "^8.0.0", 58 | "@typescript-eslint/parser": "^8.0.0", 59 | "csso": "^5.0.5", 60 | "eslint": "^8.0.0", 61 | "eslint-config-prettier": "^9.0.0", 62 | "eslint-plugin-prettier": "^5.0.0", 63 | "jest": "^29.7.0", 64 | "leo-profanity": "^1.7.0", 65 | "prettier": "^3.0.0", 66 | "source-map-support": "^0.5.21", 67 | "supertest": "^7.0.0", 68 | "ts-jest": "^29.2.5", 69 | "ts-loader": "^9.4.3", 70 | "ts-node": "^10.9.1", 71 | "tsconfig-paths": "^4.2.0", 72 | "typescript": "^5.1.3", 73 | "xss": "^1.0.15" 74 | }, 75 | "pnpm": { 76 | "onlyBuiltDependencies": [ 77 | "@nestjs/core", 78 | "@scarf/scarf" 79 | ] 80 | }, 81 | "jest": { 82 | "coverageThreshold": { 83 | "global": { 84 | "branches": 80, 85 | "functions": 80, 86 | "lines": 80, 87 | "statements": 80 88 | } 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /sql/ddl_1.0.0.sql: -------------------------------------------------------------------------------- 1 | -- Drop tables if they exist 2 | DROP TABLE IF EXISTS shared; 3 | DROP TABLE IF EXISTS shared_types; 4 | 5 | -- Create table for shared types 6 | CREATE TABLE shared_types ( 7 | key VARCHAR(50) PRIMARY KEY -- Type key (e.g., 'WORKSPACE', 'RICE') 8 | ); 9 | 10 | -- Create table for shared items 11 | CREATE TABLE shared ( 12 | id UUID NOT NULL PRIMARY KEY, -- Unique identifier 13 | slug VARCHAR(75) NOT NULL UNIQUE, -- Unique user-friendly identifier 14 | type VARCHAR(15) NOT NULL REFERENCES shared_types(key) ON DELETE CASCADE, -- Foreign key to shared_types 15 | version VARCHAR(10) NOT NULL, -- Data version 16 | os VARCHAR(30) NOT NULL, -- Operating system 17 | name VARCHAR(75) NOT NULL, -- Name of the rice 18 | author VARCHAR(100) NOT NULL, -- Name of the author 19 | token UUID NOT NULL, -- Unique authorization token 20 | visits INTEGER DEFAULT 0 NOT NULL, -- Visit counter, initialized to 0 21 | level INTEGER DEFAULT 0 NOT NULL, -- Level: 0 (Public), 1 (Verified) 22 | created_at TIMESTAMP DEFAULT NOW(), -- Creation date 23 | updated_at TIMESTAMP -- Last update date 24 | ); 25 | 26 | -- Insert default types 27 | INSERT INTO shared_types (key) VALUES ('WORKSPACE'), ('RICE'); 28 | 29 | -- Create function to increment visit count 30 | CREATE OR REPLACE FUNCTION increment_visits(slug_param TEXT) 31 | RETURNS VOID AS $$ 32 | BEGIN 33 | UPDATE shared 34 | SET visits = visits + 1 35 | WHERE slug = slug_param; 36 | END; 37 | $$ LANGUAGE plpgsql; 38 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => {}); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) { } 7 | } 8 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; 4 | import { APP_GUARD } from '@nestjs/core'; 5 | import { GitHubModule } from './github/github.module'; 6 | import { SharedModule } from './shared/shared.module'; 7 | import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter'; 8 | import { CacheModule } from '@nestjs/cache-manager'; 9 | 10 | @Module({ 11 | imports: [ 12 | CacheModule.register({ 13 | ttl: 300, // 5 minutes 14 | max: 100, // maximum number of items in cache 15 | }), 16 | ConfigModule.forRoot({ 17 | isGlobal: true, 18 | }), 19 | ThrottlerModule.forRoot([ 20 | { 21 | name: 'ip', 22 | ttl: 60000, 23 | limit: 20, 24 | } 25 | ]), 26 | GitHubModule, 27 | SharedModule, 28 | ], 29 | controllers: [], 30 | providers: [ 31 | { 32 | provide: APP_GUARD, 33 | useClass: ThrottlerGuard, 34 | }, 35 | ], 36 | }) 37 | export class AppModule { } 38 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService {} 5 | -------------------------------------------------------------------------------- /src/common/filters/throttler-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpStatus, 6 | } from '@nestjs/common'; 7 | import { ThrottlerException } from '@nestjs/throttler'; 8 | import { Response } from 'express'; 9 | 10 | @Catch(ThrottlerException) 11 | export class ThrottlerExceptionFilter implements ExceptionFilter { 12 | catch(exception: ThrottlerException, host: ArgumentsHost) { 13 | const ctx = host.switchToHttp(); 14 | const response = ctx.getResponse(); 15 | 16 | response.status(HttpStatus.TOO_MANY_REQUESTS).json({ 17 | statusCode: HttpStatus.TOO_MANY_REQUESTS, 18 | message: 'Too many requests. Please try again later.', 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | } from '@nestjs/common'; 7 | import { Response } from 'express'; 8 | 9 | @Catch(HttpException) 10 | export class HttpExceptionFilter implements ExceptionFilter { 11 | catch(exception: HttpException, host: ArgumentsHost) { 12 | const ctx = host.switchToHttp(); 13 | const response = ctx.getResponse(); 14 | const status = exception.getStatus(); 15 | const exceptionResponse = exception.getResponse(); 16 | 17 | response.status(status).json({ 18 | statusCode: status, 19 | ...(typeof exceptionResponse === 'string' 20 | ? { message: exceptionResponse } 21 | : exceptionResponse), 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/github/github.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GitHubService } from './github.service'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [GitHubService], 8 | exports: [GitHubService], 9 | }) 10 | export class GitHubModule {} 11 | -------------------------------------------------------------------------------- /src/github/github.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GitHubService } from './github.service'; 3 | 4 | describe('GithubService', () => { 5 | let service: GitHubService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [GitHubService], 10 | }).compile(); 11 | 12 | service = module.get(GitHubService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/github/github.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | OnModuleInit, 4 | Logger, 5 | InternalServerErrorException, 6 | } from '@nestjs/common'; 7 | import { Octokit } from '@octokit/rest'; 8 | import { ConfigService } from '@nestjs/config'; 9 | import * as path from 'path'; 10 | 11 | /** 12 | * Type guard to verify if the error has a 'status' property of type 'number' 13 | * and a 'message' property of type 'string'. 14 | */ 15 | function isOctokitResponseError( 16 | error: any, 17 | ): error is { status: number; message: string } { 18 | return ( 19 | typeof error === 'object' && 20 | error !== null && 21 | 'status' in error && 22 | typeof error.status === 'number' && 23 | 'message' in error && 24 | typeof error.message === 'string' 25 | ); 26 | } 27 | 28 | @Injectable() 29 | export class GitHubService implements OnModuleInit { 30 | private octokit!: Octokit; 31 | private readonly logger = new Logger(GitHubService.name); 32 | private repoOwner: string; 33 | private repoName: string; 34 | private defaultBranch: string = 'main'; // Default value 35 | private directoryLocks: Map = new Map(); 36 | 37 | constructor(private configService: ConfigService) { 38 | // Initialize properties in the constructor 39 | this.repoOwner = this.configService.get('GITHUB_REPO_OWNER') || ''; 40 | this.repoName = this.configService.get('GITHUB_REPO_NAME') || ''; 41 | } 42 | 43 | async onModuleInit() { 44 | const token = this.configService.get('GITHUB_TOKEN'); 45 | if (!token) { 46 | this.logger.error( 47 | 'GITHUB_TOKEN is not defined in the environment variables', 48 | ); 49 | throw new Error('GITHUB_TOKEN is not defined'); 50 | } 51 | 52 | if (!this.repoOwner || !this.repoName) { 53 | this.logger.error( 54 | 'GITHUB_REPO_OWNER or GITHUB_REPO_NAME is not defined in the environment variables', 55 | ); 56 | throw new Error('GITHUB_REPO_OWNER or GITHUB_REPO_NAME is not defined'); 57 | } 58 | 59 | this.octokit = new Octokit({ 60 | auth: token, 61 | }); 62 | 63 | // Fetch the default branch of the repository 64 | try { 65 | const { data: repo } = await this.octokit.repos.get({ 66 | owner: this.repoOwner, 67 | repo: this.repoName, 68 | }); 69 | this.defaultBranch = repo.default_branch; 70 | this.logger.log( 71 | `Default branch of the repository: ${this.defaultBranch}`, 72 | ); 73 | } catch (error) { 74 | if (isOctokitResponseError(error)) { 75 | if (error.status === 404) { 76 | this.logger.error( 77 | `Repository ${this.repoOwner}/${this.repoName} not found.`, 78 | ); 79 | } else { 80 | this.logger.error( 81 | `Error fetching repository information: ${error.message} (Status: ${error.status})`, 82 | ); 83 | } 84 | } else { 85 | this.logger.error( 86 | `Unexpected error fetching repository information: ${error}`, 87 | ); 88 | } 89 | throw error; 90 | } 91 | } 92 | 93 | /** 94 | * Create or update a file in the repository. 95 | * Ensures that the specified directory exists by creating a .gitkeep file if necessary. 96 | * @param filePath Path of the file in the repository. 97 | * @param content Content of the file in plain text. 98 | * @param commitMessage Commit message. 99 | */ 100 | async createOrUpdateFile( 101 | filePath: string, 102 | content: string, 103 | commitMessage: string, 104 | retries = 3, 105 | ): Promise { 106 | const directoryPath = path.dirname(filePath); 107 | await this.lockDirectory(directoryPath); 108 | try { 109 | for (let attempt = 1; attempt <= retries; attempt++) { 110 | try { 111 | // Get the SHA of the file if it exists 112 | let sha: string | undefined; 113 | try { 114 | const { data: existingFile } = await this.octokit.repos.getContent({ 115 | owner: this.repoOwner, 116 | repo: this.repoName, 117 | path: filePath, 118 | ref: this.defaultBranch, 119 | }); 120 | if ('sha' in existingFile) { 121 | sha = existingFile.sha; 122 | } 123 | } catch (error) { 124 | // File does not exist, proceed to create it 125 | if (isOctokitResponseError(error)) { 126 | if (error.status !== 404) { 127 | this.logger.error( 128 | `Error checking file ${filePath}: ${error.message} (Status: ${error.status})`, 129 | ); 130 | throw error; 131 | } 132 | // If the error is 404, the file does not exist and we can proceed to create it 133 | } else { 134 | throw error; 135 | } 136 | } 137 | 138 | // Attempt to create or update the file 139 | await this.octokit.repos.createOrUpdateFileContents({ 140 | owner: this.repoOwner, 141 | repo: this.repoName, 142 | path: filePath, 143 | message: commitMessage, 144 | content: Buffer.from(content, 'utf-8').toString('base64'), 145 | sha, 146 | branch: this.defaultBranch, 147 | }); 148 | 149 | this.logger.log(`File ${filePath} created/updated successfully.`); 150 | return; 151 | } catch (error: any) { 152 | if (error.status === 409 && attempt < retries) { 153 | this.logger.warn( 154 | `Conflict creating/updating ${filePath}. Retrying (${attempt}/${retries})...`, 155 | ); 156 | const backoffTime = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s 157 | await this.delay(backoffTime); 158 | continue; 159 | } 160 | 161 | if (error.status === 409) { 162 | this.logger.error( 163 | `Persistent conflict creating/updating ${filePath}: ${error.message}`, 164 | ); 165 | throw new InternalServerErrorException( 166 | `Error creating/updating file ${filePath}: ${error.message}`, 167 | ); 168 | } 169 | 170 | this.logger.error( 171 | `Error creating/updating file ${filePath}: ${error.message}`, 172 | ); 173 | throw new InternalServerErrorException( 174 | `Error creating/updating file ${filePath}: ${error.message}`, 175 | ); 176 | } 177 | } 178 | } finally { 179 | this.unlockDirectory(directoryPath); 180 | } 181 | } 182 | 183 | /** 184 | * Deletes a file from the repository. 185 | * @param filePath Path of the file in the repository. 186 | * @param commitMessage Commit message. 187 | */ 188 | async deleteFile( 189 | filePath: string, 190 | commitMessage: string, 191 | retries = 3, 192 | ): Promise { 193 | try { 194 | for (let attempt = 1; attempt <= retries; attempt++) { 195 | try { 196 | // Get the file's SHA 197 | const { data: existingFile } = await this.octokit.repos.getContent({ 198 | owner: this.repoOwner, 199 | repo: this.repoName, 200 | path: filePath, 201 | ref: this.defaultBranch, 202 | }); 203 | 204 | if (!('sha' in existingFile)) { 205 | throw new Error(`The file ${filePath} does not have a valid SHA`); 206 | } 207 | 208 | const sha = existingFile.sha; 209 | 210 | // Attempt to delete the file 211 | await this.octokit.repos.deleteFile({ 212 | owner: this.repoOwner, 213 | repo: this.repoName, 214 | path: filePath, 215 | message: commitMessage, 216 | sha: sha, 217 | branch: this.defaultBranch, 218 | }); 219 | 220 | this.logger.log(`File ${filePath} deleted successfully.`); 221 | return; 222 | } catch (error: any) { 223 | if (error.status === 409 && attempt < retries) { 224 | this.logger.warn( 225 | `Conflict deleting ${filePath}. Retrying (${attempt}/${retries})...`, 226 | ); 227 | const backoffTime = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s 228 | await this.delay(backoffTime); 229 | continue; 230 | } 231 | 232 | if (error.status === 409) { 233 | this.logger.error( 234 | `Persistent conflict deleting ${filePath}: ${error.message}`, 235 | ); 236 | throw new InternalServerErrorException( 237 | `Error deleting file ${filePath}: ${error.message}`, 238 | ); 239 | } 240 | 241 | if (isOctokitResponseError(error) && error.status === 404) { 242 | this.logger.warn( 243 | `The file ${filePath} does not exist in the repository.`, 244 | ); 245 | return; 246 | } 247 | 248 | if (isOctokitResponseError(error)) { 249 | this.logger.error( 250 | `Error deleting file ${filePath}: ${error.message} (Status: ${error.status})`, 251 | ); 252 | } else { 253 | this.logger.error(`Error deleting file ${filePath}: ${error}`); 254 | } 255 | throw error; 256 | } 257 | } 258 | } catch (error) { 259 | this.logger.error(`Error deleting file ${filePath}: ${error}`); 260 | throw error; 261 | } 262 | } 263 | 264 | /** 265 | * Get the content of a file. 266 | * @param filePath Path of the file in the repository. 267 | * @returns Plain text file content or null if it does not exist. 268 | */ 269 | async getFileContent(filePath: string): Promise { 270 | try { 271 | const { data } = await this.octokit.repos.getContent({ 272 | owner: this.repoOwner, 273 | repo: this.repoName, 274 | path: filePath, 275 | ref: this.defaultBranch, 276 | }); 277 | 278 | if ('content' in data && data.content) { 279 | const buffer = Buffer.from(data.content, 'base64'); 280 | return buffer.toString('utf-8'); 281 | } 282 | 283 | return null; 284 | } catch (error: any) { 285 | if (isOctokitResponseError(error)) { 286 | if (error.status === 404) { 287 | return null; 288 | } 289 | } 290 | if (isOctokitResponseError(error)) { 291 | this.logger.error( 292 | `Error getting content of file ${filePath}: ${error.message} (Status: ${error.status})`, 293 | ); 294 | } else { 295 | this.logger.error( 296 | `Error getting content of file ${filePath}: ${error}`, 297 | ); 298 | } 299 | throw error; 300 | } 301 | } 302 | 303 | /** 304 | * Lists the files in a specific directory on GitHub. 305 | * @param directoryPath Path of the directory in the repository. 306 | * @returns Array of file names. 307 | */ 308 | async listFilesInDirectory(directoryPath: string): Promise { 309 | try { 310 | const { data } = await this.octokit.repos.getContent({ 311 | owner: this.repoOwner, 312 | repo: this.repoName, 313 | path: directoryPath, 314 | ref: this.defaultBranch, 315 | }); 316 | 317 | if (Array.isArray(data)) { 318 | return data.map((file) => file.name); 319 | } 320 | 321 | return []; 322 | } catch (error: any) { 323 | if (isOctokitResponseError(error) && error.status === 404) { 324 | this.logger.warn(`The directory ${directoryPath} does not exist.`); 325 | return []; 326 | } 327 | 328 | if (isOctokitResponseError(error)) { 329 | this.logger.error( 330 | `Error listing files in ${directoryPath}: ${error.message} (Status: ${error.status})`, 331 | ); 332 | } else { 333 | this.logger.error(`Error listing files in ${directoryPath}: ${error}`); 334 | } 335 | throw error; 336 | } 337 | } 338 | 339 | /** 340 | * Deletes an empty folder from the repository. 341 | * Assumes the folder is already empty. 342 | * @param folderPath Path of the folder in the repository. 343 | * @param commitMessage Commit message for the deletion. 344 | */ 345 | async deleteFolder(folderPath: string, commitMessage: string): Promise { 346 | try { 347 | // GitHub does not support direct folder deletion; instead, ensure no files remain 348 | const files = await this.listFilesInDirectory(folderPath); 349 | 350 | if (files.length > 0) { 351 | throw new Error(`Folder ${folderPath} is not empty. Cannot delete.`); 352 | } 353 | 354 | // GitHub API requires at least a dummy file like .gitkeep to keep directories 355 | const gitkeepPath = `${folderPath}/.gitkeep`; 356 | try { 357 | const { data: existingFile } = await this.octokit.repos.getContent({ 358 | owner: this.repoOwner, 359 | repo: this.repoName, 360 | path: gitkeepPath, 361 | ref: this.defaultBranch, 362 | }); 363 | 364 | if (!('sha' in existingFile)) { 365 | throw new Error( 366 | `The .gitkeep file in ${folderPath} does not have a valid SHA`, 367 | ); 368 | } 369 | 370 | const sha = existingFile.sha; 371 | 372 | // Delete the .gitkeep file 373 | await this.octokit.repos.deleteFile({ 374 | owner: this.repoOwner, 375 | repo: this.repoName, 376 | path: gitkeepPath, 377 | message: commitMessage, 378 | sha, 379 | branch: this.defaultBranch, 380 | }); 381 | 382 | this.logger.log(`Folder ${folderPath} deleted successfully.`); 383 | } catch (error: any) { 384 | if (isOctokitResponseError(error) && error.status === 404) { 385 | this.logger.warn( 386 | `The .gitkeep file in ${folderPath} does not exist.`, 387 | ); 388 | } else { 389 | throw error; 390 | } 391 | } 392 | } catch (error) { 393 | this.logger.error(`Error deleting folder ${folderPath}: ${error}`); 394 | throw new InternalServerErrorException( 395 | `Failed to delete folder: ${error}`, 396 | ); 397 | } 398 | } 399 | 400 | /** 401 | * Clears all files in the GitHub repository. 402 | * Useful for cleaning the state before running tests. 403 | */ 404 | async clearRepository(): Promise { 405 | this.logger.log('Starting GitHub repository cleanup...'); 406 | 407 | try { 408 | const files = await this.listAllFiles(); 409 | 410 | for (const file of files) { 411 | // Do not delete essential files like .gitignore or .gitkeep 412 | if ( 413 | file.path === '.gitignore' || 414 | path.basename(file.path) === '.gitkeep' 415 | ) { 416 | continue; 417 | } 418 | 419 | await this.deleteFile( 420 | file.path, 421 | `Clear repository: Remove ${file.path}`, 422 | ); 423 | } 424 | 425 | this.logger.log('GitHub repository cleaned successfully.'); 426 | } catch (error: any) { 427 | this.logger.error(`Error cleaning the repository: ${error.message}`); 428 | throw new InternalServerErrorException( 429 | `Error cleaning the repository: ${error.message}`, 430 | ); 431 | } 432 | } 433 | 434 | /** 435 | * Recursively lists all files in the GitHub repository. 436 | * @returns List of file paths in the repository. 437 | */ 438 | private async listAllFiles(): Promise> { 439 | const rootPath = ''; 440 | const files: Array<{ path: string }> = []; 441 | 442 | async function traverseDirectory( 443 | service: GitHubService, 444 | currentPath: string, 445 | accumulator: Array<{ path: string }>, 446 | ): Promise { 447 | try { 448 | const response = await service.octokit.repos.getContent({ 449 | owner: service.repoOwner, 450 | repo: service.repoName, 451 | path: currentPath, 452 | ref: service.defaultBranch, 453 | }); 454 | 455 | if (Array.isArray(response.data)) { 456 | for (const file of response.data) { 457 | if (file.type === 'file') { 458 | accumulator.push({ path: file.path }); 459 | } else if (file.type === 'dir') { 460 | await traverseDirectory(service, file.path, accumulator); 461 | } 462 | } 463 | } 464 | } catch (error: any) { 465 | if (isOctokitResponseError(error) && error.status === 404) { 466 | service.logger.warn(`Directory ${currentPath} does not exist.`); 467 | } else { 468 | service.logger.error( 469 | `Error listing files in ${currentPath}: ${error.message} (Status: ${error.status})`, 470 | ); 471 | throw new InternalServerErrorException( 472 | `Error listing files in ${currentPath}: ${error.message}`, 473 | ); 474 | } 475 | } 476 | } 477 | 478 | await traverseDirectory(this, rootPath, files); 479 | return files; 480 | } 481 | 482 | /** 483 | * Introduces a delay during tests. 484 | * @param ms Milliseconds to pause. 485 | */ 486 | private async delay(ms: number): Promise { 487 | return new Promise((resolve) => setTimeout(resolve, ms)); 488 | } 489 | 490 | /** 491 | * Simple directory lock implementation to prevent concurrent operations. 492 | * @param directoryPath Path of the directory to lock. 493 | */ 494 | private async lockDirectory(directoryPath: string): Promise { 495 | while (this.directoryLocks.get(directoryPath)) { 496 | this.logger.warn(`Directory ${directoryPath} is locked. Waiting...`); 497 | await this.delay(100); // Wait 100ms before retrying 498 | } 499 | this.directoryLocks.set(directoryPath, true); 500 | } 501 | 502 | /** 503 | * Unlocks a directory after completing operations. 504 | * @param directoryPath Path of the directory to unlock. 505 | */ 506 | private unlockDirectory(directoryPath: string): void { 507 | this.directoryLocks.set(directoryPath, false); 508 | this.logger.log(`Directory ${directoryPath} unlocked.`); 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /src/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common'; 2 | import * as winston from 'winston'; 3 | import 'winston-daily-rotate-file'; 4 | 5 | @Injectable() 6 | export class LoggerService implements NestLoggerService { 7 | private logger: winston.Logger; 8 | 9 | constructor() { 10 | const logDir = 'logs'; 11 | 12 | this.logger = winston.createLogger({ 13 | level: 'info', 14 | format: winston.format.combine( 15 | winston.format.timestamp(), 16 | winston.format.errors({ stack: true }), 17 | winston.format.json() 18 | ), 19 | defaultMeta: { service: 'zen-rices-api' }, 20 | transports: [ 21 | new winston.transports.DailyRotateFile({ 22 | filename: `${logDir}/error-%DATE%.log`, 23 | datePattern: 'YYYY-MM-DD', 24 | zippedArchive: true, 25 | maxSize: '20m', 26 | maxFiles: '14d', 27 | level: 'error', 28 | }), 29 | new winston.transports.DailyRotateFile({ 30 | filename: `${logDir}/combined-%DATE%.log`, 31 | datePattern: 'YYYY-MM-DD', 32 | zippedArchive: true, 33 | maxSize: '20m', 34 | maxFiles: '14d', 35 | }), 36 | ], 37 | }); 38 | 39 | // Add console transport in development 40 | if (process.env.NODE_ENV !== 'production') { 41 | this.logger.add(new winston.transports.Console({ 42 | format: winston.format.combine( 43 | winston.format.colorize(), 44 | winston.format.simple() 45 | ), 46 | })); 47 | } 48 | } 49 | 50 | log(message: string, context?: string) { 51 | this.logger.info(message, { context }); 52 | } 53 | 54 | error(message: string, trace?: string, context?: string) { 55 | this.logger.error(message, { trace, context }); 56 | } 57 | 58 | warn(message: string, context?: string) { 59 | this.logger.warn(message, { context }); 60 | } 61 | 62 | debug(message: string, context?: string) { 63 | this.logger.debug(message, { context }); 64 | } 65 | 66 | verbose(message: string, context?: string) { 67 | this.logger.verbose(message, { context }); 68 | } 69 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 5 | import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter'; 6 | import { HttpExceptionFilter } from './filters/http-exception.filter'; 7 | import helmet from 'helmet'; 8 | 9 | async function bootstrap() { 10 | const app = await NestFactory.create(AppModule); 11 | 12 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 13 | app.useGlobalFilters(new HttpExceptionFilter()); 14 | app.useGlobalFilters(new ThrottlerExceptionFilter()); 15 | 16 | app.use(helmet({ 17 | crossOriginResourcePolicy: { policy: "cross-origin" }, 18 | contentSecurityPolicy: { 19 | directives: { 20 | defaultSrc: [`'self'`], 21 | styleSrc: [`'self'`, `'unsafe-inline'`], 22 | imgSrc: [`'self'`, 'data:', 'https:'], 23 | scriptSrc: [`'self'`, `'unsafe-inline'`, `'unsafe-eval'`], 24 | }, 25 | }, 26 | })); 27 | 28 | const config = new DocumentBuilder() 29 | .setTitle('Zen Rices API') 30 | .setDescription('Zen Rices API management (Zen Browser)') 31 | .setVersion('1.0') 32 | .addTag('rices') 33 | .addTag('workspaces') 34 | .addBearerAuth() 35 | .addApiKey() 36 | .build(); 37 | 38 | const document = SwaggerModule.createDocument(app, config); 39 | SwaggerModule.setup('api', app, document); 40 | 41 | await app.listen(3000); 42 | 43 | console.log('API running on http://localhost:3000'); 44 | console.log('Swagger docs on http://localhost:3000/api'); 45 | } 46 | bootstrap(); 47 | -------------------------------------------------------------------------------- /src/shared/dto/create-shared.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class CreateSharedDto { 4 | @IsString() 5 | name!: string; 6 | 7 | @IsString() 8 | version!: string; 9 | 10 | @IsString() 11 | os!: string; 12 | 13 | @IsString() 14 | content!: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/dto/update-shared.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class UpdateSharedDto { 4 | @IsString() 5 | content!: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/rices.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Put, 6 | Delete, 7 | Param, 8 | Body, 9 | Headers, 10 | HttpCode, 11 | HttpStatus, 12 | UnauthorizedException, 13 | BadRequestException, 14 | Res, 15 | } from '@nestjs/common'; 16 | import { Response } from 'express'; 17 | import { SharedService } from './shared.service'; 18 | import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; 19 | import { SHARED_TYPES } from './shared.module'; 20 | 21 | @ApiTags('rices') 22 | @Controller('rices') 23 | export class RicesController { 24 | constructor(private readonly sharedService: SharedService) { } 25 | 26 | @ApiOperation({ summary: 'Upload a new Rice' }) 27 | @ApiResponse({ status: 201, description: 'Rice successfully created.' }) 28 | @ApiHeader({ 29 | name: 'x-zen-shared-name', 30 | description: 'Name of the Shared', 31 | required: true, 32 | }) 33 | @ApiHeader({ 34 | name: 'x-zen-shared-author', 35 | description: 'Author of the Shared', 36 | required: true, 37 | }) 38 | @ApiHeader({ 39 | name: 'User-Agent', 40 | description: 'User-Agent', 41 | required: true, 42 | }) 43 | @Post() 44 | async createRice( 45 | @Body() content: string, 46 | @Headers() headers: Record, 47 | @Headers('x-zen-shared-token') token: string, 48 | ) { 49 | const contentString = 50 | typeof content === 'string' ? content : JSON.stringify(content); 51 | 52 | this.validateFileSize(contentString); // Validate file size 53 | 54 | return this.sharedService.create(SHARED_TYPES.RICE, contentString, token, headers); 55 | } 56 | 57 | @ApiOperation({ summary: 'Get information about a Rice' }) 58 | @ApiResponse({ 59 | status: 200, 60 | description: 'Returns metadata of the Rice as HTML.', 61 | }) 62 | @Get(':slug') 63 | async getRice(@Param('slug') slug: string, @Res() res: Response) { 64 | const riceMetadata = await this.sharedService.getRiceMetadata(SHARED_TYPES.RICE, slug); 65 | 66 | const htmlContent = ` 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | Zen Rice - ${riceMetadata.name} 75 | 76 | 77 | 78 | 88 | 89 | `; 90 | 91 | res.setHeader('Content-Type', 'text/html'); 92 | res.send(htmlContent); 93 | } 94 | 95 | @ApiOperation({ summary: 'Update an existing Rice' }) 96 | @ApiResponse({ status: 200, description: 'Rice successfully updated.' }) 97 | @ApiHeader({ 98 | name: 'x-zen-shared-name', 99 | description: 'Name of the shared', 100 | required: true, 101 | }) 102 | @ApiHeader({ 103 | name: 'x-zen-shared-author', 104 | description: 'Author of the shared', 105 | required: true, 106 | }) 107 | @ApiHeader({ 108 | name: 'User-Agent', 109 | description: 'User-Agent', 110 | required: true, 111 | }) 112 | @Put(':slug') 113 | async updateRice( 114 | @Param('slug') slug: string, 115 | @Body() content: string, 116 | @Headers() headers: Record, 117 | @Headers('x-zen-shared-token') token: string, 118 | ) { 119 | const contentString = 120 | typeof content === 'string' ? content : JSON.stringify(content); 121 | 122 | this.validateFileSize(contentString); // Validate file size 123 | 124 | return this.sharedService.update(SHARED_TYPES.RICE, slug, token, contentString, headers); 125 | } 126 | 127 | @ApiOperation({ summary: 'Delete an existing Rice' }) 128 | @ApiResponse({ status: 204, description: 'Rice successfully deleted.' }) 129 | @HttpCode(HttpStatus.NO_CONTENT) 130 | @Delete(':slug') 131 | async removeRice( 132 | @Param('slug') slug: string, 133 | @Headers('x-zen-shared-token') token: string, 134 | ) { 135 | await this.sharedService.remove(SHARED_TYPES.RICE, slug, token); 136 | return; 137 | } 138 | 139 | @ApiOperation({ 140 | summary: 'Forcefully delete a Rice (moderation)', 141 | description: 142 | 'Requires knowledge of a moderation secret to delete the Rice.', 143 | }) 144 | @ApiResponse({ status: 204, description: 'Rice deleted by moderation.' }) 145 | @HttpCode(HttpStatus.NO_CONTENT) 146 | @Delete('moderate/delete/:slug') 147 | async removeRiceByModerator( 148 | @Param('slug') slug: string, 149 | @Headers('x-moderation-secret') moderationSecret: string, 150 | ) { 151 | if (moderationSecret !== process.env.MODERATION_SECRET) { 152 | throw new UnauthorizedException('Invalid moderation secret'); 153 | } 154 | await this.sharedService.moderateRemove(SHARED_TYPES.RICE, slug); 155 | return; 156 | } 157 | 158 | private validateFileSize(content: string) { 159 | const sizeInBytes = Buffer.byteLength(content, 'utf-8'); 160 | const maxSizeInBytes = 1 * 1024 * 512; // 1 MB 161 | if (sizeInBytes > maxSizeInBytes) { 162 | throw new BadRequestException( 163 | `The uploaded content exceeds the size limit of 512 KB.`, 164 | ); 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | // src/rices/rices.module.ts 2 | 3 | import { Module, } from '@nestjs/common'; 4 | import { SharedService } from './shared.service'; 5 | import { GitHubModule } from '../github/github.module'; 6 | import { RicesController } from './rices.controller'; 7 | import { SpacesController } from './spaces.controller'; 8 | import { SupabaseService } from '../supabase/supabase.service'; 9 | import { CacheModule } from '@nestjs/cache-manager'; 10 | 11 | export const SHARED_TYPES = { 12 | WORKSPACE: "WORKSPACE", 13 | RICE: "RICE", 14 | }; 15 | 16 | 17 | @Module({ 18 | imports: [ 19 | GitHubModule, 20 | CacheModule.register(), 21 | ], 22 | providers: [SharedService, SupabaseService], 23 | controllers: [RicesController, SpacesController], 24 | exports: [SharedService], 25 | }) 26 | export class SharedModule { } 27 | -------------------------------------------------------------------------------- /src/shared/shared.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NotFoundException, 4 | UnauthorizedException, 5 | ConflictException, 6 | BadRequestException, 7 | Inject, 8 | } from '@nestjs/common'; 9 | 10 | import xss from 'xss'; 11 | import { minify } from 'csso'; 12 | import { v4 as uuidv4 } from 'uuid'; 13 | import { generateSlug } from './utils/slug.util'; 14 | import { ConfigService } from '@nestjs/config'; 15 | import { GitHubService } from '../github/github.service'; 16 | import { SupabaseService } from '../supabase/supabase.service'; 17 | import { SHARED_TYPES } from './shared.module'; 18 | import { Cache } from 'cache-manager'; 19 | import { CACHE_MANAGER } from '@nestjs/cache-manager'; 20 | 21 | const userAgentRegex = /ZenBrowser\/(\d+\.\d\w?\.\d) \((.+)\)/; 22 | 23 | @Injectable() 24 | export class SharedService { 25 | constructor( 26 | @Inject(CACHE_MANAGER) private cacheManager: Cache, 27 | private readonly gitHubService: GitHubService, 28 | private readonly supabaseService: SupabaseService, 29 | private readonly configService: ConfigService, 30 | ) { } 31 | 32 | async create( 33 | type: string, 34 | content: string, 35 | token: string | null, 36 | headers: Record, 37 | ) { 38 | try { 39 | // Validate headers 40 | const name = headers['x-zen-shared-name']; 41 | const author = headers['x-zen-shared-author']; 42 | const userAgent = headers['user-agent']; 43 | 44 | if (!name || !author || !userAgent) { 45 | throw new BadRequestException('shared name and author are required!'); 46 | } 47 | 48 | // Validate content 49 | if (typeof content !== 'string') { 50 | throw new BadRequestException('The request body must be a string.'); 51 | } 52 | 53 | try { 54 | if (type == SHARED_TYPES.RICE) { 55 | this.validateRicesJsonStructure(content); 56 | } 57 | else if (type == SHARED_TYPES.WORKSPACE) { 58 | this.validateWorkspaceJsonStructure(content); 59 | } 60 | 61 | content = this.sanitizeJson(content); 62 | content = this.minimizeJson(content); 63 | 64 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 65 | } catch (e) { 66 | throw new BadRequestException(e); 67 | } 68 | 69 | // Validate lengths 70 | if (name.length > 75) { 71 | throw new BadRequestException( 72 | `The value of x-zen-shared-name exceeds the maximum allowed length of 75 characters.`, 73 | ); 74 | } 75 | 76 | if (author.length > 100) { 77 | throw new BadRequestException( 78 | `The value of x-zen-shared-author exceeds the maximum allowed length of 100 characters.`, 79 | ); 80 | } 81 | 82 | // Parse version and OS from User-Agent 83 | const match = userAgent.match(userAgentRegex); 84 | 85 | if (!match) { 86 | throw new BadRequestException('Invalid request'); 87 | } 88 | 89 | const [, version, os] = match; 90 | // Validate version and OS lengths 91 | if (version.length > 10) { 92 | throw new BadRequestException( 93 | `The version in User-Agent exceeds the maximum allowed length of 10 characters.`, 94 | ); 95 | } 96 | 97 | if (os.length > 30) { 98 | throw new BadRequestException( 99 | `The operating system in User-Agent exceeds the maximum allowed length of 30 characters.`, 100 | ); 101 | } 102 | 103 | // Check if a rice with the same name already exists 104 | /*const existingRice = await this.supabaseService.getRiceByName(name); 105 | if (existingRice) { 106 | throw new ConflictException( 107 | `A rice with the name '${name}' already exists.`, 108 | ); 109 | }*/ 110 | 111 | let slug: string; 112 | try { 113 | slug = `${generateSlug(name)}-${uuidv4()}`; 114 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 115 | } catch (e) { 116 | // If generateSlug throws an error, rethrow as a BadRequestException 117 | throw new BadRequestException(`Invalid name provided`); 118 | } 119 | 120 | if (!type) { 121 | throw new BadRequestException(`Invalid type provided`); 122 | } 123 | 124 | if (!token) { 125 | token = uuidv4(); 126 | } else { 127 | if (type == SHARED_TYPES.RICE) { 128 | const tokenMaxCount = this.configService.get( 129 | 'MAX_RICES_BY_TOKEN', 130 | 5, 131 | ); 132 | const tokenCount = await this.supabaseService.countSharedByToken(token); 133 | if (tokenCount >= tokenMaxCount) { 134 | throw new ConflictException( 135 | `The token '${token}' is already associated with 5 or more rices.`, 136 | ); 137 | } 138 | } 139 | } 140 | 141 | const metadata = { 142 | id: uuidv4(), 143 | slug: slug, 144 | type: type, 145 | version: version, 146 | os: os, 147 | name: name, 148 | author: author, 149 | token: token, 150 | visits: 0, 151 | level: 0, 152 | created_at: new Date().toISOString(), 153 | }; 154 | 155 | // Insert metadata into Supabase 156 | await this.supabaseService.insertShared(metadata); 157 | 158 | const uploadedFilePath = this.getSharedFilePath(type, slug); 159 | await this.gitHubService.createOrUpdateFile( 160 | uploadedFilePath, 161 | content, 162 | `Add content to shared ${slug}`, 163 | ); 164 | 165 | return { slug, token }; 166 | } catch (error) { 167 | console.error('Error in create method:', error); 168 | throw error; 169 | } 170 | } 171 | 172 | getShortSharedFilePath(type: string, slug: string) { 173 | switch (type) { 174 | case SHARED_TYPES.WORKSPACE: 175 | return `spaces/${slug}`; 176 | case SHARED_TYPES.RICE: 177 | return `rices/${slug}`; 178 | default: 179 | throw new Error(`Unknown shared type: ${type}`); 180 | } 181 | } 182 | 183 | getSharedFilePath(type: string, slug: string) { 184 | switch (type) { 185 | case SHARED_TYPES.WORKSPACE: 186 | return `spaces/${slug}/data.zenspace`; 187 | case SHARED_TYPES.RICE: 188 | return `rices/${slug}/data.zenrice`; 189 | default: 190 | throw new Error(`Unknown shared type: ${type}`); 191 | } 192 | } 193 | 194 | 195 | async findOne(type: string, slug: string) { 196 | // Try to get from cache first 197 | const cacheKey = `${type}:${slug}`; 198 | const cachedData = await this.cacheManager.get(cacheKey); 199 | 200 | if (cachedData) { 201 | return cachedData; 202 | } 203 | 204 | // If not in cache, get from database 205 | const shared = await this.supabaseService.getSharedBySlug(type, slug); 206 | if (!shared) throw new NotFoundException('shared not found'); 207 | 208 | // Fetch file from GitHub 209 | const filePath = this.getSharedFilePath(type, slug); 210 | const fileContent = await this.gitHubService.getFileContent(filePath); 211 | 212 | if (!fileContent) { 213 | throw new NotFoundException('Shared file not found in GitHub'); 214 | } 215 | 216 | // Store in cache 217 | await this.cacheManager.set(cacheKey, fileContent, 300); // 5 minutes TTL 218 | 219 | return fileContent; 220 | } 221 | 222 | async getRiceMetadata(type: string, slug: string) { 223 | const cacheKey = `metadata:${type}:${slug}`; 224 | const cachedMetadata = await this.cacheManager.get(cacheKey); 225 | 226 | if (cachedMetadata) { 227 | return cachedMetadata; 228 | } 229 | 230 | const shared = await this.supabaseService.getSharedBySlug(type, slug); 231 | if (!shared) throw new NotFoundException('Shared not found'); 232 | 233 | // Store in cache 234 | await this.cacheManager.set(cacheKey, shared, 300); 235 | 236 | return shared; 237 | } 238 | 239 | async update( 240 | type: string, 241 | slug: string, 242 | token: string, 243 | content: string, 244 | headers: Record, 245 | ) { 246 | try { 247 | // Extract fields from headers 248 | const userAgent = headers['user-agent']; 249 | 250 | if (!userAgent) { 251 | throw new BadRequestException( 252 | 'Missing required headers: User-Agent is mandatory.', 253 | ); 254 | } 255 | 256 | // Parse version and OS from User-Agent 257 | // It must have the following format: 258 | // example version: 1.0.2-b.1 259 | const match = userAgent.match(userAgentRegex); 260 | 261 | if (!match) { 262 | throw new BadRequestException('Invalid request'); 263 | } 264 | 265 | try { 266 | if (type == SHARED_TYPES.RICE) { 267 | this.validateRicesJsonStructure(content); 268 | } 269 | else if (type == SHARED_TYPES.WORKSPACE) { 270 | this.validateWorkspaceJsonStructure(content); 271 | } 272 | 273 | content = this.sanitizeJson(content); 274 | content = this.minimizeJson(content); 275 | 276 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 277 | } catch (e) { 278 | throw new BadRequestException('Invalid json request'); 279 | } 280 | 281 | const [, version, os] = match; 282 | 283 | // Check if the shared exists 284 | const shared = await this.supabaseService.getSharedBySlug(type, slug); 285 | if (!shared) { 286 | throw new NotFoundException('Shared not found'); 287 | } 288 | 289 | // Validate token, name, and author match the existing record 290 | if (shared.token !== token) { 291 | throw new UnauthorizedException('Invalid token.'); 292 | } 293 | 294 | const updatedMetadata = { 295 | ...shared, 296 | version, 297 | os, 298 | updated_at: new Date().toISOString(), 299 | }; 300 | 301 | await this.supabaseService.updateShared(slug, updatedMetadata); 302 | 303 | const uploadedFilePath = this.getSharedFilePath(type, slug); 304 | await this.gitHubService.createOrUpdateFile( 305 | uploadedFilePath, 306 | content, 307 | `Update content in shared ${slug}`, 308 | ); 309 | 310 | await this.cacheManager.del(`${type}:${slug}`); 311 | await this.cacheManager.del(`metadata:${type}:${slug}`); 312 | return { message: `shared ${slug} updated successfully.` }; 313 | } catch (error) { 314 | console.error('Error in update method:', error); 315 | throw error; 316 | } 317 | } 318 | 319 | async remove(type: string, slug: string, token: string): Promise { 320 | const shared = await this.supabaseService.getSharedBySlug(type, slug); 321 | if (!shared) throw new NotFoundException('shared not found'); 322 | if (shared.token !== token) throw new UnauthorizedException('Invalid token'); 323 | 324 | // Validate token, name, and author match the existing record 325 | if (shared.token !== token) { 326 | throw new UnauthorizedException('Invalid token.'); 327 | } 328 | 329 | await this.supabaseService.deleteShared(slug); 330 | 331 | const folderPath = this.getShortSharedFilePath(type, slug); 332 | 333 | // List all files in the folder 334 | const files = await this.gitHubService.listFilesInDirectory(folderPath); 335 | 336 | // Delete all files within the folder 337 | for (const file of files) { 338 | const filePath = `${folderPath}/${file}`; 339 | await this.gitHubService.deleteFile( 340 | filePath, 341 | `Remove file ${file} in shared ${slug}`, 342 | ); 343 | } 344 | 345 | // Finally, remove the folder itself 346 | await this.gitHubService.deleteFolder( 347 | folderPath, 348 | `Remove folder ${folderPath}`, 349 | ); 350 | await this.cacheManager.del(`${type}:${slug}`); 351 | await this.cacheManager.del(`metadata:${type}:${slug}`); 352 | } 353 | 354 | /** 355 | * Delete a shared without checking the user's token. 356 | * Exclusive use for moderators with the secret key. 357 | */ 358 | public async moderateRemove(type: string, slug: string): Promise { 359 | try { 360 | // 1. Check if shared exists in Supabase 361 | const shared = await this.supabaseService.getSharedBySlug(type, slug); 362 | if (!shared) { 363 | throw new NotFoundException('shared not found'); 364 | } 365 | 366 | // 2. Delete metadata from Supabase 367 | await this.supabaseService.deleteShared(slug); 368 | 369 | // 3. Delete data.zenrice from GitHub 370 | 371 | const jsonPath = this.getSharedFilePath(type, slug); 372 | await this.gitHubService.deleteFile( 373 | jsonPath, 374 | `[MODERATION] Remove shared ${slug}`, 375 | ); 376 | 377 | // 4. List and delete uploaded files from GitHub (if any) 378 | const filesPath = this.getShortSharedFilePath(type, slug); 379 | const files = await this.gitHubService.listFilesInDirectory(filesPath); 380 | 381 | for (const file of files) { 382 | const filePath = filesPath + `/${file}`; 383 | await this.gitHubService.deleteFile( 384 | filePath, 385 | `[MODERATION] Remove file ${file} from shared ${slug}`, 386 | ); 387 | } 388 | 389 | // 4. Finally, remove the folder itself 390 | await this.gitHubService.deleteFolder( 391 | filesPath, 392 | `[MODERATION] Remove folder ${filesPath}`, 393 | ); 394 | } catch (error) { 395 | console.error('Error removing shared by moderation:', error); 396 | if (error instanceof NotFoundException) { 397 | throw error; 398 | } 399 | throw new Error('Failed to remove shared by moderation'); 400 | } 401 | } 402 | 403 | validateWorkspaceJsonStructure(jsonString: string): boolean { 404 | return true; 405 | } 406 | 407 | validateRicesJsonStructure(jsonString: string): boolean { 408 | const requiredKeys: string[] = [ 409 | 'userChrome', 410 | 'userContent', 411 | 'enabledMods', 412 | 'preferences', 413 | 'workspaceThemes', 414 | ]; 415 | 416 | let json: Record; 417 | 418 | // Validate JSON string 419 | try { 420 | json = JSON.parse(jsonString); 421 | } catch { 422 | throw new BadRequestException('Invalid JSON string.'); 423 | } 424 | 425 | // Ensure the parsed JSON is an object 426 | if (typeof json !== 'object' || json === null) { 427 | throw new BadRequestException('The parsed JSON is not a valid object.'); 428 | } 429 | 430 | // Check for missing keys 431 | const missingKeys = requiredKeys.filter((key) => !(key in json)); 432 | 433 | if (missingKeys.length > 0) { 434 | throw new BadRequestException( 435 | `The JSON is missing the following required keys: ${missingKeys.join(', ')}`, 436 | ); 437 | } 438 | 439 | return true; 440 | } 441 | 442 | // Método para minificar los campos CSS 443 | private minimizeJson(jsonString: string): string { 444 | const json = JSON.parse(jsonString); 445 | 446 | ['userChrome', 'userContent'].forEach((key) => { 447 | if (json[key] && typeof json[key] === 'string') { 448 | json[key] = minify(json[key]).css; 449 | } 450 | }); 451 | 452 | return JSON.stringify(json); 453 | } 454 | 455 | private sanitizeJson(jsonString: string): string { 456 | const json = JSON.parse(jsonString); 457 | 458 | const sanitizedJson = Object.keys(json).reduce( 459 | (acc, key) => { 460 | const value = json[key]; 461 | if (typeof value === 'string') { 462 | acc[key] = xss(value); // Limpia las cadenas de texto 463 | } else if (typeof value === 'object' && value !== null) { 464 | acc[key] = JSON.parse(this.sanitizeJson(JSON.stringify(value))); // Recursión para objetos anidados 465 | } else { 466 | acc[key] = value; // Otros tipos permanecen igual 467 | } 468 | return acc; 469 | }, 470 | {} as Record, 471 | ); 472 | 473 | return JSON.stringify(sanitizedJson); 474 | } 475 | } 476 | -------------------------------------------------------------------------------- /src/shared/spaces.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Body, 5 | Headers, 6 | HttpCode, 7 | HttpStatus, 8 | BadRequestException, 9 | } from '@nestjs/common'; 10 | import { SharedService } from './shared.service'; 11 | import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; 12 | import { SHARED_TYPES } from './shared.module'; 13 | 14 | @ApiTags('spaces') 15 | @Controller('spaces') 16 | export class SpacesController { 17 | constructor(private readonly sharedService: SharedService) { } 18 | 19 | @ApiOperation({ summary: 'Shared a Space' }) 20 | @ApiResponse({ status: 201, description: 'Space successfully shared.' }) 21 | @ApiHeader({ 22 | name: 'x-zen-shared-name', 23 | description: 'Name of the Space', 24 | required: true, 25 | }) 26 | @ApiHeader({ 27 | name: 'x-zen-shared-author', 28 | description: 'Author of the Space', 29 | required: true, 30 | }) 31 | @ApiHeader({ 32 | name: 'User-Agent', 33 | description: 'User-Agent', 34 | required: true, 35 | }) 36 | @Post() 37 | async createSpace( 38 | @Body() content: string, 39 | @Headers() headers: Record, 40 | @Headers('x-zen-shared-token') token: string, 41 | ) { 42 | const contentString = 43 | typeof content === 'string' ? content : JSON.stringify(content); 44 | 45 | this.validateFileSize(contentString); // Validate file size 46 | 47 | return this.sharedService.create(SHARED_TYPES.WORKSPACE, contentString, token, headers); 48 | } 49 | 50 | private validateFileSize(content: string) { 51 | const sizeInBytes = Buffer.byteLength(content, 'utf-8'); 52 | const maxSizeInBytes = 1 * 1024 * 512; // 1 MB 53 | if (sizeInBytes > maxSizeInBytes) { 54 | throw new BadRequestException( 55 | `The uploaded content exceeds the size limit of 512 KB.`, 56 | ); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/shared/utils/slug.util.ts: -------------------------------------------------------------------------------- 1 | import * as leoProfanity from 'leo-profanity'; 2 | 3 | export function generateSlug(name: string): string { 4 | // Ensure the input is a string and trim whitespace 5 | if (typeof name !== 'string') { 6 | throw new Error('Input must be a string'); 7 | } 8 | const sanitizedInput = name.trim(); 9 | 10 | // Configure the profanity filter 11 | leoProfanity.loadDictionary('en'); // Ensure the dictionary is loaded 12 | 13 | // Replace accented characters with their unaccented counterparts 14 | const normalized = sanitizedInput 15 | .normalize('NFD') 16 | .replace(/\p{Diacritic}/gu, ''); 17 | 18 | // Replace any non-alphanumeric characters (excluding '-') with a hyphen 19 | const slug = normalized 20 | .toLowerCase() 21 | .replace(/[^a-z0-9]+/g, '-') // Replace invalid characters 22 | .replace(/^-+|-+$/g, ''); // Trim leading and trailing hyphens 23 | 24 | // Ensure the slug is not empty 25 | if (!slug) { 26 | throw new Error('Generated slug is empty'); 27 | } 28 | 29 | // Split the slug into individual words 30 | const words = slug.split('-'); 31 | 32 | // Check each word for inappropriate content 33 | words.forEach((word) => { 34 | if (leoProfanity.check(word)) { 35 | throw new Error(`The word "${word}" is inappropriate.`); 36 | } 37 | }); 38 | 39 | return slug; 40 | } 41 | -------------------------------------------------------------------------------- /src/supabase/supabase.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { createClient, SupabaseClient } from '@supabase/supabase-js'; 4 | 5 | @Injectable() 6 | export class SupabaseService { 7 | private supabase: SupabaseClient; 8 | private supabase_url: string; 9 | private supabase_key: string; 10 | private readonly logger = new Logger(SupabaseService.name); 11 | 12 | constructor(private configService: ConfigService) { 13 | // Initialize properties in the constructor 14 | this.supabase_url = this.configService.get('SUPABASE_URL') || ''; 15 | this.supabase_key = this.configService.get('SUPABASE_KEY') || ''; 16 | 17 | this.supabase = createClient(this.supabase_url, this.supabase_key); 18 | } 19 | 20 | async insertShared(metadata: any) { 21 | const { error } = await this.supabase.from('shared').insert(metadata); 22 | if (error) { 23 | this.logger.error( 24 | `Failed to insert shared: ${error.message}`, 25 | error.details, 26 | ); 27 | throw new Error(`Failed to insert shared: ${error.message}`); 28 | } 29 | } 30 | 31 | async getSharedById(id: string) { 32 | const { data, error } = await this.supabase 33 | .from('shared') 34 | .select('*') 35 | .eq('id', id) 36 | .single(); 37 | if (error) { 38 | this.logger.error( 39 | `Failed to fetch shared with ID ${id}: ${error.message}`, 40 | error.details, 41 | ); 42 | throw new Error(`Failed to fetch shared: ${error.message}`); 43 | } 44 | return data; 45 | } 46 | 47 | async getSharedBySlug(type: string, slug: string) { 48 | const { data, error } = await this.supabase 49 | .from('shared') 50 | .select('*') 51 | .eq('type', type) 52 | .eq('slug', slug) 53 | .single(); 54 | if (error) { 55 | this.logger.error( 56 | `Failed to fetch shared with slug ${slug}: ${error.message}`, 57 | error.details, 58 | ); 59 | return null; 60 | } 61 | return data; 62 | } 63 | 64 | async getSharedByName(name: string) { 65 | const { data, error } = await this.supabase 66 | .from('shared') 67 | .select('*') 68 | .eq('name', name) 69 | .single(); 70 | if (error && error.code !== 'PGRST116') { 71 | // Handle "no rows found" separately 72 | this.logger.error( 73 | `Failed to fetch shared with name ${name}: ${error.message}`, 74 | error.details, 75 | ); 76 | throw new Error(`Failed to fetch shared: ${error.message}`); 77 | } 78 | return data; 79 | } 80 | 81 | async updateShared(slug: string, metadata: any) { 82 | const { error } = await this.supabase 83 | .from('shared') 84 | .update(metadata) 85 | .eq('slug', slug); 86 | if (error) { 87 | this.logger.error( 88 | `Failed to update shared with slug ${slug}: ${error.message}`, 89 | error.details, 90 | ); 91 | throw new Error(`Failed to update shared: ${error.message}`); 92 | } 93 | } 94 | 95 | async deleteShared(slug: string) { 96 | const { error } = await this.supabase 97 | .from('shared') 98 | .delete() 99 | .eq('slug', slug); 100 | if (error) { 101 | this.logger.error( 102 | `Failed to delete shared with slug ${slug}: ${error.message}`, 103 | error.details, 104 | ); 105 | throw new Error(`Failed to delete shared: ${error.message}`); 106 | } 107 | } 108 | 109 | async incrementVisits(slug: string) { 110 | const { error } = await this.supabase.rpc('increment_visits', { 111 | slug_param: slug, 112 | }); 113 | 114 | if (error) { 115 | this.logger.error( 116 | `Failed to increment visits for shared with slug ${slug}: ${error.message}`, 117 | error.details, 118 | ); 119 | throw new Error(`Failed to increment visits: ${error.message}`); 120 | } 121 | } 122 | 123 | async updateLevel(slug: string, level: number) { 124 | const { error } = await this.supabase 125 | .from('shared') 126 | .update({ level }) 127 | .eq('slug', slug); 128 | if (error) { 129 | this.logger.error( 130 | `Failed to update level for shared with slug ${slug}: ${error.message}`, 131 | error.details, 132 | ); 133 | throw new Error(`Failed to update shared level: ${error.message}`); 134 | } 135 | } 136 | 137 | async countSharedByToken(token: string): Promise { 138 | const { data, error, count } = await this.supabase 139 | .from('shared') // Nombre de tu tabla en Supabase 140 | .select('*', { count: 'exact' }) 141 | .eq('token', token); 142 | 143 | if (error) { 144 | console.error('Error counting shared by token:', error); 145 | throw new Error('Failed to count shared by token'); 146 | } 147 | 148 | return count || 0; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 3 | import request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import * as path from 'path'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import { GitHubService } from '../src/github/github.service'; 8 | import { SupabaseService } from '../src/supabase/supabase.service'; 9 | 10 | describe('Rices API E2E', () => { 11 | let app: INestApplication; 12 | let gitHubService: GitHubService; 13 | let supabaseService: SupabaseService; 14 | const moderationSecret = 'testSecret999'; 15 | 16 | beforeAll(async () => { 17 | require('dotenv').config({ path: '.env.test.local' }); 18 | 19 | const moduleFixture: TestingModule = await Test.createTestingModule({ 20 | imports: [AppModule], 21 | }).compile(); 22 | 23 | gitHubService = moduleFixture.get(GitHubService); 24 | supabaseService = moduleFixture.get(SupabaseService); 25 | 26 | app = moduleFixture.createNestApplication(); 27 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 28 | 29 | await app.init(); 30 | }); 31 | 32 | afterAll(async () => { 33 | await app.close(); 34 | }); 35 | 36 | beforeEach(async () => { 37 | // Limpiar repositorio y base de datos antes de cada test si es necesario 38 | }); 39 | 40 | it('POST /rices - Create a new rice entry', async () => { 41 | const response = await request(app.getHttpServer()) 42 | .post('/rices') 43 | .field('name', 'Test Rice') 44 | .attach('file', path.join(__dirname, 'files', 'example.zenrice')) 45 | .expect(201); 46 | 47 | const { slug, token } = response.body; 48 | expect(slug).toBeDefined(); 49 | expect(token).toBeDefined(); 50 | 51 | const riceInDatabase = await supabaseService.getSharedBySlug(slug); 52 | expect(riceInDatabase).not.toBeNull(); 53 | expect(riceInDatabase.name).toBe('Test Rice'); 54 | 55 | const fileInGitHub = await gitHubService.getFileContent( 56 | `rices/${slug}/data.zenrice`, 57 | ); 58 | expect(fileInGitHub).toContain('This is an example zenrice file.'); 59 | }); 60 | 61 | it('GET /rices/:slug - Retrieve a rice entry and increment visits', async () => { 62 | const createResponse = await request(app.getHttpServer()) 63 | .post('/rices') 64 | .field('name', 'Test Rice') 65 | .attach('file', path.join(__dirname, 'files', 'example.zenrice')) 66 | .expect(201); 67 | 68 | const { slug } = createResponse.body; 69 | 70 | const initialData = await supabaseService.getSharedBySlug(slug); 71 | expect(initialData.visits).toBe(0); 72 | 73 | await request(app.getHttpServer()).get(`/rices/${slug}`).expect(200); 74 | 75 | const updatedData = await supabaseService.getSharedBySlug(slug); 76 | expect(updatedData.visits).toBe(1); 77 | }); 78 | 79 | it('PUT /rices/:slug - Update a rice entry', async () => { 80 | const createResponse = await request(app.getHttpServer()) 81 | .post('/rices') 82 | .field('name', 'Original Rice') 83 | .attach('file', path.join(__dirname, 'files', 'example.zenrice')) 84 | .expect(201); 85 | 86 | const { slug, token } = createResponse.body; 87 | 88 | const updateResponse = await request(app.getHttpServer()) 89 | .put(`/rices/${slug}`) 90 | .set('x-zen-shared-token', token) 91 | .field('name', 'Updated Rice') 92 | .attach('file', path.join(__dirname, 'files', 'example_update.zenrice')) 93 | .expect(200); 94 | 95 | expect(updateResponse.body.message).toBe(`ok`); 96 | 97 | const updatedData = await supabaseService.getSharedBySlug(slug); 98 | expect(updatedData.name).toBe('Updated Rice'); 99 | 100 | const updatedFile = await gitHubService.getFileContent( 101 | `rices/${slug}/data.zenrice`, 102 | ); 103 | expect(updatedFile).toContain( 104 | 'This is an example zenrice file (modified).', 105 | ); 106 | }); 107 | 108 | it('DELETE /rices/:slug - Delete a rice entry', async () => { 109 | const createResponse = await request(app.getHttpServer()) 110 | .post('/rices') 111 | .field('name', 'Rice to Delete') 112 | .attach('file', path.join(__dirname, 'files', 'example.zenrice')) 113 | .expect(201); 114 | 115 | const { slug, token } = createResponse.body; 116 | 117 | await request(app.getHttpServer()) 118 | .delete(`/rices/${slug}`) 119 | .set('x-zen-shared-token', token) 120 | .expect(204); 121 | 122 | const riceInDatabase = await supabaseService.getSharedBySlug(slug); 123 | expect(riceInDatabase).toBeNull(); 124 | 125 | const fileInGitHub = await gitHubService.getFileContent( 126 | `rices/${slug}/data.zenrice`, 127 | ); 128 | expect(fileInGitHub).toBeNull(); 129 | }); 130 | 131 | it('DELETE /rices/moderate/delete/:slug - Moderation delete with correct secret', async () => { 132 | const createResponse = await request(app.getHttpServer()) 133 | .post('/rices') 134 | .field('name', 'Moderation Test Rice') 135 | .attach('file', path.join(__dirname, 'files', 'example.zenrice')) 136 | .expect(201); 137 | 138 | const { slug } = createResponse.body; 139 | 140 | await request(app.getHttpServer()) 141 | .delete(`/rices/moderate/delete/${slug}`) 142 | .set('x-moderation-secret', moderationSecret) 143 | .expect(204); 144 | 145 | const riceInDatabase = await supabaseService.getSharedBySlug(slug); 146 | expect(riceInDatabase).toBeNull(); 147 | 148 | const fileInGitHub = await gitHubService.getFileContent( 149 | `rices/${slug}/data.zenrice`, 150 | ); 151 | expect(fileInGitHub).toBeNull(); 152 | }); 153 | 154 | it('DELETE /rices/moderate/delete/:slug - Moderation delete with incorrect secret', async () => { 155 | const createResponse = await request(app.getHttpServer()) 156 | .post('/rices') 157 | .field('name', 'Moderation Failure Test') 158 | .attach('file', path.join(__dirname, 'files', 'example.zenrice')) 159 | .expect(201); 160 | 161 | const { slug } = createResponse.body; 162 | 163 | await request(app.getHttpServer()) 164 | .delete(`/rices/moderate/delete/${slug}`) 165 | .set('x-moderation-secret', 'wrongSecret') 166 | .expect(401); 167 | 168 | const riceInDatabase = await supabaseService.getSharedBySlug(slug); 169 | expect(riceInDatabase).not.toBeNull(); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/files/example.zenrice: -------------------------------------------------------------------------------- 1 | This is an example zenrice file. -------------------------------------------------------------------------------- /test/files/example_update.zenrice: -------------------------------------------------------------------------------- 1 | This is an example zenrice file (modified). -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "../", 4 | "testRegex": ".e2e-spec.ts$", 5 | "transform": { 6 | "^.+\\.ts$": "ts-jest" 7 | }, 8 | "setupFilesAfterEnv": ["/test/setup.ts"], 9 | "coverageDirectory": "./coverage", 10 | "testEnvironment": "node", 11 | "maxWorkers": 1, 12 | "testTimeout": 30000 13 | } 14 | -------------------------------------------------------------------------------- /test/restclient/rice/01_create_rice.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:3000 2 | 3 | POST {{baseUrl}}/rices 4 | Content-Type: application/json 5 | x-zen-shared-name: cool-zenrice-test-base 6 | x-zen-shared-author: jhon@doe.com 7 | User-Agent: ZenBrowser/1.2b.0 (EndeavourOS x86_64) 8 | 9 | { 10 | "userChrome": "", 11 | "userContent": null, 12 | "enabledMods": [ 13 | "5bb07b6e-c89f-4f4a-a0ed-e483cc535594" 14 | ], 15 | "preferences": { 16 | "theme.custom_menubutton.default": "Firefox", 17 | "theme.custom_menubutton.custom": "url(chrome://branding/content/icon32.png)", 18 | "zen.view.use-single-toolbar": true, 19 | "zen.view.sidebar-expanded": true, 20 | "zen.tabs.vertical.right-side": false, 21 | "zen.view.experimental-no-window-controls": false, 22 | "zen.view.hide-window-controls": true, 23 | "browser.uiCustomization.state": "{\"placements\":{\"widget-overflow-fixed-list\":[],\"unified-extensions-area\":[\"ublock0_raymondhill_net-browser-action\",\"addon_darkreader_org-browser-action\",\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\"],\"nav-bar\":[\"back-button\",\"forward-button\",\"stop-reload-button\",\"customizableui-special-spring1\",\"urlbar-container\",\"customizableui-special-spring2\",\"wrapper-sidebar-button\",\"unified-extensions-button\"],\"toolbar-menubar\":[\"menubar-items\"],\"TabsToolbar\":[\"tabbrowser-tabs\"],\"vertical-tabs\":[],\"PersonalToolbar\":[\"personal-bookmarks\"],\"zen-sidebar-top-buttons\":[\"zen-sidepanel-button\"],\"zen-sidebar-icons-wrapper\":[\"zen-profile-button\",\"zen-workspaces-button\",\"downloads-button\"]},\"seen\":[\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"developer-button\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\",\"addon_darkreader_org-browser-action\",\"ublock0_raymondhill_net-browser-action\"],\"dirtyAreaCache\":[\"unified-extensions-area\",\"nav-bar\",\"toolbar-menubar\",\"TabsToolbar\",\"vertical-tabs\",\"PersonalToolbar\",\"zen-sidebar-top-buttons\",\"zen-sidebar-icons-wrapper\"],\"currentVersion\":20,\"newElementCount\":2}" 24 | }, 25 | "workspaceThemes": [ 26 | { 27 | "type": "gradient", 28 | "gradientColors": [ 29 | { 30 | "c": [ 31 | 124, 32 | 133, 33 | 255 34 | ], 35 | "isCustom": false 36 | }, 37 | { 38 | "c": [ 39 | 69, 40 | 255, 41 | 86 42 | ], 43 | "isCustom": false 44 | } 45 | ], 46 | "opacity": 0.5, 47 | "rotation": 45, 48 | "texture": 0 49 | }, 50 | { 51 | "type": "gradient", 52 | "gradientColors": [ 53 | { 54 | "c": [ 55 | 255, 56 | 133, 57 | 65 58 | ], 59 | "isCustom": false 60 | } 61 | ], 62 | "opacity": 0.6, 63 | "rotation": 45, 64 | "texture": null 65 | }, 66 | { 67 | "type": "gradient", 68 | "gradientColors": [], 69 | "opacity": 0.5, 70 | "rotation": 45, 71 | "texture": null 72 | } 73 | ] 74 | } -------------------------------------------------------------------------------- /test/restclient/rice/01b_create_rice_same_token.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:3000 2 | 3 | @previous_token = 32af2ec5-b7f0-4c61-8c9a-e67edb3faad4 4 | 5 | POST {{baseUrl}}/rices 6 | Content-Type: application/json 7 | x-zen-shared-name: cool-zenrice-test-base 8 | x-zen-shared-author: jhon@doe.com 9 | x-zen-shared-token: {{previous_token}} 10 | User-Agent: ZenBrowser/1.2b.0 (EndeavourOS x86_64) 11 | 12 | { 13 | "userChrome": "", 14 | "userContent": null, 15 | "enabledMods": [ 16 | "5bb07b6e-c89f-4f4a-a0ed-e483cc535594" 17 | ], 18 | "preferences": { 19 | "theme.custom_menubutton.default": "Firefox", 20 | "theme.custom_menubutton.custom": "url(chrome://branding/content/icon32.png)", 21 | "zen.view.use-single-toolbar": true, 22 | "zen.view.sidebar-expanded": true, 23 | "zen.tabs.vertical.right-side": false, 24 | "zen.view.experimental-no-window-controls": false, 25 | "zen.view.hide-window-controls": true, 26 | "browser.uiCustomization.state": "{\"placements\":{\"widget-overflow-fixed-list\":[],\"unified-extensions-area\":[\"ublock0_raymondhill_net-browser-action\",\"addon_darkreader_org-browser-action\",\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\"],\"nav-bar\":[\"back-button\",\"forward-button\",\"stop-reload-button\",\"customizableui-special-spring1\",\"urlbar-container\",\"customizableui-special-spring2\",\"wrapper-sidebar-button\",\"unified-extensions-button\"],\"toolbar-menubar\":[\"menubar-items\"],\"TabsToolbar\":[\"tabbrowser-tabs\"],\"vertical-tabs\":[],\"PersonalToolbar\":[\"personal-bookmarks\"],\"zen-sidebar-top-buttons\":[\"zen-sidepanel-button\"],\"zen-sidebar-icons-wrapper\":[\"zen-profile-button\",\"zen-workspaces-button\",\"downloads-button\"]},\"seen\":[\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"developer-button\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\",\"addon_darkreader_org-browser-action\",\"ublock0_raymondhill_net-browser-action\"],\"dirtyAreaCache\":[\"unified-extensions-area\",\"nav-bar\",\"toolbar-menubar\",\"TabsToolbar\",\"vertical-tabs\",\"PersonalToolbar\",\"zen-sidebar-top-buttons\",\"zen-sidebar-icons-wrapper\"],\"currentVersion\":20,\"newElementCount\":2}" 27 | }, 28 | "workspaceThemes": [ 29 | { 30 | "type": "gradient", 31 | "gradientColors": [ 32 | { 33 | "c": [ 34 | 124, 35 | 133, 36 | 255 37 | ], 38 | "isCustom": false 39 | }, 40 | { 41 | "c": [ 42 | 69, 43 | 255, 44 | 86 45 | ], 46 | "isCustom": false 47 | } 48 | ], 49 | "opacity": 0.5, 50 | "rotation": 45, 51 | "texture": 0 52 | }, 53 | { 54 | "type": "gradient", 55 | "gradientColors": [ 56 | { 57 | "c": [ 58 | 255, 59 | 133, 60 | 65 61 | ], 62 | "isCustom": false 63 | } 64 | ], 65 | "opacity": 0.6, 66 | "rotation": 45, 67 | "texture": null 68 | }, 69 | { 70 | "type": "gradient", 71 | "gradientColors": [], 72 | "opacity": 0.5, 73 | "rotation": 45, 74 | "texture": null 75 | } 76 | ] 77 | } -------------------------------------------------------------------------------- /test/restclient/rice/02_download_rice.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:3000 2 | @previous_slug = cool-zenrice-test-base-a069a4c2-237d-433f-ab1c-38c6e6ba5244 3 | 4 | 5 | GET {{baseUrl}}/rices/{{previous_slug}} -------------------------------------------------------------------------------- /test/restclient/rice/03_update_rice.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:3000 2 | @previous_slug = cool-zenrice-test-base-1d576eeb-de28-4df8-a12f-bcfc8f0e9b6e 3 | @previous_token = 00472a9f-8a8c-423d-b4a5-7137c4cc13f6 4 | 5 | PUT {{baseUrl}}/rices/{{previous_slug}} 6 | Content-Type: application/json 7 | x-zen-shared-token: {{previous_token}} 8 | User-Agent: ZenBrowser/1.2b.0 (EndeavourOS x86_64) 9 | 10 | { 11 | "userChrome": "", 12 | "userContent": null, 13 | "enabledMods": [ 14 | "5bb07b6e-c89f-4f4a-a0ed-e483cc535594", 15 | "5bb07b6e-c89f-4f4a-a0ed-e483cc535594" 16 | ], 17 | "preferences": { 18 | "theme.custom_menubutton.default": "Firefox", 19 | "theme.custom_menubutton.custom": "url(chrome://branding/content/icon32.png)", 20 | "zen.view.use-single-toolbar": true, 21 | "zen.view.sidebar-expanded": true, 22 | "zen.tabs.vertical.right-side": false, 23 | "zen.view.experimental-no-window-controls": false, 24 | "zen.view.hide-window-controls": true, 25 | "browser.uiCustomization.state": "{\"placements\":{\"widget-overflow-fixed-list\":[],\"unified-extensions-area\":[\"ublock0_raymondhill_net-browser-action\",\"addon_darkreader_org-browser-action\",\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\"],\"nav-bar\":[\"back-button\",\"forward-button\",\"stop-reload-button\",\"customizableui-special-spring1\",\"urlbar-container\",\"customizableui-special-spring2\",\"wrapper-sidebar-button\",\"unified-extensions-button\"],\"toolbar-menubar\":[\"menubar-items\"],\"TabsToolbar\":[\"tabbrowser-tabs\"],\"vertical-tabs\":[],\"PersonalToolbar\":[\"personal-bookmarks\"],\"zen-sidebar-top-buttons\":[\"zen-sidepanel-button\"],\"zen-sidebar-icons-wrapper\":[\"zen-profile-button\",\"zen-workspaces-button\",\"downloads-button\"]},\"seen\":[\"_7a7a4a92-a2a0-41d1-9fd7-1e92480d612d_-browser-action\",\"developer-button\",\"cookieautodelete_kennydo_com-browser-action\",\"tab-unloader-we_afnankhan-browser-action\",\"addon_darkreader_org-browser-action\",\"ublock0_raymondhill_net-browser-action\"],\"dirtyAreaCache\":[\"unified-extensions-area\",\"nav-bar\",\"toolbar-menubar\",\"TabsToolbar\",\"vertical-tabs\",\"PersonalToolbar\",\"zen-sidebar-top-buttons\",\"zen-sidebar-icons-wrapper\"],\"currentVersion\":20,\"newElementCount\":2}" 26 | }, 27 | "workspaceThemes": [ 28 | { 29 | "type": "gradient", 30 | "gradientColors": [ 31 | { 32 | "c": [ 33 | 124, 34 | 133, 35 | 255 36 | ], 37 | "isCustom": false 38 | }, 39 | { 40 | "c": [ 41 | 69, 42 | 255, 43 | 86 44 | ], 45 | "isCustom": false 46 | } 47 | ], 48 | "opacity": 0.5, 49 | "rotation": 45, 50 | "texture": 0 51 | }, 52 | { 53 | "type": "gradient", 54 | "gradientColors": [ 55 | { 56 | "c": [ 57 | 255, 58 | 133, 59 | 65 60 | ], 61 | "isCustom": false 62 | } 63 | ], 64 | "opacity": 0.6, 65 | "rotation": 45, 66 | "texture": null 67 | }, 68 | { 69 | "type": "gradient", 70 | "gradientColors": [], 71 | "opacity": 0.5, 72 | "rotation": 45, 73 | "texture": null 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /test/restclient/rice/04_delete_rice.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:3000 2 | @previous_slug = cool-zenrice-test-base-1d576eeb-de28-4df8-a12f-bcfc8f0e9b6e 3 | @previous_token = 00472a9f-8a8c-423d-b4a5-7137c4cc13f6 4 | 5 | DELETE {{baseUrl}}/rices/{{previous_slug}} 6 | x-zen-shared-token: {{previous_token}} 7 | -------------------------------------------------------------------------------- /test/restclient/spaces/01_create_space.http: -------------------------------------------------------------------------------- 1 | @baseUrl = http://localhost:3000 2 | 3 | POST {{baseUrl}}/spaces 4 | Content-Type: application/json 5 | x-zen-shared-name: cool-zenrice-test-base 6 | x-zen-shared-author: jhon@doe.com 7 | User-Agent: ZenBrowser/1.2b.0 (EndeavourOS x86_64) 8 | 9 | { 10 | "space_id": "UNIQUE-IDENTIFIER", 11 | "owner_id": "USER_OR_TEAM_ID", 12 | "name": "Project X Workspace", 13 | "description": "Workspace for Project X collaboration", 14 | "tabs": [ 15 | { 16 | "url": "https://example.com", 17 | "title": "Example Site", 18 | "pinned": true, 19 | "archived": false, 20 | "metadata": { 21 | "last_accessed": "2024-02-20T14:30:00Z", 22 | "preview_image": "base64_thumbnail" 23 | } 24 | } 25 | ], 26 | "permissions": { 27 | "public_sharing": false, 28 | "edit_restrictions": "owner_only" 29 | } 30 | } -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import * as path from 'path'; 3 | 4 | dotenv.config({ path: path.resolve(__dirname, '.env.test.local') }); 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2022", 5 | "lib": ["ES2022", "DOM"], 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "skipLibCheck": true, 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true 16 | }, 17 | "exclude": ["node_modules", "dist", "test"] 18 | } --------------------------------------------------------------------------------