├── .dockerignore ├── .github └── workflows │ └── publish-docker-image.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── astro.config.mjs ├── backend ├── configs │ └── app.config.ts ├── controllers │ ├── accessToken.controller.ts │ ├── config.controller.ts │ ├── history.controller.ts │ ├── schedule.controller.ts │ └── user.controller.ts ├── lib │ ├── logger.ts │ └── sanatize.ts ├── middlewares │ ├── authenticateJWT.ts │ ├── userExistCheck.ts │ └── validateSchema.middleware.ts ├── models │ ├── accessToken.model.ts │ ├── config.model.ts │ ├── schedule.model.ts │ └── user.model.ts ├── routes │ ├── accessToken.route.ts │ ├── config.route.ts │ ├── history.route.ts │ ├── index.route.ts │ ├── schedule.route.ts │ └── user.route.ts ├── server.ts ├── services │ ├── accessToken.service.ts │ ├── config.service.ts │ ├── encryption.service.ts │ ├── history.service.ts │ ├── schedule.service.ts │ └── user.service.ts └── types │ ├── AuthenticatedRequest.d.ts │ ├── CronJob.d.ts │ ├── User.d.ts │ └── express.d.ts ├── package-lock.json ├── package.json ├── prisma ├── client.js └── schema.prisma ├── public ├── DMSans.ttf ├── favicon.png └── favicon.svg ├── src ├── components │ ├── BaseButton.tsx │ ├── BaseInput.tsx │ ├── Pagination │ │ ├── PageElement.tsx │ │ └── Pagination.tsx │ ├── PhoneNavMenu.tsx │ ├── Setup.tsx │ ├── dashboard │ │ ├── AddSchedulePopup.tsx │ │ ├── EditSchedulePopup.tsx │ │ ├── History.tsx │ │ ├── HistoryList.tsx │ │ ├── Schedule.tsx │ │ ├── ScheduleList.tsx │ │ └── settings │ │ │ ├── AccessToken.tsx │ │ │ ├── AccessTokensSettings.tsx │ │ │ ├── BaseSettingPanel.tsx │ │ │ ├── EditAccessTokenPopup.tsx │ │ │ ├── StorageSettings.tsx │ │ │ └── UserSettings.tsx │ ├── icons │ │ ├── BurgerMenuIcon.tsx │ │ ├── GitIcon.tsx │ │ ├── MenuIcon.tsx │ │ ├── ReloadIcon.tsx │ │ ├── ShortArrow.tsx │ │ ├── TimeIcon.tsx │ │ └── UserIcon.tsx │ └── login │ │ └── LoginForm.tsx ├── env.d.ts ├── layouts │ ├── DashboardLayout.astro │ ├── Layout.astro │ └── SettingsLayout.astro ├── lib │ ├── formatTimestamp.ts │ └── updateIfNotBelow.ts ├── pages │ ├── dashboard │ │ ├── history.astro │ │ ├── index.astro │ │ ├── overview.astro │ │ ├── schedules.astro │ │ └── settings │ │ │ ├── about.astro │ │ │ ├── access-tokens.astro │ │ │ ├── index.astro │ │ │ ├── storage.astro │ │ │ └── user.astro │ ├── index.astro │ ├── login.astro │ └── setup.astro └── types │ └── scheduleTypes.ts └── tailwind.config.mjs /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /data 3 | /backups 4 | README.md 5 | /.gitignore 6 | /.vscode 7 | /gitsave 8 | .git 9 | -------------------------------------------------------------------------------- /.github/workflows/publish-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | docker: 9 | runs-on: self-hosted 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v3 15 | - name: Login to Docker Hub 16 | uses: docker/login-action@v3 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 20 | - uses: actions-ecosystem/action-get-latest-tag@v1 21 | id: get-latest-tag 22 | - name: Build and push 23 | uses: docker/build-push-action@v6 24 | with: 25 | context: . 26 | push: true 27 | tags: | 28 | timwitzdam/gitsave:${{ steps.get-latest-tag.outputs.tag }} 29 | timwitzdam/gitsave:latest 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | 26 | GitSave.db 27 | dev.db 28 | test.http 29 | backups 30 | data 31 | tmp -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to GitSave 2 | 3 | Thank you for contributing to GitSave! This document outlines the process for contributing to the project and how to set up your development environment. 4 | 5 | ## Setting up your development environment 6 | 7 | 1. **Fork this repository**\ 8 | You can do that in GitHub itself. 9 | 10 | 3. **Clone your forked repository** 11 | ``` 12 | git clone https://github.com//GitSave && cd GitSave 13 | ``` 14 | 15 | 4. **Install dependencies** 16 | ``` 17 | npm install 18 | ``` 19 | 20 | 5. **Set up the database** 21 | ``` 22 | npx prisma push 23 | ``` 24 | 25 | 6. **Run the development server** 26 | For frontend development only: 27 | ``` 28 | npm run dev 29 | ``` 30 | 31 | or for the production environment (with available backend): 32 | ``` 33 | npm run build && node server.js 34 | ``` 35 | 36 | ## Making Contributions 37 | 38 | 1. Create a new branch for your feature or bug fix: 39 | ``` 40 | git checkout -b your-contribution-name 41 | ``` 42 | 43 | 2. Make your changes and commit them with a clear, descriptive commit message. 44 | 45 | 3. Push your changes to your fork: 46 | ``` 47 | git push origin your-contribution-name 48 | ``` 49 | 50 | 4. Open a pull request against the main repository. 51 | 52 | ## Reporting issues 53 | 54 | If you find a bug or have a suggestion for improvement, please open an issue in the GitHub repository. 55 | 56 | ## Questions? 57 | 58 | If you have any questions about contributing, feel free to open an issue or contact the maintainer per [email](mailto:contact@witzdam.com). 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.13.1-alpine AS base 2 | WORKDIR /app 3 | 4 | COPY package.json package-lock.json ./ 5 | RUN apk add --no-cache openssl 6 | 7 | FROM base AS prod-deps 8 | RUN npm install --production 9 | 10 | FROM base AS build-deps 11 | RUN npm install --production=false 12 | 13 | FROM build-deps AS build 14 | COPY . . 15 | RUN npx prisma db push 16 | RUN npx prisma generate 17 | RUN npm run build 18 | 19 | FROM base AS runtime 20 | COPY --from=prod-deps /app/node_modules ./node_modules 21 | COPY --from=build /app/dist ./dist 22 | 23 | COPY . . 24 | RUN mkdir -p /app/data 25 | RUN npx prisma db push 26 | RUN npx prisma generate 27 | RUN apk add --no-cache git 28 | 29 | EXPOSE 3000 30 | CMD npx prisma db push && npx tsx backend/server.ts 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | GitSave Logo 3 |

GitSave

4 |

Easily back up your Git repositories on a schedule.

5 |
6 |
7 | 8 | ![GitSave 3 pages animation](https://i.imgur.com/i0SNNiL.gif) 9 | 10 | https://github.com/user-attachments/assets/301b28ca-6b72-490a-8efb-217e39fb73d3 11 | 12 | # Git happens... 13 | 14 | So be prepared and keep backups of your own and favourite Git repositories. 15 | 16 | ## 🛠️ Features 17 | 18 | - Easy to use and responsive web interface 19 | - Automated install using Docker 20 | - Scheduling of backups 21 | - Support for GitHub, GitLab and other Git platforms 22 | - Support for own authentication provider 23 | - Pause/resume schedules 24 | - View backup history 25 | - Support for backing up to a SMB share 26 | 27 | ## 🚀 Deploy GitSave for yourself 28 | 29 | > [!WARNING] 30 | > Make sure to change the env variables "JWT_SECRET" and "ENCRYPTION_SECRET" to something secure. [This website](https://jwtsecret.com/) may help you with that. 31 | > The ENCRYPTION_SECRET must be 32 characters long. 32 | 33 | ### Single run command 34 | 35 | ```bash 36 | docker run -d --restart=always -p 3000:3000 -v gitsave:/app/data -v ./backups:/app/backups -e JWT_SECRET={YOUR_SECRET_HERE} -e ENCRYPTION_SECRET={YOUR_SECRET_HERE_32_CHARACTERS} -e DISABLE_AUTH=false --name GitSave timwitzdam/gitsave:latest 37 | ``` 38 | 39 | ### Docker compose 40 | 41 | 1. Create .env file 42 | 43 | ```bash 44 | # You can generate a JWT_SECRET here: https://jwtsecret.com/ 45 | JWT_SECRET="REPLACE_THIS" 46 | DISABLE_AUTH=false 47 | ENCRYPTION_SECRET="REPLACE_THIS_WITH_32_CHARACTERS_SECRET" 48 | ``` 49 | 50 | 2. Create `docker-compose.yml` file 51 | 52 | ```yaml 53 | services: 54 | gitsave: 55 | image: timwitzdam/gitsave:latest 56 | container_name: GitSave 57 | restart: always 58 | ports: 59 | - "3000:3000" 60 | volumes: 61 | - gitsave:/app/data 62 | - ./backups:/app/backups 63 | environment: 64 | - JWT_SECRET=${JWT_SECRET:?error} 65 | - DISABLE_AUTH=${DISABLE_AUTH:?error} 66 | - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:?error} 67 | 68 | volumes: 69 | gitsave: 70 | ``` 71 | 72 | ## 👀 Any questions, suggestions or problems? 73 | 74 | You're welcome to contribute to GitSave or open an issue if you have any suggestions or find any problems. 75 | 76 | I'm also available via mail: [contact@witzdam.com](mailto:contact@witzdam.com) 77 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import tailwind from "@astrojs/tailwind"; 3 | import react from "@astrojs/react"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | integrations: [tailwind(), react()], 8 | }); 9 | -------------------------------------------------------------------------------- /backend/configs/app.config.ts: -------------------------------------------------------------------------------- 1 | import { exit } from "process"; 2 | 3 | export const PORT = 3000; 4 | if (!process.env.JWT_SECRET) { 5 | console.error("JWT_SECRET is not set"); 6 | exit(1); 7 | } 8 | if (!process.env.ENCRYPTION_SECRET) { 9 | console.error("ENCRYPTION_SECRET is not set"); 10 | exit(1); 11 | } 12 | if (process.env.ENCRYPTION_SECRET.length !== 32) { 13 | console.error("ENCRYPTION_SECRET must be 32 bytes"); 14 | exit(1); 15 | } 16 | export const JWT_SECRET = process.env.JWT_SECRET; 17 | export const DISABLE_AUTH = process.env.DISABLE_AUTH === "true"; 18 | export const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET; 19 | -------------------------------------------------------------------------------- /backend/controllers/accessToken.controller.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { AccessTokenService } from "../services/accessToken.service"; 3 | import { AuthenticatedRequest } from "../types/AuthenticatedRequest"; 4 | import { scheduleCronJobs } from "../server"; 5 | 6 | export async function getAccessTokens(req: Request, res: Response) { 7 | const username = (req as AuthenticatedRequest).user.username; 8 | const accessTokens = await AccessTokenService.getAccessToken(username); 9 | 10 | return res.json(accessTokens); 11 | } 12 | 13 | export async function createAccessToken(req: Request, res: Response) { 14 | const { name, token } = req.body; 15 | const username = (req as AuthenticatedRequest).user.username; 16 | 17 | const accessToken = await AccessTokenService.createAccessToken( 18 | name, 19 | token, 20 | username, 21 | ); 22 | 23 | return res.json(accessToken); 24 | } 25 | 26 | export async function updateAccessToken(req: Request, res: Response) { 27 | const { name, token } = req.body; 28 | const { id } = req.params; 29 | 30 | const accessToken = await AccessTokenService.updateAccessToken( 31 | Number(id), 32 | name, 33 | token, 34 | ); 35 | scheduleCronJobs(); 36 | 37 | return res.json(accessToken); 38 | } 39 | 40 | export async function deleteAccessToken(req: Request, res: Response) { 41 | const { id } = req.params; 42 | 43 | await AccessTokenService.deleteAccessToken(Number(id)); 44 | 45 | scheduleCronJobs(); 46 | 47 | return res.status(204).send(); 48 | } 49 | -------------------------------------------------------------------------------- /backend/controllers/config.controller.ts: -------------------------------------------------------------------------------- 1 | import Logger from "../lib/logger"; 2 | import { ConfigService } from "../services/config.service"; 3 | import SambaClient from "samba-client"; 4 | import { Request, Response } from "express"; 5 | import { DISABLE_AUTH } from "../configs/app.config"; 6 | 7 | const logger = new Logger("config.controller"); 8 | 9 | export async function getStorageConfig(req: Request, res: Response) { 10 | const storageConfig = await ConfigService.getStorageConfig(); 11 | 12 | return res.json(storageConfig); 13 | } 14 | 15 | export async function updateStorageConfig(req: Request, res: Response) { 16 | const { location, serverAddress, remoteLocation, username, password } = 17 | req.body; 18 | 19 | try { 20 | if (location === "smb_share") { 21 | if (!serverAddress || !remoteLocation || !username || !password) { 22 | return res.status(400).json({ error: "Missing required fields" }); 23 | } 24 | 25 | const client = new SambaClient({ 26 | address: `//${serverAddress}`, 27 | username, 28 | password, 29 | }); 30 | 31 | // Test connection 32 | await client.listFiles("", ""); 33 | 34 | // Update configurations 35 | await Promise.all([ 36 | ConfigService.updateConfigEntry("default_location", "smb_share"), 37 | ConfigService.updateConfigEntry("smb_address", serverAddress), 38 | ConfigService.updateConfigEntry("smb_location", remoteLocation), 39 | ConfigService.updateConfigEntry("smb_username", username), 40 | ConfigService.updateConfigEntry("smb_password", password), 41 | ]); 42 | } else if (location === "local_folder") { 43 | await ConfigService.updateConfigEntry("default_location", "local_folder"); 44 | } 45 | 46 | res.json({ message: "Setting saved" }); 47 | } catch (error) { 48 | logger.error(`Error in storage configuration: ${error.message}`); 49 | res.status(500).json({ 50 | error: 51 | "Error connecting to storage. Please check the wiki on GitHub for more information.", 52 | details: "https://witzdam.com/docs/gitsave/how-to-set-up-smb-share", 53 | }); 54 | } 55 | } 56 | 57 | export async function getAuthDisabled(req: Request, res: Response) { 58 | return res.json({ DISABLE_AUTH }); 59 | } 60 | -------------------------------------------------------------------------------- /backend/controllers/history.controller.ts: -------------------------------------------------------------------------------- 1 | import { HistoryService } from "../services/history.service"; 2 | import { Request, Response } from "express"; 3 | import { AuthenticatedRequest } from "../types/AuthenticatedRequest"; 4 | 5 | interface PaginationQuery { 6 | limit?: string; 7 | offset?: string; 8 | } 9 | 10 | export async function getHistory( 11 | req: Request<{}, {}, {}, PaginationQuery>, 12 | res: Response 13 | ) { 14 | const { limit, offset } = req.query; 15 | const username = (req as AuthenticatedRequest).user.username; 16 | 17 | // Convert string values to numbers 18 | const numLimit = limit ? parseInt(limit, 10) : undefined; 19 | const numOffset = offset ? parseInt(offset, 10) : undefined; 20 | 21 | // Check if values are missing or invalid 22 | if ( 23 | numLimit === undefined || 24 | numOffset === undefined || 25 | isNaN(numLimit) || 26 | isNaN(numOffset) || 27 | numLimit <= 0 || 28 | numOffset < 0 29 | ) { 30 | return res.status(400).send("Invalid limit or offset"); 31 | } 32 | 33 | const { backupHistory, totalCount } = await HistoryService.getHistory( 34 | username, 35 | numOffset, 36 | numLimit 37 | ); 38 | return res.json({ backupHistory, totalCount }); 39 | } 40 | -------------------------------------------------------------------------------- /backend/controllers/schedule.controller.ts: -------------------------------------------------------------------------------- 1 | import { ScheduleService } from "../services/schedule.service"; 2 | import { execFile } from "child_process"; 3 | import Logger from "../lib/logger"; 4 | import { 5 | createBackup, 6 | resumeCronJob, 7 | scheduleCronJobs, 8 | stopCronJob, 9 | } from "../server"; 10 | import { Request, Response } from "express"; 11 | import { AuthenticatedRequest } from "../types/AuthenticatedRequest"; 12 | 13 | const logger = new Logger("schedule.controller"); 14 | 15 | export async function getSchedules(req: Request, res: Response) { 16 | const username = (req as AuthenticatedRequest).user.username; 17 | const schedules = await ScheduleService.getSchedulesByUser(username); 18 | return res.json(schedules); 19 | } 20 | 21 | export async function createSchedule(req: Request, res: Response) { 22 | const schedule = req.body; 23 | const username = (req as AuthenticatedRequest).user.username; 24 | 25 | let repoUrl: URL; 26 | try { 27 | repoUrl = new URL(schedule.repository); 28 | } catch (error) { 29 | return res.status(400).send("Invalid repository URL"); 30 | } 31 | 32 | const initialUrl = repoUrl.href; 33 | if (schedule.private) { 34 | const accessToken = await ScheduleService.getAccessToken( 35 | schedule.accessTokenId, 36 | ); 37 | if (!accessToken) { 38 | return res.status(400).send("Access token not found"); 39 | } 40 | 41 | repoUrl.href = repoUrl.href.replace("https://", `https://${accessToken}@`); 42 | } 43 | 44 | const options = { 45 | env: { 46 | ...process.env, 47 | GIT_ASKPASS: "/bin/false", 48 | }, 49 | }; 50 | const child = execFile( 51 | "git", 52 | ["ls-remote", repoUrl.href], 53 | options, 54 | async (error, stdout, stderr) => { 55 | if (error) { 56 | logger.error(error.toString()); 57 | return res 58 | .status(400) 59 | .send( 60 | "Invalid repository. Either it does not exist or you forgot to select an access token.", 61 | ); 62 | } 63 | 64 | const newSchedule = await ScheduleService.addSchedule( 65 | username, 66 | schedule, 67 | initialUrl, 68 | ); 69 | scheduleCronJobs(); 70 | return res.json(newSchedule); 71 | }, 72 | ); 73 | 74 | const timeout = setTimeout(() => { 75 | child.kill(); 76 | }, 5000); 77 | 78 | child.on("exit", (code) => { 79 | clearTimeout(timeout); 80 | if (code === null) { 81 | return res.status(400).send("The process took too long and was aborted."); 82 | } 83 | }); 84 | } 85 | 86 | export async function updateSchedule(req: Request, res: Response) { 87 | const { id } = req.params; 88 | const schedule = req.body; 89 | 90 | let url: URL; 91 | 92 | try { 93 | url = new URL(schedule.repository); 94 | } catch (error) { 95 | return res.status(400).send("Invalid repository URL"); 96 | } 97 | 98 | const options = { 99 | env: { 100 | ...process.env, 101 | GIT_ASKPASS: "/bin/false", 102 | }, 103 | }; 104 | const child = execFile( 105 | "git", 106 | ["ls-remote", url.href], 107 | options, 108 | async (error, stdout, stderr) => { 109 | if (error) { 110 | logger.error(error.toString()); 111 | return res 112 | .status(400) 113 | .send( 114 | "Invalid repository. Either it does not exist or you forgot to select an access token.", 115 | ); 116 | } 117 | 118 | const updatedSchedule = await ScheduleService.editSchedule(schedule, id); 119 | scheduleCronJobs(); 120 | return res.json(updatedSchedule); 121 | }, 122 | ); 123 | 124 | const timeout = setTimeout(() => { 125 | child.kill(); 126 | }, 5000); 127 | 128 | child.on("exit", (code) => { 129 | clearTimeout(timeout); 130 | if (code === null) { 131 | return res.status(400).send("The process took too long and was aborted."); 132 | } 133 | }); 134 | } 135 | 136 | export async function backupScheduleNow(req: Request, res: Response) { 137 | const { id } = req.params; 138 | 139 | const schedule = await ScheduleService.getScheduleById(id); 140 | 141 | if (!schedule) { 142 | return res.status(404).send("Schedule not found"); 143 | } 144 | 145 | if (schedule.accessTokenId) { 146 | const accessToken = await ScheduleService.getAccessToken( 147 | schedule.accessTokenId.toString(), 148 | ); 149 | if (!accessToken) { 150 | return res.status(400).send("Access token not found"); 151 | } 152 | 153 | schedule.repository = schedule.repository.replace( 154 | "https://", 155 | `https://${accessToken}@`, 156 | ); 157 | } 158 | 159 | createBackup( 160 | schedule.id, 161 | schedule.name, 162 | schedule.repository, 163 | schedule.keepLast, 164 | ); 165 | return res.send( 166 | "Backup started. Depending on the size of the repository, this may take a while.", 167 | ); 168 | } 169 | 170 | export async function pauseSchedule(req: Request, res: Response) { 171 | const { id } = req.params; 172 | 173 | const pausedSchedule = await ScheduleService.pauseSchedule(id); 174 | stopCronJob(parseInt(id)); 175 | return res.json(pausedSchedule); 176 | } 177 | 178 | export async function resumeSchedule(req: Request, res: Response) { 179 | const { id } = req.params; 180 | 181 | const resumedSchedule = await ScheduleService.resumeSchedule(id); 182 | resumeCronJob(parseInt(id)); 183 | return res.json(resumedSchedule); 184 | } 185 | 186 | export async function deleteSchedule(req: Request, res: Response) { 187 | const { id } = req.params; 188 | 189 | await ScheduleService.deleteSchedule(id); 190 | stopCronJob(parseInt(id), true); 191 | return res.send("Schedule deleted"); 192 | } 193 | -------------------------------------------------------------------------------- /backend/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from "../services/user.service"; 2 | import bcrypt from "bcryptjs"; 3 | import { Request, Response } from "express"; 4 | import { AuthenticatedRequest } from "../types/AuthenticatedRequest"; 5 | 6 | export async function login(req: Request, res: Response) { 7 | const username = req.body.username; 8 | const password = req.body.password; 9 | 10 | const user = await UserService.getUserByUsername(username); 11 | 12 | if (!user) { 13 | return res.status(401).send("Invalid credentials"); 14 | } 15 | 16 | bcrypt.compare(password, user.password, (err, result) => { 17 | if (err || !result) { 18 | return res.status(401).send("Invalid credentials"); 19 | } 20 | 21 | const token = UserService.createJWTToken(username); 22 | res.json({ token }); 23 | }); 24 | } 25 | 26 | export async function register(req: Request, res: Response) { 27 | const username = req.body.username; 28 | const password = req.body.password; 29 | 30 | bcrypt.hash(password, 10, (err, hash) => { 31 | if (err) { 32 | return res.status(500).send("Internal server error"); 33 | } 34 | 35 | const token = UserService.createJWTToken(username); 36 | UserService.createUser(username, hash); 37 | 38 | res.json({ token }); 39 | }); 40 | } 41 | 42 | export async function getUsername(req: Request, res: Response) { 43 | const username = (req as AuthenticatedRequest).user.username; 44 | return res.json({ username }); 45 | } 46 | 47 | export async function setUsername(req: Request, res: Response) { 48 | const newUsername = req.body.username; 49 | const user = await UserService.getUserByUsername(newUsername); 50 | 51 | if (user) { 52 | return res.status(409).json({ message: "Username already exists" }); 53 | } 54 | 55 | const username = (req as AuthenticatedRequest).user.username; 56 | 57 | await UserService.updateUsername(username, newUsername); 58 | const token = UserService.createJWTToken(newUsername); 59 | 60 | return res.json({ token }); 61 | } 62 | 63 | export async function changePassword(req: Request, res: Response) { 64 | const oldPassword = req.body.password; 65 | const newPassword = req.body.newPassword; 66 | const username = (req as AuthenticatedRequest).user.username; 67 | 68 | const user = await UserService.getUserByUsername(username); 69 | 70 | if (!user) { 71 | return res.status(401).send("Invalid credentials"); 72 | } 73 | 74 | bcrypt.compare(oldPassword, user.password, (err, result) => { 75 | if (err || !result) { 76 | return res.status(401).send("Invalid credentials"); 77 | } 78 | 79 | bcrypt.hash(newPassword, 10, (err, hash) => { 80 | if (err) { 81 | return res.status(500).send("Internal server error"); 82 | } 83 | 84 | UserService.updateUserPassword(username, hash); 85 | return res.json({ message: "Password updated" }); 86 | }); 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /backend/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, appendFile } from "fs"; 2 | import { join } from "path"; 3 | 4 | const logDirectory = "./data/logs"; 5 | 6 | if (!existsSync(logDirectory)) { 7 | mkdirSync(logDirectory); 8 | } 9 | 10 | function createLogString(level: string, message: string, fileName: string) { 11 | const date = new Date().toLocaleString("en-US", { 12 | year: "numeric", 13 | month: "short", 14 | day: "numeric", 15 | hour: "numeric", 16 | minute: "numeric", 17 | second: "numeric", 18 | hour12: false, 19 | }); 20 | return `[${date}] [${level.toUpperCase()}] [${fileName}]: ${message}`; 21 | } 22 | 23 | function logToFile(level: string, message: string) { 24 | const logFilePath = join(logDirectory, `${level}.log`); 25 | 26 | appendFile(logFilePath, message, (err) => { 27 | if (err) { 28 | console.error(`Failed to write log to file: ${err}`); 29 | } 30 | }); 31 | } 32 | 33 | export default class Logger { 34 | private fileName: string; 35 | constructor(fileName: string) { 36 | this.fileName = fileName; 37 | } 38 | 39 | info(message: string) { 40 | const level = "info"; 41 | const logMessage = createLogString(level, message, this.fileName); 42 | console.log(logMessage); 43 | logToFile(level, logMessage); 44 | } 45 | 46 | error(message: string) { 47 | const level = "error"; 48 | const logMessage = createLogString(level, message, this.fileName); 49 | console.error(logMessage); 50 | logToFile(level, logMessage); 51 | } 52 | 53 | warn(message: string) { 54 | const level = "warn"; 55 | const logMessage = createLogString(level, message, this.fileName); 56 | console.warn(logMessage); 57 | logToFile(level, logMessage); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/lib/sanatize.ts: -------------------------------------------------------------------------------- 1 | export function sanitize(input: string, replacement = "") { 2 | var illegalRe = /[\/\?<>\\:\*\|"]/g; 3 | var controlRe = /[\x00-\x1f\x80-\x9f]/g; 4 | var reservedRe = /^\.+$/; 5 | var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i; 6 | var windowsTrailingRe = /[\. ]+$/; 7 | 8 | if (typeof input !== "string") { 9 | throw new Error("Input must be string"); 10 | } 11 | 12 | var sanitized = input 13 | .replace(illegalRe, replacement) 14 | .replace(controlRe, replacement) 15 | .replace(reservedRe, replacement) 16 | .replace(windowsReservedRe, replacement) 17 | .replace(windowsTrailingRe, replacement) 18 | .replace(" ", "-"); 19 | 20 | return sanitized; 21 | } 22 | -------------------------------------------------------------------------------- /backend/middlewares/authenticateJWT.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import type { Request, Response, NextFunction } from "express"; 3 | import { DISABLE_AUTH, JWT_SECRET } from "../configs/app.config"; 4 | import { User } from "../types/User"; 5 | import { AuthenticatedRequest } from "../types/AuthenticatedRequest"; 6 | import prisma from "../../prisma/client"; 7 | 8 | function getCookie(cookies: string | undefined, name: string) { 9 | const value = `; ${cookies}`; 10 | const parts = value.split(`; ${name}=`); 11 | if (parts.length === 2) return parts.pop()?.split(";").shift(); 12 | } 13 | 14 | export const authenticateJWT = ( 15 | req: Request, 16 | res: Response, 17 | next: NextFunction, 18 | redirectOnSuccess: boolean | undefined = undefined, 19 | ) => { 20 | if (DISABLE_AUTH) { 21 | prisma.user.findFirst().then((user) => { 22 | if (!user) { 23 | return res.status(500).json({ message: "No user found" }); 24 | } 25 | 26 | if (redirectOnSuccess === true) { 27 | return res.redirect("/dashboard"); 28 | } 29 | 30 | (req as AuthenticatedRequest).user = user as User; 31 | return next(); 32 | }); 33 | } else { 34 | const token = getCookie(req.headers.cookie, "auth_session"); 35 | 36 | if (token) { 37 | jwt.verify(token, JWT_SECRET, (err, user) => { 38 | if (err) { 39 | if (redirectOnSuccess === false) { 40 | return res.redirect("/login"); 41 | } else if (redirectOnSuccess === undefined) { 42 | return res.status(403).json({ message: "Invalid token" }); 43 | } 44 | } 45 | 46 | if (redirectOnSuccess === true) { 47 | return res.redirect("/dashboard"); 48 | } 49 | 50 | (req as AuthenticatedRequest).user = user as User; 51 | next(); 52 | }); 53 | } else { 54 | if (redirectOnSuccess === false) { 55 | return res.redirect("/login"); 56 | } else if (redirectOnSuccess === true) { 57 | next(); 58 | } else { 59 | res.status(401).json({ message: "No token provided" }); 60 | } 61 | } 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /backend/middlewares/userExistCheck.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import prisma from "../../prisma/client"; 3 | 4 | export const userExistCheck = ( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction 8 | ) => { 9 | prisma.user.findMany({}).then((users) => { 10 | if (users.length > 0) { 11 | return res.redirect("/login"); 12 | } else { 13 | next(); 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /backend/middlewares/validateSchema.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { Schema } from "zod"; 3 | 4 | export function validateSchema(schema: Schema) { 5 | return (req: Request, res: Response, next: NextFunction) => { 6 | try { 7 | schema.parse(req.body); 8 | next(); 9 | } catch (e) { 10 | res.status(400).json({ error: "Invalid request body" }); 11 | } 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /backend/models/accessToken.model.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const createAccessTokenRequest = z.object({ 4 | name: z.string(), 5 | token: z.string(), 6 | }); 7 | 8 | export const updateAccessTokenRequest = z.object({ 9 | name: z.string(), 10 | token: z.string().optional(), 11 | }); 12 | -------------------------------------------------------------------------------- /backend/models/config.model.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const updateStorageConfigRequest = z.object({ 4 | location: z.enum(["smb_share", "local_folder"]), 5 | serverAddress: z.string().nullable(), 6 | remoteLocation: z.string().nullable(), 7 | username: z.string().nullable(), 8 | password: z.string().nullable(), 9 | }); 10 | -------------------------------------------------------------------------------- /backend/models/schedule.model.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const postScheduleRequest = z.object({ 4 | name: z.string(), 5 | repository: z.string(), 6 | every: z.number(), 7 | timespan: z.enum(["minutes", "hours", "days"]), 8 | private: z.string().nullable(), 9 | accessTokenId: z.string().nullable(), 10 | keepLast: z.number(), 11 | }); 12 | 13 | export const putScheduleRequest = z.object({ 14 | name: z.string(), 15 | repository: z.string(), 16 | every: z.number(), 17 | timespan: z.enum(["minutes", "hours", "days"]), 18 | keepLast: z.number(), 19 | }); 20 | -------------------------------------------------------------------------------- /backend/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const auth = z.object({ 4 | username: z.string(), 5 | password: z.string(), 6 | }); 7 | 8 | export const changeUsernameRequest = z.object({ 9 | username: z.string(), 10 | }); 11 | 12 | export const changePasswordRequest = z.object({ 13 | password: z.string(), 14 | newPassword: z.string(), 15 | }); 16 | -------------------------------------------------------------------------------- /backend/routes/accessToken.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { authenticateJWT } from "../middlewares/authenticateJWT"; 3 | import { 4 | createAccessToken, 5 | deleteAccessToken, 6 | getAccessTokens, 7 | updateAccessToken, 8 | } from "../controllers/accessToken.controller"; 9 | import { validateSchema } from "../middlewares/validateSchema.middleware"; 10 | import { 11 | createAccessTokenRequest, 12 | updateAccessTokenRequest, 13 | } from "../models/accessToken.model"; 14 | 15 | const accessTokenRouter = express.Router(); 16 | 17 | accessTokenRouter.get("", authenticateJWT, getAccessTokens); 18 | accessTokenRouter.post( 19 | "", 20 | authenticateJWT, 21 | validateSchema(createAccessTokenRequest), 22 | createAccessToken, 23 | ); 24 | accessTokenRouter.put( 25 | "/:id", 26 | authenticateJWT, 27 | validateSchema(updateAccessTokenRequest), 28 | updateAccessToken, 29 | ); 30 | accessTokenRouter.delete("/:id", authenticateJWT, deleteAccessToken); 31 | 32 | export default accessTokenRouter; 33 | -------------------------------------------------------------------------------- /backend/routes/config.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { authenticateJWT } from "../middlewares/authenticateJWT"; 3 | import { 4 | getAuthDisabled, 5 | getStorageConfig, 6 | updateStorageConfig, 7 | } from "../controllers/config.controller"; 8 | import { validateSchema } from "../middlewares/validateSchema.middleware"; 9 | import { updateStorageConfigRequest } from "../models/config.model"; 10 | 11 | const configRouter = express.Router(); 12 | 13 | configRouter.get("/storage", authenticateJWT, getStorageConfig); 14 | configRouter.put( 15 | "/storage", 16 | authenticateJWT, 17 | validateSchema(updateStorageConfigRequest), 18 | updateStorageConfig, 19 | ); 20 | configRouter.get("/authDisabled", getAuthDisabled); 21 | 22 | export default configRouter; 23 | -------------------------------------------------------------------------------- /backend/routes/history.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { authenticateJWT } from "../middlewares/authenticateJWT"; 3 | import { getHistory } from "../controllers/history.controller"; 4 | 5 | const historyRouter = express.Router(); 6 | 7 | historyRouter.get("", authenticateJWT, getHistory); 8 | 9 | export default historyRouter; 10 | -------------------------------------------------------------------------------- /backend/routes/index.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import userRouter from "./user.route"; 3 | import scheduleRouter from "./schedule.route"; 4 | import historyRouter from "./history.route"; 5 | import accessTokenRouter from "./accessToken.route"; 6 | import configRouter from "./config.route"; 7 | 8 | const apiRouter = express.Router(); 9 | 10 | apiRouter.use(userRouter); 11 | apiRouter.use("/schedules", scheduleRouter); 12 | apiRouter.use("/history", historyRouter); 13 | apiRouter.use("/access-tokens", accessTokenRouter); 14 | apiRouter.use("/config", configRouter); 15 | 16 | export default apiRouter; 17 | -------------------------------------------------------------------------------- /backend/routes/schedule.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { authenticateJWT } from "../middlewares/authenticateJWT"; 3 | import { 4 | backupScheduleNow, 5 | createSchedule, 6 | deleteSchedule, 7 | getSchedules, 8 | pauseSchedule, 9 | resumeSchedule, 10 | updateSchedule, 11 | } from "../controllers/schedule.controller"; 12 | import { validateSchema } from "../middlewares/validateSchema.middleware"; 13 | import { 14 | postScheduleRequest, 15 | putScheduleRequest, 16 | } from "../models/schedule.model"; 17 | 18 | const scheduleRouter = express.Router(); 19 | 20 | scheduleRouter.get("", authenticateJWT, getSchedules); 21 | scheduleRouter.post( 22 | "", 23 | authenticateJWT, 24 | validateSchema(postScheduleRequest), 25 | createSchedule 26 | ); 27 | scheduleRouter.put( 28 | "/:id", 29 | authenticateJWT, 30 | validateSchema(putScheduleRequest), 31 | updateSchedule 32 | ); 33 | scheduleRouter.post("/:id/backup", authenticateJWT, backupScheduleNow); 34 | scheduleRouter.put("/:id/pause", authenticateJWT, pauseSchedule); 35 | scheduleRouter.put("/:id/resume", authenticateJWT, resumeSchedule); 36 | scheduleRouter.delete("/:id", authenticateJWT, deleteSchedule); 37 | 38 | export default scheduleRouter; 39 | -------------------------------------------------------------------------------- /backend/routes/user.route.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { validateSchema } from "../middlewares/validateSchema.middleware"; 3 | import { 4 | auth, 5 | changePasswordRequest, 6 | changeUsernameRequest, 7 | } from "../models/user.model"; 8 | import { 9 | changePassword, 10 | getUsername, 11 | login, 12 | register, 13 | setUsername, 14 | } from "../controllers/user.controller"; 15 | import { userExistCheck } from "../middlewares/userExistCheck"; 16 | import { authenticateJWT } from "../middlewares/authenticateJWT"; 17 | 18 | const userRouter = express.Router(); 19 | 20 | userRouter.post("/login", validateSchema(auth), login); 21 | userRouter.post("/register", validateSchema(auth), userExistCheck, register); 22 | userRouter.get("/user/name", authenticateJWT, getUsername); 23 | userRouter.put( 24 | "/user/name", 25 | validateSchema(changeUsernameRequest), 26 | authenticateJWT, 27 | setUsername 28 | ); 29 | userRouter.post( 30 | "/user/password", 31 | validateSchema(changePasswordRequest), 32 | authenticateJWT, 33 | changePassword 34 | ); 35 | 36 | export default userRouter; 37 | -------------------------------------------------------------------------------- /backend/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import prisma from "../prisma/client"; 3 | import { execFile } from "child_process"; 4 | import cron from "node-cron"; 5 | import fs from "fs"; 6 | import { authenticateJWT } from "./middlewares/authenticateJWT"; 7 | import { userExistCheck } from "./middlewares/userExistCheck"; 8 | import { sanitize } from "./lib/sanatize"; 9 | import Logger from "./lib/logger"; 10 | import { PORT } from "./configs/app.config"; 11 | import apiRouter from "./routes/index.route"; 12 | import { ConfigService } from "./services/config.service"; 13 | import { CronJob } from "./types/CronJob"; 14 | import { EncryptionService } from "./services/encryption.service"; 15 | 16 | const logger = new Logger("server"); 17 | logger.info("Server starting"); 18 | 19 | const app = express(); 20 | let cronJobs: CronJob[] = []; 21 | app.use(express.json()); 22 | 23 | async function prepareDatabase() { 24 | async function createAppConfig(key: string, value: string, dataType: string) { 25 | try { 26 | await prisma.appConfig.findFirstOrThrow({ 27 | where: { key }, 28 | }); 29 | } catch (error) { 30 | await prisma.appConfig.create({ 31 | data: { 32 | key, 33 | value, 34 | dataType, 35 | }, 36 | }); 37 | } 38 | } 39 | 40 | await createAppConfig("default_location", "local_folder", "string"); 41 | await createAppConfig("smb_address", "", "string"); 42 | await createAppConfig("smb_location", "", "string"); 43 | await createAppConfig("smb_username", "", "string"); 44 | await createAppConfig("smb_password", "", "string"); 45 | } 46 | 47 | app.use("/api", apiRouter); 48 | 49 | interface backupHistoryEntry { 50 | backupJobId: number; 51 | success: boolean; 52 | message?: string; 53 | } 54 | 55 | function createHistoryEntry(data: backupHistoryEntry) { 56 | prisma.backupHistory 57 | .create({ 58 | data: data, 59 | }) 60 | .then((schedule) => { 61 | return true; 62 | }) 63 | .catch((error) => { 64 | console.error(error); 65 | return false; 66 | }); 67 | } 68 | 69 | export function createBackup( 70 | id: number, 71 | name: string, 72 | repository: string, 73 | keepLast: number, 74 | ) { 75 | let backupJobData: backupHistoryEntry = { 76 | backupJobId: id, 77 | success: false, 78 | }; 79 | const currentTimestamp = Math.floor(Date.now() / 1000); 80 | const options = { 81 | env: { 82 | ...process.env, 83 | GIT_ASKPASS: "/bin/false", 84 | }, 85 | }; 86 | prisma.appConfig 87 | .findUnique({ 88 | where: { 89 | key: "default_location", 90 | }, 91 | }) 92 | .then((defaultLocation) => { 93 | let folderName = `./backups/${id}_${sanitize(name)}`; 94 | if (defaultLocation?.value === "smb_share") { 95 | folderName = `./tmp/backups/${id}-${sanitize(name)}`; 96 | } 97 | const child = execFile( 98 | "git", 99 | ["clone", "--mirror", repository, `${folderName}/${currentTimestamp}`], 100 | options, 101 | async (error, stdout, stderr) => { 102 | if (error) { 103 | if (error.killed) { 104 | backupJobData = { 105 | backupJobId: id, 106 | success: false, 107 | message: 108 | "Request took too long (has the access token expired?)", 109 | }; 110 | } else { 111 | backupJobData = { 112 | backupJobId: id, 113 | success: false, 114 | message: "Missing privileges or cannot access", 115 | }; 116 | } 117 | } else { 118 | backupJobData = { 119 | backupJobId: id, 120 | success: true, 121 | }; 122 | if (defaultLocation?.value === "smb_share") { 123 | const config = await ConfigService.getStorageConfig(); 124 | execFile( 125 | "smbclient", 126 | [ 127 | `//${config.smbAddress}`, 128 | "-U", 129 | config.smbUsername, 130 | "--password", 131 | config.smbPassword, 132 | "-c", 133 | `prompt OFF; recurse ON; mkdir ${config.smbLocation 134 | }/${id}-${sanitize(name)}/${currentTimestamp}; cd ${config.smbLocation 135 | }/${id}-${sanitize( 136 | name, 137 | )}/${currentTimestamp}; lcd ${folderName}/${currentTimestamp}; mput *`, 138 | ], 139 | options, 140 | (error, stdout, stderr) => { 141 | if (error) { 142 | logger.error( 143 | "Something went wrong backing up to SMB. Make sure the server is reachable and the credentials are correct.", 144 | ); 145 | } else { 146 | createHistoryEntry(backupJobData); 147 | fs.rmSync(`${folderName}/${currentTimestamp}`, { 148 | recursive: true, 149 | force: true, 150 | }); 151 | } 152 | }, 153 | ); 154 | } else { 155 | fs.readdir(`${folderName}/`, (err, files) => { 156 | if (files.length >= keepLast) { 157 | const oldFiles = files.filter( 158 | (f) => parseInt(f) != currentTimestamp, 159 | ); 160 | oldFiles.sort((a, b) => parseInt(a) - parseInt(b)); 161 | for (const oldFile of oldFiles.slice( 162 | 0, 163 | oldFiles.length - keepLast + 1, 164 | )) { 165 | fs.rmSync(`${folderName}/${oldFile}`, { 166 | recursive: true, 167 | force: true, 168 | }); 169 | } 170 | } 171 | }); 172 | createHistoryEntry(backupJobData); 173 | } 174 | } 175 | }, 176 | ); 177 | 178 | const timeout = setTimeout(() => { 179 | child.kill(); 180 | }, 15000); 181 | 182 | child.on("exit", (code) => { 183 | clearTimeout(timeout); 184 | if (code === null) { 185 | backupJobData = { 186 | backupJobId: id, 187 | success: false, 188 | message: "Request took too long (has the access token expired?)", 189 | }; 190 | createHistoryEntry(backupJobData); 191 | } 192 | }); 193 | }); 194 | } 195 | 196 | export function scheduleCronJobs() { 197 | for (const cronJob of cronJobs) { 198 | cronJob.job.stop(); 199 | } 200 | cronJobs = []; 201 | 202 | prisma.backupJob 203 | .findMany() 204 | .then((backupJobs) => { 205 | logger.info( 206 | `Scheduling ${backupJobs.length} backup job${backupJobs.length === 0 || backupJobs.length > 1 ? "s" : "" 207 | }`, 208 | ); 209 | for (const job of backupJobs) { 210 | if (job.accessTokenId) { 211 | prisma.accessToken 212 | .findUnique({ 213 | where: { 214 | id: job.accessTokenId, 215 | }, 216 | select: { 217 | token: true, 218 | }, 219 | }) 220 | .then((accessToken) => { 221 | if (!accessToken) { 222 | logger.error("Access token not found"); 223 | } else { 224 | const decryptedToken = EncryptionService.decrypt( 225 | accessToken.token, 226 | ); 227 | const repoWithToken = job.repository.replace( 228 | "https://", 229 | `https://${decryptedToken}@`, 230 | ); 231 | pushCronJob( 232 | job.cron, 233 | job.id, 234 | job.name, 235 | repoWithToken, 236 | job.keepLast, 237 | job.paused, 238 | ); 239 | } 240 | }) 241 | .catch((error) => { 242 | logger.error(error); 243 | }); 244 | } else { 245 | pushCronJob( 246 | job.cron, 247 | job.id, 248 | job.name, 249 | job.repository, 250 | job.keepLast, 251 | job.paused, 252 | ); 253 | } 254 | } 255 | }) 256 | .catch((error) => { 257 | logger.error(error); 258 | }); 259 | 260 | function pushCronJob( 261 | cronString: string, 262 | id: number, 263 | name: string, 264 | repository: string, 265 | keepLast: number, 266 | paused: boolean, 267 | ) { 268 | const c = cron.schedule(cronString, () => { 269 | logger.info(`Starting backup for ${name}`); 270 | createBackup(id, name, repository, keepLast); 271 | logger.info(`Backup for ${name} completed`); 272 | }); 273 | cronJobs.push({ id: id, job: c }); 274 | if (paused) { 275 | c.stop(); 276 | } 277 | } 278 | } 279 | 280 | export function stopCronJob(id: number, deleteJob = false) { 281 | const job = cronJobs.find((j) => j.id === id); 282 | if (job) { 283 | job.job.stop(); 284 | if (deleteJob) cronJobs = cronJobs.filter((j) => j.id !== id); 285 | } 286 | } 287 | 288 | export function resumeCronJob(id: number) { 289 | const job = cronJobs.find((j) => j.id === id); 290 | if (job) { 291 | job.job.start(); 292 | } 293 | } 294 | 295 | async function main() { 296 | await prepareDatabase(); 297 | app.use( 298 | "/dashboard", 299 | (req, res, next) => authenticateJWT(req, res, next, false), 300 | express.static("dist/dashboard"), 301 | ); 302 | app.use( 303 | "/login", 304 | (req, res, next) => authenticateJWT(req, res, next, true), 305 | express.static("dist/login"), 306 | ); 307 | app.use("/setup", userExistCheck, express.static("dist/setup")); 308 | app.use(express.static("dist")); 309 | 310 | scheduleCronJobs(); 311 | app.listen(PORT, () => { 312 | logger.info(`Server running on port ${PORT}`); 313 | }); 314 | } 315 | 316 | main().catch((error) => { 317 | logger.error("An error occurred while starting the server:" + error); 318 | process.exit(1); 319 | }); 320 | -------------------------------------------------------------------------------- /backend/services/accessToken.service.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma/client"; 2 | import { EncryptionService } from "./encryption.service"; 3 | 4 | export class AccessTokenService { 5 | static async getAccessToken(username: string) { 6 | const accessToken = await prisma.accessToken.findMany({ 7 | where: { 8 | username: username, 9 | }, 10 | select: { 11 | id: true, 12 | name: true, 13 | }, 14 | }); 15 | 16 | return accessToken; 17 | } 18 | 19 | static async createAccessToken( 20 | name: string, 21 | token: string, 22 | username: string, 23 | ) { 24 | const encryptedToken = EncryptionService.encrypt(token); 25 | const accessToken = await prisma.accessToken.create({ 26 | data: { 27 | name: name, 28 | token: encryptedToken, 29 | username: username, 30 | }, 31 | }); 32 | 33 | return accessToken; 34 | } 35 | 36 | static async updateAccessToken(id: number, name: string, token: string) { 37 | let accessToken; 38 | if (!token) { 39 | const accessToken = await prisma.accessToken.update({ 40 | where: { 41 | id: id, 42 | }, 43 | data: { 44 | name: name, 45 | }, 46 | }); 47 | 48 | return accessToken; 49 | } else { 50 | const encryptedToken = EncryptionService.encrypt(token); 51 | const accessToken = await prisma.accessToken.update({ 52 | where: { 53 | id: id, 54 | }, 55 | data: { 56 | name: name, 57 | token: encryptedToken, 58 | }, 59 | }); 60 | 61 | return accessToken; 62 | } 63 | } 64 | 65 | static async deleteAccessToken(id: number) { 66 | const accessToken = await prisma.accessToken.delete({ 67 | where: { 68 | id: id, 69 | }, 70 | }); 71 | 72 | return accessToken; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /backend/services/config.service.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma/client"; 2 | 3 | export class ConfigService { 4 | static async getStorageConfig(includePassword = false) { 5 | const config = await prisma.appConfig.findMany({ 6 | where: { 7 | OR: [ 8 | { 9 | key: "default_location", 10 | }, 11 | { 12 | key: "smb_address", 13 | }, 14 | { 15 | key: "smb_location", 16 | }, 17 | { 18 | key: "smb_username", 19 | }, 20 | ...(includePassword 21 | ? [ 22 | { 23 | key: "smb_password", 24 | }, 25 | ] 26 | : []), 27 | ], 28 | }, 29 | }); 30 | let storageConfig: { 31 | defaultLocation: string; 32 | smbAddress: string; 33 | smbLocation: string; 34 | smbUsername: string; 35 | smbPassword: string; 36 | } = { 37 | defaultLocation: "", 38 | smbAddress: "", 39 | smbLocation: "", 40 | smbUsername: "", 41 | smbPassword: "", 42 | }; 43 | for (const item of config) { 44 | switch (item.key) { 45 | case "default_location": 46 | storageConfig.defaultLocation = item.value; 47 | break; 48 | case "smb_address": 49 | storageConfig.smbAddress = item.value; 50 | break; 51 | case "smb_location": 52 | storageConfig.smbLocation = item.value; 53 | break; 54 | case "smb_username": 55 | storageConfig.smbUsername = item.value; 56 | break; 57 | case "smb_password": 58 | storageConfig.smbPassword = item.value; 59 | break; 60 | } 61 | } 62 | 63 | return storageConfig; 64 | } 65 | 66 | static async updateConfigEntry(key: string, value: string) { 67 | await prisma.appConfig.update({ 68 | where: { key }, 69 | data: { value }, 70 | }); 71 | } 72 | 73 | static async getConfigEntry(key: string) { 74 | const config = await prisma.appConfig.findFirst({ 75 | where: { key }, 76 | }); 77 | 78 | return config; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /backend/services/encryption.service.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { ENCRYPTION_SECRET } from "../configs/app.config"; 3 | 4 | const ALGORITHM = "aes-256-cbc"; 5 | const IV_LENGTH = 16; 6 | 7 | export class EncryptionService { 8 | static encrypt(text: string) { 9 | const iv = crypto.randomBytes(IV_LENGTH); 10 | const cipher = crypto.createCipheriv( 11 | ALGORITHM, 12 | Buffer.from(ENCRYPTION_SECRET.padEnd(32, " ")), 13 | iv, 14 | ); 15 | let encrypted = cipher.update(text); 16 | encrypted = Buffer.concat([encrypted, cipher.final()]); 17 | return iv.toString("hex") + ":" + encrypted.toString("hex"); 18 | } 19 | 20 | static decrypt(encryptedText: string): string { 21 | const parts = encryptedText.split(":"); 22 | const iv = Buffer.from(parts[0], "hex"); 23 | const encryptedData = Buffer.from(parts[1], "hex"); 24 | const decipher = crypto.createDecipheriv( 25 | ALGORITHM, 26 | Buffer.from(ENCRYPTION_SECRET.padEnd(32, " ")), 27 | iv, 28 | ); 29 | let decrypted = decipher.update(encryptedData); 30 | decrypted = Buffer.concat([decrypted, decipher.final()]); 31 | return decrypted.toString(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/services/history.service.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma/client"; 2 | 3 | export class HistoryService { 4 | static async getHistory(username: string, offset: number, limit: number) { 5 | try { 6 | const [backupHistory, totalCount] = await Promise.all([ 7 | prisma.backupHistory.findMany({ 8 | where: { 9 | OR: [ 10 | { 11 | backupJob: { 12 | username: username, 13 | }, 14 | }, 15 | { 16 | backupJob: null, 17 | }, 18 | ], 19 | }, 20 | orderBy: { 21 | timestamp: "desc", 22 | }, 23 | skip: offset, 24 | take: limit, 25 | include: { 26 | backupJob: { 27 | select: { 28 | name: true, 29 | }, 30 | }, 31 | }, 32 | }), 33 | prisma.backupHistory.count({ 34 | where: { 35 | OR: [ 36 | { 37 | backupJob: { 38 | username: username, 39 | }, 40 | }, 41 | { 42 | backupJob: null, 43 | }, 44 | ], 45 | }, 46 | }), 47 | ]); 48 | 49 | return { backupHistory, totalCount }; 50 | } catch (error) { 51 | throw new Error(`Error retrieving history: ${error.message}`); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /backend/services/schedule.service.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma/client"; 2 | import { EncryptionService } from "./encryption.service"; 3 | 4 | export class ScheduleService { 5 | static async getSchedulesByUser(username: string) { 6 | const schedules = await prisma.backupJob.findMany({ 7 | where: { 8 | username: username, 9 | }, 10 | include: { 11 | backupHistory: { 12 | orderBy: { 13 | timestamp: "desc", 14 | }, 15 | take: 1, 16 | }, 17 | }, 18 | }); 19 | 20 | return schedules; 21 | } 22 | 23 | static async getScheduleById(id: string) { 24 | const schedule = await prisma.backupJob.findUnique({ 25 | where: { 26 | id: parseInt(id), 27 | }, 28 | }); 29 | 30 | return schedule; 31 | } 32 | 33 | static async addSchedule( 34 | username: string, 35 | schedule: { 36 | name: string; 37 | repository: string; 38 | every: number; 39 | timespan: string; 40 | private: string; 41 | accessTokenId: string; 42 | keepLast: number; 43 | }, 44 | initialUrl: string, 45 | ) { 46 | const newSchedule = await prisma.backupJob.create({ 47 | data: { 48 | name: schedule.name, 49 | repository: initialUrl, 50 | cron: this.createCronExpression(schedule.every, schedule.timespan), 51 | username: username, 52 | accessTokenId: 53 | schedule.private === "on" ? parseInt(schedule.accessTokenId) : null, 54 | keepLast: schedule.keepLast, 55 | }, 56 | }); 57 | 58 | return newSchedule; 59 | } 60 | 61 | static async editSchedule( 62 | schedule: { 63 | name: string; 64 | repository: string; 65 | every: number; 66 | timespan: string; 67 | keepLast: number; 68 | }, 69 | id: string, 70 | ) { 71 | const updatedSchedule = await prisma.backupJob.update({ 72 | where: { 73 | id: parseInt(id), 74 | }, 75 | data: { 76 | name: schedule.name, 77 | repository: schedule.repository, 78 | cron: this.createCronExpression(schedule.every, schedule.timespan), 79 | keepLast: schedule.keepLast, 80 | }, 81 | }); 82 | 83 | return updatedSchedule; 84 | } 85 | 86 | static async pauseSchedule(id: string) { 87 | const schedule = await prisma.backupJob.update({ 88 | where: { 89 | id: parseInt(id), 90 | }, 91 | data: { 92 | paused: true, 93 | }, 94 | }); 95 | 96 | return schedule; 97 | } 98 | 99 | static async resumeSchedule(id: string) { 100 | const schedule = await prisma.backupJob.update({ 101 | where: { 102 | id: parseInt(id), 103 | }, 104 | data: { 105 | paused: false, 106 | }, 107 | }); 108 | 109 | return schedule; 110 | } 111 | 112 | static async deleteSchedule(id: string) { 113 | const schedule = await prisma.backupJob.delete({ 114 | where: { 115 | id: parseInt(id), 116 | }, 117 | }); 118 | 119 | return schedule; 120 | } 121 | 122 | static async getAccessToken(id: string) { 123 | const accessToken = await prisma.accessToken.findUnique({ 124 | where: { id: parseInt(id) }, 125 | select: { token: true }, 126 | }); 127 | 128 | if (accessToken === null) return undefined; 129 | const decryptedAccessToken = EncryptionService.decrypt(accessToken.token); 130 | 131 | return decryptedAccessToken; 132 | } 133 | 134 | static createCronExpression(every: number, timespan: string) { 135 | const currentDate = new Date(); 136 | const currentHour = currentDate.getHours(); 137 | const currentMinute = currentDate.getMinutes(); 138 | 139 | switch (timespan) { 140 | case "minutes": 141 | return `*/${every} * * * *`; 142 | case "hours": 143 | return `${currentMinute} */${every} * * *`; 144 | case "days": 145 | return `${currentMinute} ${currentHour} */${every} * *`; 146 | default: 147 | return ""; 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /backend/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma/client"; 2 | import { JWT_SECRET } from "../configs/app.config"; 3 | import jwt from "jsonwebtoken"; 4 | 5 | export class UserService { 6 | static async getUserByUsername(username: string) { 7 | const user = await prisma.user.findUnique({ 8 | where: { 9 | username: username, 10 | }, 11 | }); 12 | 13 | return user; 14 | } 15 | 16 | static async createUser(username: string, hash: string) { 17 | const user = await prisma.user.create({ 18 | data: { 19 | username: username, 20 | password: hash, 21 | }, 22 | }); 23 | 24 | return user; 25 | } 26 | 27 | static async updateUsername(username: string, newUsername: string) { 28 | const user = await prisma.user.update({ 29 | where: { 30 | username: username, 31 | }, 32 | data: { 33 | username: newUsername, 34 | }, 35 | }); 36 | 37 | return user; 38 | } 39 | 40 | static async updateUserPassword(username: string, hash: string) { 41 | const user = await prisma.user.update({ 42 | where: { 43 | username: username, 44 | }, 45 | data: { 46 | password: hash, 47 | }, 48 | }); 49 | 50 | return user; 51 | } 52 | 53 | static createJWTToken(username: string) { 54 | const jwtToken = jwt.sign({ username: username }, JWT_SECRET, { 55 | expiresIn: "7d", 56 | }); 57 | return jwtToken; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/types/AuthenticatedRequest.d.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import { User } from "./User"; 3 | 4 | export interface AuthenticatedRequest extends Request { 5 | user: User; 6 | } 7 | -------------------------------------------------------------------------------- /backend/types/CronJob.d.ts: -------------------------------------------------------------------------------- 1 | import { ScheduledTask } from "node-cron"; 2 | 3 | export interface CronJob { 4 | id: number; 5 | job: ScheduledTask; 6 | } 7 | -------------------------------------------------------------------------------- /backend/types/User.d.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | username: string; 3 | } 4 | -------------------------------------------------------------------------------- /backend/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import "express"; 2 | import { User } from "./User"; 3 | 4 | declare module "express" { 5 | export interface Request { 6 | user?: User; 7 | } 8 | 9 | export interface RequestWith { 10 | user: User; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitsave", 3 | "type": "module", 4 | "version": "1.3.6", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "build": "astro build", 8 | "start": "npm run build && npx tsx --env-file .env --watch backend/server.ts" 9 | }, 10 | "dependencies": { 11 | "@astrojs/check": "^0.9.4", 12 | "@astrojs/react": "^4.2.1", 13 | "@astrojs/tailwind": "^6.0.0", 14 | "@prisma/client": "^5.18.0", 15 | "@types/react": "^18.3.4", 16 | "@types/react-dom": "^18.3.0", 17 | "astro": "^5.4.2", 18 | "bcryptjs": "^2.4.3", 19 | "express": "^4.21.2", 20 | "jsonwebtoken": "^9.0.2", 21 | "node-cron": "^3.0.3", 22 | "react": "^18.3.1", 23 | "react-dom": "^18.3.1", 24 | "samba-client": "^7.2.0", 25 | "tailwindcss": "^3.4.10", 26 | "tsx": "^4.19.2", 27 | "zod": "^3.24.1" 28 | }, 29 | "devDependencies": { 30 | "@types/bcryptjs": "^2.4.6", 31 | "@types/express": "^4.17.21", 32 | "@types/jsonwebtoken": "^9.0.7", 33 | "@types/node": "^22.10.2", 34 | "@types/node-cron": "^3.0.11", 35 | "prisma": "^5.18.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /prisma/client.js: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma = new PrismaClient(); 4 | 5 | export default prisma; 6 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = "file:../data/GitSave.db" 11 | } 12 | 13 | model user { 14 | username String @id 15 | password String 16 | backupJobs backupJob[] 17 | accessTokens accessToken[] 18 | } 19 | 20 | model backupJob { 21 | id Int @id @default(autoincrement()) 22 | name String 23 | repository String 24 | cron String 25 | paused Boolean @default(false) 26 | keepLast Int @default(5) 27 | user user @relation(fields: [username], references: [username]) 28 | username String 29 | backupHistory backupHistory[] 30 | accessToken accessToken? @relation(fields: [accessTokenId], references: [id]) 31 | accessTokenId Int? 32 | } 33 | 34 | model backupHistory { 35 | id Int @id @default(autoincrement()) 36 | backupJob backupJob? @relation(fields: [backupJobId], references: [id], onDelete: SetNull) 37 | backupJobId Int? 38 | timestamp DateTime @default(now()) 39 | success Boolean 40 | message String? 41 | } 42 | 43 | model accessToken { 44 | id Int @id @default(autoincrement()) 45 | token String 46 | name String 47 | user user @relation(fields: [username], references: [username]) 48 | username String 49 | backupJobs backupJob[] 50 | } 51 | 52 | model appConfig { 53 | id Int @id @default(autoincrement()) 54 | key String @unique 55 | value String 56 | dataType String 57 | updatedAt DateTime @updatedAt 58 | } 59 | -------------------------------------------------------------------------------- /public/DMSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimWitzdam/GitSave/96420663a34279198fc8ea79b9d7cf290c608cf6/public/DMSans.ttf -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimWitzdam/GitSave/96420663a34279198fc8ea79b9d7cf290c608cf6/public/favicon.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/BaseButton.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | type?: "primary" | "secondary"; 3 | buttonType?: "button" | "submit" | "reset"; 4 | children: React.ReactNode; 5 | id?: string; 6 | fullWidth?: boolean; 7 | onClick?: () => void; 8 | }; 9 | 10 | export default function BaseButton({ 11 | type = "primary", 12 | buttonType = "button", 13 | children, 14 | id, 15 | fullWidth, 16 | onClick, 17 | }: Props) { 18 | const buttonStyles = { 19 | primary: "bg-bg-300 border-transparent hover:border-border-100", 20 | secondary: "bg-transparent border-border-200 hover:border-border-100", 21 | }; 22 | 23 | return ( 24 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/BaseInput.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | type: string; 3 | value?: string | number; 4 | placeholder?: string; 5 | name?: string; 6 | label?: string; 7 | width?: string; 8 | required?: boolean; 9 | onChange?: (e: React.ChangeEvent) => void; 10 | }; 11 | 12 | export default function BaseInput(props: Props) { 13 | return ( 14 |
15 | {props.label && ( 16 | 19 | )} 20 |
23 | 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Pagination/PageElement.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | current: boolean; 5 | children: React.ReactNode; 6 | }; 7 | 8 | export default function PageElement(props: Props) { 9 | return ( 10 | 14 | {props.children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ShortArrow from "../icons/ShortArrow"; 3 | import PageElement from "./PageElement"; 4 | 5 | type Props = { 6 | currentPage: number; 7 | totalItems: number; 8 | perPage: number; 9 | }; 10 | 11 | type PageElement = number | "..."; 12 | 13 | export default function Pagination(props: Props) { 14 | let pageElements: PageElement[] = []; 15 | let totalPages = Math.ceil(props.totalItems / props.perPage); 16 | 17 | if (props.currentPage >= 2) { 18 | if (props.currentPage + 1 > totalPages) { 19 | if (totalPages <= 4) { 20 | for (let i = 0; totalPages > i; i++) { 21 | pageElements[i] = i + 1; 22 | } 23 | } else { 24 | pageElements[4] = totalPages; 25 | pageElements[3] = totalPages - 1; 26 | pageElements[2] = totalPages - 2; 27 | pageElements[1] = "..."; 28 | pageElements[0] = 1; 29 | } 30 | } else { 31 | if (totalPages <= 4) { 32 | for (let i = 0; totalPages > i; i++) { 33 | pageElements[i] = i + 1; 34 | } 35 | } else { 36 | if ( 37 | props.currentPage + 1 === totalPages || 38 | props.currentPage + 2 === totalPages 39 | ) { 40 | pageElements[4] = totalPages; 41 | pageElements[3] = totalPages - 1; 42 | pageElements[2] = totalPages - 2; 43 | pageElements[1] = "..."; 44 | pageElements[0] = 1; 45 | } else { 46 | pageElements[0] = props.currentPage - 1; 47 | pageElements[1] = props.currentPage; 48 | pageElements[2] = props.currentPage + 1; 49 | pageElements[3] = "..."; 50 | pageElements[4] = totalPages; 51 | } 52 | } 53 | } 54 | } else { 55 | if (totalPages > 4) { 56 | pageElements[0] = 1; 57 | pageElements[1] = 2; 58 | pageElements[2] = 3; 59 | pageElements[3] = "..."; 60 | pageElements[4] = totalPages; 61 | } else { 62 | for (let i = 0; totalPages > i; i++) { 63 | pageElements[i] = i + 1; 64 | } 65 | } 66 | } 67 | 68 | return ( 69 |
70 | 74 |
75 | 76 |
77 |
78 | {pageElements.map( 79 | (pageElement: PageElement, index: number) => 80 | pageElement && ( 81 | 85 | {pageElement} 86 | 87 | ) 88 | )} 89 | 93 | 94 | 95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/components/PhoneNavMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import GitIcon from "./icons/GitIcon"; 3 | 4 | type Props = { 5 | links: { name: string; href: string }[]; 6 | }; 7 | 8 | export default function PhoneNavMenu(props: Props) { 9 | return ( 10 |
14 |
15 |
16 | 17 |
18 |
19 | 36 |
37 |
38 |
39 | 63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/components/Setup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseButton from "./BaseButton"; 3 | import BaseInput from "./BaseInput"; 4 | import updateIfNotBelow from "../lib/updateIfNotBelow"; 5 | 6 | export default function Setup() { 7 | const [error, setError] = React.useState(""); 8 | const [success, setSuccess] = React.useState(""); 9 | const [step, setStep] = React.useState(0); 10 | const [every, setEvery] = React.useState(1); 11 | const [keepLast, setKeepLast] = React.useState(5); 12 | const [loading, setLoading] = React.useState(false); 13 | const [showAddAccessToken, setShowAddAccessToken] = React.useState(false); 14 | const [privateRepo, setPrivateRepo] = React.useState(false); 15 | const [accessToken, setAccessToken] = React.useState("default"); 16 | const [availableAccessTokens, setAvailableAccessTokens] = React.useState< 17 | { id: number; name: string }[] 18 | >([]); 19 | 20 | function handleRegisterSubmit(e: React.FormEvent) { 21 | e.preventDefault(); 22 | setError(""); 23 | const form = e.currentTarget; 24 | const formData = new FormData(form); 25 | const data = Object.fromEntries(formData.entries()); 26 | 27 | const username = data.username; 28 | const password = data.password; 29 | const confirmPassword = data.confirmPassword; 30 | 31 | if (password !== confirmPassword) { 32 | setError("Passwords do not match"); 33 | return; 34 | } 35 | 36 | setLoading(true); 37 | fetch("/api/register", { 38 | method: "POST", 39 | headers: { 40 | "Content-Type": "application/json", 41 | }, 42 | body: JSON.stringify({ username, password }), 43 | }) 44 | .then((res) => { 45 | setLoading(false); 46 | if (res.ok) { 47 | return res.json(); 48 | } else { 49 | res.text().then((errorMessage) => setError(errorMessage)); 50 | } 51 | }) 52 | .then((data) => { 53 | if (!data) return; 54 | document.cookie = `auth_session=${data.token}; max-age=604800; path=/; SameSite=Strict`; 55 | setSuccess("Registered successfully. Onto step 2..."); 56 | setTimeout(() => { 57 | setStep(2); 58 | setSuccess(""); 59 | }, 2000); 60 | }) 61 | .catch((err) => { 62 | setError("An error occurred. Please try again."); 63 | }); 64 | } 65 | 66 | function handleMonitorSubmit(e: React.FormEvent) { 67 | e.preventDefault(); 68 | setError(""); 69 | 70 | const formData = new FormData(e.currentTarget); 71 | const name = formData.get("name") as string; 72 | const repository = formData.get("repository") as string; 73 | const every = parseInt(formData.get("every") as string); 74 | const timespan = formData.get("timespan") as string; 75 | const privateRepo = formData.get("private") as string; 76 | const accessTokenId = formData.get("access-token") as string; 77 | const keepLast = parseInt(formData.get("keep-last") as string); 78 | 79 | setLoading(true); 80 | fetch("/api/schedules", { 81 | method: "POST", 82 | headers: { 83 | "Content-Type": "application/json", 84 | }, 85 | body: JSON.stringify({ 86 | name, 87 | repository, 88 | every, 89 | timespan, 90 | private: privateRepo, 91 | accessTokenId, 92 | keepLast, 93 | }), 94 | }) 95 | .then((res) => { 96 | if (res.ok) { 97 | return res.json(); 98 | } else { 99 | res.text().then((errorMessage) => setError(errorMessage)); 100 | } 101 | }) 102 | .then((data) => { 103 | setLoading(false); 104 | if (!data) return; 105 | setSuccess( 106 | "Schedule created successfully. Redirecting to dashboard...", 107 | ); 108 | setTimeout(() => { 109 | setSuccess(""); 110 | location.href = "/dashboard"; 111 | }, 2000); 112 | }); 113 | } 114 | 115 | function updateAccessToken(e: React.ChangeEvent) { 116 | if (e.target.value === "new") { 117 | setShowAddAccessToken(true); 118 | return; 119 | } 120 | setAccessToken(e.target.value); 121 | } 122 | 123 | function handleAccessTokenSubmit(e: React.FormEvent) { 124 | e.preventDefault(); 125 | const form = e.currentTarget; 126 | const formData = new FormData(form); 127 | const name = formData.get("access-token-name") as string; 128 | const token = formData.get("access-token") as string; 129 | 130 | fetch("/api/access-tokens", { 131 | method: "POST", 132 | headers: { 133 | "Content-Type": "application/json", 134 | }, 135 | body: JSON.stringify({ name, token }), 136 | }).then(() => { 137 | form.reset(); 138 | setShowAddAccessToken(false); 139 | fetchAccessTokens(); 140 | }); 141 | } 142 | 143 | function fetchAccessTokens() { 144 | fetch("/api/access-tokens") 145 | .then((res) => res.json()) 146 | .then((data) => setAvailableAccessTokens(data)); 147 | } 148 | 149 | return ( 150 |
151 | {step === 0 && ( 152 |
153 |

154 | Welcome to GitSave 155 |

156 |

157 | Let's get you started with a quick 2 step initial setup 158 |

159 | setStep(1)} fullWidth> 160 | Get started 161 | 162 |
163 | )} 164 | {step === 1 && ( 165 |
166 |

167 | 1. Account creation 168 |

169 |
170 | 175 | Use your own authentication provider → 176 | 177 | 183 | 189 | 195 | 196 |
197 | {loading && ( 198 | 206 | 207 | 208 | )} 209 | Create account 210 |
211 |
212 | 213 |
214 | )} 215 | {step === 2 && ( 216 |
217 |
218 |

219 | 2. Adding first schedule 220 |

221 |
222 | (location.href = "/dashboard")} 225 | > 226 | Skip for now 227 | 228 |
229 |
230 |
231 | 238 |
239 | Repository 240 |
241 | 247 |
248 |
249 | 250 | ) => 255 | setPrivateRepo(e.target.checked) 256 | } 257 | /> 258 |
259 | {privateRepo && ( 260 | 276 | )} 277 |
278 |
279 |
280 |
281 | 282 |
283 |
284 | Every 285 |
286 |
287 | updateIfNotBelow(e, setEvery)} 292 | width="sm:w-24" 293 | required 294 | /> 295 |
296 | 310 |
311 |
312 |
313 | 314 |
315 |
316 | Keep last 317 |
318 |
319 | updateIfNotBelow(e, setKeepLast)} 324 | required 325 | /> 326 |
327 |
328 | backup{keepLast === 0 || keepLast > 1 ? "s" : ""} 329 |
330 |
331 |
332 | 333 |
334 | {loading && ( 335 | 343 | 344 | 345 | )} 346 | Create schedule 347 |
348 |
349 |
350 |
351 |
352 |
353 |

Add access token

354 |
355 | 378 |
379 | setShowAddAccessToken(false)} 383 | > 384 | Go back 385 | 386 | Add access token 387 |
388 |
389 |
390 |
391 | )} 392 | {error &&

{error}

} 393 | {success &&

{success}

} 394 |
395 | ); 396 | } 397 | -------------------------------------------------------------------------------- /src/components/dashboard/AddSchedulePopup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseInput from "../BaseInput"; 3 | import BaseButton from "../BaseButton"; 4 | import updateIfNotBelow from "../../lib/updateIfNotBelow"; 5 | 6 | export default function AddSchedulePopup() { 7 | const [every, setEvery] = React.useState(1); 8 | const [keepLast, setKeepLast] = React.useState(5); 9 | const [loading, setLoading] = React.useState(false); 10 | const [error, setError] = React.useState(""); 11 | const [success, setSuccess] = React.useState(false); 12 | const [showAddAccessToken, setShowAddAccessToken] = React.useState(false); 13 | const [privateRepo, setPrivateRepo] = React.useState(false); 14 | const [accessToken, setAccessToken] = React.useState("default"); 15 | const [availableAccessTokens, setAvailableAccessTokens] = React.useState< 16 | { id: number; name: string }[] 17 | >([]); 18 | 19 | function updateAccessToken(e: React.ChangeEvent) { 20 | if (e.target.value === "new") { 21 | setShowAddAccessToken(true); 22 | return; 23 | } 24 | setAccessToken(e.target.value); 25 | } 26 | 27 | function handleSubmit(e: React.FormEvent) { 28 | e.preventDefault(); 29 | setError(""); 30 | 31 | const form = e.currentTarget; 32 | const formData = new FormData(form); 33 | const name = formData.get("name") as string; 34 | const repository = formData.get("repository") as string; 35 | const every = parseInt(formData.get("every") as string); 36 | const timespan = formData.get("timespan") as string; 37 | const privateRepo = formData.get("private") as string; 38 | const accessTokenId = formData.get("access-token") as string; 39 | const keepLast = formData.get("keep-last") as string; 40 | 41 | setLoading(true); 42 | fetch("/api/schedules", { 43 | method: "POST", 44 | headers: { 45 | "Content-Type": "application/json", 46 | }, 47 | body: JSON.stringify({ 48 | name, 49 | repository, 50 | every, 51 | timespan, 52 | private: privateRepo, 53 | accessTokenId, 54 | keepLast: parseInt(keepLast), 55 | }), 56 | }) 57 | .then((res) => { 58 | if (res.ok) { 59 | form.reset(); 60 | return res.json(); 61 | } else { 62 | res.text().then((errorMessage) => setError(errorMessage)); 63 | } 64 | }) 65 | .then((data) => { 66 | setLoading(false); 67 | if (!data) return; 68 | setSuccess(true); 69 | }); 70 | } 71 | 72 | function handleAccessTokenSubmit(e: React.FormEvent) { 73 | e.preventDefault(); 74 | const form = e.currentTarget; 75 | const formData = new FormData(form); 76 | const name = formData.get("access-token-name") as string; 77 | const token = formData.get("access-token") as string; 78 | 79 | fetch("/api/access-tokens", { 80 | method: "POST", 81 | headers: { 82 | "Content-Type": "application/json", 83 | }, 84 | body: JSON.stringify({ name, token }), 85 | }).then(() => { 86 | form.reset(); 87 | setShowAddAccessToken(false); 88 | fetchAccessTokens(); 89 | }); 90 | } 91 | 92 | function fetchAccessTokens() { 93 | fetch("/api/access-tokens") 94 | .then((res) => res.json()) 95 | .then((data) => setAvailableAccessTokens(data)); 96 | } 97 | 98 | React.useEffect(() => { 99 | fetchAccessTokens(); 100 | }, []); 101 | 102 | return ( 103 |
107 |
112 |

Add schedule

113 |
114 |
115 | 122 |
123 | Repository 124 |
125 | 131 |
132 |
133 | 134 | ) => 139 | setPrivateRepo(e.target.checked) 140 | } 141 | /> 142 |
143 | {privateRepo && ( 144 | 160 | )} 161 |
162 |
163 |
164 |
165 | 166 |
167 |
168 | Every 169 |
170 |
171 | updateIfNotBelow(e, setEvery)} 176 | width="sm:w-24" 177 | required 178 | /> 179 |
180 | 194 |
195 |
196 |
197 | 198 |
199 |
200 | Keep last 201 |
202 |
203 | updateIfNotBelow(e, setKeepLast)} 208 | required 209 | /> 210 |
211 |
212 | backup{keepLast === 0 || keepLast > 1 ? "s" : ""} 213 |
214 |
215 | {error &&

{error}

} 216 |
217 |
218 |
219 |
220 | 221 | Cancel 222 | 223 | 224 |
225 | {loading && ( 226 | 234 | 235 | 236 | )} 237 | Save 238 |
239 |
240 |
241 |
242 |
245 |

Schedule added

246 |
247 |

248 | Your schedule has been added successfully. 249 |

250 | 251 | Close 252 | 253 |
254 |
255 |
258 |

Add access token

259 |
260 | 283 |
284 | setShowAddAccessToken(false)} 288 | > 289 | Go back 290 | 291 | Add access token 292 |
293 |
294 |
295 |
296 | ); 297 | } 298 | -------------------------------------------------------------------------------- /src/components/dashboard/EditSchedulePopup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseInput from "../BaseInput"; 3 | import BaseButton from "../BaseButton"; 4 | import updateIfNotBelow from "../../lib/updateIfNotBelow"; 5 | 6 | type Props = { 7 | id: number; 8 | name: string; 9 | cron: string; 10 | repository: string; 11 | keepLast: number; 12 | closeEdit: () => void; 13 | }; 14 | 15 | export default function EditSchedulePopup(props: Props) { 16 | const [loading, setLoading] = React.useState(false); 17 | const [error, setError] = React.useState(""); 18 | const [success, setSuccess] = React.useState(false); 19 | const [name, setName] = React.useState(props.name); 20 | const [repository, setRepository] = React.useState(props.repository); 21 | 22 | const extractedCronParams = extractCronParameters(props.cron); 23 | const [every, setEvery] = React.useState(extractedCronParams.every); 24 | const [timespan, setTimespan] = React.useState(extractedCronParams.timespan); 25 | const [keepLast, setKeepLast] = React.useState(props.keepLast); 26 | 27 | function extractCronParameters(cronExpression: string) { 28 | const parts = cronExpression.split(" "); 29 | 30 | if (parts.length !== 5) { 31 | throw new Error("Invalid cron expression"); 32 | } 33 | 34 | if (parts[0].includes("/")) { 35 | return { every: parseInt(parts[0].split("/")[1]), timespan: "minutes" }; 36 | } else if (parts[1].includes("/")) { 37 | return { every: parseInt(parts[1].split("/")[1]), timespan: "hours" }; 38 | } else if (parts[2].includes("/")) { 39 | return { every: parseInt(parts[2].split("/")[1]), timespan: "days" }; 40 | } else { 41 | throw new Error("Unable to determine timespan from cron expression"); 42 | } 43 | } 44 | 45 | function handleSubmit(e: React.FormEvent) { 46 | e.preventDefault(); 47 | setError(""); 48 | 49 | const formData = new FormData(e.currentTarget); 50 | const name = formData.get("name") as string; 51 | const repository = formData.get("repository") as string; 52 | const every = parseInt(formData.get("every") as string); 53 | const timespan = formData.get("timespan") as string; 54 | const keepLast = parseInt(formData.get("keep-last") as string); 55 | 56 | setLoading(true); 57 | fetch(`/api/schedules/${props.id}`, { 58 | method: "PUT", 59 | headers: { 60 | "Content-Type": "application/json", 61 | }, 62 | body: JSON.stringify({ name, repository, every, timespan, keepLast }), 63 | }) 64 | .then((res) => { 65 | if (res.ok) { 66 | return res.json(); 67 | } else { 68 | res.text().then((errorMessage) => setError(errorMessage)); 69 | } 70 | }) 71 | .then((data) => { 72 | setLoading(false); 73 | if (!data) return; 74 | setSuccess(true); 75 | }); 76 | } 77 | 78 | return ( 79 |
83 |
88 |

Edit schedule

89 |
90 |
91 | setName(e.target.value)} 98 | required 99 | /> 100 | setRepository(e.target.value)} 107 | required 108 | /> 109 |
110 | 111 |
112 |
113 | Every 114 |
115 |
116 | updateIfNotBelow(e, setEvery)} 121 | width="sm:w-24" 122 | required 123 | /> 124 |
125 | 141 |
142 |
143 |
144 | 145 |
146 |
147 | Keep last 148 |
149 |
150 | updateIfNotBelow(e, setKeepLast)} 155 | required 156 | /> 157 |
158 |
159 | backup{keepLast === 0 || keepLast > 1 ? "s" : ""} 160 |
161 |
162 | {error &&

{error}

} 163 |
164 |
165 |
166 |
167 | 173 | Cancel 174 | 175 | 176 |
177 | {loading && ( 178 | 186 | 187 | 188 | )} 189 | Save 190 |
191 |
192 |
193 |
194 |
197 |

Schedule edited

198 |
199 |

200 | Your schedule has been edited successfully. 201 |

202 | 203 | Close 204 | 205 |
206 |
207 |
208 | ); 209 | } 210 | -------------------------------------------------------------------------------- /src/components/dashboard/History.tsx: -------------------------------------------------------------------------------- 1 | import { formatTimestamp } from "../../lib/formatTimestamp"; 2 | 3 | type Props = { 4 | timestamp: string; 5 | name: string; 6 | success: boolean; 7 | message: string | null; 8 | last?: boolean; 9 | }; 10 | 11 | export default function History(props: Props) { 12 | return ( 13 |
14 |

15 | {formatTimestamp(props.timestamp)} 16 |

17 |

{props.name}

18 |
19 |
22 | 23 | {props.success 24 | ? "Backup successful" 25 | : `Backup failed (${props.message})`} 26 | 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/dashboard/HistoryList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import History from "./History"; 3 | import type { ScheduleHistory } from "../../types/scheduleTypes"; 4 | import Pagination from "../Pagination/Pagination"; 5 | import BaseButton from "../BaseButton"; 6 | 7 | type Props = { 8 | disablePaging?: boolean; 9 | }; 10 | 11 | export default function HistoryList({ disablePaging = false }: Props) { 12 | const [history, setHistory] = React.useState([]); 13 | const [total, setTotal] = React.useState(0); 14 | const [currentPage, setCurrentPage] = React.useState(1); 15 | const [fetched, setFetched] = React.useState(false); 16 | const [loading, setLoading] = React.useState(true); 17 | const perPage = disablePaging ? 5 : 10; 18 | 19 | function fetchHistory(limit: number, offset: number) { 20 | setLoading(true); 21 | fetch(`/api/history?limit=${limit}&offset=${offset}`) 22 | .then((res) => { 23 | if (!res.ok) { 24 | throw new Error("Failed to fetch history"); 25 | } 26 | return res.json(); 27 | }) 28 | .then((data) => { 29 | setLoading(false); 30 | setHistory(data.backupHistory); 31 | setTotal(data.totalCount); 32 | setFetched(true); 33 | }); 34 | } 35 | 36 | React.useEffect(() => { 37 | let offset = 0; 38 | const reloadButton = document.getElementById("reload") as HTMLButtonElement; 39 | 40 | reloadButton.addEventListener("click", () => { 41 | fetchHistory(perPage, offset); 42 | }); 43 | 44 | if (!disablePaging) { 45 | setCurrentPage( 46 | location.search ? parseInt(location.search.split("?page=")[1]) : 1 47 | ); 48 | const tmp = location.search 49 | ? parseInt(location.search.split("?page=")[1]) 50 | : 1; 51 | offset = tmp === 1 ? 0 : tmp * perPage - perPage; 52 | } 53 | 54 | fetchHistory(perPage, offset); 55 | }, []); 56 | 57 | return ( 58 |
59 | {loading ? ( 60 |
61 |
62 | {Array.from(Array(5).keys()).map((index) => ( 63 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | ))} 75 |
76 |
77 | ) : history.length === 0 ? ( 78 |
79 |
80 |

No history found

81 |
82 |
83 | ) : ( 84 |
87 | {history.map((item, index) => ( 88 | 96 | ))} 97 | {disablePaging && !loading && ( 98 | 99 | Show all 100 | 101 | )} 102 |
103 | )} 104 | {!disablePaging && !loading && fetched && total > perPage && ( 105 | 110 | )} 111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /src/components/dashboard/Schedule.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import MenuIcon from "../icons/MenuIcon"; 3 | import TimeIcon from "../icons/TimeIcon"; 4 | 5 | type Props = { 6 | name: string; 7 | link: string; 8 | paused: boolean; 9 | lastBackup: string; 10 | last?: boolean; 11 | success: boolean; 12 | editClick: () => void; 13 | backupNowClick: () => void; 14 | pauseClick: () => void; 15 | deleteClick: () => void; 16 | }; 17 | 18 | export default function Schedule(props: Props) { 19 | const [showMenu, setShowMenu] = React.useState(false); 20 | const menuRef = useRef(null); 21 | 22 | useEffect(() => { 23 | function handleClickOutside(event: MouseEvent) { 24 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 25 | setShowMenu(false); 26 | } 27 | } 28 | 29 | document.addEventListener("mousedown", handleClickOutside); 30 | return () => { 31 | document.removeEventListener("mousedown", handleClickOutside); 32 | }; 33 | }, []); 34 | 35 | return ( 36 |
37 |
38 |
39 |
42 |
43 |

{props.name}

44 | {props.link} 45 |
46 |
47 |
48 | 51 |
54 | 60 | 66 | 72 | 78 |
79 |
80 |
81 |
82 | 83 | 84 | Last backup: {props.lastBackup} 85 | 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/components/dashboard/ScheduleList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Schedule from "./Schedule"; 3 | import type { ScheduleWithHistory } from "../../types/scheduleTypes"; 4 | import EditSchedulePopup from "./EditSchedulePopup"; 5 | import BaseButton from "../BaseButton"; 6 | import { formatTimestamp } from "../../lib/formatTimestamp"; 7 | 8 | export default function ScheduleList() { 9 | const [schedules, setSchedules] = React.useState([]); 10 | const [loading, setLoading] = React.useState(true); 11 | const [editMenuDetails, setEditMenuDetails] = React.useState<{ 12 | id: number; 13 | name: string; 14 | cron: string; 15 | repository: string; 16 | keepLast: number; 17 | } | null>(null); 18 | const [showBackupNow, setShowBackupNow] = React.useState(""); 19 | const [showDelete, setShowDelete] = React.useState(null); 20 | 21 | function loadSchedules() { 22 | fetch("/api/schedules", { 23 | headers: { 24 | "Content-Type": "application/json", 25 | }, 26 | }) 27 | .then((res) => { 28 | if (res.ok) { 29 | return res.json(); 30 | } else { 31 | console.error("Failed to load schedules"); 32 | } 33 | }) 34 | .then((schedules) => { 35 | if (!schedules) return; 36 | setLoading(false); 37 | setSchedules(schedules); 38 | }) 39 | .catch((error) => { 40 | console.error(error); 41 | }); 42 | } 43 | 44 | function backupNow(id: number) { 45 | fetch(`/api/schedules/${id}/backup`, { 46 | method: "POST", 47 | headers: { 48 | "Content-Type": "application/json", 49 | }, 50 | }) 51 | .then((res) => { 52 | if (!res.ok) { 53 | console.error("Failed to backup schedule"); 54 | } 55 | 56 | res.text().then((text) => setShowBackupNow(text)); 57 | }) 58 | .catch((error) => { 59 | console.error(error); 60 | }); 61 | } 62 | 63 | function handlePauseClick(id: number, paused: boolean) { 64 | fetch(`/api/schedules/${id}/${paused ? "resume" : "pause"}`, { 65 | method: "PUT", 66 | headers: { 67 | "Content-Type": "application/json", 68 | }, 69 | }) 70 | .then((res) => { 71 | if (!res.ok) { 72 | console.error("Failed to pause/resume schedule"); 73 | } 74 | 75 | loadSchedules(); 76 | }) 77 | .catch((error) => { 78 | console.error(error); 79 | }); 80 | } 81 | 82 | function handleDeleteClick(id: number) { 83 | fetch(`/api/schedules/${id}`, { 84 | method: "DELETE", 85 | headers: { 86 | "Content-Type": "application/json", 87 | }, 88 | }) 89 | .then((res) => { 90 | if (!res.ok) { 91 | console.error("Failed to delete schedule"); 92 | } 93 | 94 | setShowDelete(null); 95 | loadSchedules(); 96 | }) 97 | .catch((error) => { 98 | setShowDelete(null); 99 | console.error(error); 100 | }); 101 | } 102 | 103 | React.useEffect(() => { 104 | loadSchedules(); 105 | document.addEventListener("reloadSchedules", loadSchedules); 106 | }, []); 107 | 108 | function closeEditMenu() { 109 | setEditMenuDetails(null); 110 | location.reload(); 111 | } 112 | 113 | return ( 114 |
117 | {loading ? ( 118 |
119 |
120 | {Array.from(Array(3).keys()).map((index) => ( 121 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | ))} 133 |
134 |
135 | ) : schedules.length === 0 ? ( 136 |
137 |

138 | You don't have any schedules yet. Create one by clicking the button 139 | on the top right. 140 |

141 |
142 | ) : ( 143 | schedules.map((schedule, index) => ( 144 | 157 | setEditMenuDetails({ 158 | id: schedule.id, 159 | name: schedule.name, 160 | cron: schedule.cron, 161 | repository: schedule.repository, 162 | keepLast: schedule.keepLast, 163 | }) 164 | } 165 | backupNowClick={() => backupNow(schedule.id)} 166 | pauseClick={() => handlePauseClick(schedule.id, schedule.paused)} 167 | deleteClick={() => setShowDelete(schedule.id)} 168 | /> 169 | )) 170 | )} 171 | {editMenuDetails && ( 172 | 180 | )} 181 | {showBackupNow && ( 182 |
183 |
186 |

Backup now

187 |
188 | {showBackupNow} 189 |
190 |
191 | setShowBackupNow("")} fullWidth> 192 | Close 193 | 194 |
195 |
196 |
197 | )} 198 | {showDelete && ( 199 |
200 |
203 |

Delete schedule

204 |
205 |

206 | Are you sure you want to delete this schedule? 207 |

208 |
209 |
210 | setShowDelete(null)} 213 | fullWidth 214 | > 215 | Cancel 216 | 217 | handleDeleteClick(showDelete)} 219 | fullWidth 220 | > 221 | Delete 222 | 223 |
224 |
225 |
226 | )} 227 |
228 | ); 229 | } 230 | -------------------------------------------------------------------------------- /src/components/dashboard/settings/AccessToken.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import MenuIcon from "../../icons/MenuIcon"; 3 | 4 | type Props = { 5 | id: number; 6 | name: string; 7 | editClick: () => void; 8 | deleteClick: () => void; 9 | }; 10 | 11 | export default function AccessToken(props: Props) { 12 | const [showMenu, setShowMenu] = React.useState(false); 13 | const menuRef = useRef(null); 14 | 15 | useEffect(() => { 16 | function handleClickOutside(event: MouseEvent) { 17 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 18 | setShowMenu(false); 19 | } 20 | } 21 | 22 | document.addEventListener("mousedown", handleClickOutside); 23 | return () => { 24 | document.removeEventListener("mousedown", handleClickOutside); 25 | }; 26 | }, []); 27 | return ( 28 |
29 | {props.name} 30 |
31 | 37 |
40 | 46 | 52 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/dashboard/settings/AccessTokensSettings.tsx: -------------------------------------------------------------------------------- 1 | import BaseButton from "../../BaseButton"; 2 | import React, { useEffect } from "react"; 3 | import BaseSettingPanel from "./BaseSettingPanel"; 4 | import AccessToken from "./AccessToken"; 5 | import EditAccessTokenPopup from "./EditAccessTokenPopup"; 6 | 7 | export default function AccessTokensSettings() { 8 | const [editMenu, setEditMenu] = React.useState<{ 9 | id: number; 10 | name: string; 11 | } | null>(null); 12 | const [tokens, setTokens] = React.useState<{ name: string; id: number }[]>( 13 | [], 14 | ); 15 | const [deleteMenu, setDeleteMenu] = React.useState(null); 16 | 17 | useEffect(() => { 18 | fetch("/api/access-tokens", { 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | }) 23 | .then((res) => { 24 | if (res.ok) { 25 | return res.json(); 26 | } else { 27 | console.error("Failed to load access tokens"); 28 | } 29 | }) 30 | .then((data) => { 31 | if (!data) return; 32 | setTokens(data); 33 | }); 34 | }, []); 35 | 36 | function handleDeleteClick(id: number) { 37 | fetch(`/api/access-tokens/${id}`, { 38 | method: "DELETE", 39 | headers: { 40 | "Content-Type": "application/json", 41 | }, 42 | }).then((res) => { 43 | setDeleteMenu(null); 44 | location.reload(); 45 | }); 46 | } 47 | 48 | return ( 49 |
50 | 51 |
52 | {tokens.map((token) => ( 53 | setEditMenu(token)} 57 | deleteClick={() => setDeleteMenu(token.id)} 58 | /> 59 | ))} 60 | {tokens.length === 0 && ( 61 |
No access tokens found
62 | )} 63 |
64 | {editMenu && ( 65 | setEditMenu(null)} 69 | /> 70 | )} 71 | {deleteMenu && ( 72 |
73 |
76 |

Delete access token

77 |
78 |

All schedules using this token will stop working.

79 |
80 |

Are you sure you want to delete this access token?

81 |
82 |
83 | setDeleteMenu(null)} 86 | fullWidth 87 | > 88 | Cancel 89 | 90 | handleDeleteClick(deleteMenu)} 92 | fullWidth 93 | > 94 | Delete 95 | 96 |
97 |
98 |
99 | )} 100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/components/dashboard/settings/BaseSettingPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | heading: string; 5 | children: React.ReactNode; 6 | }; 7 | 8 | export default function BaseSettingPanel(props: Props) { 9 | return ( 10 |
11 |

{props.heading}

12 | {props.children} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/dashboard/settings/EditAccessTokenPopup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseInput from "../../BaseInput"; 3 | import BaseButton from "../../BaseButton"; 4 | import updateIfNotBelow from "../../../lib/updateIfNotBelow"; 5 | 6 | type Props = { 7 | id: number; 8 | name: string; 9 | closeEdit: () => void; 10 | }; 11 | 12 | export default function EditAccessTokenPopup(props: Props) { 13 | const [loading, setLoading] = React.useState(false); 14 | const [error, setError] = React.useState(""); 15 | const [success, setSuccess] = React.useState(false); 16 | const [name, setName] = React.useState(props.name); 17 | const [token, setToken] = React.useState(""); 18 | 19 | function handleSubmit(e: React.FormEvent) { 20 | e.preventDefault(); 21 | setError(""); 22 | 23 | const formData = new FormData(e.currentTarget); 24 | const name = formData.get("name") as string; 25 | const newToken = formData.get("token") as string; 26 | 27 | setLoading(true); 28 | fetch(`/api/access-tokens/${props.id}`, { 29 | method: "PUT", 30 | headers: { 31 | "Content-Type": "application/json", 32 | }, 33 | body: JSON.stringify({ name, newToken }), 34 | }) 35 | .then((res) => { 36 | if (res.ok) { 37 | return res.json(); 38 | } else { 39 | res.text().then((errorMessage) => setError(errorMessage)); 40 | } 41 | }) 42 | .then((data) => { 43 | setLoading(false); 44 | if (!data) return; 45 | setSuccess(true); 46 | location.reload(); 47 | }); 48 | } 49 | 50 | return ( 51 |
52 |
57 |

Edit access token

58 |
59 |
60 | setName(e.target.value)} 67 | required 68 | /> 69 |
70 | setToken(e.target.value)} 77 | /> 78 |

79 | Note: Changing the token value will delete the old value. 80 |

81 |
82 |
83 | {error &&

{error}

} 84 |
85 |
86 |
87 |
88 | 94 | Cancel 95 | 96 | 97 |
98 | {loading && ( 99 | 107 | 108 | 109 | )} 110 | Save 111 |
112 |
113 |
114 |
115 |
118 |

Access token edited

119 |
120 |

121 | Your access token has been edited successfully. 122 |

123 | 124 | Close 125 | 126 |
127 |
128 |
129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/components/dashboard/settings/StorageSettings.tsx: -------------------------------------------------------------------------------- 1 | import BaseInput from "../../BaseInput"; 2 | import BaseButton from "../../BaseButton"; 3 | import React from "react"; 4 | import BaseSettingPanel from "./BaseSettingPanel"; 5 | 6 | export default function StorageSettings() { 7 | const [location, setLocation] = React.useState("local_folder"); 8 | const [serverAddress, setServerAddress] = React.useState(""); 9 | const [remoteLocation, setRemoteLocation] = React.useState(""); 10 | const [username, setUsername] = React.useState(""); 11 | const [password, setPassword] = React.useState(""); 12 | const [locationError, setLocationError] = React.useState(""); 13 | const [locationSuccess, setLocationSuccess] = React.useState(""); 14 | 15 | React.useEffect(() => { 16 | fetch("/api/config/storage", { 17 | headers: { 18 | "Content-Type": "application/json", 19 | }, 20 | }) 21 | .then((res) => { 22 | if (res.ok) { 23 | return res.json(); 24 | } 25 | }) 26 | .then((data) => { 27 | if (!data) return; 28 | 29 | setLocation(data.defaultLocation); 30 | setServerAddress(data.smbAddress); 31 | setRemoteLocation(data.smbLocation); 32 | setUsername(data.smbUsername); 33 | }) 34 | .catch((err) => { 35 | console.error(err); 36 | }); 37 | }, []); 38 | 39 | function handleDefaultLocationSubmit(e: React.FormEvent) { 40 | e.preventDefault(); 41 | setLocationError(""); 42 | setLocationSuccess(""); 43 | 44 | fetch("/api/config/storage", { 45 | method: "PUT", 46 | headers: { 47 | "Content-Type": "application/json", 48 | }, 49 | body: JSON.stringify({ 50 | location, 51 | serverAddress, 52 | remoteLocation, 53 | username, 54 | password, 55 | }), 56 | }) 57 | .then((res) => { 58 | if (res.ok) { 59 | return res.json(); 60 | } else { 61 | setLocationError("An error occurred. Please check the server logs."); 62 | } 63 | }) 64 | .then((data) => { 65 | if (!data) return; 66 | 67 | setLocationSuccess(data.message); 68 | }) 69 | .catch((err) => { 70 | console.error(err); 71 | }); 72 | } 73 | 74 | return ( 75 |
76 | 77 |
78 | 87 | {location === "smb_share" && ( 88 |
89 | setServerAddress(e.target.value)} 94 | required 95 | /> 96 | setRemoteLocation(e.target.value)} 101 | required 102 | /> 103 | setUsername(e.target.value)} 108 | required 109 | /> 110 |
111 | setPassword(e.target.value)} 116 | required 117 | /> 118 |
119 |

120 | Warning: Password will 121 | be stored in plain text. 122 |

123 |
124 |
125 |
126 | )} 127 | 128 | Save 129 | {locationError &&

{locationError}

} 130 | {locationSuccess && ( 131 |

{locationSuccess}

132 | )} 133 |
134 |
135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /src/components/dashboard/settings/UserSettings.tsx: -------------------------------------------------------------------------------- 1 | import BaseInput from "../../BaseInput"; 2 | import BaseButton from "../../BaseButton"; 3 | import React from "react"; 4 | import BaseSettingPanel from "./BaseSettingPanel"; 5 | 6 | export default function UserSettings() { 7 | const [username, setUsername] = React.useState(""); 8 | const [usernameError, setUsernameError] = React.useState(""); 9 | const [usernameSuccess, setUsernameSuccess] = React.useState(""); 10 | const [passwordError, setPasswordError] = React.useState(""); 11 | const [passwordSuccess, setPasswordSuccess] = React.useState(""); 12 | 13 | React.useEffect(() => { 14 | const username = localStorage.getItem("username") || ""; 15 | setUsername(username); 16 | 17 | // In case username changed on a different device 18 | fetch("/api/user/name", { 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | }) 23 | .then((res) => { 24 | if (res.ok) { 25 | return res.json(); 26 | } 27 | }) 28 | .then((data) => { 29 | if (!data) return; 30 | 31 | localStorage.setItem("username", data.username); 32 | setUsername(data.username); 33 | }) 34 | .catch((err) => { 35 | console.error(err); 36 | }); 37 | }, []); 38 | 39 | function handleUsernameSubmit(e: React.FormEvent) { 40 | e.preventDefault(); 41 | setUsernameError(""); 42 | setUsernameSuccess(""); 43 | 44 | fetch("/api/user/name", { 45 | method: "PUT", 46 | headers: { 47 | "Content-Type": "application/json", 48 | }, 49 | body: JSON.stringify({ username }), 50 | }) 51 | .then((res) => { 52 | if (res.ok) { 53 | return res.json(); 54 | } else { 55 | setUsernameError("An error occurred. Please check the server logs."); 56 | } 57 | }) 58 | .then((data) => { 59 | if (!data) return; 60 | 61 | localStorage.setItem("username", data.username); 62 | setUsername(data.username); 63 | document.cookie = `auth_session=${data.token}; max-age=604800; path=/; SameSite=Strict`; 64 | setUsernameSuccess("Username updated successfully."); 65 | }) 66 | .catch((err) => { 67 | console.error(err); 68 | }); 69 | } 70 | 71 | function handlePasswordSubmit(e: React.FormEvent) { 72 | e.preventDefault(); 73 | const form = e.currentTarget; 74 | const formData = new FormData(form); 75 | const data = Object.fromEntries(formData.entries()); 76 | const password = data.password; 77 | const newPassword = data.newPassword; 78 | const confirmNewPassword = data.confirmNewPassword; 79 | setPasswordError(""); 80 | setPasswordSuccess(""); 81 | 82 | if (newPassword !== confirmNewPassword) { 83 | setPasswordError("Passwords do not match."); 84 | return; 85 | } 86 | 87 | fetch("/api/user/password", { 88 | method: "POST", 89 | headers: { 90 | "Content-Type": "application/json", 91 | }, 92 | body: JSON.stringify({ password, newPassword }), 93 | }) 94 | .then((res) => { 95 | if (res.ok) { 96 | return res.json(); 97 | } else { 98 | setPasswordError("An error occurred. Please check the server logs."); 99 | } 100 | }) 101 | .then((data) => { 102 | if (!data) return; 103 | setPasswordSuccess("Password updated successfully."); 104 | form.reset(); 105 | }) 106 | .catch((err) => { 107 | console.error(err); 108 | }); 109 | } 110 | 111 | return ( 112 |
113 | 114 |
115 |
116 | setUsername(e.target.value)} 121 | /> 122 |
123 | Save 124 | {usernameError &&

{usernameError}

} 125 | {usernameSuccess && ( 126 |

{usernameSuccess}

127 | )} 128 |
129 |
130 | 131 | 132 |
133 |
134 | 140 | 146 | 152 |
153 | Update password 154 | {passwordError &&

{passwordError}

} 155 | {passwordSuccess && ( 156 |

{passwordSuccess}

157 | )} 158 |
159 |
160 |
161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/components/icons/BurgerMenuIcon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size?: number; 3 | }; 4 | 5 | export default function BurgerMenuIcon({ size = 22 }: Props) { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icons/GitIcon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size?: number; 3 | }; 4 | 5 | export default function GitIcon({ size = 28 }: Props) { 6 | return ( 7 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/icons/MenuIcon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size?: number; 3 | }; 4 | 5 | export default function MenuIcon({ size = 10 }: Props) { 6 | return ( 7 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/icons/ReloadIcon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size?: number; 3 | }; 4 | 5 | export default function ReloadIcon({ size = 16 }: Props) { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icons/ShortArrow.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size?: number; 3 | }; 4 | 5 | export default function ShortArrow({ size = 8 }: Props) { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icons/TimeIcon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size?: number; 3 | }; 4 | 5 | export default function TimeIcon({ size = 14 }: Props) { 6 | return ( 7 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/icons/UserIcon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | size?: number; 3 | }; 4 | 5 | export default function UserIcon({ size = 16 }: Props) { 6 | return ( 7 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseButton from "../BaseButton"; 3 | import BaseInput from "../BaseInput"; 4 | 5 | export default function LoginForm() { 6 | const [error, setError] = React.useState(""); 7 | const [success, setSuccess] = React.useState(""); 8 | 9 | function handleLoginSubmit(e: React.FormEvent) { 10 | e.preventDefault(); 11 | setError(""); 12 | const form = e.currentTarget; 13 | const formData = new FormData(form); 14 | const data = Object.fromEntries(formData.entries()); 15 | 16 | const username = data.username; 17 | const password = data.password; 18 | 19 | fetch("/api/login", { 20 | method: "POST", 21 | headers: { 22 | "Content-Type": "application/json", 23 | }, 24 | body: JSON.stringify({ username, password }), 25 | }) 26 | .then((res) => { 27 | if (res.ok) { 28 | return res.json(); 29 | } else if (res.status === 401) { 30 | setError("Invalid username or password"); 31 | } else { 32 | setError("An error occurred. Please try again."); 33 | } 34 | }) 35 | .then((data) => { 36 | if (!data) return; 37 | document.cookie = `auth_session=${data.token}; max-age=604800; path=/; SameSite=Strict`; 38 | setSuccess("Logged in successfully. Redirecting..."); 39 | setTimeout(() => { 40 | window.location.href = "/dashboard"; 41 | }, 1500); 42 | }) 43 | .catch((err) => { 44 | setError("An error occurred. Please try again."); 45 | }); 46 | } 47 | 48 | return ( 49 |
50 |
55 | 61 | 67 | 68 | Forgot password? 69 | 70 | Sign in 71 | 72 | {error &&

{error}

} 73 | {success &&

{success}

} 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/layouts/DashboardLayout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "./Layout.astro"; 3 | import GitIcon from "../components/icons/GitIcon"; 4 | import UserIcon from "../components/icons/UserIcon"; 5 | import BurgerMenuIcon from "../components/icons/BurgerMenuIcon"; 6 | import PhoneNavMenu from "../components/PhoneNavMenu"; 7 | 8 | const links = [ 9 | { name: "Overview", href: "/dashboard/overview" }, 10 | { name: "Schedules", href: "/dashboard/schedules/" }, 11 | { name: "History", href: "/dashboard/history/" }, 12 | { name: "Settings", href: "/dashboard/settings/" }, 13 | ]; 14 | --- 15 | 16 | 59 | 60 | 61 |
62 |
63 |
66 | 67 | 68 |
69 |
70 |
71 | 77 | 88 |
89 | 95 |
96 |
97 | 111 | 112 |
113 |
114 |
115 | 116 |
117 |
118 | 119 |
120 | -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 | 34 | 35 | -------------------------------------------------------------------------------- /src/layouts/SettingsLayout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import ShortArrow from "../components/icons/ShortArrow"; 3 | import DashboardLayout from "./DashboardLayout.astro"; 4 | 5 | type Props = { 6 | currentPage: string; 7 | }; 8 | 9 | const { currentPage }: Props = Astro.props; 10 | 11 | const settings = [ 12 | { 13 | name: "Overview", 14 | slug: "overview", 15 | hidden: true, 16 | }, 17 | { 18 | name: "User", 19 | slug: "user", 20 | }, 21 | { 22 | name: "Access tokens", 23 | slug: "access-tokens", 24 | }, 25 | { 26 | name: "Storage", 27 | slug: "storage", 28 | }, 29 | { 30 | name: "About", 31 | slug: "about", 32 | }, 33 | ]; 34 | 35 | function getCurrentPage() { 36 | return settings.find((setting) => setting.slug === currentPage); 37 | } 38 | --- 39 | 40 | 60 | 61 | 62 |
63 |

{getCurrentPage()?.name}

64 |
65 |
66 | { 67 | currentPage === "overview" && ( 68 |
69 | {settings 70 | .filter((setting) => !setting.hidden) 71 | .map((setting) => ( 72 | 76 | {setting.name} 77 | 78 | 79 | ))} 80 |
81 | ) 82 | } 83 |
84 | 100 |
101 | 102 |
103 |
104 |
105 |
106 | -------------------------------------------------------------------------------- /src/lib/formatTimestamp.ts: -------------------------------------------------------------------------------- 1 | export function formatTimestamp(timestamp: string) { 2 | const now = new Date().getTime(); 3 | const then = new Date(timestamp); 4 | const diffInSeconds = Math.floor((now - then.getTime()) / 1000); 5 | 6 | if (diffInSeconds < 60) { 7 | return "Just now"; 8 | } 9 | 10 | const diffInMinutes = Math.floor(diffInSeconds / 60); 11 | if (diffInMinutes < 60) { 12 | return `${diffInMinutes} minute${diffInMinutes > 1 ? "s" : ""} ago`; 13 | } 14 | 15 | const diffInHours = Math.floor(diffInMinutes / 60); 16 | if (diffInHours < 24) { 17 | return `${diffInHours} hour${diffInHours > 1 ? "s" : ""} ago`; 18 | } 19 | 20 | const diffInDays = Math.floor(diffInHours / 24); 21 | if (diffInDays < 30) { 22 | return `${diffInDays} day${diffInDays > 1 ? "s" : ""} ago`; 23 | } 24 | 25 | const options: Intl.DateTimeFormatOptions = { 26 | day: "2-digit", 27 | month: "2-digit", 28 | year: "numeric", 29 | }; 30 | return then.toLocaleDateString("de-DE", options); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/updateIfNotBelow.ts: -------------------------------------------------------------------------------- 1 | export default function updateIfNotBelow( 2 | e: React.ChangeEvent, 3 | setFunction: React.Dispatch>, 4 | minValue = 1, 5 | ) { 6 | if (parseInt(e.target.value) < minValue) { 7 | setFunction(minValue); 8 | return; 9 | } 10 | 11 | setFunction(parseInt(e.target.value)); 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/dashboard/history.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import HistoryList from "../../components/dashboard/HistoryList"; 3 | import ReloadIcon from "../../components/icons/ReloadIcon"; 4 | import DashboardLayout from "../../layouts/DashboardLayout.astro"; 5 | --- 6 | 7 | History | GitSave 8 | 9 |
10 |

History

11 | 17 |
18 |
19 | 20 |
21 |
22 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 | 8 | -------------------------------------------------------------------------------- /src/pages/dashboard/overview.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import HistoryList from "../../components/dashboard/HistoryList"; 3 | import ScheduleList from "../../components/dashboard/ScheduleList"; 4 | import ReloadIcon from "../../components/icons/ReloadIcon"; 5 | import DashboardLayout from "../../layouts/DashboardLayout.astro"; 6 | --- 7 | 8 | Overview | GitSave 9 | 16 | 17 | 18 |
19 |

Overview

20 | 26 |
27 |
28 |

Schedules

29 |
30 | 31 |
32 |

History

33 |
34 | 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /src/pages/dashboard/schedules.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import AddSchedulePopup from "../../components/dashboard/AddSchedulePopup"; 3 | import ScheduleList from "../../components/dashboard/ScheduleList"; 4 | import DashboardLayout from "../../layouts/DashboardLayout.astro"; 5 | --- 6 | 7 | Schedules | GitSave 8 | 30 | 31 | 32 |
33 |

Schedules

34 | 40 |
41 |
42 |
43 | 44 |
45 |
46 | 47 |
48 | -------------------------------------------------------------------------------- /src/pages/dashboard/settings/about.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import GitIcon from "../../../components/icons/GitIcon"; 3 | import SettingsLayout from "../../../layouts/SettingsLayout.astro"; 4 | const applicationVersion = process.env.npm_package_version; 5 | --- 6 | 7 | 25 | 26 | About | GitSave 27 | 28 |
29 |
32 |
33 | 34 |
35 | GitSave 36 |
37 |
38 |

Version: {applicationVersion}

39 | GitHub 44 | 55 |
56 |
57 |
58 | -------------------------------------------------------------------------------- /src/pages/dashboard/settings/access-tokens.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import AccessTokensSettings from "../../../components/dashboard/settings/AccessTokensSettings"; 3 | import SettingsLayout from "../../../layouts/SettingsLayout.astro"; 4 | --- 5 | 6 | Access tokens settings | GitSave 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/dashboard/settings/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import SettingsLayout from "../../../layouts/SettingsLayout.astro"; 3 | --- 4 | 5 | 10 | 11 | Settings | GitSave 12 | 13 | -------------------------------------------------------------------------------- /src/pages/dashboard/settings/storage.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import StorageSettings from "../../../components/dashboard/settings/StorageSettings"; 3 | import SettingsLayout from "../../../layouts/SettingsLayout.astro"; 4 | --- 5 | 6 | Storage settings | GitSave 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/dashboard/settings/user.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import UserSettings from "../../../components/dashboard/settings/UserSettings"; 3 | import SettingsLayout from "../../../layouts/SettingsLayout.astro"; 4 | --- 5 | 6 | User settings | GitSave 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import prisma from "../../prisma/client"; 3 | 4 | const users = await prisma.user.findMany({}); 5 | const userExists = users.length > 0; 6 | 7 | --- 8 | 9 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Astro 51 | 52 | 53 |

If you're not redirected, click here

54 | 55 | 56 | -------------------------------------------------------------------------------- /src/pages/login.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../layouts/Layout.astro"; 3 | import LoginForm from "../components/login/LoginForm"; 4 | import GitIcon from "../components/icons/GitIcon"; 5 | --- 6 | 7 | Login | GitSave 8 | 9 |
10 |
11 |
14 | 15 | GitSave 16 |
17 |

Sign in

18 | 19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /src/pages/setup.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../layouts/Layout.astro"; 3 | import GitIcon from "../components/icons/GitIcon"; 4 | import SetupComponent from "../components/Setup"; 5 | --- 6 | 7 | Setup | GitSave 8 | 9 |
10 |
11 |
14 | 15 | GitSave 16 |
17 | 18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /src/types/scheduleTypes.ts: -------------------------------------------------------------------------------- 1 | export type Schedule = { 2 | id: number; 3 | name: string; 4 | repository: string; 5 | cron: string; 6 | paused: boolean; 7 | username: string; 8 | keepLast: number; 9 | }; 10 | 11 | export type ScheduleHistory = { 12 | id: number; 13 | backupJobId: number; 14 | backupJob?: { 15 | name: string; 16 | }; 17 | timestamp: string; 18 | success: boolean; 19 | message: string | null; 20 | }; 21 | 22 | export type ScheduleWithHistory = Schedule & { 23 | backupHistory: ScheduleHistory[]; 24 | }; 25 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | bg: { 8 | 100: "#282828", 9 | 200: "#222222", 10 | 300: "#191919", 11 | 400: "#0F0F0F", 12 | }, 13 | border: { 14 | 100: "#5E5E5E", 15 | 200: "#353535", 16 | }, 17 | secondary: "#969699", 18 | red: "#FF453A", 19 | green: "#30D158", 20 | orange: "#FF9F0A", 21 | }, 22 | }, 23 | }, 24 | plugins: [], 25 | }; 26 | --------------------------------------------------------------------------------