diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 286e191f..390e2daa 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,6 +1,7 @@ name: "🐛 Bug report" description: Report a bug to help us improve the project. labels: ["bug"] +title: "fix: " body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/enhancement-request.yml b/.github/ISSUE_TEMPLATE/enhancement-request.yml index f65f37c2..21ae1a8e 100644 --- a/.github/ISSUE_TEMPLATE/enhancement-request.yml +++ b/.github/ISSUE_TEMPLATE/enhancement-request.yml @@ -1,6 +1,7 @@ name: "🌈 Enhancement request" description: Suggest an idea or enhancement for the project. labels: ["enhancement"] +title: "enhancement: " body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 28e533b8..1c34e83b 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,6 +1,7 @@ name: "🚀 Feature request" description: Suggest an idea or enhancement for the project. -labels: ["enhancement"] +labels: ["feature"] +title: "feat: " body: - type: markdown attributes: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a2483c1..f7552d25 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,10 @@ permissions: jobs: autofix: runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/continuous-release.yml b/.github/workflows/continuous-release.yml index 8f9d25df..110f4eb1 100644 --- a/.github/workflows/continuous-release.yml +++ b/.github/workflows/continuous-release.yml @@ -4,6 +4,9 @@ on: [push, pull_request] jobs: build: runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index 73ff5f86..79d46ac9 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ dist .idea .config/shelve* + +*/dist/ +*/.output/ diff --git a/apps/shelve/.config/shelve.config.json b/apps/shelve/.config/shelve.config.json index b17fa141..658aa118 100644 --- a/apps/shelve/.config/shelve.config.json +++ b/apps/shelve/.config/shelve.config.json @@ -1,4 +1,4 @@ { "$schema": "https://raw.githubusercontent.com/HugoRCD/shelve/main/packages/types/shelveConfigSchema.json", "project": "shelve" -} \ No newline at end of file +} diff --git a/apps/shelve/app/components/OTP.vue b/apps/shelve/app/components/OTP.vue index 58615095..782783c9 100644 --- a/apps/shelve/app/components/OTP.vue +++ b/apps/shelve/app/components/OTP.vue @@ -15,7 +15,6 @@ const digits = reactive<[string | null]>([null]) const otp = defineModel({ type: String }) for (let i = 0; i < props.digitCount; i++) { - // eslint-disable-next-line vue/no-ref-object-reactivity-loss digits[i] = otp.value![i] || null } diff --git a/apps/shelve/package.json b/apps/shelve/package.json index 4a5e4b47..23869bc4 100644 --- a/apps/shelve/package.json +++ b/apps/shelve/package.json @@ -31,8 +31,8 @@ "@nuxtjs/seo": "^2.0.0-rc.23", "@prisma/client": "^5.21.1", "@shelve/cli": "^2.4.0", - "@shelve/crypto": "workspace:*", - "@shelve/types": "workspace:*", + "@shelve/crypto": "*", + "@shelve/types": "*", "@tsparticles/engine": "^3.5.0", "@tsparticles/slim": "^3.5.0", "@vitejs/plugin-vue": "^5.1.4", diff --git a/apps/shelve/server/api/admin/users/[userId].delete.ts b/apps/shelve/server/api/admin/users/[userId].delete.ts index 211c3d40..aa3734cc 100644 --- a/apps/shelve/server/api/admin/users/[userId].delete.ts +++ b/apps/shelve/server/api/admin/users/[userId].delete.ts @@ -1,12 +1,13 @@ -import { H3Event } from 'h3' -import { deleteUser } from '~~/server/app/userService' +import type { H3Event } from 'h3' +import { UserService } from '~~/server/services/user.service' export default eventHandler(async (event: H3Event) => { + const userService = new UserService() const { user } = event.context const id = getRouterParam(event, 'userId') as string if (!id) throw createError({ statusCode: 400, statusMessage: 'missing params' }) if (user.id === parseInt(id)) throw createError({ statusCode: 400, statusMessage: 'you can\'t delete your own account' }) - await deleteUser(parseInt(id)) + await userService.deleteUser(parseInt(id)) return { statusCode: 200, message: 'user deleted', diff --git a/apps/shelve/server/api/admin/users/[userId].put.ts b/apps/shelve/server/api/admin/users/[userId].put.ts index df8c67df..19998b3b 100644 --- a/apps/shelve/server/api/admin/users/[userId].put.ts +++ b/apps/shelve/server/api/admin/users/[userId].put.ts @@ -1,4 +1,4 @@ -import { H3Event } from 'h3' +import type { H3Event } from 'h3' export default eventHandler(async (event: H3Event) => { const { user } = event.context diff --git a/apps/shelve/server/api/auth/currentUser.get.ts b/apps/shelve/server/api/auth/currentUser.get.ts index 2becffbc..2d464c00 100644 --- a/apps/shelve/server/api/auth/currentUser.get.ts +++ b/apps/shelve/server/api/auth/currentUser.get.ts @@ -1,8 +1,9 @@ -import { H3Event } from 'h3' -import { getUserByAuthToken } from '~~/server/app/tokenService' +import type { H3Event } from 'h3' +import { TokenService } from '~~/server/services/token.service' export default eventHandler(async (event: H3Event) => { + const tokenService = new TokenService() const authToken = getCookie(event, 'authToken') || '' - return await getUserByAuthToken(authToken) + return await tokenService.getUserByAuthToken(authToken) }) diff --git a/apps/shelve/server/api/github/repos.get.ts b/apps/shelve/server/api/github/repos.get.ts index 1aa44939..069c13fb 100644 --- a/apps/shelve/server/api/github/repos.get.ts +++ b/apps/shelve/server/api/github/repos.get.ts @@ -1,6 +1,7 @@ -import { H3Event } from 'h3' -import { getUserRepos } from '~~/server/app/githubService' +import type { H3Event } from 'h3' +import { GitHubService } from '~~/server/services/github.service' export default defineEventHandler(async (event: H3Event) => { - return await getUserRepos(event) + const githubService = new GitHubService() + return await githubService.getUserRepos(event) }) diff --git a/apps/shelve/server/api/github/upload.post.ts b/apps/shelve/server/api/github/upload.post.ts index f9c3a5c5..42618299 100644 --- a/apps/shelve/server/api/github/upload.post.ts +++ b/apps/shelve/server/api/github/upload.post.ts @@ -1,5 +1,5 @@ -import { H3Event } from 'h3' -import { uploadFile } from '~~/server/app/githubService' +import type { H3Event } from 'h3' +import { GitHubService } from '~~/server/services/github.service' export default defineEventHandler(async (event: H3Event) => { const formData = await readMultipartFormData(event) @@ -17,9 +17,10 @@ export default defineEventHandler(async (event: H3Event) => { statusMessage: 'No file provided' }) } + const gitHubService = new GitHubService() const file = new File([fileField.data], fileField.filename, { type: fileField.type }) const repoName = 'astra' - return await uploadFile(event, file, repoName) + return await gitHubService.uploadFile(event, file, repoName) }) diff --git a/apps/shelve/server/api/project/[id]/index.delete.ts b/apps/shelve/server/api/project/[id]/index.delete.ts index c2676ef8..3eb75b70 100644 --- a/apps/shelve/server/api/project/[id]/index.delete.ts +++ b/apps/shelve/server/api/project/[id]/index.delete.ts @@ -1,11 +1,12 @@ -import { H3Event } from 'h3' -import { deleteProject } from '~~/server/app/projectService' +import type { H3Event } from 'h3' +import { ProjectService } from '~~/server/services/project.service' export default eventHandler(async (event: H3Event) => { const { user } = event.context const id = getRouterParam(event, 'id') as string if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - await deleteProject(id, user.id) + const projectService = new ProjectService() + await projectService.deleteProject(id, user.id) return { statusCode: 200, message: 'Project deleted', diff --git a/apps/shelve/server/api/project/[id]/index.get.ts b/apps/shelve/server/api/project/[id]/index.get.ts index 53487206..e805c865 100644 --- a/apps/shelve/server/api/project/[id]/index.get.ts +++ b/apps/shelve/server/api/project/[id]/index.get.ts @@ -1,8 +1,9 @@ -import { H3Event } from 'h3' -import { getProjectById } from '~~/server/app/projectService' +import type { H3Event } from 'h3' +import { ProjectService } from '~~/server/services/project.service' export default eventHandler(async (event: H3Event) => { const id = getRouterParam(event, 'id') as string if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - return await getProjectById(parseInt(id)) + const projectService = new ProjectService() + return await projectService.getProjectById(+id) }) diff --git a/apps/shelve/server/api/project/[id]/index.put.ts b/apps/shelve/server/api/project/[id]/index.put.ts index 2ce0f91b..38ac7662 100644 --- a/apps/shelve/server/api/project/[id]/index.put.ts +++ b/apps/shelve/server/api/project/[id]/index.put.ts @@ -1,13 +1,14 @@ -import { H3Event } from 'h3' -import { updateProject } from '~~/server/app/projectService' +import type { H3Event } from 'h3' +import { ProjectService } from '~~/server/services/project.service' export default eventHandler(async (event: H3Event) => { const { user } = event.context const id = getRouterParam(event, 'id') as string if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) + const projectService = new ProjectService() const projectUpdateInput = await readBody(event) delete projectUpdateInput.variables delete projectUpdateInput.team projectUpdateInput.name = projectUpdateInput.name.trim() - return await updateProject(projectUpdateInput, parseInt(id), user.id) + return await projectService.updateProject(projectUpdateInput, +id, user.id) }) diff --git a/apps/shelve/server/api/project/[id]/team/[teamId].delete.ts b/apps/shelve/server/api/project/[id]/team/[teamId].delete.ts index bb5483df..97850655 100644 --- a/apps/shelve/server/api/project/[id]/team/[teamId].delete.ts +++ b/apps/shelve/server/api/project/[id]/team/[teamId].delete.ts @@ -1,11 +1,12 @@ -import { H3Event } from 'h3' -import { removeTeamFromProject } from '~~/server/app/projectService' +import type { H3Event } from 'h3' +import { ProjectService } from '~~/server/services/project.service' export default defineEventHandler(async (event: H3Event) => { const id = getRouterParam(event, 'id') as string const teamId = getRouterParam(event, 'teamId') as string if (!id || !teamId) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - await removeTeamFromProject(+id, +teamId) + const projectService = new ProjectService() + await projectService.removeTeamFromProject(+id, +teamId) return { statusCode: 200, message: 'Team removed from project', diff --git a/apps/shelve/server/api/project/[id]/team/[teamId].post.ts b/apps/shelve/server/api/project/[id]/team/[teamId].post.ts index 193e7e9c..79ea4bc5 100644 --- a/apps/shelve/server/api/project/[id]/team/[teamId].post.ts +++ b/apps/shelve/server/api/project/[id]/team/[teamId].post.ts @@ -1,11 +1,12 @@ -import { H3Event } from 'h3' -import { addTeamToProject } from '~~/server/app/projectService' +import type { H3Event } from 'h3' +import { ProjectService } from '~~/server/services/project.service' export default defineEventHandler(async (event: H3Event) => { const id = getRouterParam(event, 'id') as string const teamId = getRouterParam(event, 'teamId') as string if (!id || !teamId) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - await addTeamToProject(+id, +teamId) + const projectService = new ProjectService() + await projectService.addTeamToProject(+id, +teamId) return { statusCode: 200, message: 'Team added to project', diff --git a/apps/shelve/server/api/project/index.get.ts b/apps/shelve/server/api/project/index.get.ts index f16a932e..b0d19a8b 100644 --- a/apps/shelve/server/api/project/index.get.ts +++ b/apps/shelve/server/api/project/index.get.ts @@ -1,8 +1,9 @@ -import { H3Event } from 'h3' -import { getProjectsByUserId } from '~~/server/app/projectService' +import type { H3Event } from 'h3' +import { ProjectService } from '~~/server/services/project.service' export default eventHandler(async (event: H3Event) => { + const projectService = new ProjectService() const { user } = event.context if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' }) - return await getProjectsByUserId(user.id) + return await projectService.getProjectsByUserId(user.id) }) diff --git a/apps/shelve/server/api/project/index.post.ts b/apps/shelve/server/api/project/index.post.ts index d1a904d8..0649cf00 100644 --- a/apps/shelve/server/api/project/index.post.ts +++ b/apps/shelve/server/api/project/index.post.ts @@ -1,10 +1,11 @@ -import { H3Event } from 'h3' -import { createProject } from '~~/server/app/projectService' +import type { H3Event } from 'h3' +import { ProjectService } from '~~/server/services/project.service' export default eventHandler(async (event: H3Event) => { + const projectService = new ProjectService() const { user } = event.context const projectCreateInput = await readBody(event) delete projectCreateInput.variables projectCreateInput.name = projectCreateInput.name.trim() - return await createProject(projectCreateInput, user.id) + return await projectService.createProject(projectCreateInput, user.id) }) diff --git a/apps/shelve/server/api/project/name/[name].get.ts b/apps/shelve/server/api/project/name/[name].get.ts index 7db40756..1971ac0f 100644 --- a/apps/shelve/server/api/project/name/[name].get.ts +++ b/apps/shelve/server/api/project/name/[name].get.ts @@ -1,5 +1,4 @@ -import { H3Event } from 'h3' - +import type { H3Event } from 'h3' export default eventHandler(async (event: H3Event) => { const paramName = getRouterParam(event, 'name') diff --git a/apps/shelve/server/api/teams/[teamId]/index.delete.ts b/apps/shelve/server/api/teams/[teamId]/index.delete.ts index 2425a4ce..c9ad3721 100644 --- a/apps/shelve/server/api/teams/[teamId]/index.delete.ts +++ b/apps/shelve/server/api/teams/[teamId]/index.delete.ts @@ -1,11 +1,12 @@ -import { H3Event } from 'h3' -import { deleteTeam } from '~~/server/app/teamsService' +import type { H3Event } from 'h3' +import { TeamService } from '~~/server/services/teams.service' export default eventHandler(async (event: H3Event) => { const { user } = event.context const id = getRouterParam(event, 'teamId') as string if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - await deleteTeam({ + const teamService = new TeamService() + await teamService.deleteTeam({ teamId: parseInt(id), userId: user.id, userRole: user.role, diff --git a/apps/shelve/server/api/teams/[teamId]/index.put.ts b/apps/shelve/server/api/teams/[teamId]/index.put.ts deleted file mode 100644 index a15598dd..00000000 --- a/apps/shelve/server/api/teams/[teamId]/index.put.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -import { updateTeam } from "~/server/app/teamsService"; -import { H3Event } from "h3"; - -export default eventHandler(async (event: H3Event) => { - const user = event.context.user; - const id = getRouterParam(event, "teamId") as string; - if (!id) throw createError({ statusCode: 400, statusMessage: "Missing params" }); - const teamUpdateInput = await readBody(event); - await updateTeam(teamUpdateInput, user.id); - return { - statusCode: 200, - message: "Team updated", - }; -}); -*/ diff --git a/apps/shelve/server/api/teams/[teamId]/members/[id].delete.ts b/apps/shelve/server/api/teams/[teamId]/members/[id].delete.ts index aed52b56..0a59a0f3 100644 --- a/apps/shelve/server/api/teams/[teamId]/members/[id].delete.ts +++ b/apps/shelve/server/api/teams/[teamId]/members/[id].delete.ts @@ -1,12 +1,13 @@ -import { H3Event } from 'h3' -import { removeMember } from '~~/server/app/teamsService' +import type { H3Event } from 'h3' +import { TeamService } from '~~/server/services/teams.service' export default eventHandler(async (event: H3Event) => { const { user } = event.context const teamId = getRouterParam(event, 'teamId') as string const memberId = getRouterParam(event, 'id') as string if (!teamId || !memberId) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - await removeMember(parseInt(teamId), parseInt(memberId), user.id) + const teamService = new TeamService() + await teamService.removeMember(+teamId, +memberId, user.id) return { statusCode: 200, message: 'Member removed', diff --git a/apps/shelve/server/api/teams/[teamId]/members/index.post.ts b/apps/shelve/server/api/teams/[teamId]/members/index.post.ts index 739cc390..a5364384 100644 --- a/apps/shelve/server/api/teams/[teamId]/members/index.post.ts +++ b/apps/shelve/server/api/teams/[teamId]/members/index.post.ts @@ -1,10 +1,11 @@ -import { H3Event } from 'h3' -import { upsertMember } from '~~/server/app/teamsService' +import type { H3Event } from 'h3' +import { TeamService } from '~~/server/services/teams.service' export default eventHandler(async (event: H3Event) => { const { user } = event.context const teamId = getRouterParam(event, 'teamId') as string if (!teamId) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) + const teamService = new TeamService() const addMemberInput = await readBody(event) - return await upsertMember(parseInt(teamId), addMemberInput, user.id) + return await teamService.upsertMember(+teamId, addMemberInput, user.id) }) diff --git a/apps/shelve/server/api/teams/index.get.ts b/apps/shelve/server/api/teams/index.get.ts index 8627a002..56b189ed 100644 --- a/apps/shelve/server/api/teams/index.get.ts +++ b/apps/shelve/server/api/teams/index.get.ts @@ -1,7 +1,8 @@ -import { H3Event } from 'h3' -import { getTeamByUserId } from '~~/server/app/teamsService' +import type { H3Event } from 'h3' +import { TeamService } from '~~/server/services/teams.service' export default eventHandler((event: H3Event) => { + const teamService = new TeamService() const { user } = event.context - return getTeamByUserId(user.id) + return teamService.getTeamByUserId(user.id) }) diff --git a/apps/shelve/server/api/teams/index.post.ts b/apps/shelve/server/api/teams/index.post.ts index c97042b6..e0ab8d80 100644 --- a/apps/shelve/server/api/teams/index.post.ts +++ b/apps/shelve/server/api/teams/index.post.ts @@ -1,11 +1,12 @@ -import { H3Event } from 'h3' +import type { H3Event } from 'h3' import type { CreateTeamInput } from '@shelve/types' -import { createTeam } from '~~/server/app/teamsService' +import { TeamService } from '~~/server/services/teams.service' export default eventHandler(async (event: H3Event) => { const { user } = event.context const createTeamInput = await readBody(event) as CreateTeamInput if (!createTeamInput.name) throw createError({ statusCode: 400, statusMessage: 'Cannot create team without name' }) + const teamService = new TeamService() createTeamInput.name = createTeamInput.name.trim() - return await createTeam(createTeamInput, user.id) + return await teamService.createTeam(createTeamInput, user.id) }) diff --git a/apps/shelve/server/api/tokens/[id].delete.ts b/apps/shelve/server/api/tokens/[id].delete.ts index 7497480f..8135a2f4 100644 --- a/apps/shelve/server/api/tokens/[id].delete.ts +++ b/apps/shelve/server/api/tokens/[id].delete.ts @@ -1,16 +1,12 @@ -import { H3Event } from 'h3' - +import type { H3Event } from 'h3' +import { TokenService } from '~~/server/services/token.service' export default defineEventHandler(async (event: H3Event) => { + const tokenService = new TokenService() const { user } = event.context const id = getRouterParam(event, 'id') as string if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - await prisma.token.delete({ - where: { - id: +id, - userId: user.id, - }, - }) + await tokenService.deleteUserToken(+id, user.id) }) diff --git a/apps/shelve/server/api/tokens/[token].get.ts b/apps/shelve/server/api/tokens/[token].get.ts index 1058eec0..6defda7c 100644 --- a/apps/shelve/server/api/tokens/[token].get.ts +++ b/apps/shelve/server/api/tokens/[token].get.ts @@ -1,9 +1,10 @@ -import { H3Event } from 'h3' -import { getUserByAuthToken } from '~~/server/app/tokenService' +import type { H3Event } from 'h3' +import { TokenService } from '~~/server/services/token.service' export default defineEventHandler((event: H3Event) => { + const tokenService = new TokenService() const token = getRouterParam(event, 'token') as string if (!token) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - return getUserByAuthToken(token) + return tokenService.getUserByAuthToken(token) }) diff --git a/apps/shelve/server/api/tokens/index.get.ts b/apps/shelve/server/api/tokens/index.get.ts index 1e613f65..179190c6 100644 --- a/apps/shelve/server/api/tokens/index.get.ts +++ b/apps/shelve/server/api/tokens/index.get.ts @@ -1,8 +1,9 @@ -import { H3Event } from 'h3' -import { getTokensByUserId } from '~~/server/app/tokenService' +import type { H3Event } from 'h3' +import { TokenService } from '~~/server/services/token.service' export default defineEventHandler((event: H3Event) => { + const tokenService = new TokenService() const { user } = event.context - return getTokensByUserId(user.id) + return tokenService.getTokensByUserId(user.id) }) diff --git a/apps/shelve/server/api/tokens/index.post.ts b/apps/shelve/server/api/tokens/index.post.ts index 46ab7eb4..bacf8220 100644 --- a/apps/shelve/server/api/tokens/index.post.ts +++ b/apps/shelve/server/api/tokens/index.post.ts @@ -1,9 +1,10 @@ -import { H3Event } from 'h3' -import { createToken } from '~~/server/app/tokenService' +import type { H3Event } from 'h3' +import { TokenService } from '~~/server/services/token.service' export default defineEventHandler(async (event: H3Event) => { + const tokenService = new TokenService() const { user } = event.context const body = await readBody(event) - return createToken({ name: body.name, userId: user.id }) + return tokenService.createToken({ name: body.name, userId: user.id }) }) diff --git a/apps/shelve/server/api/user/index.delete.ts b/apps/shelve/server/api/user/index.delete.ts index a3f3aa57..76ed49b6 100644 --- a/apps/shelve/server/api/user/index.delete.ts +++ b/apps/shelve/server/api/user/index.delete.ts @@ -1,9 +1,10 @@ -import { H3Event } from 'h3' -import { deleteUser } from '~~/server/app/userService' +import type { H3Event } from 'h3' +import { UserService } from '~~/server/services/user.service' export default eventHandler(async (event: H3Event) => { + const userService = new UserService() const { user } = event.context - await deleteUser(user.id) + await userService.deleteUser(user.id) return { statusCode: 200, message: 'User deleted', diff --git a/apps/shelve/server/api/user/index.put.ts b/apps/shelve/server/api/user/index.put.ts index be554e51..e73f8d0b 100644 --- a/apps/shelve/server/api/user/index.put.ts +++ b/apps/shelve/server/api/user/index.put.ts @@ -1,14 +1,15 @@ -import { H3Event } from 'h3' +import type { H3Event } from 'h3' import type { UpdateUserInput } from '@shelve/types' -import { updateUser } from '~~/server/app/userService' +import { UserService } from '~~/server/services/user.service' export default eventHandler(async (event: H3Event) => { + const userService = new UserService() const { user } = event.context const { authToken } = event.context const updateUserInput = await readBody(event) as UpdateUserInput updateUserInput.username = updateUserInput.username?.trim() updateUserInput.email = updateUserInput.email?.trim() - const updatedUser = await updateUser(user, updateUserInput, authToken) + const updatedUser = await userService.updateUser(user, updateUserInput, authToken) await setUserSession(event, { user: { username: updatedUser.username, diff --git a/apps/shelve/server/api/user/teammate/index.get.ts b/apps/shelve/server/api/user/teammate/index.get.ts index 440e0cf0..b6eb9a5a 100644 --- a/apps/shelve/server/api/user/teammate/index.get.ts +++ b/apps/shelve/server/api/user/teammate/index.get.ts @@ -1,7 +1,8 @@ -import { H3Event } from 'h3' -import { getTeammatesByUserId } from '~~/server/app/teammateService' +import type { H3Event } from 'h3' +import { TeammateService } from '~~/server/services/teammate.service' export default eventHandler((event: H3Event) => { + const teammateService = new TeammateService() const { user } = event.context - return getTeammatesByUserId(user.id) + return teammateService.getTeammatesByUserId(user.id) }) diff --git a/apps/shelve/server/api/variable/[id]/[env].delete.ts b/apps/shelve/server/api/variable/[id]/[env].delete.ts index 53f61e48..b8972c5c 100644 --- a/apps/shelve/server/api/variable/[id]/[env].delete.ts +++ b/apps/shelve/server/api/variable/[id]/[env].delete.ts @@ -1,11 +1,12 @@ -import { H3Event } from 'h3' -import { deleteVariable } from '~~/server/app/variableService' +import type { H3Event } from 'h3' +import { VariableService } from '~~/server/services/variable.service' export default eventHandler(async (event: H3Event) => { const id = getRouterParam(event, 'id') as string const env = getRouterParam(event, 'env') as string if (!id && !env) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - await deleteVariable(parseInt(id), env) + const variableService = new VariableService() + await variableService.deleteVariable(+id, env) return { statusCode: 200, message: 'Variable deleted', diff --git a/apps/shelve/server/api/variable/[id]/[env].get.ts b/apps/shelve/server/api/variable/[id]/[env].get.ts index 283e35aa..1d794166 100644 --- a/apps/shelve/server/api/variable/[id]/[env].get.ts +++ b/apps/shelve/server/api/variable/[id]/[env].get.ts @@ -1,10 +1,11 @@ -import { Environment } from '@shelve/types' -import { H3Event } from 'h3' -import { getVariablesByProjectId } from '~~/server/app/variableService' +import type { Environment } from '@shelve/types' +import type { H3Event } from 'h3' +import { VariableService } from '~~/server/services/variable.service' export default eventHandler(async (event: H3Event) => { const id = getRouterParam(event, 'id') as string const env = getRouterParam(event, 'env') as Environment if (!id && !env) throw createError({ statusCode: 400, statusMessage: 'Missing params' }) - return await getVariablesByProjectId(parseInt(id), env) + const variableService = new VariableService() + return await variableService.getVariablesByProjectId(+id, env) }) diff --git a/apps/shelve/server/api/variable/index.delete.ts b/apps/shelve/server/api/variable/index.delete.ts index 13817b57..36da8008 100644 --- a/apps/shelve/server/api/variable/index.delete.ts +++ b/apps/shelve/server/api/variable/index.delete.ts @@ -1,11 +1,12 @@ -import { H3Event } from 'h3' -import { deleteVariables } from '~~/server/app/variableService' +import type { H3Event } from 'h3' +import { VariableService } from '~~/server/services/variable.service' export default defineEventHandler(async (event: H3Event) => { + const variableService = new VariableService() const { user } = event.context const body = await readBody(event) const variablesId = body.variables - await deleteVariables(variablesId, user) + await variableService.deleteVariables(variablesId, user) return { statusCode: 200, message: 'Variables deleted', diff --git a/apps/shelve/server/api/variable/index.post.ts b/apps/shelve/server/api/variable/index.post.ts index 43ba670a..5a181477 100644 --- a/apps/shelve/server/api/variable/index.post.ts +++ b/apps/shelve/server/api/variable/index.post.ts @@ -1,11 +1,13 @@ -import { type VariablesCreateInput } from '@shelve/types' -import { H3Event } from 'h3' -import { upsertVariable } from '~~/server/app/variableService' -import { getProjectById } from '~~/server/app/projectService' +import type { VariablesCreateInput } from '@shelve/types' +import type { H3Event } from 'h3' +import { VariableService } from '~~/server/services/variable.service' +import { ProjectService } from '~~/server/services/project.service' export default eventHandler(async (event: H3Event) => { + const variableService = new VariableService() + const projectService = new ProjectService() const variablesCreateInput = await readBody(event) as VariablesCreateInput - const project = await getProjectById(variablesCreateInput.projectId) + const project = await projectService.getProjectById(variablesCreateInput.projectId) if (!project) throw createError({ statusCode: 400, statusMessage: 'Project not found' }) - return await upsertVariable(variablesCreateInput) + return await variableService.upsertVariable(variablesCreateInput) }) diff --git a/apps/shelve/server/api/variable/project/[projectId].get.ts b/apps/shelve/server/api/variable/project/[projectId].get.ts index 21466820..0dc3d75d 100644 --- a/apps/shelve/server/api/variable/project/[projectId].get.ts +++ b/apps/shelve/server/api/variable/project/[projectId].get.ts @@ -1,8 +1,9 @@ -import { H3Event } from 'h3' -import { getVariablesByProjectId } from '~~/server/app/variableService' +import type { H3Event } from 'h3' +import { VariableService } from '~~/server/services/variable.service' export default eventHandler(async (event: H3Event) => { const id = getRouterParam(event, 'projectId') as string if (!id) throw createError({ statusCode: 400, statusMessage: 'missing params' }) - return await getVariablesByProjectId(parseInt(id)) + const variableService = new VariableService() + return await variableService.getVariablesByProjectId(+id) }) diff --git a/apps/shelve/server/api/vault.ts b/apps/shelve/server/api/vault.ts index c3f13c60..9f3d7046 100644 --- a/apps/shelve/server/api/vault.ts +++ b/apps/shelve/server/api/vault.ts @@ -1,167 +1,11 @@ -import { H3Event } from 'h3' -import { seal, unseal } from '@shelve/crypto' - -type TTLFormat = '1d' | '7d' | '30d' - -type EncryptRequest = { - value: string - reads: number - ttl: TTLFormat -} - -type StoredData = { - encryptedValue: string - reads: number - createdAt: number - ttl: TTLFormat -} - -type DecryptResponse = { - decryptedValue: string - reads: number - ttl: string -} - -class VaultService { - - private readonly storage: Storage - private readonly encryptionKey: string - private readonly siteUrl: string - private readonly PREFIX = 'vault:' - - private readonly TTL_MAP = { - '1d': 24 * 60 * 60, // 1 day in seconds - '7d': 7 * 24 * 60 * 60, // 7 days in seconds - '30d': 30 * 24 * 60 * 60 // 30 days in seconds - } - - constructor() { - const config = useRuntimeConfig() - this.encryptionKey = config.private.encryptionKey - this.siteUrl = config.public.siteUrl - this.storage = useStorage('vault') - } - - private generateKey(id: string): string { - return `${this.PREFIX}${id}` - } - - private generateRandomId(): string { - return Math.random().toString(36).slice(2) - } - - private calculateTimeLeft(createdAt: number, ttl: TTLFormat): number { - const ttlInSeconds = this.TTL_MAP[ttl] - const now = Date.now() - const expiresAt = createdAt + (ttlInSeconds * 1000) - return Math.max(0, Math.floor((expiresAt - now) / 1000)) - } - - private formatTimeLeft(seconds: number): string { - if (seconds <= 0) return 'Expired' - - const days = Math.floor(seconds / (24 * 60 * 60)) - const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)) - const minutes = Math.floor((seconds % (60 * 60)) / 60) - - const parts = [] - - if (days > 0) { - parts.push(`${days}d`) - } - if (hours > 0) { - parts.push(`${hours}h`) - } - if (minutes > 0) { - parts.push(`${minutes}m`) - } - - // If less than a minute left - if (parts.length === 0) { - return 'Less than 1 minute' - } - - return parts.join(' ') - } - - async decrypt(id: string): Promise { - const key = this.generateKey(id) - const storedData = await this.storage.getItem(key) - - if (!storedData) { - throw createError({ - statusCode: 400, - statusMessage: 'Invalid id or link has expired' - }) - } - - const { encryptedValue, reads, createdAt, ttl } = storedData - const timeLeft = this.calculateTimeLeft(createdAt, ttl) - - if (timeLeft <= 0) { - await this.storage.removeItem(key) - throw createError({ - statusCode: 400, - statusMessage: 'Link has expired' - }) - } - - if (reads <= 0) { - await this.storage.removeItem(key) - throw createError({ - statusCode: 400, - statusMessage: 'Maximum number of reads reached' - }) - } - - const decryptedValue = await unseal(encryptedValue, this.encryptionKey) - - const updatedReads = reads - 1 - await this.storage.setItem(key, { - ...storedData, - reads: updatedReads - }) - - if (updatedReads === 0) { - await this.storage.removeItem(key) - } - - return { - decryptedValue, - reads: updatedReads, - ttl: this.formatTimeLeft(timeLeft) - } - } - - async encrypt(data: EncryptRequest): Promise { - const encryptedValue = await seal(data.value, this.encryptionKey) - const randomId = this.generateRandomId() - const key = this.generateKey(randomId) - - const storedData: StoredData = { - encryptedValue, - reads: data.reads, - createdAt: Date.now(), - ttl: data.ttl - } - - await this.storage.setItem(key, storedData) - return this.generateShareUrl(randomId) - } - - private generateShareUrl(id: string): string { - return `${this.siteUrl}/vault?id=${id}` - } - -} +import type { H3Event } from 'h3' +import { VaultService } from '~~/server/services/vault.service' export default defineEventHandler(async (event: H3Event) => { const vault = new VaultService() const { id } = await getQuery(event) - if (id) { - return vault.decrypt(id) - } + if (id) return vault.decrypt(id) const body = await readBody(event) return vault.encrypt(body) diff --git a/apps/shelve/server/app/githubService.ts b/apps/shelve/server/app/githubService.ts deleted file mode 100644 index 5159a7bb..00000000 --- a/apps/shelve/server/app/githubService.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { H3Event } from 'h3' - -export async function getUserRepos(event: H3Event) { - const { user, secure } = await getUserSession(event) - const { githubToken } = secure - - const repos = await $fetch('https://api.github.com/user/repos?per_page=100', { - headers: { - Authorization: `token ${githubToken}`, - }, - }) - console.log(`Found ${repos.length} repositories for user ${user.username}`) - console.log(repos.map(repo => { - return { - name: repo.name, - owner: repo.owner.login, - } - })) - - return repos -} - -export async function uploadFile(event: H3Event, file: File, repoName: string) { - const { user, secure } = await getUserSession(event) - const { githubToken } = secure - - const fileContent = await file.arrayBuffer() - const content = Buffer.from(fileContent).toString('base64') - - return await $fetch(`https://api.github.com/repos/${ user.username }/${ repoName }/contents/${ file.name }`, { - method: 'PUT', - headers: { - Authorization: `token ${ githubToken }`, - }, - body: { - message: 'push from shelve', - content: content, - branch: 'master' - } - }) -} diff --git a/apps/shelve/server/app/projectService.ts b/apps/shelve/server/app/projectService.ts deleted file mode 100644 index 47195507..00000000 --- a/apps/shelve/server/app/projectService.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { CreateProjectInput, ProjectUpdateInput, Team } from '@shelve/types' - -type CreateProjectInputWithAll = CreateProjectInput & { ownerId: number, team?: { connect: { id: number } } } - -export async function createProject(project: CreateProjectInput, userId: number) { - await deleteCachedUserProjects(userId) - const projectAlreadyExists = await isProjectAlreadyExists(project.name, userId) - if (projectAlreadyExists) throw new Error('Project already exists') - - const projectData = { - ...project, - ownerId: userId, - users: { connect: { id: userId } } - } as CreateProjectInputWithAll - - if (project.team) { - projectData.team = { connect: { id: project.team.id } } as Team & { connect: { id: number } } - } - - return prisma.project.create({ data: projectData }) -} - -async function isProjectAlreadyExists(name: string, userId: number): Promise { - const project = await prisma.project.findFirst({ - where: { - name: { - equals: name, - mode: 'insensitive', - }, - ownerId: userId, - }, - }) - return !!project -} - -export async function updateProject(project: ProjectUpdateInput, projectId: number, userId: number) { - await deleteCachedUserProjects(userId) - await deleteCachedProjectById(projectId) - const findProject = await prisma.project.findFirst({ - where: { - id: projectId, - }, - }) - if (!findProject) throw new Error('Project not found') - if (findProject.name !== project.name) { - const projectAlreadyExists = await isProjectAlreadyExists(project.name, userId) - if (projectAlreadyExists) throw new Error('Project already exists') - } - return prisma.project.update({ - where: { - id: projectId, - }, - data: project, - }) -} - -export const getProjectById = cachedFunction((id: number) => { - return prisma.project.findUnique({ - where: { - id, - }, - include: { - team: { - include: { - members: { - include: { - user: { - select: { - id: true, - username: true, - email: true, - avatar: true, - } - } - } - } - } - } - } - }) -}, { - maxAge: 60 * 60, - name: 'getProjectById', - getKey: (id: number) => `projectId:${id}`, -}) - -export async function addTeamToProject(projectId: number, teamId: number) { - await deleteCachedProjectById(projectId) - return prisma.project.update({ - where: { - id: projectId, - }, - data: { - team: { - connect: { - id: teamId, - } - } - } - }) -} - -export async function removeTeamFromProject(projectId: number, teamId: number) { - await deleteCachedProjectById(projectId) - return prisma.project.update({ - where: { - id: projectId, - }, - data: { - team: { - disconnect: { - id: teamId, - } - } - } - }) -} - -export const getProjectsByUserId = cachedFunction(async (userId: number) => { - const [projects, teams] = await Promise.all([ - prisma.project.findMany({ - where: { - ownerId: userId, - }, - }), - prisma.team.findMany({ - where: { - members: { - some: { - userId, - } - } - }, - include: { - projects: true, - } - }) - ]) - const teamProjects = teams.map(team => team.projects) - return [...projects, ...teamProjects].flat().filter((project, index, self) => self.findIndex(p => p.id === project.id) === index) -}, { - maxAge: 60 * 60, - name: 'getProjectsByUserId', - getKey: (userId: number) => `userId:${userId}`, -}) - -export async function deleteProject(id: string, userId: number) { - await deleteCachedUserProjects(userId) - await deleteCachedProjectById(id) - return prisma.project.delete({ - where: { - id: parseInt(id), - ownerId: userId, - } - }) -} - -export async function deleteCachedUserProjects(userId: number) { - return await useStorage('cache').removeItem(`nitro:functions:getProjectsByUserId:userId:${userId}.json`) -} -export async function deleteCachedProjectById(id: number) { - return await useStorage('cache').removeItem(`nitro:functions:getProjectById:projectId:${id}.json`) -} diff --git a/apps/shelve/server/app/resendService.ts b/apps/shelve/server/app/resendService.ts deleted file mode 100644 index b919a9c9..00000000 --- a/apps/shelve/server/app/resendService.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Resend } from 'resend' -import { render } from '@vue-email/render' -import verifyOtp from '~~/server/emails/verifyOtp.vue' - -export async function sendOtp(email: string, otp: string) { - const runtimeConfig = useRuntimeConfig() - const resend = new Resend(runtimeConfig.private.resendApiKey) - const { siteUrl } = runtimeConfig.public - let template - try { - template = await render(verifyOtp, { - otp, - redirectUrl: `${siteUrl}/login?email=${email}&otp=${otp}`, - }) - } catch (error) { - template = `

OTP: ${otp}

` - } - - try { - await resend.emails.send({ - from: 'HugoRCD ', - to: [email], - subject: 'Welcome to Shelve!', - html: template, - }).then((response) => { - console.log('Email sent: ', response) - }) - } catch (error) { - console.log('Error sending email: ', error) - } -} diff --git a/apps/shelve/server/app/teammateService.ts b/apps/shelve/server/app/teammateService.ts deleted file mode 100644 index d2caf03f..00000000 --- a/apps/shelve/server/app/teammateService.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function getTeammatesByUserId(userId: number) { - return prisma.teammate.findMany({ - where: { - userId: userId, - }, - orderBy: { - updatedAt: 'desc', - }, - take: 4, - include: { - teammate: { - select: { - avatar: true, - email: true, - username: true, - }, - } - } - }) -} diff --git a/apps/shelve/server/app/teamsService.ts b/apps/shelve/server/app/teamsService.ts deleted file mode 100644 index 8d695500..00000000 --- a/apps/shelve/server/app/teamsService.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { type CreateTeamInput, Role, TeamRole } from '@shelve/types' -import { deleteCachedUserProjects } from '~~/server/app/projectService' - -export async function createTeam(createTeamInput: CreateTeamInput, userId: number) { - await deleteCachedTeamByUserId(userId) - return prisma.team.create({ - data: { - name: createTeamInput.name, - members: { - create: { - role: TeamRole.OWNER, - user: { - connect: { - id: userId, - }, - }, - } - }, - }, - include: { - members: { - include: { - user: { - select: { - id: true, - username: true, - email: true, - avatar: true, - } - } - } - } - } - }) -} - -export async function upsertTeammate(userId: number, teammateId: number, isUpdated: boolean) { - const updateOrCreateTeammate = (userId: number, teammateId: number) => { - return prisma.teammate.upsert({ - where: { - userId_teammateId: { - userId, - teammateId, - }, - }, - update: { - updatedAt: new Date(), - count: { - increment: isUpdated ? 0 : 1, - }, - }, - create: { - userId, - teammateId, - }, - }) - } - - await Promise.all([ - updateOrCreateTeammate(userId, teammateId), - updateOrCreateTeammate(teammateId, userId), - ]) -} - -export async function upsertMember(teamId: number, addMemberInput: { - email: string; - role: TeamRole -}, requesterId: number) { - const team = await prisma.team.findFirst({ - where: { - id: teamId, - members: { - some: { - userId: requesterId, - role: { - in: [TeamRole.ADMIN, TeamRole.OWNER], - } - }, - }, - }, - include: { - members: true, - } - }) - if (!team) throw createError({ statusCode: 401, statusMessage: 'unauthorized' }) - const user = await prisma.user.findFirst({ - where: { - email: addMemberInput.email.trim(), - }, - }) - if (!user) throw createError({ statusCode: 400, statusMessage: 'user not found' }) - await deleteCachedTeamByUserId(requesterId) - await deleteCachedUserProjects(requesterId) - const member = await prisma.member.upsert({ - where: { - id: team.members.find((member) => member.userId === user.id)?.id || -1, - }, - update: { - role: addMemberInput.role, - }, - create: { - role: addMemberInput.role, - teamId, - userId: user.id, - }, - include: { - user: { - select: { - id: true, - username: true, - email: true, - avatar: true, - } - } - } - }) - const isUpdated: boolean = new Date(member.createdAt).getTime() !== new Date(member.updatedAt).getTime() - await upsertTeammate(requesterId, user.id, isUpdated) - return member -} - -async function isTeamMate(userId: number, requesterId: number): Promise { - const foundedTeamMate = await prisma.teammate.findFirst({ - where: { - userId: { - equals: userId, - }, - teammateId: { - equals: requesterId, - }, - }, - }) - return !!foundedTeamMate -} - -export async function deleteTeammate(userId: number, requesterId: number) { - const isTeammate = await isTeamMate(userId, requesterId) - if (!isTeammate) return - const updatedUser = await prisma.teammate.update({ - where: { - userId_teammateId: { - userId: requesterId, - teammateId: userId, - }, - }, - data: { - count: { - decrement: 1, - }, - }, - select: { - count: true, - }, - }) - if (updatedUser.count === 0) { - await prisma.teammate.delete({ - where: { - userId_teammateId: { - userId: requesterId, - teammateId: userId, - }, - }, - }) - } -} - -export async function removeMember(teamId: number, memberId: number, requesterId: number) { - const team = await prisma.team.findFirst({ - where: { - id: teamId, - members: { - some: { - userId: requesterId, - role: { - in: [TeamRole.ADMIN, TeamRole.OWNER], - } - }, - }, - }, - }) - if (!team) throw createError({ statusCode: 401, statusMessage: 'unauthorized' }) - - await deleteCachedTeamByUserId(requesterId) - await deleteCachedUserProjects(requesterId) - - const member = await prisma.member.findFirst({ - where: { - id: memberId, - }, - select: { - userId: true, - }, - }) - await deleteTeammate(member.userId, requesterId) - await deleteTeammate(requesterId, member.userId) - return prisma.member.delete({ - where: { - id: memberId, - }, - }) -} - -type DeleteTeamInput = { - teamId: number - userId: number - userRole: Role -} - -export async function deleteTeam(deleteTeamInput: DeleteTeamInput) { - const { teamId, userId, userRole } = deleteTeamInput - const team = await prisma.team.findFirst({ - where: { - id: teamId, - members: { - some: { - userId, - role: TeamRole.OWNER, - }, - }, - }, - }) - if (userRole !== Role.ADMIN && !team) throw createError({ statusCode: 401, statusMessage: 'unauthorized' }) - await deleteCachedTeamByUserId(userId) - const allMembers = await prisma.member.findMany({ - where: { - teamId, - }, - }) - for (const member of allMembers) { - if (member.userId === userId) continue - await deleteTeammate(member.userId, userId) - await deleteTeammate(userId, member.userId) - } - return prisma.team.delete({ - where: { - id: teamId, - }, - }) -} - -export const getTeamByUserId = cachedFunction((userId: number) => { - return prisma.team.findMany({ - where: { - members: { - some: { - userId: userId, - }, - } - }, - include: { - members: { - include: { - user: { - select: { - id: true, - username: true, - email: true, - avatar: true, - } - } - } - } - } - }) -}, { - maxAge: 60 * 60, - name: 'getTeamByUserId', - getKey: (userId: number) => `userId:${userId}`, -}) - -export function deleteCachedTeamByUserId(userId: number) { - return useStorage('cache').removeItem(`nitro:functions:getTeamByUserId:userId:${userId}.json`) -} diff --git a/apps/shelve/server/app/tokenService.ts b/apps/shelve/server/app/tokenService.ts deleted file mode 100644 index eb854b4b..00000000 --- a/apps/shelve/server/app/tokenService.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { seal, unseal } from '@shelve/crypto' - -const { encryptionKey } = useRuntimeConfig().private - -function updateUsedAt(tokenId: string) { - return prisma.token.update({ - where: { - id: tokenId, - }, - data: { - updatedAt: new Date(), - }, - }) -} - -export async function getUserByAuthToken(authToken: string) { - const userId = +authToken.split('_')[1] - - const userTokens = await prisma.token.findMany({ - where: { - userId - }, - }) - - let foundToken - - for (const token of userTokens) { - token.token = await unseal(token.token, encryptionKey) - if (token.token === authToken) { - foundToken = token - break - } - } - - if (!foundToken) throw new Error('Invalid token') - - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - id: true, - username: true, - email: true, - role: true, - }, - }) - - await updateUsedAt(foundToken.id) - return user -} - -export async function getTokensByUserId(userId: number) { - const tokens = await prisma.token.findMany({ - where: { - userId, - }, - orderBy: { - createdAt: 'desc', - }, - }) - for (const token of tokens) { - token.token = await unseal(token.token, encryptionKey) - } - return tokens -} - -function generateUserToken(userId) { - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - let token = '' - - const userIdHash = Array.from(userId).reduce((acc, char) => acc + char.charCodeAt(0), 0) - - for (let i = 0; i < 25; i++) { - const randomIndex = (Math.floor(Math.random() * characters.length) + userIdHash) % characters.length - token += characters.charAt(randomIndex) - } - - return `she_${userId}_${token}` -} - -export async function createToken({ name, userId }: { name: string, userId: number }) { - await prisma.token.create({ - data: { - token: await seal(generateUserToken(userId), encryptionKey), - name, - userId - }, - }) -} diff --git a/apps/shelve/server/app/userService.ts b/apps/shelve/server/app/userService.ts deleted file mode 100644 index ae7e9a6c..00000000 --- a/apps/shelve/server/app/userService.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { publicUser, User, CreateUserInput, UpdateUserInput } from '@shelve/types' - -export async function upsertUser(createUserInput: CreateUserInput): Promise { - const foundUser = await prisma.user.findUnique({ - where: { - username: createUserInput.username, - }, - }) - const newUsername = foundUser ? `${createUserInput.username}_#${Math.floor(Math.random() * 1000)}` : createUserInput.username - const user = await prisma.user.upsert({ - where: { - email: createUserInput.email, - }, - update: { - updatedAt: new Date(), - }, - create: { - ...createUserInput, - username: newUsername, - }, - }) - return formatUser(user) -} - -export function getUserByEmail(email: string): Promise { - return prisma.user.findUnique({ - where: { - email, - } - }) -} - -export async function deleteUser(userId: number): Promise { - await prisma.user.delete({ - where: { - id: userId, - }, - }) -} - -export async function updateUser(user: User, updateUserInput: UpdateUserInput, authToken: string): Promise { - const newUsername = updateUserInput.username - if (newUsername && newUsername !== user.username) { - const usernameTaken = await prisma.user.findFirst({ - where: { - username: newUsername, - }, - }) - if (usernameTaken) throw createError({ statusCode: 400, message: 'Username already taken' }) - } - const updatedUser = await prisma.user.update({ - where: { id: user.id }, - data: updateUserInput, - }) - await removeCachedUserToken(authToken) - return formatUser(updatedUser) -} - -export async function removeCachedUserToken(authToken: string): Promise { - await useStorage('redis').removeItem(`nitro:functions:getUserByAuthToken:authToken:${authToken}.json`) -} diff --git a/apps/shelve/server/app/variableService.ts b/apps/shelve/server/app/variableService.ts deleted file mode 100644 index 24a89706..00000000 --- a/apps/shelve/server/app/variableService.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Environment, User, Variable, VariablesCreateInput } from '@shelve/types' -import { unseal, seal } from '@shelve/crypto' - -const varAssociation = { - production: 'production', - preview: 'preview', - staging: 'preview', - development: 'development', - prod: 'production', - dev: 'development', -} - -const { encryptionKey } = useRuntimeConfig().private - -function getEnvString(env: string) { - return env.split('|').map((env) => varAssociation[env as Environment]).join('|') -} - -async function encryptVariable(variables: VariablesCreateInput['variables'], autoUppercase?: boolean): Promise { - for (const variable of variables) { - if (autoUppercase) variable.key = variable.key.toUpperCase() - const encryptedValue = await seal(variable.value, encryptionKey) - delete variable.index - variable.environment = getEnvString(variable.environment) - variable.value = encryptedValue - } - return variables -} - -async function decryptVariable(variables: VariablesCreateInput['variables']): Promise { - for (const variable of variables) { - const decryptedValue = await unseal(variable.value, encryptionKey) - variable.environment = getEnvString(variable.environment) - variable.value = decryptedValue - } - return variables -} - -export async function upsertVariable(variablesCreateInput: VariablesCreateInput) { - const encryptedVariables = await encryptVariable(variablesCreateInput.variables, variablesCreateInput.autoUppercase) - - if (variablesCreateInput.variables.length === 1) { // use on main form variable/update - const [variableCreateInput] = encryptedVariables - if (!variableCreateInput) throw new Error('Invalid variable') - return prisma.variables.upsert({ - where: { id: variableCreateInput.id || -1 }, - update: variableCreateInput, - create: variableCreateInput, - }) - } // use on edit form variable/update or with CLI push command - const method = variablesCreateInput.method || 'merge' - if (method === 'overwrite') { - await prisma.variables.deleteMany({ where: { - projectId: variablesCreateInput.projectId, - environment: varAssociation[variablesCreateInput.environment] - } }) - return prisma.variables.createMany({ - data: encryptedVariables, - skipDuplicates: true, - }) - } - const existingVariables = await prisma.variables.findMany({ - where: { - projectId: variablesCreateInput.projectId, - key: { - in: encryptedVariables.map(variable => variable.key) - } - }, - select: { - id: true, - } - }) - for (const variable of encryptedVariables) { - if (existingVariables.some(existingVariable => existingVariable.id === variable.id)) { - await prisma.variables.update({ - where: { id: variable.id }, - data: variable, - }) - } - await prisma.variables.create({ - data: variable, - }) - } - return encryptedVariables -} - -export async function getVariablesByProjectId(projectId: number, environment?: Environment): Promise { - const options = environment ? { - projectId, - environment: { - contains: varAssociation[environment], - } - } : { projectId } - const variables = await prisma.variables.findMany({ where: options, orderBy: { updatedAt: 'desc' } }) - return await decryptVariable(variables) -} - -export async function deleteVariable(id: number, environment: string): Promise { - const envs = environment.split('|').map((env) => decodeURIComponent(env)) - await prisma.variables.delete({ - where: { - id, - environment: { - in: envs, - }, - }, - }) -} - -export async function deleteVariables(variablesId: number[], user: User): Promise { - const variables = await prisma.variables.findMany({ - where: { - id: { - in: variablesId - } - }, - select: { - id: true, - project: { - select: { - ownerId: true - } - } - } - }) - if (!variables.every(variable => variable.project.ownerId === user.id)) { - throw new Error('You do not have permission to delete these variables') - } - await prisma.variables.deleteMany({ where: { id: { in: variables.map(variable => variable.id) } } }) -} diff --git a/apps/shelve/server/middleware/1.serverAuth.ts b/apps/shelve/server/middleware/1.serverAuth.ts index a0198e99..aac58521 100644 --- a/apps/shelve/server/middleware/1.serverAuth.ts +++ b/apps/shelve/server/middleware/1.serverAuth.ts @@ -1,5 +1,5 @@ -import { H3Event } from 'h3' -import { getUserByAuthToken } from '~~/server/app/tokenService' +import type { H3Event } from 'h3' +import { TokenService } from '~~/server/services/token.service' export default defineEventHandler(async (event: H3Event) => { const protectedRoutes = [ @@ -16,11 +16,12 @@ export default defineEventHandler(async (event: H3Event) => { if (!protectedRoutes.some((route) => event.path?.startsWith(route))) { return } + const tokenService = new TokenService() const authToken = getCookie(event, 'authToken') if (authToken) { - const user = await getUserByAuthToken(authToken) + const user = await tokenService.getUserByAuthToken(authToken) if (!user) throw createError({ statusCode: 401, statusMessage: 'Invalid token' }) event.context.user = user return diff --git a/apps/shelve/server/middleware/2.serverAdmin.ts b/apps/shelve/server/middleware/2.serverAdmin.ts index ff23b31b..97487f77 100644 --- a/apps/shelve/server/middleware/2.serverAdmin.ts +++ b/apps/shelve/server/middleware/2.serverAdmin.ts @@ -1,5 +1,5 @@ +import type { H3Event } from 'h3' import { Role } from '@shelve/types' -import { H3Event } from 'h3' export default defineEventHandler((event: H3Event) => { const protectedRoutes = ['/api/admin'] diff --git a/apps/shelve/server/routes/auth/github.ts b/apps/shelve/server/routes/auth/github.ts index a1f565a8..14980cd0 100644 --- a/apps/shelve/server/routes/auth/github.ts +++ b/apps/shelve/server/routes/auth/github.ts @@ -1,4 +1,4 @@ -import { upsertUser } from '~~/server/app/userService' +import { UserService } from '~~/server/services/user.service' export default defineOAuthGitHubEventHandler({ config: { @@ -6,8 +6,9 @@ export default defineOAuthGitHubEventHandler({ scope: ['repo', 'user:email'], }, async onSuccess(event, { user, tokens }) { + const userService = new UserService() try { - const _user = await upsertUser({ + const _user = await userService.upsertUser({ email: user.email, avatar: user.avatar_url, username: user.login, diff --git a/apps/shelve/server/services/github.service.ts b/apps/shelve/server/services/github.service.ts new file mode 100644 index 00000000..5c564e16 --- /dev/null +++ b/apps/shelve/server/services/github.service.ts @@ -0,0 +1,75 @@ +import { H3Event } from 'h3' + +type GitHubRepo = { + name: string + owner: string +} + +export class GitHubService { + + private readonly GITHUB_API = 'https://api.github.com' + private readonly REPOS_PER_PAGE = 100 + private readonly DEFAULT_BRANCH = 'main' + private readonly DEFAULT_COMMIT_MESSAGE = 'push from shelve' + + /** + * Get user's GitHub repositories + */ + async getUserRepos(event: H3Event): Promise { + const { user, secure } = await getUserSession(event) + const { githubToken } = secure + + const repos = await $fetch(`${this.GITHUB_API}/user/repos?per_page=${this.REPOS_PER_PAGE}`, { + headers: { + Authorization: `token ${githubToken}`, + }, + }) + + console.log(`Found ${repos.length} repositories for user ${user.username}`) + console.log(repos.map(repo => ({ + name: repo.name, + owner: repo.owner.login, + }))) + + return repos + } + + /** + * Upload file to GitHub repository + */ + async uploadFile(event: H3Event, file: File, repoName: string): Promise { + const { user, secure } = await getUserSession(event) + const { githubToken } = secure + + const content = await this.getFileContent(file) + const uploadUrl = this.buildUploadUrl(user.username, repoName, file.name) + + return await $fetch(uploadUrl, { + method: 'PUT', + headers: { + Authorization: `token ${githubToken}`, + }, + body: { + message: this.DEFAULT_COMMIT_MESSAGE, + content, + branch: this.DEFAULT_BRANCH + } + }) + } + + /** + * Convert file to base64 + */ + private async getFileContent(file: File): Promise { + const fileContent = await file.arrayBuffer() + return Buffer.from(fileContent).toString('base64') + } + + /** + * Build GitHub API upload URL + */ + private buildUploadUrl(username: string, repoName: string, fileName: string): string { + return `${this.GITHUB_API}/repos/${username}/${repoName}/contents/${fileName}` + } + +} diff --git a/apps/shelve/server/services/project.service.ts b/apps/shelve/server/services/project.service.ts new file mode 100644 index 00000000..805119ff --- /dev/null +++ b/apps/shelve/server/services/project.service.ts @@ -0,0 +1,235 @@ +import type { CreateProjectInput, ProjectUpdateInput, Team } from '@shelve/types' + +export class ProjectService { + + private readonly storage: Storage + private readonly CACHE_TTL = 60 * 60 // 1 hour + private readonly CACHE_PREFIX = { + projects: 'nitro:functions:getProjectsByUserId:userId:', + project: 'nitro:functions:getProjectById:projectId:' + } + + constructor() { + this.storage = useStorage('cache') + } + + /** + * Create a new project + */ + async createProject(project: CreateProjectInput, userId: number): Promise { + await this.deleteCachedUserProjects(userId) + await this.validateProjectName(project.name, userId) + + const projectData = this.buildProjectData(project, userId) + return prisma.project.create({ data: projectData }) + } + + /** + * Update existing project + */ + async updateProject(project: ProjectUpdateInput, projectId: number, userId: number): Promise { + await this.deleteCachedUserProjects(userId) + await this.deleteCachedProjectById(projectId) + + const existingProject = await this.findProjectById(projectId) + + if (existingProject.name !== project.name) { + await this.validateProjectName(project.name, userId) + } + + return prisma.project.update({ + where: { id: projectId }, + data: project, + }) + } + + /** + * Get project by ID + */ + async getProjectById(id: number): Promise { + return cachedFunction(() => { + return prisma.project.findUnique({ + where: { id }, + include: this.getProjectInclude() + }) + }, { + maxAge: this.CACHE_TTL, + name: 'getProjectById', + getKey: (id: number) => `projectId:${id}`, + })(id) + } + + /** + * Get all projects for a user + */ + async getProjectsByUserId(userId: number): Promise { + return cachedFunction(async () => { + const [projects, teams] = await Promise.all([ + this.getUserProjects(userId), + this.getUserTeamProjects(userId) + ]) + + const teamProjects = teams.map(team => team.projects) + return this.removeDuplicateProjects([...projects, ...teamProjects].flat()) + }, { + maxAge: this.CACHE_TTL, + name: 'getProjectsByUserId', + getKey: (userId: number) => `userId:${userId}`, + })(userId) + } + + /** + * Add team to project + */ + async addTeamToProject(projectId: number, teamId: number): Promise { + await this.deleteCachedProjectById(projectId) + return prisma.project.update({ + where: { id: projectId }, + data: { + team: { + connect: { id: teamId } + } + } + }) + } + + /** + * Remove team from project + */ + async removeTeamFromProject(projectId: number, teamId: number): Promise { + await this.deleteCachedProjectById(projectId) + return prisma.project.update({ + where: { id: projectId }, + data: { + team: { + disconnect: { id: teamId } + } + } + }) + } + + /** + * Delete project + */ + async deleteProject(id: string, userId: number): Promise { + await this.deleteCachedUserProjects(userId) + await this.deleteCachedProjectById(parseInt(id)) + + return prisma.project.delete({ + where: { + id: parseInt(id), + ownerId: userId, + } + }) + } + + /** + * Private helper methods + */ + private async validateProjectName(name: string, userId: number): Promise { + const exists = await this.isProjectAlreadyExists(name, userId) + if (exists) { + throw createError({ + statusCode: 400, + message: 'Project already exists' + }) + } + } + + private async isProjectAlreadyExists(name: string, userId: number): Promise { + const project = await prisma.project.findFirst({ + where: { + name: { + equals: name, + mode: 'insensitive', + }, + ownerId: userId, + }, + }) + return !!project + } + + private async findProjectById(id: number): Promise { + const project = await prisma.project.findFirst({ + where: { id }, + }) + if (!project) { + throw createError({ + statusCode: 404, + message: 'Project not found' + }) + } + return project + } + + private buildProjectData(project: CreateProjectInput, userId: number): CreateProjectInputWithAll { + const projectData = { + ...project, + ownerId: userId, + users: { connect: { id: userId } } + } as CreateProjectInputWithAll + + if (project.team) { + projectData.team = { + connect: { id: project.team.id } + } as Team & { connect: { id: number } } + } + + return projectData + } + + private async getUserProjects(userId: number) { + return prisma.project.findMany({ + where: { ownerId: userId }, + }) + } + + private async getUserTeamProjects(userId: number) { + return prisma.team.findMany({ + where: { + members: { + some: { userId } + } + }, + include: { + projects: true, + } + }) + } + + private removeDuplicateProjects(projects: Project[]): Project[] { + return projects.filter((project, index, self) => + self.findIndex(p => p.id === project.id) === index + ) + } + + private getProjectInclude() { + return { + team: { + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + email: true, + avatar: true, + } + } + } + } + } + } + } + } + + private async deleteCachedUserProjects(userId: number): Promise { + await this.storage.removeItem(`${this.CACHE_PREFIX.projects}${userId}.json`) + } + + private async deleteCachedProjectById(id: number): Promise { + await this.storage.removeItem(`${this.CACHE_PREFIX.project}${id}.json`) + } + +} diff --git a/apps/shelve/server/services/resend.service.ts b/apps/shelve/server/services/resend.service.ts new file mode 100644 index 00000000..14ef2282 --- /dev/null +++ b/apps/shelve/server/services/resend.service.ts @@ -0,0 +1,51 @@ +import { Resend } from 'resend' +import { render } from '@vue-email/render' +import verifyOtp from '~~/server/emails/verifyOtp.vue' + +export class EmailService { + + private readonly resend: Resend + private readonly siteUrl: string + private readonly SENDER = 'HugoRCD ' + + constructor() { + const config = useRuntimeConfig() + this.resend = new Resend(config.private.resendApiKey) + this.siteUrl = config.public.siteUrl + } + + /** + * Send OTP verification email + */ + async sendOtp(email: string, otp: string): Promise { + const template = await this.generateTemplate(email, otp) + + try { + await this.resend.emails.send({ + from: this.SENDER, + to: [email], + subject: 'Welcome to Shelve!', + html: template, + }).then((response) => { + console.log('Email sent: ', response) + }) + } catch (error) { + console.log('Error sending email: ', error) + } + } + + /** + * Generate email template + */ + private async generateTemplate(email: string, otp: string): Promise { + try { + return await render(verifyOtp, { + otp, + redirectUrl: `${this.siteUrl}/login?email=${email}&otp=${otp}`, + }) + } catch (error) { + return `

OTP: ${otp}

` + } + } + +} diff --git a/apps/shelve/server/services/teammate.service.ts b/apps/shelve/server/services/teammate.service.ts new file mode 100644 index 00000000..b31766eb --- /dev/null +++ b/apps/shelve/server/services/teammate.service.ts @@ -0,0 +1,29 @@ +export class TeammateService { + + private readonly DEFAULT_LIMIT = 4 + + /** + * Get recent teammates for a user + */ + getTeammatesByUserId(userId: number, limit: number = this.DEFAULT_LIMIT) { + return prisma.teammate.findMany({ + where: { + userId: userId, + }, + orderBy: { + updatedAt: 'desc', + }, + take: limit, + include: { + teammate: { + select: { + avatar: true, + email: true, + username: true, + }, + } + } + }) + } + +} diff --git a/apps/shelve/server/services/teams.service.ts b/apps/shelve/server/services/teams.service.ts new file mode 100644 index 00000000..e22e3f98 --- /dev/null +++ b/apps/shelve/server/services/teams.service.ts @@ -0,0 +1,279 @@ +import type { CreateTeamInput, DeleteTeamInput, Team, Member } from '@shelve/types' +import { Role, TeamRole } from '@shelve/types' +import { ProjectService } from '~~/server/services/project.service' + +export class TeamService { + + private readonly storage: Storage + private readonly CACHE_TTL = 60 * 60 // 1 hour + private readonly CACHE_PREFIX = 'nitro:functions:getTeamByUserId:userId:' + + constructor() { + this.storage = useStorage('cache') + } + + /** + * Create a new team + */ + async createTeam(createTeamInput: CreateTeamInput, userId: number): Promise { + await this.deleteCachedTeamByUserId(userId) + return prisma.team.create({ + data: { + name: createTeamInput.name, + members: { + create: { + role: TeamRole.OWNER, + user: { + connect: { id: userId }, + }, + } + }, + }, + include: this.getTeamInclude() + }) + } + + /** + * Upsert team member + */ + async upsertMember(teamId: number, addMemberInput: { email: string; role: TeamRole }, requesterId: number): Promise { + const projectService = new ProjectService() + const team = await this.validateTeamAccess(teamId, requesterId) + const user = await this.findUserByEmail(addMemberInput.email) + + await this.deleteCachedTeamByUserId(requesterId) + await projectService.deleteCachedUserProjects(requesterId) + + const member = await prisma.member.upsert({ + where: { + id: team.members.find((member) => member.userId === user.id)?.id || -1, + }, + update: { + role: addMemberInput.role, + }, + create: { + role: addMemberInput.role, + teamId, + userId: user.id, + }, + include: { + user: { + select: { + id: true, + username: true, + email: true, + avatar: true, + } + } + } + }) + + const isUpdated = new Date(member.createdAt).getTime() !== new Date(member.updatedAt).getTime() + await this.upsertTeammate(requesterId, user.id, isUpdated) + return member + } + + /** + * Remove team member + */ + async removeMember(teamId: number, memberId: number, requesterId: number): Promise { + const projectService = new ProjectService() + await this.validateTeamAccess(teamId, requesterId) + await this.deleteCachedTeamByUserId(requesterId) + await projectService.deleteCachedUserProjects(requesterId) + + const member = await this.getMemberById(memberId) + await this.deleteTeammate(member.userId, requesterId) + await this.deleteTeammate(requesterId, member.userId) + + return prisma.member.delete({ + where: { id: memberId }, + }) + } + + /** + * Delete team + */ + async deleteTeam(deleteTeamInput: DeleteTeamInput): Promise { + const { teamId, userId, userRole } = deleteTeamInput + const team = await this.validateTeamOwnership(teamId, userId) + + if (userRole !== Role.ADMIN && !team) { + throw createError({ statusCode: 401, statusMessage: 'unauthorized' }) + } + + await this.deleteCachedTeamByUserId(userId) + await this.cleanupTeammates(teamId, userId) + + return prisma.team.delete({ + where: { id: teamId }, + }) + } + + /** + * Get teams by user ID + */ + getTeamByUserId(userId: number): Promise { + return cachedFunction(() => { + return prisma.team.findMany({ + where: { + members: { + some: { userId } + } + }, + include: this.getTeamInclude() + }) + }, { + maxAge: this.CACHE_TTL, + name: 'getTeamByUserId', + getKey: (userId: number) => `userId:${userId}`, + })(userId) + } + + /** + * Private helper methods + */ + private async upsertTeammate(userId: number, teammateId: number, isUpdated: boolean): Promise { + const updateOrCreateTeammate = (userId: number, teammateId: number) => { + return prisma.teammate.upsert({ + where: { + userId_teammateId: { userId, teammateId }, + }, + update: { + updatedAt: new Date(), + count: { + increment: isUpdated ? 0 : 1, + }, + }, + create: { + userId, + teammateId, + }, + }) + } + + await Promise.all([ + updateOrCreateTeammate(userId, teammateId), + updateOrCreateTeammate(teammateId, userId), + ]) + } + + private async deleteTeammate(userId: number, requesterId: number): Promise { + if (!await this.isTeamMate(userId, requesterId)) return + + const updatedUser = await prisma.teammate.update({ + where: { + userId_teammateId: { + userId: requesterId, + teammateId: userId, + }, + }, + data: { + count: { decrement: 1 }, + }, + select: { count: true }, + }) + + if (updatedUser.count === 0) { + await prisma.teammate.delete({ + where: { + userId_teammateId: { + userId: requesterId, + teammateId: userId, + }, + }, + }) + } + } + + private async isTeamMate(userId: number, requesterId: number): Promise { + const foundedTeamMate = await prisma.teammate.findFirst({ + where: { + userId: { equals: userId }, + teammateId: { equals: requesterId }, + }, + }) + return !!foundedTeamMate + } + + private async validateTeamAccess(teamId: number, requesterId: number): Promise { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userId: requesterId, + role: { in: [TeamRole.ADMIN, TeamRole.OWNER] }, + } + }, + }, + include: { members: true }, + }) + if (!team) throw createError({ statusCode: 401, statusMessage: 'unauthorized' }) + return team + } + + private validateTeamOwnership(teamId: number, userId: number): Promise { + return prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userId, + role: TeamRole.OWNER, + } + }, + }, + }) + } + + private async findUserByEmail(email: string) { + const user = await prisma.user.findFirst({ + where: { email: email.trim() }, + }) + if (!user) throw createError({ statusCode: 400, statusMessage: 'user not found' }) + return user + } + + private getMemberById(memberId: number) { + return prisma.member.findFirst({ + where: { id: memberId }, + select: { userId: true }, + }) + } + + private async cleanupTeammates(teamId: number, userId: number): Promise { + const allMembers = await prisma.member.findMany({ + where: { teamId }, + }) + + for (const member of allMembers) { + if (member.userId === userId) continue + await this.deleteTeammate(member.userId, userId) + await this.deleteTeammate(userId, member.userId) + } + } + + private deleteCachedTeamByUserId(userId: number): Promise { + return this.storage.removeItem(`${this.CACHE_PREFIX}${userId}.json`) + } + + private getTeamInclude() { + return { + members: { + include: { + user: { + select: { + id: true, + username: true, + email: true, + avatar: true, + } + } + } + } + } + } + +} + diff --git a/apps/shelve/server/services/token.service.ts b/apps/shelve/server/services/token.service.ts new file mode 100644 index 00000000..573e63f2 --- /dev/null +++ b/apps/shelve/server/services/token.service.ts @@ -0,0 +1,148 @@ +import { seal, unseal } from '@shelve/crypto' +import type { Token, User } from '@shelve/types' + +export class TokenService { + + private readonly encryptionKey: string + private readonly TOKEN_PREFIX = 'she_' + private readonly TOKEN_LENGTH = 25 + + constructor() { + this.encryptionKey = useRuntimeConfig().private.encryptionKey + } + + /** + * Get user by authentication token + */ + async getUserByAuthToken(authToken: string): Promise { + const userId = this.extractUserId(authToken) + const userTokens = await this.getUserTokens(userId) + const foundToken = await this.findMatchingToken(userTokens, authToken) + + if (!foundToken) { + throw createError({ + statusCode: 401, + message: 'Invalid token' + }) + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + email: true, + role: true, + }, + }) + + await this.updateUsedAt(foundToken.id) + return user + } + + /** + * Get all tokens for a user + */ + async getTokensByUserId(userId: number): Promise { + const tokens = await prisma.token.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }) + + return Promise.all(tokens.map(async (token) => ({ + ...token, + token: await unseal(token.token, this.encryptionKey) + }))) + } + + /** + * Create a new token for a user + */ + async createToken({ name, userId }: { name: string; userId: number }): Promise { + const token = this.generateUserToken(userId) + const encryptedToken = await seal(token, this.encryptionKey) + + await prisma.token.create({ + data: { + token: encryptedToken, + name, + userId + }, + }) + } + + /** + * Update token's last used timestamp + */ + private async updateUsedAt(tokenId: string): Promise { + await prisma.token.update({ + where: { id: tokenId }, + data: { updatedAt: new Date() }, + }) + } + + /** + * Delete a token for a user + */ + private deleteUserToken(id: string, userId: number): Promise { + return prisma.token.delete({ + where: { + id, + userId, + }, + }) + } + + /** + * Generate a new token for a user + */ + private generateUserToken(userId: number): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let token = '' + const userIdHash = this.calculateUserIdHash(userId) + + for (let i = 0; i < this.TOKEN_LENGTH; i++) { + const randomIndex = (Math.floor(Math.random() * characters.length) + userIdHash) % characters.length + token += characters.charAt(randomIndex) + } + + return `${this.TOKEN_PREFIX}${userId}_${token}` + } + + /** + * Calculate hash from user ID + */ + private calculateUserIdHash(userId: number): number { + return Array.from(String(userId)).reduce((acc, char) => acc + char.charCodeAt(0), 0) + } + + /** + * Extract user ID from token + */ + private extractUserId(authToken: string): number { + return +authToken.split('_')[1] + } + + /** + * Get all tokens for a user + */ + private getUserTokens(userId: number): Promise { + return prisma.token.findMany({ + where: { userId } + }) + } + + /** + * Find matching token from list + */ + private async findMatchingToken(tokens: Token[], authToken: string): Promise { + for (const token of tokens) { + const decryptedToken = await unseal(token.token, this.encryptionKey) + if (decryptedToken === authToken) { + return token + } + } + return null + } + +} diff --git a/apps/shelve/server/services/user.service.ts b/apps/shelve/server/services/user.service.ts new file mode 100644 index 00000000..e4a2db8c --- /dev/null +++ b/apps/shelve/server/services/user.service.ts @@ -0,0 +1,114 @@ +import type { publicUser, User, CreateUserInput, UpdateUserInput } from '@shelve/types' + +export class UserService { + + private readonly storage: Storage + + constructor() { + this.storage = useStorage('redis') + } + + /** + * Creates or updates a user + */ + async upsertUser(createUserInput: CreateUserInput): Promise { + const foundUser = await prisma.user.findUnique({ + where: { + username: createUserInput.username, + }, + }) + + const newUsername = this.generateUniqueUsername(createUserInput.username, foundUser) + + const user = await prisma.user.upsert({ + where: { + email: createUserInput.email, + }, + update: { + updatedAt: new Date(), + }, + create: { + ...createUserInput, + username: newUsername, + }, + }) + + return this.formatUser(user) + } + + /** + * Deletes a user by ID + */ + async deleteUser(userId: number): Promise { + await prisma.user.delete({ + where: { id: userId }, + }) + } + + /** + * Updates a user's information + */ + async updateUser(user: User, updateUserInput: UpdateUserInput, authToken: string): Promise { + const newUsername = updateUserInput.username + + if (newUsername && newUsername !== user.username) { + await this.validateUsername(newUsername) + } + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: updateUserInput, + }) + + await this.removeCachedUserToken(authToken) + return this.formatUser(updatedUser) + } + + /** + * Removes a cached user token + */ + private async removeCachedUserToken(authToken: string): Promise { + const cacheKey = this.generateCacheKey(authToken) + await this.storage.removeItem(cacheKey) + } + + /** + * Generates a unique username if the original is taken + */ + private generateUniqueUsername(username: string, existingUser: User | null): string { + if (!existingUser) return username + return `${username}_#${Math.floor(Math.random() * 1000)}` + } + + /** + * Validates if a username is available + */ + private async validateUsername(username: string): Promise { + const usernameTaken = await prisma.user.findFirst({ + where: { username }, + }) + + if (usernameTaken) { + throw createError({ + statusCode: 400, + message: 'Username already taken' + }) + } + } + + /** + * Generates a cache key for user tokens + */ + private generateCacheKey(authToken: string): string { + return `nitro:functions:getUserByAuthToken:authToken:${authToken}.json` + } + + /** + * Formats a user object for public consumption + */ + private formatUser(user: User): publicUser { + // Implement your user formatting logic here + return user as publicUser + } + +} diff --git a/apps/shelve/server/services/variable.service.ts b/apps/shelve/server/services/variable.service.ts new file mode 100644 index 00000000..aca319de --- /dev/null +++ b/apps/shelve/server/services/variable.service.ts @@ -0,0 +1,212 @@ +import type { Environment, User, Variable, VariablesCreateInput } from '@shelve/types' +import { unseal, seal } from '@shelve/crypto' + +export class VariableService { + + private readonly encryptionKey: string + + private readonly ENV_ASSOCIATION = { + production: 'production', + preview: 'preview', + staging: 'preview', + development: 'development', + prod: 'production', + dev: 'development', + } as const + + constructor() { + this.encryptionKey = useRuntimeConfig().private.encryptionKey + } + + /** + * Upsert variables + */ + async upsertVariable(variablesCreateInput: VariablesCreateInput): Promise { + const encryptedVariables = await this.encryptVariables( + variablesCreateInput.variables, + variablesCreateInput.autoUppercase + ) + + if (variablesCreateInput.variables.length === 1) { + return this.upsertSingleVariable(encryptedVariables[0]) + } + + return this.upsertMultipleVariables( + encryptedVariables, + variablesCreateInput.projectId, + variablesCreateInput.method || 'merge', + variablesCreateInput.environment + ) + } + + /** + * Get variables by project ID + */ + async getVariablesByProjectId(projectId: number, environment?: Environment): Promise { + const where = this.buildVariableQuery(projectId, environment) + const variables = await prisma.variables.findMany({ + where, + orderBy: { updatedAt: 'desc' } + }) + return this.decryptVariables(variables) + } + + /** + * Delete single variable + */ + async deleteVariable(id: number, environment: string): Promise { + const envs = environment.split('|').map((env) => decodeURIComponent(env)) + await prisma.variables.delete({ + where: { + id, + environment: { in: envs }, + }, + }) + } + + /** + * Delete multiple variables + */ + async deleteVariables(variablesId: number[], user: User): Promise { + await this.validateVariablesOwnership(variablesId, user) + await prisma.variables.deleteMany({ + where: { id: { in: variablesId } } + }) + } + + /** + * Private helper methods + */ + private async encryptVariables( + variables: VariablesCreateInput['variables'], + autoUppercase?: boolean + ): Promise { + return Promise.all(variables.map(async (variable) => { + const processed = { ...variable } + if (autoUppercase) { + processed.key = processed.key.toUpperCase() + } + processed.value = await seal(processed.value, this.encryptionKey) + processed.environment = this.getEnvString(processed.environment) + delete processed.index + return processed + })) + } + + private async decryptVariables( + variables: VariablesCreateInput['variables'] + ): Promise { + return Promise.all(variables.map(async (variable) => { + const processed = { ...variable } + processed.value = await unseal(processed.value, this.encryptionKey) + processed.environment = this.getEnvString(processed.environment) + return processed + })) + } + + private getEnvString(env: string): string { + return env.split('|') + .map((env) => this.ENV_ASSOCIATION[env as Environment]) + .join('|') + } + + private async upsertSingleVariable(variable: Variable): Promise { + if (!variable) { + throw createError({ + statusCode: 400, + message: 'Invalid variable' + }) + } + + return prisma.variables.upsert({ + where: { id: variable.id || -1 }, + update: variable, + create: variable, + }) + } + + private async upsertMultipleVariables( + variables: Variable[], + projectId: number, + method: 'merge' | 'overwrite', + environment: Environment + ): Promise { + if (method === 'overwrite') { + await this.deleteExistingVariables(projectId, environment) + await prisma.variables.createMany({ + data: variables, + skipDuplicates: true, + }) + return variables + } + + return this.mergeVariables(variables, projectId) + } + + private async deleteExistingVariables(projectId: number, environment: Environment): Promise { + await prisma.variables.deleteMany({ + where: { + projectId, + environment: this.ENV_ASSOCIATION[environment] + } + }) + } + + private async mergeVariables(variables: Variable[], projectId: number): Promise { + const existingVariables = await prisma.variables.findMany({ + where: { + projectId, + key: { in: variables.map(v => v.key) } + }, + select: { id: true } + }) + + for (const variable of variables) { + if (existingVariables.some(ev => ev.id === variable.id)) { + await prisma.variables.update({ + where: { id: variable.id }, + data: variable, + }) + } else { + await prisma.variables.create({ + data: variable, + }) + } + } + + return variables + } + + private buildVariableQuery(projectId: number, environment?: Environment) { + if (!environment) return { projectId } + return { + projectId, + environment: { + contains: this.ENV_ASSOCIATION[environment], + } + } + } + + private async validateVariablesOwnership(variablesId: number[], user: User): Promise { + const variables = await prisma.variables.findMany({ + where: { + id: { in: variablesId } + }, + select: { + id: true, + project: { + select: { ownerId: true } + } + } + }) + + const hasPermission = variables.every(v => v.project.ownerId === user.id) + if (!hasPermission) { + throw createError({ + statusCode: 403, + message: 'You do not have permission to delete these variables' + }) + } + } + +} diff --git a/apps/shelve/server/services/vault.service.ts b/apps/shelve/server/services/vault.service.ts new file mode 100644 index 00000000..b75f1a87 --- /dev/null +++ b/apps/shelve/server/services/vault.service.ts @@ -0,0 +1,135 @@ +import type { DecryptResponse, EncryptRequest, StoredData, TTLFormat } from '@shelve/types' +import { seal, unseal } from '@shelve/crypto' + +export class VaultService { + + private readonly storage: Storage + private readonly encryptionKey: string + private readonly siteUrl: string + private readonly PREFIX = 'vault:' + + private readonly TTL_MAP = { + '1d': 24 * 60 * 60, // 1 day in seconds + '7d': 7 * 24 * 60 * 60, // 7 days in seconds + '30d': 30 * 24 * 60 * 60 // 30 days in seconds + } + + constructor() { + const config = useRuntimeConfig() + this.encryptionKey = config.private.encryptionKey + this.siteUrl = config.public.siteUrl + this.storage = useStorage('vault') + } + + private generateKey(id: string): string { + return `${this.PREFIX}${id}` + } + + private generateRandomId(): string { + return Math.random().toString(36).slice(2) + } + + private calculateTimeLeft(createdAt: number, ttl: TTLFormat): number { + const ttlInSeconds = this.TTL_MAP[ttl] + const now = Date.now() + const expiresAt = createdAt + (ttlInSeconds * 1000) + return Math.max(0, Math.floor((expiresAt - now) / 1000)) + } + + private formatTimeLeft(seconds: number): string { + if (seconds <= 0) return 'Expired' + + const days = Math.floor(seconds / (24 * 60 * 60)) + const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)) + const minutes = Math.floor((seconds % (60 * 60)) / 60) + + const parts = [] + + if (days > 0) { + parts.push(`${days}d`) + } + if (hours > 0) { + parts.push(`${hours}h`) + } + if (minutes > 0) { + parts.push(`${minutes}m`) + } + + // If less than a minute left + if (parts.length === 0) { + return 'Less than 1 minute' + } + + return parts.join(' ') + } + + async decrypt(id: string): Promise { + const key = this.generateKey(id) + const storedData = await this.storage.getItem(key) + + if (!storedData) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid id or link has expired' + }) + } + + const { encryptedValue, reads, createdAt, ttl } = storedData + const timeLeft = this.calculateTimeLeft(createdAt, ttl) + + if (timeLeft <= 0) { + await this.storage.removeItem(key) + throw createError({ + statusCode: 400, + statusMessage: 'Link has expired' + }) + } + + if (reads <= 0) { + await this.storage.removeItem(key) + throw createError({ + statusCode: 400, + statusMessage: 'Maximum number of reads reached' + }) + } + + const decryptedValue = await unseal(encryptedValue, this.encryptionKey) + + const updatedReads = reads - 1 + await this.storage.setItem(key, { + ...storedData, + reads: updatedReads + }) + + if (updatedReads === 0) { + await this.storage.removeItem(key) + } + + return { + decryptedValue, + reads: updatedReads, + ttl: this.formatTimeLeft(timeLeft) + } + } + + async encrypt(data: EncryptRequest): Promise { + const encryptedValue = await seal(data.value, this.encryptionKey) + const randomId = this.generateRandomId() + const key = this.generateKey(randomId) + + const storedData: StoredData = { + encryptedValue, + reads: data.reads, + createdAt: Date.now(), + ttl: data.ttl + } + + await this.storage.setItem(key, storedData) + return this.generateShareUrl(randomId) + } + + private generateShareUrl(id: string): string { + return `${this.siteUrl}/vault?id=${id}` + } + +} diff --git a/bun.lockb b/bun.lockb index b5503fc3..18846941 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b88dc0ff..914c541c 100644 --- a/package.json +++ b/package.json @@ -4,18 +4,18 @@ "type": "module", "repository": "https://github.com/HugoRCD/shelve", "scripts": { - "build": "turbo build", + "build": "turbo run build", "build:app": "turbo run build --filter=@shelve/app", "build:cli": "turbo run build --filter=@shelve/cli", - "dev": "turbo dev --filter=@shelve/app", - "dev:app": "turbo dev --filter=@shelve/app", - "dev:cli": "turbo dev --filter=@shelve/cli", + "dev": "turbo run dev --filter=@shelve/app", + "dev:app": "turbo run dev --filter=@shelve/app", + "dev:cli": "turbo run dev --filter=@shelve/cli", "dev:prepare": "turbo run dev:prepare", "generate": "turbo run generate", - "lint": "turbo lint", - "lint:fix": "turbo lint:fix", - "test": "turbo test", - "typecheck": "turbo typecheck" + "lint": "turbo run lint", + "lint:fix": "turbo run lint:fix", + "test": "turbo run test", + "typecheck": "turbo run typecheck" }, "devDependencies": { "@hrcd/eslint-config": "edge", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3fda1cb2..c881f194 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,16 +46,16 @@ "npm-registry-fetch": "^18.0.2", "nypm": "^0.3.12", "ofetch": "^1.4.1", - "semver": "^7.6.3", - "unbuild": "^2.0.0" + "semver": "^7.6.3" }, "devDependencies": { - "@shelve/types": "workspace:*", + "@shelve/types": "*", "@types/bun": "^1.1.12", "@types/npm-registry-fetch": "^8.0.7", "@types/semver": "^7.5.8", "eslint": "^9.13.0", "release-it": "^17.10.0", + "unbuild": "^2.0.0", "vitest": "^2.1.3" } } diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index a7154d7d..5d4573ad 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -2,7 +2,7 @@ import fs from 'fs' import { intro, isCancel, outro, select } from '@clack/prompts' import { loadConfig, setupDotenv, type ConfigLayer } from 'c12' import consola from 'consola' -import { SHELVE_JSON_SCHEMA, ShelveConfig } from '@shelve/types' +import { SHELVE_JSON_SCHEMA, type ShelveConfig } from '@shelve/types' import { getProjects } from './project' import { getKeyValue } from './env' import { onCancel } from './index' diff --git a/packages/crypto/build.config.ts b/packages/crypto/build.config.ts new file mode 100644 index 00000000..4aedf54e --- /dev/null +++ b/packages/crypto/build.config.ts @@ -0,0 +1,12 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + declaration: true, + rollup: { + inlineDependencies: true, + resolve: { + exportConditions: ['production', 'node'], + }, + }, + entries: ['src/index'], +}) diff --git a/packages/crypto/eslint.config.js b/packages/crypto/eslint.config.js index 3fdf1e79..8f36791f 100644 --- a/packages/crypto/eslint.config.js +++ b/packages/crypto/eslint.config.js @@ -1,13 +1,3 @@ import { createConfig } from "@hrcd/eslint-config" -export default createConfig({ - typescript: { - strict: true - }, - features: { - jsdoc: { - enable: true, - strict: true - } - } -}) +export default createConfig({}) diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 59981623..e8165ceb 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -2,21 +2,27 @@ "name": "@shelve/crypto", "version": "1.0.0", "type": "module", - "private": true, + "publishConfig": { + "access": "public" + }, "scripts": { - "dev": "bun run src/index.ts" + "dev": "bun run src/index.ts", + "dev:prepare": "unbuild", + "build": "unbuild", + "release": "bun run build && npm publish" }, - "files": [ - "index.ts" - ], + "main": "./dist/index.mjs", "exports": { - ".": "./src/index.ts" + ".": "./dist/index.mjs" }, - "main": "index.ts", - "types": "index.ts", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], "devDependencies": { "@types/crypto-js": "^4.2.2", - "typescript": "latest" + "typescript": "latest", + "unbuild": "^2.0.0" }, "dependencies": { "crypto-js": "^4.2.0", diff --git a/packages/types/build.config.ts b/packages/types/build.config.ts new file mode 100644 index 00000000..4aedf54e --- /dev/null +++ b/packages/types/build.config.ts @@ -0,0 +1,12 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + declaration: true, + rollup: { + inlineDependencies: true, + resolve: { + exportConditions: ['production', 'node'], + }, + }, + entries: ['src/index'], +}) diff --git a/packages/types/package.json b/packages/types/package.json index 23c1c17e..74311f16 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,17 +1,26 @@ { "name": "@shelve/types", - "version": "0.0.0", + "version": "1.0.1", "type": "module", - "private": true, - "files": [ - "index.ts" - ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "bun run src/index.ts", + "dev:prepare": "unbuild", + "build": "unbuild", + "release": "bun run build && npm publish" + }, + "main": "./dist/index.mjs", "exports": { - ".": "./src/index.ts" + ".": "./dist/index.mjs" }, - "main": "index.ts", - "types": "index.ts", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], "devDependencies": { - "typescript": "latest" + "typescript": "latest", + "unbuild": "^2.0.0" } } diff --git a/packages/types/src/Cli.ts b/packages/types/src/Cli.ts index 1635cb10..bcdc5cc0 100644 --- a/packages/types/src/Cli.ts +++ b/packages/types/src/Cli.ts @@ -1,4 +1,4 @@ -import { Env, Environment } from './Variables' +import type { Env, Environment } from './Variables' export const SHELVE_JSON_SCHEMA = 'https://raw.githubusercontent.com/HugoRCD/shelve/main/packages/types/shelveConfigSchema.json' diff --git a/packages/types/src/Project.ts b/packages/types/src/Project.ts index 7f13b8e1..1789c185 100644 --- a/packages/types/src/Project.ts +++ b/packages/types/src/Project.ts @@ -1,6 +1,6 @@ -import type { Variable } from "./Variables"; -import type { User } from "./User"; -import type { Team } from "./Team"; +import type { Variable } from './Variables' +import type { User } from './User' +import type { Team } from './Team' export type Project = { id: number; diff --git a/packages/types/src/Session.ts b/packages/types/src/Session.ts deleted file mode 100644 index 07e4400e..00000000 --- a/packages/types/src/Session.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type Session = { - id: number; - userId: number; - authToken: string; - current: boolean; - device: string; - location?: string; - isCli: boolean; - createdAt: Date; - updatedAt: Date; -}; - -export type SessionWithCurrent = Session & { - current: boolean; -}; - -export type DeviceInfo = { - userAgent: string; - location?: string; - isCli?: boolean; -}; - -export type CreateSessionInput = { - email: string; - password?: string; - otp: string; - deviceInfo: DeviceInfo; -}; diff --git a/packages/types/src/Team.ts b/packages/types/src/Team.ts index fa761edc..2ce87f5a 100644 --- a/packages/types/src/Team.ts +++ b/packages/types/src/Team.ts @@ -1,10 +1,11 @@ -import type { Project } from "./Project"; -import type { User } from "./User"; +import { Role } from './User' +import type { Project } from './Project' +import type { User } from './User' export enum TeamRole { - OWNER = "owner", - ADMIN = "admin", - DEVELOPER = "developer", + OWNER = 'owner', + ADMIN = 'admin', + DEVELOPER = 'developer', } export type Member = { @@ -37,3 +38,9 @@ export type UpdateTeamInput = { members: Member[]; projects: Project[]; } + +export type DeleteTeamInput = { + teamId: number + userId: number + userRole: Role +} diff --git a/packages/types/src/User.ts b/packages/types/src/User.ts index 7da5fe4e..1a87bb32 100644 --- a/packages/types/src/User.ts +++ b/packages/types/src/User.ts @@ -1,6 +1,6 @@ export enum Role { - ADMIN = "admin", - USER = "user", + ADMIN = 'admin', + USER = 'user', } export type User = { diff --git a/packages/types/src/Vault.ts b/packages/types/src/Vault.ts new file mode 100644 index 00000000..2ff3d5e5 --- /dev/null +++ b/packages/types/src/Vault.ts @@ -0,0 +1,20 @@ +export type TTLFormat = '1d' | '7d' | '30d' + +export type EncryptRequest = { + value: string + reads: number + ttl: TTLFormat +} + +export type StoredData = { + encryptedValue: string + reads: number + createdAt: number + ttl: TTLFormat +} + +export type DecryptResponse = { + decryptedValue: string + reads: number + ttl: string +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 61607215..1e708871 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,7 +1,7 @@ export * from './Variables' export * from './User' export * from './Project' -export * from './Session' export * from './Team' export * from './Token' export * from './Cli' +export * from './Vault' diff --git a/turbo.json b/turbo.json index 3cc1bf2c..2a93b22f 100644 --- a/turbo.json +++ b/turbo.json @@ -5,15 +5,15 @@ "build": { "dependsOn": ["^build"], "outputs": [ - "dist/**", - ".output/**" + "**/dist/**", + "**/.output/**" ] }, "generate" : { "dependsOn": ["^generate"], "outputs": [ - "dist/**", - ".output/**" + "**/dist/**", + "**/.output/**" ] }, "dev": { @@ -21,7 +21,10 @@ "persistent": true }, "dev:prepare": { - "cache": false + "cache": false, + "outputs": [ + "**/dist/**" + ] }, "lint": {}, "lint:fix": {},