├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── .dockerignore ├── public ├── logo.webp ├── favicon.ico ├── pwa-192x192.png ├── pwa-512x512.png ├── robots.txt └── logo.svg ├── fonts ├── Outfit.woff2 └── ChivoMono.woff2 ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20250819132912_add_leaderboard_toggle │ │ └── migration.sql │ ├── 20250507124855_add_files_branches_to_summaries │ │ └── migration.sql │ ├── 20250829143643_add_lastlogin_to_user │ │ └── migration.sql │ ├── 20251002111902_drop_unessesary_indexes │ │ └── migration.sql │ ├── 20250830175406_leaderboard_optin_first_check │ │ └── migration.sql │ ├── 20250828124536_add_epilogue_auth │ │ └── migration.sql │ ├── 20250516211020_add_stats_table │ │ └── migration.sql │ ├── 20250926115638_convert_to_hypertables │ │ └── migration.sql │ ├── 20250419153706_init │ │ └── migration.sql │ └── 20251002153924_migrate_to_real_timestampz │ │ └── migration.sql ├── db.ts └── schema.prisma ├── .gitignore ├── prisma.config.ts ├── tsconfig.json ├── server ├── api │ ├── auth │ │ ├── logout.get.ts │ │ ├── github.get.ts │ │ ├── epilogue.get.ts │ │ ├── login.post.ts │ │ ├── github │ │ │ └── link.get.ts │ │ ├── epilogue │ │ │ ├── link.get.ts │ │ │ └── callback.get.ts │ │ └── register.post.ts │ ├── user │ │ ├── regenerateSummaries.get.ts │ │ ├── apikey.get.ts │ │ ├── index.get.ts │ │ ├── purge.get.ts │ │ ├── delete.get.ts │ │ └── index.post.ts │ ├── public │ │ ├── leaderboard.get.ts │ │ ├── stats.get.ts │ │ └── [...badge].get.ts │ ├── stats.get.ts │ ├── admin.get.ts │ ├── external │ │ ├── user.get.ts │ │ ├── stats.get.ts │ │ ├── heartbeat.post.ts │ │ ├── heartbeats.post.ts │ │ └── batch.post.ts │ └── import │ │ └── status.get.ts ├── cron │ ├── clean-users.ts │ └── summarize.ts ├── utils │ ├── logging.ts │ └── stats.ts └── middleware │ ├── cors.ts │ ├── auth.ts │ └── rate-limit.ts ├── Dockerfile ├── app ├── components │ ├── Ui │ │ ├── Key.vue │ │ ├── Toast.vue │ │ ├── Input.vue │ │ ├── RadioButton.vue │ │ ├── Button.vue │ │ ├── Modal.vue │ │ ├── Select.vue │ │ ├── NumberInput.vue │ │ └── ListModal.vue │ ├── Navbar.vue │ └── LeaderboardSetup.vue ├── composables │ ├── useTimeRangeOptions.ts │ └── useToast.ts ├── layouts │ ├── navbar.vue │ └── default.vue ├── app.vue └── pages │ ├── stats.vue │ ├── register.vue │ ├── leaderboard.vue │ └── login.vue ├── styles ├── login.scss ├── admin.scss ├── leaderboard.scss ├── stats.scss ├── settings.scss └── index.scss ├── .env.example ├── package.json ├── docker-compose.yml ├── types └── import.ts ├── CONTRIBUTING.md ├── nuxt.config.ts ├── README.md └── CODE_OF_CONDUCT.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 0pandadev 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .output 3 | .nuxt 4 | .git -------------------------------------------------------------------------------- /public/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Ziit/HEAD/public/logo.webp -------------------------------------------------------------------------------- /fonts/Outfit.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Ziit/HEAD/fonts/Outfit.woff2 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Ziit/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /fonts/ChivoMono.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Ziit/HEAD/fonts/ChivoMono.woff2 -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Ziit/HEAD/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/Ziit/HEAD/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://ziit.app/sitemap.xml 5 | 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /prisma/migrations/20250819132912_add_leaderboard_toggle/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "public"."User" ADD COLUMN "leaderboardEnabled" BOOLEAN NOT NULL DEFAULT true; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250507124855_add_files_branches_to_summaries/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Summaries" ADD COLUMN "branches" JSONB, 3 | ADD COLUMN "files" JSONB; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20250829143643_add_lastlogin_to_user/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "public"."User" ADD COLUMN "lastlogin" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20251002111902_drop_unessesary_indexes/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "public"."Summaries_date_idx"; 3 | 4 | -- DropIndex 5 | DROP INDEX "public"."Summaries_userId_date_key"; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20250830175406_leaderboard_optin_first_check/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "public"."User" ADD COLUMN "leaderboardFirstSet" BOOLEAN NOT NULL DEFAULT false, 3 | ALTER COLUMN "leaderboardEnabled" SET DEFAULT false; 4 | -------------------------------------------------------------------------------- /prisma/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaPg } from "@prisma/adapter-pg"; 2 | import { PrismaClient } from "./generated/client"; 3 | export * from "@prisma/client"; 4 | 5 | const adapter = new PrismaPg({ 6 | connectionString: process.env.NUXT_DATABASE_URL!, 7 | }); 8 | 9 | export const prisma = new PrismaClient({ adapter }); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | prisma/generated -------------------------------------------------------------------------------- /prisma.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "prisma/config"; 2 | 3 | export default defineConfig({ 4 | schema: "prisma/schema.prisma", 5 | migrations: { 6 | path: "prisma/migrations", 7 | }, 8 | datasource: { 9 | url: 10 | process.env.NUXT_DATABASE_URL || 11 | "postgresql://placeholder:placeholder@localhost:5432/placeholder", 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "files": [], 4 | "references": [ 5 | { 6 | "path": "./.nuxt/tsconfig.app.json" 7 | }, 8 | { 9 | "path": "./.nuxt/tsconfig.server.json" 10 | }, 11 | { 12 | "path": "./.nuxt/tsconfig.shared.json" 13 | }, 14 | { 15 | "path": "./.nuxt/tsconfig.node.json" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /server/api/auth/logout.get.ts: -------------------------------------------------------------------------------- 1 | defineRouteMeta({ 2 | openAPI: { 3 | tags: ["Auth"], 4 | summary: "Logout current user", 5 | description: "Clears the session cookie and redirects to home.", 6 | responses: { 7 | 302: { description: "Redirect after logout" }, 8 | }, 9 | operationId: "getLogout", 10 | }, 11 | }); 12 | 13 | export default defineEventHandler(async (event) => { 14 | deleteCookie(event, "ziit_session"); 15 | await sendRedirect(event, "/"); 16 | }); 17 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:alpine AS build 2 | WORKDIR /app 3 | 4 | RUN apk add --no-cache openssl 5 | 6 | COPY package.json bun.lock* ./ 7 | RUN bun install --frozen-lockfile 8 | 9 | COPY --link . . 10 | RUN DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" bunx prisma generate 11 | RUN bun run build 12 | 13 | FROM node:22-alpine 14 | WORKDIR /app 15 | 16 | EXPOSE 3000 17 | 18 | COPY --from=build /app /app 19 | 20 | CMD ["sh", "-c", "npx prisma migrate deploy && node ./.output/server/index.mjs"] -------------------------------------------------------------------------------- /prisma/migrations/20250828124536_add_epilogue_auth/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[epilogueId]` on the table `User` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "public"."User" ADD COLUMN "epilogueId" TEXT, 9 | ADD COLUMN "epilogueToken" TEXT, 10 | ADD COLUMN "epilogueUsername" TEXT; 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "User_epilogueId_key" ON "public"."User"("epilogueId"); 14 | -------------------------------------------------------------------------------- /app/components/Ui/Key.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | 27 | -------------------------------------------------------------------------------- /prisma/migrations/20250516211020_add_stats_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Stats" ( 3 | "id" TEXT NOT NULL, 4 | "date" DATE NOT NULL, 5 | "totalHours" INTEGER NOT NULL, 6 | "totalUsers" BIGINT NOT NULL, 7 | "totalHeartbeats" INTEGER NOT NULL, 8 | "topEditor" TEXT NOT NULL, 9 | "topLanguage" TEXT NOT NULL, 10 | "topOS" TEXT NOT NULL, 11 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | 13 | CONSTRAINT "Stats_pkey" PRIMARY KEY ("id") 14 | ); 15 | 16 | -- CreateIndex 17 | CREATE UNIQUE INDEX "Stats_date_key" ON "Stats"("date"); 18 | -------------------------------------------------------------------------------- /styles/login.scss: -------------------------------------------------------------------------------- 1 | main { 2 | height: 100%; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | gap: 24px; 7 | flex-direction: column; 8 | } 9 | 10 | .branding { 11 | display: flex; 12 | flex-direction: column; 13 | gap: 8px; 14 | align-items: center; 15 | 16 | .title { 17 | font-size: 40px; 18 | line-height: 32px; 19 | } 20 | } 21 | 22 | .description { 23 | color: var(--text-secondary); 24 | } 25 | 26 | .form { 27 | display: flex; 28 | flex-direction: column; 29 | gap: 8px; 30 | } 31 | 32 | .buttons { 33 | display: flex; 34 | gap: 24px; 35 | } 36 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NUXT_DATABASE_URL="postgresql://postgres:root@localhost:5432/ziit?schema=public" 2 | NUXT_PASETO_KEY="" # RUN THIS COMMAND IN YOUR TERMINAL AND USE THE OUTPUT -> echo k4.local.$(openssl rand -base64 32) 3 | NUXT_ADMIN_KEY="" # This is the password for the admin dashboard. RUN THIS COMMAND IN YOUR TERMINAL AND USE THE OUTPUT -> openssl rand -base64 64 4 | NUXT_BASE_URL="http://localhost:3000" # In a dev environment you normally dont need to change this 5 | NUXT_DISABLE_REGISTRATION=false # In a dev environment it is not recommended to change this 6 | NUXT_GITHUB_CLIENT_ID="" # client id https://docs.ziit.app/deploy/github-oauth 7 | NUXT_GITHUB_CLIENT_SECRET="" # client secret https://docs.ziit.app/deploy/github-oauth 8 | NUXT_EPILOGUE_APP_ID="" # application id https://docs.ziit.app/deploy/epilogue-oauth 9 | NUXT_EPILOGUE_APP_SECRET="" # application secret https://docs.ziit.app/deploy/epilogue-oauth 10 | -------------------------------------------------------------------------------- /prisma/migrations/20250926115638_convert_to_hypertables/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `Summaries` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "public"."Heartbeats" DROP CONSTRAINT "Heartbeats_summariesId_fkey"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "public"."Summaries" DROP CONSTRAINT "Summaries_pkey", 12 | ADD CONSTRAINT "Summaries_pkey" PRIMARY KEY ("id", "date"); 13 | 14 | -- CreateIndex 15 | CREATE INDEX "Heartbeats_summariesId_idx" ON "public"."Heartbeats"("summariesId"); 16 | 17 | -- Convert to hypertables 18 | CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; 19 | SELECT create_hypertable('"Summaries"', 'date', migrate_data => true, chunk_time_interval => INTERVAL '2 weeks'); 20 | SELECT create_hypertable('"Heartbeats"', 'timestamp', migrate_data => true, chunk_time_interval => 604800000000); -------------------------------------------------------------------------------- /server/cron/clean-users.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~~/prisma/db"; 2 | import { defineCronHandler } from "#nuxt/cron"; 3 | 4 | export default defineCronHandler( 5 | "daily", 6 | async () => { 7 | const date = new Date(); 8 | date.setDate(date.getDate() - 90); 9 | 10 | const users = await prisma.user.findMany({ 11 | where: { 12 | lastlogin: { 13 | lt: date, 14 | }, 15 | heartbeats: { 16 | none: {}, 17 | }, 18 | summaries: { 19 | none: {}, 20 | }, 21 | }, 22 | }); 23 | 24 | if (users.length > 0) { 25 | await prisma.user.deleteMany({ 26 | where: { 27 | id: { 28 | in: users.map((user) => user.id), 29 | }, 30 | }, 31 | }); 32 | 33 | handleLog(`Deleted ${users.length} inactive users`); 34 | } 35 | }, 36 | { 37 | timeZone: "UTC", 38 | runOnInit: true, 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /styles/admin.scss: -------------------------------------------------------------------------------- 1 | a { 2 | color: inherit; 3 | } 4 | 5 | table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | 10 | td { 11 | color: var(--text-secondary); 12 | padding: 0 16px; 13 | white-space: nowrap; 14 | } 15 | 16 | td.sorted { 17 | color: var(--text); 18 | } 19 | 20 | .header { 21 | border: 1px solid var(--border); 22 | background-color: var(--background); 23 | user-select: none; 24 | 25 | td::after { 26 | width: 1ch; 27 | text-align: center; 28 | opacity: 0; 29 | content: "↓"; 30 | } 31 | 32 | td.sorted.asc::after { 33 | content: "↑"; 34 | opacity: 1; 35 | } 36 | 37 | td.sorted.desc::after { 38 | content: "↓"; 39 | opacity: 1; 40 | } 41 | } 42 | 43 | .id { 44 | color: var(--text-muted); 45 | font-family: ChivoMono; 46 | } 47 | 48 | .row { 49 | height: 48px; 50 | } 51 | } 52 | 53 | .auth { 54 | display: flex; 55 | flex-direction: column; 56 | gap: 16px; 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "prisma generate && nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@prisma/adapter-pg": "^7.1.0", 14 | "@prisma/client": "7.1.0", 15 | "bcrypt": "6.0.0", 16 | "busboy": "1.6.0", 17 | "paseto-ts": "2.0.5", 18 | "stream-json": "1.9.1", 19 | "zod": "4.1.13" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "24.10.2", 23 | "@nuxtjs/sitemap": "7.4.9", 24 | "@types/bcrypt": "6.0.0", 25 | "@types/busboy": "1.5.4", 26 | "@vite-pwa/nuxt": "1.1.0", 27 | "@waradu/keyboard": "8.0.0", 28 | "badgen": "3.2.3", 29 | "chart.js": "4.5.1", 30 | "lucide-vue-next": "0.556.0", 31 | "nuxt": "4.2.1", 32 | "nuxt-cron": "1.8.0", 33 | "prettier": "3.7.4", 34 | "prisma": "7.1.0", 35 | "sass-embedded": "1.93.3", 36 | "vue": "3.5.25", 37 | "vue-router": "4.6.3" 38 | } 39 | } -------------------------------------------------------------------------------- /styles/leaderboard.scss: -------------------------------------------------------------------------------- 1 | table { 2 | width: 100%; 3 | border-collapse: collapse; 4 | border-spacing: 0; 5 | 6 | td { 7 | color: var(--text-secondary); 8 | padding: 0 16px; 9 | } 10 | 11 | td.sorted { 12 | color: var(--text); 13 | } 14 | 15 | .header { 16 | border: 1px solid var(--border); 17 | background-color: var(--background); 18 | user-select: none; 19 | 20 | td { 21 | cursor: pointer; 22 | } 23 | 24 | td::after { 25 | width: 1ch; 26 | text-align: center; 27 | opacity: 0; 28 | content: "↓"; 29 | } 30 | 31 | td.sorted.asc::after { 32 | content: "↑"; 33 | opacity: 1; 34 | } 35 | 36 | td.sorted.desc::after { 37 | content: "↓"; 38 | opacity: 1; 39 | } 40 | } 41 | 42 | .userid { 43 | color: var(--text-muted); 44 | font-family: ChivoMono; 45 | } 46 | 47 | .row { 48 | height: 48px; 49 | } 50 | } 51 | 52 | @media (max-width: 768px) { 53 | .userid { 54 | white-space: nowrap; 55 | overflow: hidden; 56 | text-overflow: ellipsis; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/components/Ui/Toast.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | 18 | 52 | -------------------------------------------------------------------------------- /app/composables/useTimeRangeOptions.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "vue"; 2 | 3 | export function useTimeRangeOptions() { 4 | const timeRangeOptions = computed(() => [ 5 | { label: "Today", value: "today" as TimeRange, key: "D" }, 6 | { label: "Yesterday", value: "yesterday" as TimeRange, key: "E" }, 7 | { label: "Last 7 Days", value: "week" as TimeRange, key: "W" }, 8 | { label: "Last 30 Days", value: "month" as TimeRange, key: "T" }, 9 | { 10 | label: "Last 90 Days", 11 | value: "last-90-days" as TimeRange, 12 | key: "N", 13 | }, 14 | { 15 | label: "Month to Date", 16 | value: "month-to-date" as TimeRange, 17 | key: "M", 18 | }, 19 | { label: "Last Month", value: "last-month" as TimeRange, key: "P" }, 20 | { 21 | label: "Year to Date", 22 | value: "year-to-date" as TimeRange, 23 | key: "Y", 24 | }, 25 | { 26 | label: "Last 12 Months", 27 | value: "last-12-months" as TimeRange, 28 | key: "L", 29 | }, 30 | { label: "All Time", value: "all-time" as TimeRange, key: "A" }, 31 | // { 32 | // label: "Custom Range", 33 | // value: "custom-range" as TimeRange, 34 | // key: "C", 35 | // }, 36 | ]); 37 | 38 | return { timeRangeOptions }; 39 | } -------------------------------------------------------------------------------- /app/layouts/navbar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 60 | -------------------------------------------------------------------------------- /app/components/Ui/Input.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 27 | 28 | 56 | -------------------------------------------------------------------------------- /server/api/user/regenerateSummaries.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { regenerateSummariesForUser } from "~~/server/utils/summarize"; 3 | import { handleApiError} from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["User", "Summaries"], 8 | summary: "Regenerate summaries for current user", 9 | description: "Triggers regeneration of statistics summaries for the authenticated user.", 10 | responses: { 11 | 200: { description: "Regeneration result" }, 12 | 500: { description: "Failed to regenerate summaries" }, 13 | }, 14 | operationId: "getRegenerateSummaries", 15 | }, 16 | }); 17 | 18 | export default defineEventHandler(async (event: H3Event) => { 19 | try { 20 | const userId = event.context.user.id; 21 | const result = await regenerateSummariesForUser(userId); 22 | return result; 23 | } catch (error: any) { 24 | if (error && typeof error === "object" && error.statusCode) throw error; 25 | const userId = event.context.user.id; 26 | const detailedMessage = error instanceof Error ? error.message : "An unknown error occurred during summary regeneration."; 27 | throw handleApiError( 28 | 69, 29 | `Failed to regenerate summaries for user ${userId}: ${detailedMessage}`, 30 | "Failed to regenerate summaries. Please try again." 31 | ); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /server/utils/logging.ts: -------------------------------------------------------------------------------- 1 | import { createError, H3Error } from "h3"; 2 | 3 | type ErrorStatusCode = 400 | 401 | 403 | 404 | 409 | 413 | 429 | 69; 4 | 5 | const standardMessages: Record = { 6 | 400: "Invalid Request", 7 | 401: "Unauthorized", 8 | 403: "Forbidden", 9 | 404: "Not Found", 10 | 409: "Conflict", 11 | 413: "Content Too Large", 12 | 429: "Too Many Requests", 13 | 69: "Server On Fire", 14 | }; 15 | 16 | export function handleApiError( 17 | statusCode: ErrorStatusCode, 18 | detailedMessage: string, 19 | frontendMessage?: string 20 | ): H3Error { 21 | const effectiveCode = 22 | process.env.NODE_ENV === "production" && statusCode === 69 23 | ? 500 24 | : statusCode; 25 | 26 | const clientResponseMessage = 27 | frontendMessage || standardMessages[statusCode] || standardMessages[69]; 28 | 29 | console.error( 30 | `${new Date().toISOString()} Error ${effectiveCode}: ${detailedMessage}` 31 | ); 32 | 33 | return createError({ 34 | statusCode: effectiveCode, 35 | message: clientResponseMessage, 36 | statusMessage: clientResponseMessage, 37 | }); 38 | } 39 | 40 | export function handleLog(...message: any[]) { 41 | console.log(new Date().toISOString(), ...message); 42 | } 43 | 44 | export function handleError(...message: any[]) { 45 | console.error(new Date().toISOString(), ...message); 46 | } 47 | -------------------------------------------------------------------------------- /app/components/Ui/RadioButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | 28 | 65 | -------------------------------------------------------------------------------- /app/composables/useToast.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | interface Toast { 4 | id: string; 5 | message: string; 6 | type: string; 7 | } 8 | 9 | const toasts = ref([]); 10 | let nextId = 0; 11 | 12 | export const useToast = ( 13 | msg?: string, 14 | toastType = "error", 15 | duration = 5000, 16 | ) => { 17 | const show = (text: string, msgType = "error", msgDuration = 3000) => { 18 | if (!text || text.trim() === "") { 19 | return; 20 | } 21 | 22 | const id = `toast-${nextId++}`; 23 | const toast = { id, message: text, type: msgType }; 24 | toasts.value.push(toast); 25 | 26 | setTimeout(() => { 27 | hideById(id); 28 | }, msgDuration); 29 | }; 30 | 31 | const error = (text: string, msgDuration = 3000) => 32 | show(text, "error", msgDuration); 33 | 34 | const success = (text: string, msgDuration = 3000) => 35 | show(text, "success", msgDuration); 36 | 37 | const hideById = (id: string) => { 38 | const index = toasts.value.findIndex((toast) => toast.id === id); 39 | if (index !== -1) { 40 | toasts.value.splice(index, 1); 41 | } 42 | }; 43 | 44 | const hide = () => { 45 | toasts.value = []; 46 | }; 47 | 48 | if (msg) { 49 | show(msg, toastType, duration); 50 | } 51 | 52 | return { 53 | toasts, 54 | show, 55 | error, 56 | success, 57 | hide, 58 | hideById, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /styles/stats.scss: -------------------------------------------------------------------------------- 1 | main { 2 | height: 100%; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | gap: 24px; 7 | flex-direction: column; 8 | } 9 | 10 | .container { 11 | display: flex; 12 | flex-wrap: wrap; 13 | justify-content: center; 14 | gap: clamp(32px, 10vw, 64px); 15 | 16 | .numbers, 17 | .top { 18 | display: flex; 19 | flex-direction: column; 20 | gap: clamp(16px, 4vw, 64px); 21 | flex: 1 1 auto; 22 | min-width: 250px; 23 | 24 | .item { 25 | display: flex; 26 | flex-direction: column; 27 | gap: 16px; 28 | 29 | p { 30 | color: var(--text-secondary); 31 | } 32 | 33 | h2 { 34 | font-size: clamp(2.5rem, 8vw, 64px); 35 | font-family: ChivoMono; 36 | font-weight: 400; 37 | line-height: clamp(3rem, 6vw, 50px); 38 | word-break: break-word; 39 | overflow-wrap: break-word; 40 | white-space: nowrap; 41 | } 42 | } 43 | } 44 | } 45 | 46 | @media (max-width: 768px) { 47 | .container { 48 | flex-direction: column; 49 | align-items: stretch; 50 | gap: clamp(32px, 6vw, 48px); 51 | 52 | .numbers, 53 | .top { 54 | flex: none; 55 | width: 100%; 56 | 57 | .item { 58 | h2 { 59 | font-size: clamp(3rem, 12vw, 64px); 60 | line-height: clamp(3.5rem, 10vw, 50px); 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/components/Ui/Button.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | 65 | -------------------------------------------------------------------------------- /server/middleware/cors.ts: -------------------------------------------------------------------------------- 1 | import { useRuntimeConfig } from "#imports"; 2 | 3 | export default defineEventHandler((event) => { 4 | const config = useRuntimeConfig(); 5 | const corsOrigin = config.baseUrl || event.node.req.headers.origin || ""; 6 | const isProduction = process.env.NODE_ENV === "production"; 7 | 8 | if (event.path?.startsWith("/api/")) { 9 | if (event.path?.startsWith("/api/external/")) { 10 | setResponseHeaders(event, { 11 | "Access-Control-Allow-Origin": "vscode-webview://*", 12 | "Access-Control-Allow-Methods": "POST", 13 | "Access-Control-Allow-Headers": "authorization,content-type", 14 | "Access-Control-Allow-Credentials": "true", 15 | }); 16 | } else { 17 | const origin = event.node.req.headers.origin || corsOrigin; 18 | setResponseHeaders(event, { 19 | "Access-Control-Allow-Origin": origin, 20 | "Access-Control-Allow-Methods": "GET,POST,OPTIONS", 21 | "Access-Control-Allow-Headers": "authorization,content-type", 22 | "Access-Control-Allow-Credentials": "true", 23 | }); 24 | } 25 | } 26 | 27 | setHeader(event, "X-Frame-Options", "DENY"); 28 | setHeader(event, "X-Content-Type-Options", "nosniff"); 29 | setHeader(event, "X-XSS-Protection", "1; mode=block"); 30 | setHeader( 31 | event, 32 | "Permissions-Policy", 33 | "camera=(), microphone=(), geolocation=()" 34 | ); 35 | 36 | if (isProduction) { 37 | setHeader( 38 | event, 39 | "Strict-Transport-Security", 40 | "max-age=31536000; includeSubDomains; preload" 41 | ); 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /server/api/public/leaderboard.get.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~~/prisma/db"; 2 | import { handleApiError } from "~~/server/utils/logging"; 3 | 4 | defineRouteMeta({ 5 | openAPI: { 6 | tags: ["Public", "Leaderboard"], 7 | summary: "Get public leaderboard", 8 | description: 9 | "Returns leaderboard with user rankings based on total coding time.", 10 | responses: { 11 | 200: { description: "Leaderboard data with user rankings" }, 12 | 500: { description: "Failed to fetch leaderboard" }, 13 | }, 14 | operationId: "getPublicLeaderboard", 15 | }, 16 | }); 17 | 18 | export default defineCachedEventHandler( 19 | async () => { 20 | try { 21 | const leaderboardData = await prisma.$queryRaw< 22 | Array<{ 23 | user_id: string; 24 | total_minutes: number; 25 | top_editor: string | null; 26 | top_os: string | null; 27 | top_language: string | null; 28 | }> 29 | >` 30 | SELECT * FROM get_leaderboard_stats() 31 | `; 32 | 33 | return leaderboardData.map((row) => ({ 34 | userId: row.user_id, 35 | totalMinutes: row.total_minutes, 36 | topEditor: row.top_editor, 37 | topOS: row.top_os, 38 | topLanguage: row.top_language, 39 | })); 40 | } catch (fallbackError) { 41 | console.error("Even basic query failed:", fallbackError); 42 | throw handleApiError( 43 | 69, 44 | "Database query failed. Please try again later.", 45 | "Failed to fetch leaderboard" 46 | ); 47 | } 48 | }, 49 | { 50 | maxAge: 5 * 60, 51 | } 52 | ); 53 | -------------------------------------------------------------------------------- /server/api/user/apikey.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { prisma } from "~~/prisma/db"; 3 | import { handleApiError } from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["User"], 8 | summary: "Regenerate API key", 9 | description: "Generates a new API key for the authenticated user.", 10 | responses: { 11 | 200: { 12 | description: "New API key", 13 | content: { 14 | "application/json": { 15 | schema: { 16 | type: "object", 17 | properties: { apiKey: { type: "string", format: "uuid" } }, 18 | }, 19 | }, 20 | }, 21 | }, 22 | 500: { description: "Failed to generate API key" }, 23 | }, 24 | operationId: "getUserRegenerateApiKey", 25 | }, 26 | }); 27 | 28 | export default defineEventHandler(async (event: H3Event) => { 29 | try { 30 | const apiKey = crypto.randomUUID(); 31 | 32 | const updatedUser = await prisma.user.update({ 33 | where: { 34 | id: event.context.user.id, 35 | }, 36 | data: { 37 | apiKey, 38 | }, 39 | select: { 40 | apiKey: true, 41 | }, 42 | }); 43 | 44 | return { 45 | apiKey: updatedUser.apiKey, 46 | }; 47 | } catch (error: any) { 48 | const detailedMessage = 49 | error instanceof Error 50 | ? error.message 51 | : "An unknown error occurred while generating API key."; 52 | throw handleApiError( 53 | 69, 54 | `Failed to generate API key for user ${event.context.user.id}: ${detailedMessage}`, 55 | "Failed to generate API key. Please try again." 56 | ); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /server/api/user/index.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { prisma } from "~~/prisma/db"; 3 | import { handleApiError } from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["User"], 8 | summary: "Get current user profile", 9 | description: "Returns basic profile fields for the authenticated user.", 10 | responses: { 11 | 200: { description: "User object" }, 12 | 404: { description: "User not found" }, 13 | 500: { description: "Failed to fetch user data" }, 14 | }, 15 | operationId: "getUserProfile", 16 | }, 17 | }); 18 | 19 | export default defineEventHandler(async (event: H3Event) => { 20 | try { 21 | const user = await prisma.user.findUnique({ 22 | where: { id: event.context.user.id }, 23 | select: { 24 | id: true, 25 | email: true, 26 | githubId: true, 27 | githubUsername: true, 28 | epilogueId: true, 29 | epilogueUsername: true, 30 | apiKey: true, 31 | keystrokeTimeout: true, 32 | leaderboardEnabled: true, 33 | leaderboardFirstSet: true, 34 | }, 35 | }); 36 | 37 | if (!user) { 38 | throw handleApiError( 39 | 404, 40 | `User not found for ID ${event.context.user.id}`, 41 | "User not found." 42 | ); 43 | } 44 | 45 | return user; 46 | } catch (error: any) { 47 | const detailedMessage = 48 | error instanceof Error 49 | ? error.message 50 | : "An unknown error occurred while fetching user data."; 51 | throw handleApiError( 52 | 69, 53 | `Failed to fetch user data for user ${event.context.user.id}: ${detailedMessage}`, 54 | "Failed to fetch user data. Please try again." 55 | ); 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /server/api/user/purge.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { prisma } from "~~/prisma/db"; 3 | import { handleApiError } from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["User"], 8 | summary: "Delete current user data", 9 | description: 10 | "Deletes all heartbeats and summaries for the authenticated user using optimized bulk operations.", 11 | responses: { 12 | 200: { description: "User data purged successfully" }, 13 | 404: { description: "User not found" }, 14 | 500: { description: "Failed to delete user data" }, 15 | }, 16 | operationId: "purgeUserData", 17 | }, 18 | }); 19 | 20 | export default defineEventHandler(async (event: H3Event) => { 21 | try { 22 | const userId = event.context.user.id; 23 | 24 | await prisma.$transaction(async (tx) => { 25 | const summariesResult = await tx.$executeRaw` 26 | DELETE FROM "Summaries" 27 | WHERE "userId" = ${userId} 28 | `; 29 | 30 | const heartbeatsResult = await tx.$executeRaw` 31 | DELETE FROM "Heartbeats" 32 | WHERE "userId" = ${userId} 33 | `; 34 | 35 | return { 36 | summariesDeleted: summariesResult, 37 | heartbeatsDeleted: heartbeatsResult, 38 | }; 39 | }); 40 | 41 | return { 42 | success: true, 43 | message: "User data successfully purged", 44 | userId: userId, 45 | }; 46 | } catch (error: any) { 47 | const detailedMessage = 48 | error instanceof Error 49 | ? error.message 50 | : "An unknown error occurred while purging user data."; 51 | throw handleApiError( 52 | 69, 53 | `Failed to purge user data ${event.context.user.id}: ${detailedMessage}`, 54 | "Failed to purge user data. Please try again." 55 | ); 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 | 91 | -------------------------------------------------------------------------------- /app/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 28 | 29 | 58 | -------------------------------------------------------------------------------- /styles/settings.scss: -------------------------------------------------------------------------------- 1 | *:not(.input):not(.layout) { 2 | width: fit-content; 3 | } 4 | 5 | .container { 6 | display: flex; 7 | flex-direction: column; 8 | gap: 64px; 9 | } 10 | 11 | section { 12 | display: flex; 13 | flex-direction: column; 14 | gap: 16px; 15 | } 16 | 17 | .account-information .items { 18 | display: flex; 19 | gap: 24px; 20 | } 21 | 22 | .account-information .items .keys, 23 | .steps, 24 | .setting-description { 25 | color: var(--text-secondary); 26 | } 27 | 28 | .title { 29 | font-size: 18px; 30 | font-weight: 500; 31 | } 32 | 33 | .buttons, 34 | .keys, 35 | .values, 36 | .steps, 37 | .setting-group { 38 | display: flex; 39 | flex-direction: column; 40 | } 41 | 42 | .buttons, 43 | .setting-group { 44 | gap: 8px; 45 | } 46 | 47 | .keys, 48 | .values, 49 | .steps { 50 | gap: 4px; 51 | } 52 | 53 | kbd { 54 | font-family: ChivoMono; 55 | background-color: var(--element); 56 | padding: 2px 6px; 57 | } 58 | 59 | .radio-group { 60 | display: flex; 61 | gap: 16px; 62 | } 63 | 64 | a { 65 | text-decoration: underline; 66 | } 67 | 68 | #wakaTimeFileUpload::file-selector-button { 69 | background-color: var(--text); 70 | color: var(--background); 71 | font-family: Outfit; 72 | font-weight: 500; 73 | border: none; 74 | padding: 2px 6px; 75 | border-radius: 4px; 76 | cursor: pointer; 77 | font-family: inherit; 78 | margin-right: 8px; 79 | } 80 | 81 | .danger-zone .setting-group p { 82 | color: #ff5555 !important; 83 | } 84 | 85 | .import-status { 86 | font-family: ChivoMono; 87 | display: flex; 88 | flex-direction: column; 89 | gap: 2px; 90 | 91 | .spinner { 92 | margin-right: 2px; 93 | } 94 | 95 | .bar-container { 96 | width: 100%; 97 | 98 | .bar { 99 | height: 1px; 100 | background-color: var(--accent); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /server/api/auth/github.get.ts: -------------------------------------------------------------------------------- 1 | import { handleApiError} from "~~/server/utils/logging"; 2 | 3 | defineRouteMeta({ 4 | openAPI: { 5 | tags: ["Auth", "GitHub"], 6 | summary: "Start GitHub OAuth", 7 | description: "Initializes GitHub OAuth and redirects the client to GitHub authorization page.", 8 | responses: { 9 | 302: { description: "Redirect to GitHub OAuth" }, 10 | 403: { description: "Registration disabled" }, 11 | 500: { description: "Failed to initialize GitHub auth" }, 12 | }, 13 | operationId: "getGithubAuthStart", 14 | }, 15 | }); 16 | 17 | export default defineEventHandler(async (event) => { 18 | try { 19 | const config = useRuntimeConfig(); 20 | 21 | if (config.disableRegistering === "true") { 22 | throw createError({ 23 | statusCode: 403, 24 | message: "Registration is currently disabled", 25 | }); 26 | } 27 | 28 | const state = crypto.randomUUID(); 29 | 30 | setCookie(event, "github_oauth_state", state, { 31 | httpOnly: true, 32 | secure: process.env.NODE_ENV === "production", 33 | maxAge: 60 * 10, 34 | path: "/", 35 | }); 36 | 37 | const githubAuthUrl = new URL("https://github.com/login/oauth/authorize"); 38 | githubAuthUrl.searchParams.append("client_id", config.githubClientId); 39 | githubAuthUrl.searchParams.append( 40 | "redirect_uri", 41 | `${config.baseUrl}/api/auth/github/callback` 42 | ); 43 | githubAuthUrl.searchParams.append("state", state); 44 | githubAuthUrl.searchParams.append("scope", "read:user user:email"); 45 | 46 | return sendRedirect(event, githubAuthUrl.toString()); 47 | } catch (error: any) { 48 | if (error && typeof error === "object" && error.statusCode) throw error; 49 | const detailedMessage = error instanceof Error ? error.message : "An unknown error occurred during GitHub auth initialization."; 50 | throw handleApiError(69, `Failed to initialize GitHub authentication: ${detailedMessage}`, "Could not initiate GitHub authentication. Please try again."); 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: ghcr.io/0pandadev/ziit:v1.1.1 4 | ports: 5 | - "3000:3000" 6 | environment: 7 | NUXT_DATABASE_URL: "postgresql://postgres:CHANGEME@postgres:5432/ziit" # change the password for production use 8 | NUXT_PASETO_KEY: "" # RUN THIS COMMAND IN YOUR TERMINAL AND USE THE OUTPUT -> echo k4.local.$(openssl rand -base64 32) 9 | NUXT_ADMIN_KEY: "" # This is the password for the admin dashboard. RUN THIS COMMAND IN YOUR TERMINAL AND USE THE OUTPUT -> openssl rand -hex 64 10 | NUXT_HOST: "" # Base URL of the Ziit instance, including protocol e.g. https://ziit.example.com 11 | NUXT_DISABLE_REGISTRATION: false # By default users are allowed to register a new account 12 | NUXT_GITHUB_CLIENT_ID: "" # client id https://docs.ziit.app/deploy/github-oauth 13 | NUXT_GITHUB_CLIENT_SECRET: "" # client secret https://docs.ziit.app/deploy/github-oauth 14 | NUXT_EPILOGUE_APP_ID: "" # application id https://docs.ziit.app/deploy/epilogue-oauth 15 | NUXT_EPILOGUE_APP_SECRET: "" # application secret https://docs.ziit.app/deploy/epilogue-oauth 16 | restart: unless-stopped 17 | depends_on: 18 | postgres: 19 | condition: service_healthy 20 | networks: 21 | - ziit-network 22 | 23 | postgres: 24 | image: timescale/timescaledb-ha:pg17 25 | restart: unless-stopped 26 | environment: 27 | POSTGRES_USER: postgres 28 | POSTGRES_PASSWORD: CHANGEME # change this for production use 29 | POSTGRES_DB: ziit 30 | PGDATA: /var/lib/postgresql/data 31 | volumes: 32 | - postgres:/var/lib/postgresql/data 33 | post_start: 34 | - command: chown -R 1000:1000 /var/lib/postgresql/data 35 | user: root 36 | networks: 37 | ziit-network: 38 | aliases: 39 | - postgres 40 | healthcheck: 41 | test: ["CMD-SHELL", "pg_isready -U postgres -d ziit"] 42 | interval: 10s 43 | timeout: 5s 44 | retries: 5 45 | start_period: 10s 46 | 47 | volumes: 48 | postgres: 49 | 50 | networks: 51 | ziit-network: 52 | driver: bridge 53 | -------------------------------------------------------------------------------- /server/api/user/delete.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { prisma } from "~~/prisma/db"; 3 | import { handleApiError } from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["User"], 8 | summary: "Delete current user", 9 | description: "Deletes the authenticated user from the database.", 10 | responses: { 11 | 200: { description: "Deleted user object" }, 12 | 400: { description: "User has associated data that prevents deletion" }, 13 | 404: { description: "User not found" }, 14 | 500: { description: "Failed to delete user" }, 15 | }, 16 | operationId: "deleteUser", 17 | }, 18 | }); 19 | 20 | export default defineEventHandler(async (event: H3Event) => { 21 | try { 22 | const user = await prisma.user.delete({ 23 | where: { id: event.context.user.id }, 24 | select: { 25 | id: true, 26 | createdAt: true, 27 | }, 28 | }); 29 | return user; 30 | } catch (error: any) { 31 | if (error.code === "P2003") { 32 | throw handleApiError( 33 | 400, 34 | `Cannot delete user ${event.context.user.id}: User still has associated data`, 35 | "Cannot delete user. Please remove all associated data first before deleting your account." 36 | ); 37 | } 38 | 39 | if (error.code === "P2025") { 40 | throw handleApiError( 41 | 404, 42 | `User ${event.context.user.id} not found`, 43 | "User not found." 44 | ); 45 | } 46 | 47 | if (error.code && error.code.startsWith("P")) { 48 | throw handleApiError( 49 | 400, 50 | `Prisma error ${error.code}: ${error.message}`, 51 | "Database operation failed. Please try again." 52 | ); 53 | } 54 | 55 | const detailedMessage = 56 | error instanceof Error 57 | ? error.message 58 | : "An unknown error occurred while deleting user."; 59 | 60 | throw handleApiError( 61 | 69, 62 | `Failed to delete user ${event.context.user.id}: ${detailedMessage}`, 63 | "Failed to delete user. Please try again." 64 | ); 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /types/import.ts: -------------------------------------------------------------------------------- 1 | export enum ImportStatus { 2 | Processing = "Processing", 3 | ProcessingHeartbeats = "Processing heartbeats", 4 | Uploading = "Uploading", 5 | CreatingDataDumpRequest = "Creating data dump request", 6 | WaitingForDataDump = "Waiting for data dump", 7 | Downloading = "Downloading", 8 | FetchingMetadata = "Fetching metadata", 9 | Completed = "Completed", 10 | Failed = "Failed", 11 | } 12 | 13 | export type ImportMethod = "wakatime-api" | "wakatime-file" | "wakapi"; 14 | 15 | export interface ImportJob { 16 | id: string; 17 | fileName: string; 18 | status: ImportStatus; 19 | message?: string; 20 | progress: number; 21 | importedCount?: number; 22 | error?: string; 23 | userId: string; 24 | type?: ImportMethod; 25 | totalSize?: number; 26 | uploadedSize?: number; 27 | processedCount?: number; 28 | totalToProcess?: number; 29 | fileId?: string; 30 | data?: { 31 | heartbeatsByDate?: Record; 32 | apiKey?: string; 33 | instanceUrl?: string; 34 | jobId?: string; 35 | }; 36 | } 37 | 38 | export interface QueueJob { 39 | id: string; 40 | type: "wakatime-api" | "wakatime-file" | "wakapi"; 41 | userId: string; 42 | data: { 43 | apiKey?: string; 44 | exportData?: WakatimeExportData; 45 | instanceUrl?: string; 46 | jobId?: string; 47 | heartbeatsByDate?: Record; 48 | }; 49 | createdAt: Date; 50 | allocatedWorkers?: number; 51 | } 52 | 53 | export interface WorkChunk { 54 | id: string; 55 | jobId: string; 56 | type: "date-range" | "heartbeat-batch"; 57 | data: { 58 | days?: any[]; 59 | dates?: string[]; 60 | heartbeatsByDate?: Record; 61 | chunkIndex: number; 62 | totalChunks: number; 63 | originalJob: QueueJob; 64 | processedDays?: number; 65 | }; 66 | status: "pending" | "processing" | "completed" | "failed"; 67 | progress: number; 68 | workerId?: number; 69 | } 70 | 71 | export interface JobUpdateOptions { 72 | status: ImportStatus; 73 | current?: number; 74 | total?: number; 75 | importedCount?: number; 76 | error?: string; 77 | additionalInfo?: string; 78 | } 79 | -------------------------------------------------------------------------------- /app/components/LeaderboardSetup.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 55 | 56 | 88 | -------------------------------------------------------------------------------- /app/components/Ui/Modal.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 42 | 43 | 97 | -------------------------------------------------------------------------------- /server/api/stats.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { TimeRangeEnum } from "~/composables/useStats"; 3 | import type { TimeRange } from "~/composables/useStats"; 4 | import { handleApiError} from "~~/server/utils/logging"; 5 | import { calculateStats } from "~~/server/utils/stats"; 6 | 7 | defineRouteMeta({ 8 | openAPI: { 9 | tags: ["Stats"], 10 | summary: "Get authenticated user stats", 11 | description: "Returns aggregated statistics for the authenticated user.", 12 | parameters: [ 13 | { in: "query", name: "timeRange", required: true, schema: { type: "string", enum: Object.values(TimeRangeEnum) as any } }, 14 | { in: "query", name: "midnightOffsetSeconds", required: false, schema: { type: "integer" } }, 15 | ], 16 | responses: { 17 | 200: { description: "Stats result" }, 18 | 400: { description: "Invalid parameters" }, 19 | 500: { description: "Failed to retrieve statistics" }, 20 | }, 21 | operationId: "getStats", 22 | }, 23 | }); 24 | 25 | export default defineEventHandler(async (event: H3Event) => { 26 | const userId = event.context.user.id; 27 | 28 | try { 29 | const query = getQuery(event); 30 | const timeRange = query.timeRange as TimeRange; 31 | const midnightOffsetSeconds = query.midnightOffsetSeconds 32 | ? parseInt(query.midnightOffsetSeconds as string, 10) 33 | : undefined; 34 | 35 | if (!timeRange || !Object.values(TimeRangeEnum).includes(timeRange)) { 36 | throw handleApiError( 37 | 400, 38 | `Invalid timeRange value: ${timeRange}. User ID: ${userId}`, 39 | "Invalid time range specified." 40 | ); 41 | } 42 | 43 | return await calculateStats(userId, timeRange, midnightOffsetSeconds); 44 | } catch (error: any) { 45 | if (error && typeof error === "object" && error.statusCode) throw error; 46 | const detailedMessageBase = error instanceof Error ? error.message : "Unknown error in stats endpoint"; 47 | const detailedMessage = `Stats endpoint failed for user ${userId}. Original error: ${detailedMessageBase}`; 48 | throw handleApiError( 49 | 69, 50 | detailedMessage, 51 | "Failed to retrieve statistics." 52 | ); 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /server/api/admin.get.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { prisma } from "~~/prisma/db"; 3 | import { H3Event } from "h3"; 4 | 5 | const adminKeySchema = z.base64(); 6 | 7 | export default defineEventHandler(async (event: H3Event) => { 8 | try { 9 | const authHeader = getHeader(event, "authorization"); 10 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 11 | throw handleApiError( 12 | 401, 13 | "Admin API error: Missing or invalid Admin key format in header." 14 | ); 15 | } 16 | 17 | const config = useRuntimeConfig(); 18 | const adminKey = authHeader.substring(7); 19 | const validatedAdminKey = adminKeySchema.safeParse(adminKey); 20 | 21 | if (!validatedAdminKey.success) { 22 | throw handleApiError(401, `Admin API error: Invalid Admin key format.`); 23 | } 24 | 25 | if (validatedAdminKey.data === config.adminKey) { 26 | const results = await prisma.$queryRaw< 27 | Array<{ 28 | id: string; 29 | email: string; 30 | github_username: string | null; 31 | created_at: Date; 32 | last_login: Date; 33 | heartbeats_count: number; 34 | summaries_count: number; 35 | total_minutes: number; 36 | }> 37 | >`SELECT * FROM get_admin_dashboard_stats()`; 38 | 39 | return results.map((user) => ({ 40 | id: user.id, 41 | email: user.email, 42 | github_username: user.github_username, 43 | created_at: user.created_at, 44 | last_login: user.last_login, 45 | heartbeats_count: Number(user.heartbeats_count), 46 | summaries_count: Number(user.summaries_count), 47 | total_minutes: Number(user.total_minutes), 48 | })); 49 | } 50 | } catch (error) { 51 | const adminKey = 52 | getHeader(event, "authorization")?.substring(7, 11) || "UNKNOWN"; 53 | throw handleApiError( 54 | 69, 55 | `Admin API error: Error occurred getting the user data. API Key prefix: ${adminKey}... Error: ${ 56 | error instanceof Error 57 | ? error.message 58 | : "An unknown error occurred getting the user data." 59 | }`, 60 | "Failed to process your request." 61 | ); 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { decrypt } from "paseto-ts/v4"; 2 | 3 | import type { H3Event } from "h3"; 4 | import { prisma } from "~~/prisma/db"; 5 | 6 | const AUTH_CONFIG = { 7 | publicApiPaths: [ 8 | "/api/external/", 9 | "/api/auth/", 10 | "/api/public/", 11 | "/api/leaderboard/", 12 | ], 13 | publicPages: [ 14 | "/stats", 15 | "/leaderboard", 16 | "/login", 17 | "/register", 18 | "/sitemap.xml", 19 | "/robots.txt", 20 | "/_openapi.json", 21 | ], 22 | sessionCookieName: "ziit_session", 23 | loginRedirectPath: "/login", 24 | }; 25 | 26 | export default defineEventHandler(async (event: H3Event) => { 27 | const path = getRequestURL(event).pathname; 28 | const sessionCookie = getCookie(event, AUTH_CONFIG.sessionCookieName); 29 | 30 | if (isPublicPath(path)) { 31 | return; 32 | } 33 | 34 | if (!sessionCookie) { 35 | if (path.startsWith("/api/")) { 36 | throw createError({ 37 | statusCode: 401, 38 | message: "Unauthorized", 39 | }); 40 | } 41 | return sendRedirect(event, AUTH_CONFIG.loginRedirectPath); 42 | } 43 | 44 | try { 45 | const config = useRuntimeConfig(); 46 | const { payload } = decrypt(config.pasetoKey, sessionCookie); 47 | 48 | if (!payload || typeof payload !== "object" || !("userId" in payload)) { 49 | throw new Error("Invalid token"); 50 | } 51 | 52 | const user = await prisma.user.findUnique({ 53 | where: { id: payload.userId }, 54 | }); 55 | 56 | if (!user) { 57 | throw new Error("User not found"); 58 | } 59 | 60 | event.context.user = user; 61 | return; 62 | } catch (error) { 63 | console.error("Auth middleware error:", error); 64 | deleteCookie(event, AUTH_CONFIG.sessionCookieName); 65 | 66 | if (path.startsWith("/api/")) { 67 | throw createError({ 68 | statusCode: 401, 69 | message: "Unauthorized", 70 | }); 71 | } 72 | return sendRedirect(event, AUTH_CONFIG.loginRedirectPath); 73 | } 74 | }); 75 | 76 | function isPublicPath(path: string): boolean { 77 | for (const publicPath of AUTH_CONFIG.publicApiPaths) { 78 | if (path.startsWith(publicPath)) { 79 | return true; 80 | } 81 | } 82 | return AUTH_CONFIG.publicPages.includes(path); 83 | } 84 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Ziit 2 | 3 | Thank you for considering contributing to Ziit! I appreciate your time and effort in improving this project. The following is a set of guidelines for contributing to Ziit. These guidelines are intended to make it easier for you to get involved. 4 | 5 | ## Types of Contributions 6 | 7 | I welcome a wide range of contributions, including but not limited to: 8 | 9 | - **Bug Fixes**: Resolve existing issues in the code. 10 | - **New Features**: Implement new features or enhance existing ones. 11 | - **Documentation**: Improve the clarity and depth of documentation. 12 | - **Code Refactoring**: Clean up the code to improve readability, performance, or maintainability. 13 | - **UI/UX Enhancements**: Improve the user interface or user experience of Ziit. 14 | - **Testing**: Add or improve tests to ensure code quality. 15 | - **IDE Extensions**: Develop an extension for your favorite IDE to add it to the list of supported ones. 16 | 17 | ### Reporting Bugs 18 | 19 | If you find a bug, please open an issue and describe the problem in detail. Include steps to reproduce the bug, the expected behavior, and any relevant information about your environment. Please verify that the bug has not been reported already. 20 | 21 | 22 | Open the issue in it's corresponding GitHub repository: 23 | 24 | - [Ziit](https://github.com/0PandaDEV/Ziit/issues) 25 | - [Ziit Docs](https://github.com/0PandaDEV/Ziit-docs/issues) 26 | - [Ziit VSCode Extension](https://github.com/0PandaDEV/Ziit-vscode/issues) 27 | - [Ziit JetBrains Extension](https://github.com/0PandaDEV/Ziit-jetbrains/issues) 28 | 29 | 30 | ### Suggesting Features 31 | 32 | I welcome suggestions for new features or improvements to existing ones. To suggest a feature, please open an issue. 33 | _Use the correct Github Repository based on the list above_ 34 | 35 | --- 36 | 37 | ## Code of Conduct 38 | 39 | Please note that this project is governed by a [Code of Conduct](/contribute/code-of-conduct). By participating in this project, you agree to abide by its terms. 40 | 41 | ## License 42 | 43 | By contributing to Ziit, you agree that your contributions will be licensed under the [AGPL-3.0 License](https://github.com/0PandaDEV/Ziit/blob/main/LICENSE). 44 | 45 | --- 46 | 47 | Thank you for your interest in contributing to Ziit! I look forward to your contributions. 48 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 37 | 38 | 97 | -------------------------------------------------------------------------------- /server/api/auth/epilogue.get.ts: -------------------------------------------------------------------------------- 1 | import { handleApiError } from "~~/server/utils/logging"; 2 | 3 | defineRouteMeta({ 4 | openAPI: { 5 | tags: ["Auth", "Epilogue"], 6 | summary: "Start Epilogue OAuth", 7 | description: 8 | "Initializes Epilogue OAuth and redirects the client to Epilogue authorization page.", 9 | responses: { 10 | 302: { description: "Redirect to Epilogue OAuth" }, 11 | 403: { description: "Registration disabled" }, 12 | 500: { description: "Failed to initialize Epilogue auth" }, 13 | }, 14 | operationId: "getEpilogueAuthStart", 15 | }, 16 | }); 17 | 18 | export default defineEventHandler(async (event) => { 19 | try { 20 | const config = useRuntimeConfig(); 21 | 22 | if (config.disableRegistering === "true") { 23 | throw createError({ 24 | statusCode: 403, 25 | message: "Registration is currently disabled", 26 | }); 27 | } 28 | 29 | if (!config.epilogueAppId || !config.baseUrl) { 30 | throw createError({ 31 | statusCode: 500, 32 | message: "Epilogue auth is not configured", 33 | }); 34 | } 35 | 36 | const state = crypto.randomUUID(); 37 | 38 | setCookie(event, "epilogue_oauth_state", state, { 39 | httpOnly: true, 40 | secure: process.env.NODE_ENV === "production", 41 | maxAge: 60 * 10, 42 | path: "/", 43 | }); 44 | 45 | const host = getHeader(event, "host"); 46 | const protocol = getHeader(event, "x-forwarded-proto") || "http"; 47 | const frontendUrl = `${protocol}://${host}`; 48 | 49 | const epilogueAuthUrl = new URL("https://auth.epilogue.team/authorize/"); 50 | epilogueAuthUrl.searchParams.append("app_id", config.epilogueAppId); 51 | epilogueAuthUrl.searchParams.append( 52 | "redirect_url", 53 | `${config.baseUrl}/api/auth/epilogue/callback` 54 | ); 55 | epilogueAuthUrl.searchParams.append( 56 | "cancel_url", 57 | `${frontendUrl}/login?error=cancelled` 58 | ); 59 | epilogueAuthUrl.searchParams.append("state", state); 60 | 61 | return sendRedirect(event, epilogueAuthUrl.toString()); 62 | } catch (error: any) { 63 | if (error && typeof error === "object" && error.statusCode) throw error; 64 | const detailedMessage = 65 | error instanceof Error 66 | ? error.message 67 | : "An unknown error occurred during Epilogue auth initialization."; 68 | throw handleApiError( 69 | 69, 70 | `Failed to initialize Epilogue authentication: ${detailedMessage}`, 71 | "Could not initiate Epilogue authentication. Please try again." 72 | ); 73 | } 74 | }); 75 | -------------------------------------------------------------------------------- /server/middleware/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import { createError, getRequestIP, setResponseHeaders } from "h3"; 2 | import type { H3Event } from "h3"; 3 | import { useStorage } from "#imports"; 4 | 5 | const storage = useStorage(); 6 | 7 | type RateLimitProfile = { limit: number; window: number }; 8 | type PathMapping = { path: string; profile: keyof typeof profiles }; 9 | 10 | const profiles = { 11 | default: { limit: 50, window: 60000 }, 12 | auth: { limit: 5, window: 1800000 }, 13 | external: { limit: 100, window: 30000 }, 14 | stats: { limit: 50, window: 60000 }, 15 | import: { limit: 20, window: 1800000 }, 16 | public: { limit: 50, window: 60000 }, 17 | } as const; 18 | 19 | const RATE_LIMIT_CONFIG = { 20 | profiles, 21 | pathMappings: [ 22 | { path: "/api/auth", profile: "auth" }, 23 | { path: "/api/external", profile: "external" }, 24 | { path: "/api/stats", profile: "stats" }, 25 | { path: "/api/import", profile: "import" }, 26 | { path: "/api/public", profile: "public" }, 27 | ] as PathMapping[], 28 | defaultProfile: "default" as keyof typeof profiles, 29 | apiOnly: true, 30 | storageKeyPrefix: "rate-limit", 31 | }; 32 | 33 | function getRateLimitProfile(path: string): RateLimitProfile { 34 | for (const mapping of RATE_LIMIT_CONFIG.pathMappings) { 35 | if (path.startsWith(mapping.path)) { 36 | return RATE_LIMIT_CONFIG.profiles[mapping.profile]; 37 | } 38 | } 39 | return RATE_LIMIT_CONFIG.profiles[RATE_LIMIT_CONFIG.defaultProfile]; 40 | } 41 | 42 | export default defineEventHandler(async (event: H3Event) => { 43 | const path = (event.path || event.node?.req?.url || "/") as string; 44 | 45 | if (RATE_LIMIT_CONFIG.apiOnly && !path.startsWith("/api")) { 46 | return; 47 | } 48 | 49 | const ip = getRequestIP(event, { xForwardedFor: true }); 50 | 51 | const limitConfig = getRateLimitProfile(path); 52 | const key = `${RATE_LIMIT_CONFIG.storageKeyPrefix}:${ip}:${path}`; 53 | 54 | const current = ((await storage.getItem(key)) as { 55 | count: number; 56 | reset: number; 57 | }) || { count: 0, reset: Date.now() + limitConfig.window }; 58 | 59 | if (Date.now() > current.reset) { 60 | current.count = 0; 61 | current.reset = Date.now() + limitConfig.window; 62 | } 63 | 64 | if (current.count >= limitConfig.limit) { 65 | throw createError({ 66 | statusCode: 429, 67 | message: "Too many requests, please try again later", 68 | }); 69 | } 70 | 71 | current.count++; 72 | await storage.setItem(key, current); 73 | 74 | setResponseHeaders(event, { 75 | "X-RateLimit-Limit": String(limitConfig.limit), 76 | "X-RateLimit-Remaining": String(limitConfig.limit - current.count), 77 | "X-RateLimit-Reset": String(Math.ceil((current.reset - Date.now()) / 1000)), 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | compatibilityDate: "2025-04-09", 4 | devtools: { enabled: false }, 5 | modules: [ 6 | "@nuxtjs/sitemap", 7 | "nuxt-cron", 8 | "@vite-pwa/nuxt", 9 | "@waradu/keyboard/nuxt", 10 | ], 11 | ssr: true, 12 | runtimeConfig: { 13 | pasetoKey: process.env.NUXT_PASETO_KEY, 14 | adminKey: process.env.NUXT_ADMIN_KEY, 15 | baseUrl: process.env.NUXT_BASE_URL, 16 | disableRegistering: process.env.NUXT_DISABLE_REGISTRATION, 17 | githubClientId: process.env.NUXT_GITHUB_CLIENT_ID, 18 | githubClientSecret: process.env.NUXT_GITHUB_CLIENT_SECRET, 19 | epilogueAppId: process.env.NUXT_EPILOGUE_APP_ID, 20 | epilogueAppSecret: process.env.NUXT_EPILOGUE_APP_SECRET, 21 | }, 22 | nitro: { 23 | preset: "node-server", 24 | experimental: { 25 | openAPI: process.env.NODE_ENV === "development", 26 | wasm: false, 27 | }, 28 | minify: true, 29 | }, 30 | app: { 31 | head: { 32 | charset: "utf-8", 33 | viewport: 34 | "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no", 35 | title: "Ziit", 36 | }, 37 | }, 38 | cron: { 39 | runOnInit: true, 40 | timeZone: "UTC+0", 41 | jobsDir: "cron", 42 | }, 43 | routeRules: { 44 | "/api/**": { 45 | cors: true, 46 | }, 47 | "/leaderboard": { cache: { maxAge: 5 * 60 } }, 48 | "/stats": { cache: { maxAge: 5 * 60 } }, 49 | }, 50 | sitemap: { 51 | defaults: { 52 | lastmod: new Date().toISOString(), 53 | priority: 0.9, 54 | changefreq: "weekly", 55 | }, 56 | urls: [ 57 | { 58 | loc: "/stats", 59 | lastmod: new Date().toISOString(), 60 | priority: 1, 61 | changefreq: "daily", 62 | }, 63 | { 64 | loc: "/leaderboard", 65 | lastmod: new Date().toISOString(), 66 | priority: 0.9, 67 | changefreq: "daily", 68 | }, 69 | ], 70 | }, 71 | pwa: { 72 | manifest: { 73 | name: "Ziit", 74 | short_name: "Ziit", 75 | theme_color: "#191919", 76 | background_color: "#191919", 77 | display: "standalone", 78 | orientation: "portrait", 79 | icons: [ 80 | { 81 | src: "/pwa-192x192.png", 82 | sizes: "192x192", 83 | type: "image/png", 84 | }, 85 | { 86 | src: "/pwa-512x512.png", 87 | sizes: "512x512", 88 | type: "image/png", 89 | }, 90 | ], 91 | }, 92 | devOptions: { 93 | enabled: true, 94 | suppressWarnings: true, 95 | navigateFallback: "/", 96 | navigateFallbackAllowlist: [/^\/$/], 97 | type: "module", 98 | }, 99 | }, 100 | }); 101 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client" 6 | output = "./generated" 7 | previewFeatures = ["postgresqlExtensions"] 8 | } 9 | 10 | datasource db { 11 | provider = "postgresql" 12 | extensions = [pg_stat_statements, timescaledb] 13 | } 14 | 15 | model User { 16 | id String @id @default(cuid()) 17 | email String @unique 18 | passwordHash String? 19 | githubId String? @unique 20 | githubUsername String? 21 | githubAccessToken String? 22 | epilogueId String? @unique 23 | epilogueUsername String? 24 | epilogueToken String? 25 | apiKey String @unique @default(uuid()) 26 | keystrokeTimeout Int @default(5) 27 | leaderboardEnabled Boolean @default(false) 28 | leaderboardFirstSet Boolean @default(false) 29 | createdAt DateTime @default(now()) 30 | lastlogin DateTime @default(now()) 31 | heartbeats Heartbeats[] 32 | summaries Summaries[] 33 | } 34 | 35 | model Heartbeats { 36 | id String @default(cuid()) 37 | timestamp DateTime @db.Timestamptz 38 | userId String 39 | user User @relation(fields: [userId], references: [id]) 40 | project String? 41 | editor String? 42 | language String? 43 | os String? 44 | file String? 45 | branch String? 46 | createdAt DateTime @default(now()) @db.Timestamptz 47 | summariesId String? 48 | 49 | @@id([id, timestamp]) 50 | @@index([userId, timestamp(sort: Desc)]) 51 | @@index([timestamp(sort: Desc)]) 52 | @@index([userId, project, timestamp(sort: Desc)]) 53 | @@index([userId, language, timestamp(sort: Desc)]) 54 | @@index([userId, editor, timestamp(sort: Desc)]) 55 | @@index([userId, os, timestamp(sort: Desc)]) 56 | @@index([branch]) 57 | @@index([file]) 58 | @@index([summariesId]) 59 | } 60 | 61 | model Summaries { 62 | id String @default(cuid()) 63 | userId String 64 | user User @relation(fields: [userId], references: [id]) 65 | date DateTime @db.Date 66 | totalMinutes Int 67 | projects Json? 68 | languages Json? 69 | editors Json? 70 | os Json? 71 | files Json? 72 | branches Json? 73 | createdAt DateTime @default(now()) 74 | 75 | @@id([id, date]) 76 | @@index([userId, date(sort: Desc)]) 77 | } 78 | 79 | model Stats { 80 | id String @id @default(cuid()) 81 | date DateTime @db.Date 82 | totalHours Int 83 | totalUsers BigInt 84 | totalHeartbeats Int 85 | topEditor String 86 | topLanguage String 87 | topOS String 88 | createdAt DateTime @default(now()) 89 | 90 | @@unique([date]) 91 | } 92 | -------------------------------------------------------------------------------- /server/api/external/user.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { z } from "zod"; 3 | import { prisma } from "~~/prisma/db"; 4 | import { handleApiError } from "~~/server/utils/logging"; 5 | 6 | defineRouteMeta({ 7 | openAPI: { 8 | tags: ["External", "User"], 9 | summary: "Get user via API key", 10 | description: 11 | "Returns public user data for the account identified by the Bearer API key.", 12 | security: [{ bearerAuth: [] }], 13 | responses: { 14 | 200: { description: "User object" }, 15 | 401: { description: "Invalid or missing API key" }, 16 | 404: { description: "User not found" }, 17 | 500: { description: "Server error" }, 18 | }, 19 | operationId: "getExternalUser", 20 | }, 21 | }); 22 | 23 | const apiKeySchema = z.uuid(); 24 | 25 | export default defineEventHandler(async (event: H3Event) => { 26 | try { 27 | const authHeader = getHeader(event, "authorization"); 28 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 29 | throw handleApiError( 30 | 401, 31 | "External User API: Missing or invalid API key format in header.", 32 | "API key is missing or improperly formatted." 33 | ); 34 | } 35 | 36 | const apiKey = authHeader.substring(7); 37 | const validationResult = apiKeySchema.safeParse(apiKey); 38 | 39 | if (!validationResult.success) { 40 | throw handleApiError( 41 | 401, 42 | `External User API: Invalid API key format. Key prefix: ${apiKey.substring(0, 4)}...`, 43 | "Invalid API key format." 44 | ); 45 | } 46 | 47 | const userResult = await prisma.$queryRaw< 48 | Array<{ 49 | id: string; 50 | email: string; 51 | githubId: string | null; 52 | githubUsername: string | null; 53 | apiKey: string; 54 | keystrokeTimeout: number; 55 | }> 56 | >` 57 | SELECT id, email, "githubId", "githubUsername", "apiKey", "keystrokeTimeout" 58 | FROM "User" 59 | WHERE "apiKey" = ${apiKey} 60 | LIMIT 1 61 | `; 62 | 63 | const user = userResult[0] || null; 64 | 65 | if (!user) { 66 | throw handleApiError( 67 | 404, 68 | `External User API: User not found for API key prefix: ${apiKey.substring(0, 4)}...`, 69 | "User not found." 70 | ); 71 | } 72 | 73 | return user; 74 | } catch (error: any) { 75 | if (error && typeof error === "object" && error.statusCode) throw error; 76 | const detailedMessage = 77 | error instanceof Error 78 | ? error.message 79 | : "An unknown error occurred fetching external user data."; 80 | const apiKeyPrefix = 81 | getHeader(event, "authorization")?.substring(7, 11) || "UNKNOWN"; 82 | throw handleApiError( 83 | 69, 84 | `External User API: Failed to fetch user data. API Key prefix: ${apiKeyPrefix}... Error: ${detailedMessage}`, 85 | "Failed to fetch user data." 86 | ); 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /app/pages/stats.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 99 | 100 | 103 | -------------------------------------------------------------------------------- /styles/index.scss: -------------------------------------------------------------------------------- 1 | .stats-dashboard { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 24px; 5 | 6 | .chart-container { 7 | padding: 16px; 8 | border: 1px solid var(--border); 9 | height: 350px; 10 | 11 | .chart { 12 | height: 100%; 13 | } 14 | } 15 | 16 | .metrics-tables { 17 | display: grid; 18 | grid-template-columns: repeat(2, 1fr); 19 | gap: 24px; 20 | 21 | @media (max-width: 768px) { 22 | grid-template-columns: 1fr; 23 | } 24 | 25 | .section { 26 | padding: 0; 27 | background-color: transparent; 28 | border: none; 29 | gap: 8px; 30 | display: flex; 31 | flex-direction: column; 32 | overflow: hidden; 33 | 34 | .text { 35 | display: flex; 36 | justify-content: space-between; 37 | 38 | h2 { 39 | font-size: 14px; 40 | color: var(--text-secondary); 41 | font-weight: 600; 42 | text-transform: uppercase; 43 | } 44 | 45 | .extend { 46 | color: var(--text); 47 | display: flex; 48 | gap: 8px; 49 | align-items: center; 50 | 51 | &:hover { 52 | color: var(--accent); 53 | cursor: pointer; 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | .list { 61 | display: flex; 62 | flex-direction: column; 63 | gap: 4px; 64 | 65 | .item { 66 | display: grid; 67 | grid-template-columns: 1fr auto auto; 68 | gap: 1rem; 69 | align-items: center; 70 | padding: 8px 10px; 71 | position: relative; 72 | overflow: hidden; 73 | height: 34px; 74 | 75 | &::before { 76 | content: ""; 77 | position: absolute; 78 | top: 0; 79 | left: 0; 80 | height: 100%; 81 | width: var(--percentage); 82 | background: var(--element); 83 | z-index: 0; 84 | } 85 | 86 | &:last-child { 87 | border-bottom: none; 88 | } 89 | 90 | .name { 91 | font-weight: 500; 92 | color: var(--text); 93 | overflow: hidden; 94 | text-overflow: ellipsis; 95 | white-space: nowrap; 96 | position: relative; 97 | z-index: 1; 98 | } 99 | 100 | .time { 101 | text-align: right; 102 | color: var(--text); 103 | font-weight: 500; 104 | white-space: nowrap; 105 | position: relative; 106 | z-index: 1; 107 | min-width: 64px; 108 | } 109 | 110 | .percentage { 111 | text-align: right; 112 | color: var(--text-secondary); 113 | font-weight: 600; 114 | white-space: nowrap; 115 | position: relative; 116 | z-index: 1; 117 | } 118 | } 119 | } 120 | 121 | .no-data { 122 | color: var(--text-secondary); 123 | padding: 16px; 124 | padding-left: 0; 125 | font-style: italic; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /app/components/Ui/Select.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 77 | 78 | 126 | -------------------------------------------------------------------------------- /prisma/migrations/20250419153706_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateExtension 2 | CREATE EXTENSION IF NOT EXISTS "pg_stat_statements"; 3 | 4 | -- CreateTable 5 | CREATE TABLE "User" ( 6 | "id" TEXT NOT NULL, 7 | "email" TEXT NOT NULL, 8 | "passwordHash" TEXT, 9 | "githubId" TEXT, 10 | "githubUsername" TEXT, 11 | "githubAccessToken" TEXT, 12 | "apiKey" TEXT NOT NULL, 13 | "keystrokeTimeout" INTEGER NOT NULL DEFAULT 5, 14 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | 16 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Heartbeats" ( 21 | "id" TEXT NOT NULL, 22 | "timestamp" BIGINT NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | "project" TEXT, 25 | "editor" TEXT, 26 | "language" TEXT, 27 | "os" TEXT, 28 | "file" TEXT, 29 | "branch" TEXT, 30 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 | "summariesId" TEXT, 32 | 33 | CONSTRAINT "Heartbeats_pkey" PRIMARY KEY ("id","timestamp") 34 | ); 35 | 36 | -- CreateTable 37 | CREATE TABLE "Summaries" ( 38 | "id" TEXT NOT NULL, 39 | "userId" TEXT NOT NULL, 40 | "date" DATE NOT NULL, 41 | "totalMinutes" INTEGER NOT NULL, 42 | "projects" JSONB, 43 | "languages" JSONB, 44 | "editors" JSONB, 45 | "os" JSONB, 46 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 47 | 48 | CONSTRAINT "Summaries_pkey" PRIMARY KEY ("id") 49 | ); 50 | 51 | -- CreateIndex 52 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "User_githubId_key" ON "User"("githubId"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "User_apiKey_key" ON "User"("apiKey"); 59 | 60 | -- CreateIndex 61 | CREATE INDEX "Heartbeats_userId_timestamp_idx" ON "Heartbeats"("userId", "timestamp" DESC); 62 | 63 | -- CreateIndex 64 | CREATE INDEX "Heartbeats_timestamp_idx" ON "Heartbeats"("timestamp" DESC); 65 | 66 | -- CreateIndex 67 | CREATE INDEX "Heartbeats_userId_project_timestamp_idx" ON "Heartbeats"("userId", "project", "timestamp" DESC); 68 | 69 | -- CreateIndex 70 | CREATE INDEX "Heartbeats_userId_language_timestamp_idx" ON "Heartbeats"("userId", "language", "timestamp" DESC); 71 | 72 | -- CreateIndex 73 | CREATE INDEX "Heartbeats_userId_editor_timestamp_idx" ON "Heartbeats"("userId", "editor", "timestamp" DESC); 74 | 75 | -- CreateIndex 76 | CREATE INDEX "Heartbeats_userId_os_timestamp_idx" ON "Heartbeats"("userId", "os", "timestamp" DESC); 77 | 78 | -- CreateIndex 79 | CREATE INDEX "Heartbeats_branch_idx" ON "Heartbeats"("branch"); 80 | 81 | -- CreateIndex 82 | CREATE INDEX "Heartbeats_file_idx" ON "Heartbeats"("file"); 83 | 84 | -- CreateIndex 85 | CREATE INDEX "Summaries_userId_date_idx" ON "Summaries"("userId", "date" DESC); 86 | 87 | -- CreateIndex 88 | CREATE UNIQUE INDEX "Summaries_userId_date_key" ON "Summaries"("userId", "date"); 89 | 90 | -- AddForeignKey 91 | ALTER TABLE "Heartbeats" ADD CONSTRAINT "Heartbeats_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 92 | 93 | -- AddForeignKey 94 | ALTER TABLE "Heartbeats" ADD CONSTRAINT "Heartbeats_summariesId_fkey" FOREIGN KEY ("summariesId") REFERENCES "Summaries"("id") ON DELETE SET NULL ON UPDATE CASCADE; 95 | 96 | -- AddForeignKey 97 | ALTER TABLE "Summaries" ADD CONSTRAINT "Summaries_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 98 | -------------------------------------------------------------------------------- /server/utils/stats.ts: -------------------------------------------------------------------------------- 1 | import { TimeRange } from "~/composables/useStats"; 2 | import { prisma } from "~~/prisma/db"; 3 | 4 | export async function calculateStats( 5 | userId: string, 6 | timeRange: TimeRange, 7 | midnightOffsetSeconds?: number, 8 | projectFilter?: string 9 | ) { 10 | const user = await prisma.user.findUnique({ 11 | where: { id: userId }, 12 | select: { 13 | keystrokeTimeout: true, 14 | }, 15 | }); 16 | 17 | if (!user) { 18 | throw createError({ 19 | statusCode: 404, 20 | message: "User not found", 21 | }); 22 | } 23 | 24 | const timeRangeUpper = timeRange.toUpperCase().replace(/-/g, "_"); 25 | 26 | const offsetSeconds = midnightOffsetSeconds ?? 0; 27 | 28 | try { 29 | const result = await prisma.$queryRaw>` 30 | SELECT get_user_stats( 31 | ${userId}::TEXT, 32 | ${timeRangeUpper}::TEXT, 33 | ${offsetSeconds}::INT, 34 | ${projectFilter || null}::TEXT 35 | ) as stats 36 | `; 37 | 38 | if (!result || result.length === 0 || !result[0].stats) { 39 | throw new Error("No data returned from database function"); 40 | } 41 | 42 | const statsData = result[0].stats; 43 | 44 | const parsedStats = 45 | typeof statsData === "string" ? JSON.parse(statsData) : statsData; 46 | 47 | return { 48 | summaries: parsedStats.summaries || [], 49 | offsetSeconds: parsedStats.offsetSeconds || 0, 50 | ...(parsedStats.projectSeconds !== null && 51 | parsedStats.projectSeconds !== undefined && { 52 | projectSeconds: parsedStats.projectSeconds, 53 | projectFilter: parsedStats.projectFilter, 54 | }), 55 | }; 56 | } catch (error) { 57 | console.error("Error calculating stats:", error); 58 | throw createError({ 59 | statusCode: 500, 60 | message: `Error calculating stats: ${error instanceof Error ? error.message : String(error)}`, 61 | }); 62 | } 63 | } 64 | 65 | export async function getUserTimeRangeTotal( 66 | userId: string, 67 | timeRange: TimeRange, 68 | offsetSeconds?: number 69 | ) { 70 | const timeRangeUpper = timeRange.toUpperCase().replace(/-/g, "_"); 71 | const offset = offsetSeconds ?? 0; 72 | 73 | try { 74 | const result = await prisma.$queryRaw< 75 | Array<{ 76 | total_minutes: number; 77 | total_hours: number; 78 | start_date: Date; 79 | end_date: Date; 80 | }> 81 | >` 82 | SELECT * FROM get_user_time_range_total( 83 | ${userId}::TEXT, 84 | ${timeRangeUpper}::TEXT, 85 | ${offset}::INT 86 | ) 87 | `; 88 | 89 | if (!result || result.length === 0) { 90 | return { 91 | totalMinutes: 0, 92 | totalHours: 0, 93 | startDate: new Date(), 94 | endDate: new Date(), 95 | }; 96 | } 97 | 98 | return { 99 | totalMinutes: result[0].total_minutes, 100 | totalHours: result[0].total_hours, 101 | startDate: result[0].start_date, 102 | endDate: result[0].end_date, 103 | }; 104 | } catch (error) { 105 | console.error("Error getting time range total:", error); 106 | throw createError({ 107 | statusCode: 500, 108 | message: `Error getting time range total: ${error instanceof Error ? error.message : String(error)}`, 109 | }); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /server/api/auth/login.post.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { encrypt } from "paseto-ts/v4"; 3 | import { prisma } from "~~/prisma/db"; 4 | import { z } from "zod"; 5 | import { handleApiError } from "~~/server/utils/logging"; 6 | 7 | defineRouteMeta({ 8 | openAPI: { 9 | tags: ["Auth"], 10 | summary: "Login with email and password", 11 | description: "Authenticates a user and sets a session cookie.", 12 | requestBody: { 13 | required: true, 14 | content: { 15 | "application/json": { 16 | schema: { 17 | type: "object", 18 | properties: { 19 | email: { type: "string", format: "email" }, 20 | password: { type: "string" }, 21 | }, 22 | required: ["email", "password"], 23 | }, 24 | }, 25 | }, 26 | }, 27 | responses: { 28 | 302: { description: "Redirect on success" }, 29 | 400: { description: "Validation error" }, 30 | 401: { description: "Invalid credentials" }, 31 | 500: { description: "Authentication failed" }, 32 | }, 33 | operationId: "postLogin", 34 | }, 35 | }); 36 | 37 | const loginSchema = z.object({ 38 | email: z.string().email("Invalid email format"), 39 | password: z.string().min(1, "Password is required"), 40 | }); 41 | 42 | export default defineEventHandler(async (event) => { 43 | const body = await readBody(event); 44 | const config = useRuntimeConfig(); 45 | 46 | const validation = loginSchema.safeParse(body); 47 | 48 | if (!validation.success) { 49 | throw handleApiError( 50 | 400, 51 | `Login validation failed for email ${body.email}. Errors: ${JSON.stringify(validation.error)}`, 52 | validation.error.message || "Email and password are required" 53 | ); 54 | } 55 | 56 | try { 57 | const { email, password } = validation.data; 58 | 59 | const user = await prisma.user.findUnique({ 60 | where: { email }, 61 | }); 62 | 63 | if (!user || !user.passwordHash) { 64 | const errorDetail = `Invalid login attempt for email: ${email}. User not found or no password hash.`; 65 | throw handleApiError(401, errorDetail, "Invalid email or password"); 66 | } 67 | 68 | const isPasswordValid = await bcrypt.compare(password, user.passwordHash); 69 | 70 | if (!isPasswordValid) { 71 | const errorDetail = `Invalid login attempt for email: ${email}. Password mismatch.`; 72 | throw handleApiError(401, errorDetail, "Invalid email or password"); 73 | } 74 | 75 | await prisma.user.update({ 76 | where: { 77 | id: user.id, 78 | }, 79 | data: { 80 | lastlogin: new Date(), 81 | }, 82 | }); 83 | 84 | const token = encrypt(config.pasetoKey, { 85 | userId: user.id, 86 | email: user.email, 87 | expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), 88 | }); 89 | 90 | setCookie(event, "ziit_session", token, { 91 | expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), 92 | path: "/", 93 | httpOnly: true, 94 | secure: true, 95 | sameSite: "strict", 96 | }); 97 | 98 | await sendRedirect(event, "/"); 99 | } catch (error: any) { 100 | if (error && typeof error === "object" && error.statusCode) { 101 | throw error; 102 | } 103 | const detailedMessage = 104 | error instanceof Error 105 | ? error.message 106 | : "An unknown error occurred during login."; 107 | throw handleApiError( 108 | 69, 109 | `Authentication failed: ${detailedMessage}`, 110 | "Authentication failed. Please try again." 111 | ); 112 | } 113 | }); 114 | -------------------------------------------------------------------------------- /server/api/public/stats.get.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~~/prisma/db"; 2 | import { handleApiError } from "~~/server/utils/logging"; 3 | 4 | defineRouteMeta({ 5 | openAPI: { 6 | tags: ["Public", "Stats"], 7 | summary: "Get public platform stats", 8 | description: "Returns aggregate statistics across the platform.", 9 | responses: { 10 | 200: { description: "Public stats payload" }, 11 | 500: { description: "Failed to fetch stats" }, 12 | }, 13 | operationId: "getPublicStats", 14 | }, 15 | }); 16 | 17 | export default defineCachedEventHandler( 18 | async () => { 19 | try { 20 | const latestStats = await prisma.stats.findFirst({ 21 | orderBy: { date: "desc" }, 22 | }); 23 | 24 | let totalUsers: number; 25 | let totalHeartbeats: number; 26 | let totalHours: number; 27 | 28 | const now = new Date(); 29 | const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); 30 | 31 | if (latestStats && latestStats.date >= oneDayAgo) { 32 | totalUsers = Number(latestStats.totalUsers); 33 | totalHeartbeats = latestStats.totalHeartbeats; 34 | totalHours = latestStats.totalHours; 35 | 36 | const [newUsersCount, newHeartbeatsResult, newSummariesAggregate] = 37 | await Promise.all([ 38 | prisma.user.count({ 39 | where: { createdAt: { gt: latestStats.date } }, 40 | }), 41 | prisma.$queryRaw` 42 | SELECT COUNT(*) as count 43 | FROM "Heartbeats" 44 | WHERE "createdAt" > ${latestStats.date} 45 | ` as Promise<[{ count: string }]>, 46 | prisma.summaries.aggregate({ 47 | _sum: { totalMinutes: true }, 48 | where: { createdAt: { gt: latestStats.date } }, 49 | }), 50 | ]); 51 | 52 | totalUsers += newUsersCount; 53 | totalHeartbeats += Number(newHeartbeatsResult[0]?.count || 0); 54 | totalHours += Math.floor( 55 | Number(newSummariesAggregate._sum.totalMinutes || 0) / 60 56 | ); 57 | } else { 58 | const [usersCount, heartbeatsResult, summariesAggregate] = 59 | await Promise.all([ 60 | prisma.user.count(), 61 | prisma.$queryRaw`SELECT COUNT(*) as count FROM "Heartbeats"` as Promise< 62 | [{ count: string }] 63 | >, 64 | prisma.summaries.aggregate({ _sum: { totalMinutes: true } }), 65 | ]); 66 | 67 | totalUsers = usersCount; 68 | totalHeartbeats = Number(heartbeatsResult[0]?.count || 0); 69 | totalHours = Math.floor( 70 | Number(summariesAggregate._sum.totalMinutes || 0) / 60 71 | ); 72 | } 73 | 74 | const topEditor = latestStats?.topEditor || "Unknown"; 75 | const topLanguage = latestStats?.topLanguage || "Unknown"; 76 | const topOS = latestStats?.topOS || "Unknown"; 77 | 78 | const result = { 79 | totalUsers, 80 | totalHeartbeats, 81 | totalHours, 82 | topEditor, 83 | topLanguage, 84 | topOS, 85 | lastUpdated: latestStats?.createdAt || new Date(), 86 | source: latestStats ? "mixed" : "live", 87 | }; 88 | 89 | return result; 90 | } catch (error: any) { 91 | if (error && typeof error === "object" && error.statusCode) throw error; 92 | const detailedMessage = 93 | error instanceof Error 94 | ? error.message 95 | : "An unknown error occurred fetching stats"; 96 | throw handleApiError( 97 | 69, 98 | `Failed to fetch public stats: ${detailedMessage}`, 99 | "Failed to fetch stats" 100 | ); 101 | } 102 | }, 103 | { maxAge: 5 * 60 } 104 | ); 105 | -------------------------------------------------------------------------------- /server/api/auth/github/link.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { decrypt, encrypt } from "paseto-ts/v4"; 3 | import { handleApiError } from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["Auth", "GitHub"], 8 | summary: "Begin GitHub account linking", 9 | description: 10 | "Starts OAuth flow to link a GitHub account to the authenticated user. Returns an authorization URL.", 11 | responses: { 12 | 200: { 13 | description: "Authorization URL generated", 14 | content: { 15 | "application/json": { 16 | schema: { 17 | type: "object", 18 | properties: { url: { type: "string", format: "uri" } }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | 401: { description: "Unauthorized or invalid session" }, 24 | 500: { description: "Failed to generate GitHub auth URL" }, 25 | }, 26 | operationId: "getGithubLink", 27 | }, 28 | }); 29 | 30 | export default defineEventHandler(async (event: H3Event) => { 31 | const config = useRuntimeConfig(); 32 | 33 | if (config.disableRegistering === "true") { 34 | throw createError({ 35 | statusCode: 403, 36 | message: "Registration is currently disabled", 37 | }); 38 | } 39 | 40 | const sessionCookie = getCookie(event, "ziit_session"); 41 | 42 | if (!sessionCookie) { 43 | throw createError({ 44 | statusCode: 401, 45 | message: "No session found", 46 | }); 47 | } 48 | 49 | let userId: string; 50 | try { 51 | const { payload } = decrypt(config.pasetoKey, sessionCookie); 52 | 53 | if ( 54 | typeof payload !== "object" || 55 | payload === null || 56 | !("userId" in payload) 57 | ) { 58 | throw createError({ 59 | statusCode: 401, 60 | message: "Invalid token", 61 | }); 62 | } 63 | userId = payload.userId; 64 | } catch { 65 | throw createError({ 66 | statusCode: 401, 67 | message: "Invalid token", 68 | }); 69 | } 70 | 71 | const state = crypto.randomUUID(); 72 | 73 | setCookie(event, "github_oauth_state", state, { 74 | httpOnly: true, 75 | secure: process.env.NODE_ENV === "production", 76 | maxAge: 60 * 10, 77 | path: "/", 78 | sameSite: "lax", 79 | }); 80 | 81 | setCookie(event, "github_link_account", "true", { 82 | httpOnly: true, 83 | secure: process.env.NODE_ENV === "production", 84 | maxAge: 60 * 10, 85 | path: "/", 86 | sameSite: "lax", 87 | }); 88 | 89 | const token = encrypt(config.pasetoKey, { 90 | userId, 91 | exp: new Date(Date.now() + 10 * 60 * 1000).toISOString(), 92 | }); 93 | 94 | setCookie(event, "github_link_session", token, { 95 | httpOnly: true, 96 | secure: process.env.NODE_ENV === "production", 97 | maxAge: 60 * 10, 98 | path: "/", 99 | sameSite: "lax", 100 | }); 101 | 102 | const githubAuthUrl = new URL("https://github.com/login/oauth/authorize"); 103 | githubAuthUrl.searchParams.append("client_id", config.githubClientId); 104 | githubAuthUrl.searchParams.append( 105 | "redirect_uri", 106 | `${config.baseUrl}/api/auth/github/callback` 107 | ); 108 | githubAuthUrl.searchParams.append("state", state); 109 | githubAuthUrl.searchParams.append("scope", "read:user user:email"); 110 | 111 | try { 112 | return { url: githubAuthUrl.toString() }; 113 | } catch (error) { 114 | const detailedMessage = 115 | error instanceof Error 116 | ? error.message 117 | : "An unknown error occurred while generating GitHub auth URL."; 118 | throw handleApiError( 119 | 69, 120 | `Failed to generate GitHub auth URL: ${detailedMessage}`, 121 | "Could not initiate GitHub linking. Please try again." 122 | ); 123 | } 124 | }); 125 | -------------------------------------------------------------------------------- /server/api/external/stats.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { TimeRangeEnum } from "~/composables/useStats"; 3 | import type { TimeRange } from "~/composables/useStats"; 4 | import { z } from "zod"; 5 | import { calculateStats } from "~~/server/utils/stats"; 6 | import { handleApiError } from "~~/server/utils/logging"; 7 | import { prisma } from "~~/prisma/db"; 8 | 9 | defineRouteMeta({ 10 | openAPI: { 11 | tags: ["External", "Stats"], 12 | summary: "Get stats via API key", 13 | description: 14 | "Returns aggregated stats for the user identified by the Bearer API key.", 15 | security: [{ bearerAuth: [] }], 16 | parameters: [ 17 | { 18 | in: "query", 19 | name: "timeRange", 20 | required: false, 21 | schema: { type: "string", enum: Object.values(TimeRangeEnum) as any }, 22 | }, 23 | { 24 | in: "query", 25 | name: "midnightOffsetSeconds", 26 | required: false, 27 | schema: { type: "integer" }, 28 | }, 29 | ], 30 | responses: { 31 | 200: { description: "Stats result" }, 32 | 400: { description: "Invalid parameters" }, 33 | 401: { description: "Invalid or missing API key" }, 34 | 500: { description: "Server error" }, 35 | }, 36 | operationId: "getExternalStats", 37 | }, 38 | }); 39 | 40 | const apiKeySchema = z.uuid(); 41 | 42 | export default defineEventHandler(async (event: H3Event) => { 43 | try { 44 | const authHeader = getHeader(event, "authorization"); 45 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 46 | throw handleApiError( 47 | 401, 48 | "External Stats API: Missing or invalid API key format in header.", 49 | "API key is missing or improperly formatted." 50 | ); 51 | } 52 | 53 | const apiKey = authHeader.substring(7); 54 | const validationResult = apiKeySchema.safeParse(apiKey); 55 | 56 | if (!validationResult.success) { 57 | throw handleApiError( 58 | 401, 59 | `External Stats API: Invalid API key format. Key prefix: ${apiKey.substring(0, 4)}...`, 60 | "Invalid API key format." 61 | ); 62 | } 63 | 64 | const user = await prisma.user.findUnique({ 65 | where: { apiKey }, 66 | select: { id: true, apiKey: true }, 67 | }); 68 | 69 | if (!user || user.apiKey !== apiKey) { 70 | throw handleApiError( 71 | 401, 72 | `External Stats API: Invalid API key. Key prefix: ${apiKey.substring(0, 4)}...`, 73 | "Invalid API key." 74 | ); 75 | } 76 | 77 | const query = getQuery(event); 78 | const timeRange = String(query.timeRange || "today"); 79 | const midnightOffsetSeconds = query.midnightOffsetSeconds 80 | ? Number(query.midnightOffsetSeconds) 81 | : undefined; 82 | 83 | if (!Object.values(TimeRangeEnum).includes(timeRange as any)) { 84 | throw handleApiError( 85 | 400, 86 | `External Stats API: Invalid timeRange value: ${timeRange}. User ID: ${user.id}`, 87 | "Invalid time range specified." 88 | ); 89 | } 90 | 91 | return await calculateStats( 92 | user.id, 93 | timeRange as TimeRange, 94 | midnightOffsetSeconds 95 | ); 96 | } catch (error: any) { 97 | if (error && typeof error === "object" && error.statusCode) throw error; 98 | const detailedMessage = 99 | error instanceof Error 100 | ? error.message 101 | : "An unknown error occurred fetching external stats."; 102 | const apiKeyPrefix = 103 | getHeader(event, "authorization")?.substring(7, 11) || "UNKNOWN"; 104 | throw handleApiError( 105 | 69, 106 | `External Stats API: Failed to fetch statistics. API Key prefix: ${apiKeyPrefix}... Error: ${detailedMessage}`, 107 | "Failed to fetch statistics." 108 | ); 109 | } 110 | }); 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### For users confused why their account got deleted check this issue https://github.com/0pandadev/Ziit/issues/81 2 | 3 |

4 | 5 |

6 | 7 |

8 | The Swiss army knife of code time tracking. 9 |

10 | 11 |
12 | Docs 13 | 14 | Stats 15 | 16 | Leaderboard 17 | 18 | Discord 19 |
20 | 21 |
22 | 23 | [![Better Stack Badge](https://uptime.betterstack.com/status-badges/v3/monitor/1ym1e.svg)](https://status.ziit.app) 24 | ![ziit](https://ziit.app/api/public/badge/cm98il90n0000o52c3my0bf5p/ziit) 25 | 26 | 27 | > [!IMPORTANT] 28 | > Upvote Ziit on [AlternativeTo](https://alternativeto.net/software/ziit/about/), [ProductHunt](https://www.producthunt.com/posts/ziit), [PeerPush](https://peerpush.net/p/ziit) to help me promote it. 29 | 30 |
31 | Star History 32 | 33 | 34 | 35 | 36 | 37 |
38 | 39 | ## What is Ziit? 40 | 41 | Ziit (pronounced 'tseet') is an open-source, self-hostable alternative to WakaTime. It provides a clean, minimal, and fast dashboard for displaying coding statistics, while ensuring privacy by keeping all data on your own server. Ziit tracks coding activity such as projects, languages, editors, files, branches, operating systems, and time spent coding all presented in a familiar interface inspired by Plausible Analytics. 42 | 43 | ![Ziit](https://github.com/user-attachments/assets/bf8e8d72-3181-47e7-924f-537c74f68819) 44 | 45 | ## Features 46 | 47 | - Time tracking directly from VS Code to your Ziit instance of choice. 48 | - Filtering using different time ranges. 49 | - Clean & Minimal dashboard showing only the information needed. 50 | - Login with GitHub or Email and Password. 51 | - Import Data from Wakatime or a WakAPI Instance. 52 | - Saves data about your current project, OS, editor, file, language and git branch. 53 | - Badges to embed coding time for a project into a README. 54 | - Public stats page to see information from the whole Ziit instance. 55 | - Public leaderboard to see who has the most coding hours. 56 | - More to come... 57 | 58 | ## How to use Ziit 59 | 60 | First [setup an instance](https://docs.ziit.app/deploy) or sign up on the public one then install the extension by searching for "Ziit" in your favorite IDE. 61 | 62 | Supported IDE's: 63 | 64 | - [VS Code (Including all forks)](https://docs.ziit.app/extensions/vscode) 65 | - [JetBrains](https://plugins.jetbrains.com/plugin/27391-ziit) 66 | 67 | For more details on how to setup the IDE extensions checkout the [docs](https://docs.ziit.app/extensions). 68 | 69 | ## Development 70 | 71 | ### Prerequisites 72 | 73 | - [Bun](https://bun.sh/) 74 | - [TimescaleDB](https://docs.timescale.com/self-hosted/latest/install/installation-docker/) 75 | 76 | ### Setup 77 | 78 | 1. **Install dependencies:** 79 | 80 | ```bash 81 | bun i 82 | ``` 83 | 84 | 2. **Database Migrations:** 85 | Apply database schema changes. 86 | 87 | ```bash 88 | bunx prisma migrate dev 89 | ``` 90 | 91 | 3. **Set the environment variables:** 92 | It is imporatnt that you make a `.env` using the [.env.example](https://github.com/0PandaDEV/Ziit/blob/main/.env.example) as a template and set the correct values. All the instructions needed are in the example file. 93 | 94 | 4. **Run the development server:** 95 | The server will start on `http://localhost:3000`. 96 | 97 | ```bash 98 | bun dev 99 | ``` 100 | -------------------------------------------------------------------------------- /server/api/auth/epilogue/link.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { decrypt, encrypt } from "paseto-ts/v4"; 3 | import { handleApiError } from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["Auth", "Epilogue"], 8 | summary: "Begin Epilogue account linking", 9 | description: 10 | "Starts OAuth flow to link an Epilogue account to the authenticated user. Returns an authorization URL.", 11 | responses: { 12 | 200: { 13 | description: "Authorization URL generated", 14 | content: { 15 | "application/json": { 16 | schema: { 17 | type: "object", 18 | properties: { url: { type: "string", format: "uri" } }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | 401: { description: "Unauthorized or invalid session" }, 24 | 500: { description: "Failed to generate Epilogue auth URL" }, 25 | }, 26 | operationId: "getEpilogueLink", 27 | }, 28 | }); 29 | 30 | export default defineEventHandler(async (event: H3Event) => { 31 | const config = useRuntimeConfig(); 32 | 33 | if (config.disableRegistering === "true") { 34 | throw createError({ 35 | statusCode: 403, 36 | message: "Registration is currently disabled", 37 | }); 38 | } 39 | 40 | const sessionCookie = getCookie(event, "ziit_session"); 41 | 42 | if (!sessionCookie) { 43 | throw createError({ 44 | statusCode: 401, 45 | message: "No session found", 46 | }); 47 | } 48 | 49 | let userId: string; 50 | try { 51 | const { payload } = decrypt(config.pasetoKey, sessionCookie); 52 | 53 | if ( 54 | typeof payload !== "object" || 55 | payload === null || 56 | !("userId" in payload) 57 | ) { 58 | throw createError({ 59 | statusCode: 401, 60 | message: "Invalid token", 61 | }); 62 | } 63 | userId = payload.userId; 64 | } catch { 65 | throw createError({ 66 | statusCode: 401, 67 | message: "Invalid token", 68 | }); 69 | } 70 | 71 | if (!config.epilogueAppId || !config.baseUrl) { 72 | throw createError({ 73 | statusCode: 500, 74 | message: "Epilogue auth is not configured", 75 | }); 76 | } 77 | 78 | const state = crypto.randomUUID(); 79 | 80 | setCookie(event, "epilogue_oauth_state", state, { 81 | httpOnly: true, 82 | secure: process.env.NODE_ENV === "production", 83 | maxAge: 60 * 10, 84 | path: "/", 85 | sameSite: "lax", 86 | }); 87 | 88 | setCookie(event, "epilogue_link_account", "true", { 89 | httpOnly: true, 90 | secure: process.env.NODE_ENV === "production", 91 | maxAge: 60 * 10, 92 | path: "/", 93 | sameSite: "lax", 94 | }); 95 | 96 | const token = encrypt(config.pasetoKey, { 97 | userId, 98 | exp: new Date(Date.now() + 10 * 60 * 1000).toISOString(), 99 | }); 100 | 101 | setCookie(event, "epilogue_link_session", token, { 102 | httpOnly: true, 103 | secure: process.env.NODE_ENV === "production", 104 | maxAge: 60 * 10, 105 | path: "/", 106 | sameSite: "lax", 107 | }); 108 | 109 | const epilogueAuthUrl = new URL("https://auth.epilogue.team/authorize/"); 110 | epilogueAuthUrl.searchParams.append("app_id", config.epilogueAppId); 111 | epilogueAuthUrl.searchParams.append( 112 | "redirect_url", 113 | `${config.baseUrl}/api/auth/epilogue/callback` 114 | ); 115 | const host = getHeader(event, "host"); 116 | const protocol = getHeader(event, "x-forwarded-proto") || "http"; 117 | const frontendUrl = `${protocol}://${host}`; 118 | epilogueAuthUrl.searchParams.append("cancel_url", `${frontendUrl}/settings?error=link_cancelled`); 119 | epilogueAuthUrl.searchParams.append("state", state); 120 | 121 | try { 122 | return { url: epilogueAuthUrl.toString() }; 123 | } catch (error) { 124 | const detailedMessage = error instanceof Error ? error.message : "An unknown error occurred while generating Epilogue auth URL."; 125 | throw handleApiError(69, `Failed to generate Epilogue auth URL: ${detailedMessage}`, "Could not initiate Epilogue linking. Please try again."); 126 | } 127 | }); -------------------------------------------------------------------------------- /server/api/auth/register.post.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { encrypt } from "paseto-ts/v4"; 3 | import { prisma } from "~~/prisma/db"; 4 | import { z } from "zod"; 5 | import { handleApiError} from "~~/server/utils/logging"; 6 | 7 | defineRouteMeta({ 8 | openAPI: { 9 | tags: ["Auth"], 10 | summary: "Register a new user", 11 | description: "Creates a user account and sets a session cookie.", 12 | requestBody: { 13 | required: true, 14 | content: { 15 | "application/json": { 16 | schema: { 17 | type: "object", 18 | properties: { 19 | email: { type: "string", format: "email" }, 20 | password: { type: "string", description: "At least 12 characters with upper/lowercase, number, and special char." }, 21 | }, 22 | required: ["email", "password"], 23 | }, 24 | }, 25 | }, 26 | }, 27 | responses: { 28 | 302: { description: "Redirect on success" }, 29 | 400: { description: "Validation error" }, 30 | 409: { description: "Email already in use" }, 31 | 500: { description: "Registration failed" }, 32 | }, 33 | operationId: "postRegister", 34 | }, 35 | }); 36 | 37 | const passwordSchema = z 38 | .string() 39 | .min(12, "Password must be at least 12 characters") 40 | .regex(/[A-Z]/, "Password must contain at least one uppercase letter") 41 | .regex(/[a-z]/, "Password must contain at least one lowercase letter") 42 | .regex(/[0-9]/, "Password must contain at least one number") 43 | .regex( 44 | /[^A-Za-z0-9]/, 45 | "Password must contain at least one special character" 46 | ); 47 | 48 | export default defineEventHandler(async (event) => { 49 | const config = useRuntimeConfig(); 50 | 51 | if (config.disableRegistering === "true") { 52 | throw createError({ 53 | statusCode: 403, 54 | message: "Registration is currently disabled", 55 | }); 56 | } 57 | 58 | const body = await readBody(event); 59 | 60 | if ( 61 | !body.email || 62 | !body.password || 63 | typeof body.email !== "string" || 64 | typeof body.password !== "string" 65 | ) { 66 | throw handleApiError( 67 | 400, 68 | "Registration attempt with missing email or password.", 69 | "Email and password are required." 70 | ); 71 | } 72 | 73 | try { 74 | const passwordValidation = passwordSchema.safeParse(body.password); 75 | if (!passwordValidation.success) { 76 | const errorDetail = `Password validation failed for email ${body.email}: ${passwordValidation.error.message}`; 77 | throw handleApiError( 78 | 400, 79 | errorDetail, 80 | passwordValidation.error.message 81 | ); 82 | } 83 | 84 | const existingUser = await prisma.user.findUnique({ 85 | where: { email: body.email }, 86 | }); 87 | 88 | if (existingUser) { 89 | throw handleApiError( 90 | 409, 91 | `Registration attempt with existing email: ${body.email}.` 92 | ); 93 | } 94 | 95 | const saltRounds = 10; 96 | const passwordHash = await bcrypt.hash(body.password, saltRounds); 97 | 98 | const user = await prisma.user.create({ 99 | data: { 100 | email: body.email, 101 | passwordHash, 102 | }, 103 | }); 104 | 105 | const token = encrypt(config.pasetoKey, { 106 | userId: user.id, 107 | email: user.email, 108 | exp: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), 109 | }); 110 | 111 | setCookie(event, "ziit_session", token, { 112 | expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), 113 | path: "/", 114 | httpOnly: true, 115 | secure: true, 116 | sameSite: "strict", 117 | }); 118 | 119 | return sendRedirect(event, "/"); 120 | } catch (error: any) { 121 | if (error && typeof error === "object" && error.statusCode) { 122 | throw error; 123 | } 124 | const detailedMessage = 125 | error instanceof Error 126 | ? error.message 127 | : "An unknown error occurred during registration."; 128 | throw handleApiError( 129 | 69, 130 | `Registration failed: ${detailedMessage}`, 131 | "An unexpected error occurred during registration. Please try again." 132 | ); 133 | } 134 | }); 135 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "*" 9 | workflow_dispatch: 10 | inputs: 11 | tag: 12 | description: "Docker image tag" 13 | required: true 14 | default: "nightly" 15 | 16 | jobs: 17 | prepare: 18 | name: Prepare Docker Metadata 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | outputs: 23 | tags: ${{ steps.meta.outputs.tags }} 24 | labels: ${{ steps.meta.outputs.labels }} 25 | json: ${{ steps.meta.outputs.json }} 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | 30 | - name: Extract metadata for Docker 31 | id: meta 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: ghcr.io/${{ github.repository_owner }}/ziit 35 | tags: | 36 | type=raw,value=${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_type == 'tag' && github.ref_name || 'nightly' }} 37 | type=sha,format=short 38 | 39 | build: 40 | name: Build ${{ matrix.platform }} 41 | needs: prepare 42 | runs-on: ubuntu-latest 43 | permissions: 44 | contents: read 45 | packages: write 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | platform: 50 | - linux/amd64 51 | - linux/arm64 52 | steps: 53 | - name: Checkout code 54 | uses: actions/checkout@v4 55 | 56 | - name: Prepare platform name 57 | run: | 58 | platform=${{ matrix.platform }} 59 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 60 | 61 | - name: Set up QEMU 62 | uses: docker/setup-qemu-action@v3 63 | 64 | - name: Set up Docker Buildx 65 | uses: docker/setup-buildx-action@v3 66 | 67 | - name: Login to GitHub Container Registry 68 | uses: docker/login-action@v3 69 | with: 70 | registry: ghcr.io 71 | username: ${{ github.actor }} 72 | password: ${{ secrets.GITHUB_TOKEN }} 73 | 74 | - name: Build and push by digest 75 | id: build 76 | uses: docker/build-push-action@v5 77 | with: 78 | context: . 79 | platforms: ${{ matrix.platform }} 80 | labels: ${{ needs.prepare.outputs.labels }} 81 | outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/ziit,push-by-digest=true,name-canonical=true,push=true 82 | cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }} 83 | cache-to: type=gha,mode=max,scope=build-${{ env.PLATFORM_PAIR }} 84 | 85 | - name: Export digest 86 | run: | 87 | mkdir -p /tmp/digests 88 | digest="${{ steps.build.outputs.digest }}" 89 | touch "/tmp/digests/${digest#sha256:}" 90 | 91 | - name: Upload digest 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: digests-${{ env.PLATFORM_PAIR }} 95 | path: /tmp/digests/* 96 | if-no-files-found: error 97 | retention-days: 1 98 | 99 | merge: 100 | name: Create multi-arch manifest 101 | runs-on: ubuntu-latest 102 | needs: [prepare, build] 103 | permissions: 104 | contents: read 105 | packages: write 106 | steps: 107 | - name: Download digests 108 | uses: actions/download-artifact@v4 109 | with: 110 | path: /tmp/digests 111 | pattern: digests-* 112 | merge-multiple: true 113 | 114 | - name: Set up Docker Buildx 115 | uses: docker/setup-buildx-action@v3 116 | 117 | - name: Login to GitHub Container Registry 118 | uses: docker/login-action@v3 119 | with: 120 | registry: ghcr.io 121 | username: ${{ github.actor }} 122 | password: ${{ secrets.GITHUB_TOKEN }} 123 | 124 | - name: Create manifest and push 125 | working-directory: /tmp/digests 126 | run: | 127 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 128 | $(printf 'ghcr.io/${{ github.repository_owner }}/ziit@sha256:%s ' *) 129 | env: 130 | DOCKER_METADATA_OUTPUT_JSON: ${{ needs.prepare.outputs.json }} 131 | 132 | - name: Inspect image 133 | run: | 134 | docker buildx imagetools inspect ghcr.io/${{ github.repository_owner }}/ziit:${{ fromJSON(needs.prepare.outputs.json).labels['org.opencontainers.image.version'] }} 135 | -------------------------------------------------------------------------------- /app/components/Ui/NumberInput.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 134 | 135 | 187 | -------------------------------------------------------------------------------- /server/api/external/heartbeat.post.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { z } from "zod"; 3 | import { prisma } from "~~/prisma/db"; 4 | import { handleApiError } from "~~/server/utils/logging"; 5 | 6 | defineRouteMeta({ 7 | openAPI: { 8 | tags: ["External", "Heartbeat"], 9 | summary: "Create a single heartbeat", 10 | description: 11 | "Accepts one heartbeat payload authenticated via Bearer API key.", 12 | security: [{ bearerAuth: [] }], 13 | requestBody: { 14 | required: true, 15 | content: { 16 | "application/json": { 17 | schema: { 18 | type: "object", 19 | properties: { 20 | timestamp: { 21 | type: "string", 22 | format: "date-time", 23 | description: "ISO 8601 timestamp; numeric epoch also accepted.", 24 | }, 25 | project: { type: "string" }, 26 | language: { type: "string" }, 27 | editor: { type: "string" }, 28 | os: { type: "string" }, 29 | branch: { type: "string" }, 30 | file: { type: "string" }, 31 | }, 32 | required: [ 33 | "timestamp", 34 | "project", 35 | "language", 36 | "editor", 37 | "os", 38 | "file", 39 | ], 40 | }, 41 | }, 42 | }, 43 | }, 44 | responses: { 45 | 200: { 46 | description: "Heartbeat created", 47 | content: { 48 | "application/json": { 49 | schema: { 50 | type: "object", 51 | properties: { 52 | success: { type: "boolean" }, 53 | id: { type: "string" }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | 400: { description: "Validation error" }, 60 | 401: { description: "Invalid or missing API key" }, 61 | 500: { description: "Server error" }, 62 | }, 63 | operationId: "postExternalHeartbeat", 64 | }, 65 | }); 66 | 67 | const apiKeySchema = z.uuid(); 68 | 69 | const heartbeatSchema = z.object({ 70 | timestamp: z.iso.datetime().or(z.number()), 71 | project: z.string().min(1).max(255), 72 | language: z.string().min(1).max(50), 73 | editor: z.string().min(1).max(50), 74 | os: z.string().min(1).max(50), 75 | branch: z.string().max(255).optional(), 76 | file: z.string().max(255), 77 | }); 78 | 79 | export default defineEventHandler(async (event: H3Event) => { 80 | try { 81 | const authHeader = getHeader(event, "authorization"); 82 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 83 | throw handleApiError( 84 | 401, 85 | "Heartbeat API error: Missing or invalid API key format in header." 86 | ); 87 | } 88 | 89 | const apiKey = authHeader.substring(7); 90 | const validationResult = apiKeySchema.safeParse(apiKey); 91 | 92 | if (!validationResult.success) { 93 | throw handleApiError( 94 | 401, 95 | `Heartbeat API error: Invalid API key format. Key: ${apiKey.substring(0, 4)}...` 96 | ); 97 | } 98 | 99 | const user = await prisma.user.findUnique({ 100 | where: { apiKey }, 101 | select: { id: true, apiKey: true }, 102 | }); 103 | 104 | if (!user || user.apiKey !== apiKey) { 105 | throw handleApiError( 106 | 401, 107 | `Heartbeat API error: Invalid API key. Key: ${apiKey.substring(0, 4)}...` 108 | ); 109 | } 110 | 111 | const body = await readBody(event); 112 | const validatedData = heartbeatSchema.parse(body); 113 | 114 | const timestamp = 115 | typeof validatedData.timestamp === "number" 116 | ? new Date(validatedData.timestamp) 117 | : new Date(validatedData.timestamp); 118 | 119 | const heartbeat = await prisma.heartbeats.create({ 120 | data: { 121 | userId: user.id, 122 | timestamp, 123 | project: validatedData.project, 124 | language: validatedData.language, 125 | editor: validatedData.editor, 126 | os: validatedData.os, 127 | branch: validatedData.branch, 128 | file: validatedData.file, 129 | }, 130 | }); 131 | 132 | return { 133 | success: true, 134 | id: heartbeat.id, 135 | }; 136 | } catch (error: any) { 137 | if (error && typeof error === "object" && error.statusCode) throw error; 138 | if (error instanceof z.ZodError) { 139 | throw handleApiError( 140 | 400, 141 | `Heartbeat API error: Validation error. Details: ${error.message}` 142 | ); 143 | } 144 | const detailedMessage = 145 | error instanceof Error 146 | ? error.message 147 | : "An unknown error occurred processing heartbeat."; 148 | const apiKeyPrefix = 149 | getHeader(event, "authorization")?.substring(7, 11) || "UNKNOWN"; 150 | throw handleApiError( 151 | 69, 152 | `Heartbeat API error: Failed to process heartbeat. API Key prefix: ${apiKeyPrefix}... Error: ${detailedMessage}`, 153 | "Failed to process your request." 154 | ); 155 | } 156 | }); 157 | -------------------------------------------------------------------------------- /server/api/external/heartbeats.post.ts: -------------------------------------------------------------------------------- 1 | 2 | import { H3Event } from "h3"; 3 | import { z } from "zod"; 4 | import { prisma } from "~~/prisma/db"; 5 | import { handleApiError } from "~~/server/utils/logging"; 6 | 7 | // This endpoint is depracated 8 | 9 | defineRouteMeta({ 10 | openAPI: { 11 | tags: ["External", "Heartbeat"], 12 | summary: "Create a single heartbeat", 13 | description: 14 | "Accepts one heartbeat payload authenticated via Bearer API key.", 15 | security: [{ bearerAuth: [] }], 16 | requestBody: { 17 | required: true, 18 | content: { 19 | "application/json": { 20 | schema: { 21 | type: "object", 22 | properties: { 23 | timestamp: { 24 | type: "string", 25 | format: "date-time", 26 | description: "ISO 8601 timestamp; numeric epoch also accepted.", 27 | }, 28 | project: { type: "string" }, 29 | language: { type: "string" }, 30 | editor: { type: "string" }, 31 | os: { type: "string" }, 32 | branch: { type: "string" }, 33 | file: { type: "string" }, 34 | }, 35 | required: [ 36 | "timestamp", 37 | "project", 38 | "language", 39 | "editor", 40 | "os", 41 | "file", 42 | ], 43 | }, 44 | }, 45 | }, 46 | }, 47 | responses: { 48 | 200: { 49 | description: "Heartbeat created", 50 | content: { 51 | "application/json": { 52 | schema: { 53 | type: "object", 54 | properties: { 55 | success: { type: "boolean" }, 56 | id: { type: "string" }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | 400: { description: "Validation error" }, 63 | 401: { description: "Invalid or missing API key" }, 64 | 500: { description: "Server error" }, 65 | }, 66 | operationId: "postExternalHeartbeat", 67 | }, 68 | }); 69 | 70 | 71 | 72 | const apiKeySchema = z.uuid(); 73 | 74 | const heartbeatSchema = z.object({ 75 | timestamp: z.iso.datetime().or(z.number()), 76 | project: z.string().min(1).max(255), 77 | language: z.string().min(1).max(50), 78 | editor: z.string().min(1).max(50), 79 | os: z.string().min(1).max(50), 80 | branch: z.string().max(255).optional(), 81 | file: z.string().max(255), 82 | }); 83 | 84 | export default defineEventHandler(async (event: H3Event) => { 85 | try { 86 | const authHeader = getHeader(event, "authorization"); 87 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 88 | throw handleApiError( 89 | 401, 90 | "Heartbeat API error: Missing or invalid API key format in header.", 91 | ); 92 | } 93 | 94 | const apiKey = authHeader.substring(7); 95 | const validationResult = apiKeySchema.safeParse(apiKey); 96 | 97 | if (!validationResult.success) { 98 | throw handleApiError( 99 | 401, 100 | `Heartbeat API error: Invalid API key format. Key: ${apiKey.substring(0, 4)}...`, 101 | ); 102 | } 103 | 104 | const user = await prisma.user.findUnique({ 105 | where: { apiKey }, 106 | select: { id: true, apiKey: true }, 107 | }); 108 | 109 | if (!user || user.apiKey !== apiKey) { 110 | throw handleApiError( 111 | 401, 112 | `Heartbeat API error: Invalid API key. Key: ${apiKey.substring(0, 4)}...`, 113 | ); 114 | } 115 | 116 | const body = await readBody(event); 117 | const validatedData = heartbeatSchema.parse(body); 118 | 119 | const timestamp = 120 | typeof validatedData.timestamp === "number" 121 | ? new Date(validatedData.timestamp) 122 | : new Date(validatedData.timestamp); 123 | 124 | const heartbeat = await prisma.heartbeats.create({ 125 | data: { 126 | userId: user.id, 127 | timestamp, 128 | project: validatedData.project, 129 | language: validatedData.language, 130 | editor: validatedData.editor, 131 | os: validatedData.os, 132 | branch: validatedData.branch, 133 | file: validatedData.file, 134 | }, 135 | }); 136 | 137 | return { 138 | success: true, 139 | id: heartbeat.id, 140 | }; 141 | } catch (error: any) { 142 | if (error && typeof error === "object" && error.statusCode) throw error; 143 | if (error instanceof z.ZodError) { 144 | throw handleApiError( 145 | 400, 146 | `Heartbeat API error: Validation error. Details: ${error.message}`, 147 | ); 148 | } 149 | const detailedMessage = 150 | error instanceof Error 151 | ? error.message 152 | : "An unknown error occurred processing heartbeat."; 153 | const apiKeyPrefix = 154 | getHeader(event, "authorization")?.substring(7, 11) || "UNKNOWN"; 155 | throw handleApiError( 156 | 69, 157 | `Heartbeat API error: Failed to process heartbeat. API Key prefix: ${apiKeyPrefix}... Error: ${detailedMessage}`, 158 | "Failed to process your request.", 159 | ); 160 | } 161 | }); 162 | -------------------------------------------------------------------------------- /server/api/user/index.post.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { z } from "zod"; 3 | import bcrypt from "bcrypt"; 4 | import { handleApiError } from "~~/server/utils/logging"; 5 | import { prisma } from "~~/prisma/db"; 6 | 7 | defineRouteMeta({ 8 | openAPI: { 9 | tags: ["User"], 10 | summary: "Update current user settings", 11 | description: 12 | "Updates settings for the authenticated user (email, password, keystrokeTimeout).", 13 | requestBody: { 14 | required: true, 15 | content: { 16 | "application/json": { 17 | schema: { 18 | type: "object", 19 | properties: { 20 | keystrokeTimeout: { type: "integer", minimum: 1, maximum: 60 }, 21 | email: { type: "string", format: "email" }, 22 | password: { 23 | type: "string", 24 | description: "At least 12 chars with complexity.", 25 | }, 26 | leaderboardEnabled: { 27 | type: "boolean", 28 | description: 29 | "Opt-in or out of showing up on leaderboardEnabled", 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | responses: { 37 | 200: { description: "Update success" }, 38 | 400: { description: "Validation error" }, 39 | 409: { description: "Email conflict" }, 40 | 500: { description: "Failed to update user settings" }, 41 | }, 42 | operationId: "postUserSettings", 43 | }, 44 | }); 45 | 46 | const passwordSchema = z 47 | .string() 48 | .min(12, "Password must be at least 12 characters") 49 | .regex(/[A-Z]/, "Password must contain at least one uppercase letter") 50 | .regex(/[a-z]/, "Password must contain at least one lowercase letter") 51 | .regex(/[0-9]/, "Password must contain at least one number") 52 | .regex( 53 | /[^A-Za-z0-9]/, 54 | "Password must contain at least one special character" 55 | ); 56 | 57 | const userSettingsSchema = z.object({ 58 | keystrokeTimeout: z.number().min(1).max(60).optional(), 59 | email: z.string().email().optional(), 60 | password: passwordSchema.optional(), 61 | leaderboardEnabled: z.boolean().optional(), 62 | leaderboardFirstSet: z.boolean().optional(), 63 | }); 64 | 65 | export default defineEventHandler(async (event: H3Event) => { 66 | try { 67 | const body = await readBody(event); 68 | 69 | const validatedData = userSettingsSchema.safeParse(body); 70 | 71 | if (!validatedData.success) { 72 | const errorDetail = `Invalid user settings data for user ${event.context.user.id}: ${validatedData.error.message}`; 73 | const clientMessage = 74 | validatedData.error.message || "Invalid user settings data."; 75 | throw handleApiError(400, errorDetail, clientMessage); 76 | } 77 | 78 | const updateData: { 79 | keystrokeTimeout?: number; 80 | email?: string; 81 | passwordHash?: string; 82 | leaderboardEnabled?: boolean; 83 | leaderboardFirstSet?: boolean; 84 | } = {}; 85 | 86 | if (validatedData.data.keystrokeTimeout !== undefined) { 87 | updateData.keystrokeTimeout = validatedData.data.keystrokeTimeout; 88 | } 89 | 90 | if (validatedData.data.leaderboardEnabled !== undefined) { 91 | updateData.leaderboardEnabled = validatedData.data.leaderboardEnabled; 92 | } 93 | 94 | if (validatedData.data.leaderboardFirstSet !== undefined) { 95 | updateData.leaderboardFirstSet = validatedData.data.leaderboardFirstSet; 96 | } 97 | 98 | if (validatedData.data.email !== undefined) { 99 | const existingUser = await prisma.user.findUnique({ 100 | where: { email: validatedData.data.email }, 101 | }); 102 | 103 | if (existingUser && existingUser.id !== event.context.user.id) { 104 | const errorDetail = `User settings update failed for user ${event.context.user.id}: Email ${validatedData.data.email} already in use by another account.`; 105 | throw handleApiError(409, errorDetail); 106 | } 107 | 108 | updateData.email = validatedData.data.email; 109 | } 110 | 111 | if (validatedData.data.password !== undefined) { 112 | const saltRounds = 10; 113 | const passwordHash = await bcrypt.hash( 114 | validatedData.data.password, 115 | saltRounds 116 | ); 117 | updateData.passwordHash = passwordHash; 118 | } 119 | 120 | if (Object.keys(updateData).length > 0) { 121 | await prisma.user.update({ 122 | where: { 123 | id: event.context.user.id, 124 | }, 125 | data: updateData, 126 | }); 127 | } 128 | 129 | return { success: true }; 130 | } catch (error: any) { 131 | if (error && typeof error === "object" && "__h3_error__" in error) { 132 | throw error; 133 | } 134 | const detailedMessage = 135 | error instanceof Error 136 | ? error.message 137 | : "An unknown error occurred while updating user settings."; 138 | throw handleApiError( 139 | 69, 140 | `Failed to update user settings for user ${event.context.user.id}: ${detailedMessage}`, 141 | "Failed to update user settings. Please try again." 142 | ); 143 | } 144 | }); 145 | -------------------------------------------------------------------------------- /app/components/Ui/ListModal.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 84 | 85 | 205 | -------------------------------------------------------------------------------- /server/api/public/[...badge].get.ts: -------------------------------------------------------------------------------- 1 | import { TimeRangeEnum, type TimeRange, type Summary } from "~/composables/useStats"; 2 | import { badgen } from "badgen"; 3 | import { calculateStats, getUserTimeRangeTotal } from "~~/server/utils/stats"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["Public", "Badge"], 8 | summary: "Generate public stats badge", 9 | description: "Returns an SVG badge representing time spent. URL path segments define badge parameters.", 10 | parameters: [ 11 | { in: "path", name: "userId", required: true, schema: { type: "string" } }, 12 | { in: "path", name: "project", required: false, schema: { type: "string" } }, 13 | { in: "path", name: "timeRange", required: false, schema: { type: "string", enum: Object.values(TimeRangeEnum) as any } }, 14 | { in: "path", name: "color", required: false, schema: { type: "string" } }, 15 | { in: "path", name: "labelText", required: false, schema: { type: "string" } }, 16 | { in: "query", name: "style", required: false, schema: { type: "string", enum: ["classic", "flat"] } }, 17 | { in: "query", name: "icon", required: false, schema: { type: "string" } }, 18 | ], 19 | responses: { 20 | 200: { description: "SVG badge", content: { "image/svg+xml": {} } }, 21 | 400: { description: "Invalid parameters" }, 22 | }, 23 | operationId: "getPublicBadge", 24 | }, 25 | }); 26 | 27 | interface StatsResult { 28 | summaries: Summary[]; 29 | offsetSeconds: number; 30 | debug: Record; 31 | } 32 | 33 | interface StatsWithProject extends StatsResult { 34 | projectSeconds: number; 35 | projectFilter: string; 36 | } 37 | 38 | export default defineEventHandler(async (event) => { 39 | const url = new URL( 40 | event.node.req.url || "", 41 | `http://${event.node.req.headers.host || "localhost"}` 42 | ); 43 | const pathname = url.pathname; 44 | const segments = pathname.split("/").filter(Boolean); 45 | const badgeIdx = segments.indexOf("badge"); 46 | const pathParams = segments.slice(badgeIdx + 1); 47 | 48 | // /api/public/badge/:userId/:project/:timeRange/:color/:labelText 49 | const userId = pathParams[0]; 50 | const projectInput = pathParams[1] || "all"; 51 | const timeRangeParam = (pathParams[2] as TimeRange) || TimeRangeEnum.ALL_TIME; 52 | let color = pathParams[3] || "blue"; 53 | const labelText = pathParams[4]; 54 | 55 | const project = projectInput.toLowerCase(); 56 | 57 | if (!userId) { 58 | throw createError({ 59 | statusCode: 400, 60 | statusMessage: "User ID is required", 61 | }); 62 | } 63 | 64 | if ( 65 | timeRangeParam && 66 | !Object.values(TimeRangeEnum).includes(timeRangeParam as any) 67 | ) { 68 | throw createError({ 69 | statusCode: 400, 70 | statusMessage: `Invalid time range. Valid options are: ${Object.values(TimeRangeEnum).join(", ")}`, 71 | }); 72 | } 73 | 74 | const query = Object.fromEntries(url.searchParams.entries()); 75 | const style = query.style ? String(query.style) : "classic"; 76 | const icon = query.icon as string; 77 | 78 | let totalSeconds = 0; 79 | 80 | if (project === "all") { 81 | const timeTotal = await getUserTimeRangeTotal(userId, timeRangeParam); 82 | totalSeconds = timeTotal.totalMinutes * 60; 83 | } else { 84 | const stats = (await calculateStats( 85 | userId, 86 | timeRangeParam, 87 | undefined, 88 | project 89 | )) as StatsResult | StatsWithProject; 90 | 91 | if ("projectFilter" in stats) { 92 | totalSeconds = stats.projectSeconds; 93 | } else { 94 | for (const summary of stats.summaries) { 95 | if (summary.projects) { 96 | const projectsData = summary.projects as Record; 97 | for (const [projectName, seconds] of Object.entries(projectsData)) { 98 | if (projectName.toLowerCase() === project) { 99 | totalSeconds += seconds; 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | let formattedTime; 108 | if (totalSeconds === 0) { 109 | formattedTime = "0 mins"; 110 | } else if (totalSeconds < 60) { 111 | formattedTime = `${totalSeconds} secs`; 112 | } else if (totalSeconds < 3600) { 113 | formattedTime = `${Math.round(totalSeconds / 60)} mins`; 114 | } else { 115 | const hours = Math.floor(totalSeconds / 3600); 116 | const minutes = Math.round((totalSeconds % 3600) / 60); 117 | 118 | if (minutes === 0) { 119 | formattedTime = `${hours} hrs`; 120 | } else { 121 | formattedTime = `${hours} hrs ${minutes} mins`; 122 | } 123 | } 124 | 125 | const colorMap: Record = { 126 | blue: "007ec6", 127 | green: "97ca00", 128 | red: "e05d44", 129 | orange: "fe7d37", 130 | yellow: "dfb317", 131 | purple: "9f9f9f", 132 | black: "333333", 133 | }; 134 | 135 | const badgeOptions: any = { 136 | label: labelText || "Ziit", 137 | status: formattedTime, 138 | color: colorMap[color as keyof typeof colorMap] || color, 139 | style: style === "flat" ? "flat" : "classic", 140 | }; 141 | 142 | if (icon) { 143 | badgeOptions.icon = icon; 144 | } else if (!labelText) { 145 | badgeOptions.icon = "/favicon.ico"; 146 | } 147 | 148 | return badgen(badgeOptions); 149 | }); 150 | -------------------------------------------------------------------------------- /app/pages/register.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 164 | 165 | 168 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [contact@pandadev.net](mailto:contact@pandadev.net). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /app/pages/leaderboard.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 190 | 191 | 194 | -------------------------------------------------------------------------------- /server/api/external/batch.post.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from "h3"; 2 | import { z } from "zod"; 3 | import { prisma } from "~~/prisma/db"; 4 | import { handleApiError } from "~~/server/utils/logging"; 5 | 6 | defineRouteMeta({ 7 | openAPI: { 8 | tags: ["External", "Heartbeats"], 9 | summary: "Create multiple heartbeats", 10 | description: 11 | "Accepts up to 100000 heartbeats in a single request authenticated via Bearer API key.", 12 | security: [{ bearerAuth: [] }], 13 | requestBody: { 14 | required: true, 15 | content: { 16 | "application/json": { 17 | schema: { 18 | type: "array", 19 | items: { 20 | type: "object", 21 | properties: { 22 | timestamp: { 23 | type: "string", 24 | format: "date-time", 25 | description: "ISO 8601; numeric epoch also accepted.", 26 | }, 27 | project: { type: "string" }, 28 | language: { type: "string" }, 29 | editor: { type: "string" }, 30 | os: { type: "string" }, 31 | branch: { type: "string" }, 32 | file: { type: "string" }, 33 | }, 34 | required: [ 35 | "timestamp", 36 | "project", 37 | "language", 38 | "editor", 39 | "os", 40 | "file", 41 | ], 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | responses: { 48 | 200: { 49 | description: "Heartbeats processed", 50 | content: { 51 | "application/json": { 52 | schema: { 53 | type: "object", 54 | properties: { 55 | success: { type: "boolean" }, 56 | count: { type: "number" }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | 400: { description: "Validation error" }, 63 | 401: { description: "Invalid or missing API key" }, 64 | 500: { description: "Server error" }, 65 | }, 66 | operationId: "postExternalBatchHeartbeats", 67 | }, 68 | }); 69 | 70 | const apiKeySchema = z.uuid(); 71 | 72 | const heartbeatSchema = z.object({ 73 | timestamp: z.string().datetime().or(z.number()), 74 | project: z.string().min(1).max(255), 75 | language: z.string().min(1).max(50), 76 | editor: z.string().min(1).max(50), 77 | os: z.string().min(1).max(50), 78 | branch: z.string().max(255).optional(), 79 | file: z.string().max(255), 80 | }); 81 | 82 | const batchSchema = z.array(heartbeatSchema).min(1).max(100000); 83 | 84 | export default defineEventHandler(async (event: H3Event) => { 85 | try { 86 | const authHeader = getHeader(event, "authorization"); 87 | if (!authHeader || !authHeader.startsWith("Bearer ")) { 88 | throw handleApiError( 89 | 401, 90 | "Batch API error: Missing or invalid API key format in header." 91 | ); 92 | } 93 | 94 | const apiKey = authHeader.substring(7); 95 | const validationResult = apiKeySchema.safeParse(apiKey); 96 | 97 | if (!validationResult.success) { 98 | throw handleApiError( 99 | 401, 100 | `Batch API error: Invalid API key format. Key: ${apiKey.substring(0, 4)}...` 101 | ); 102 | } 103 | 104 | const user = await prisma.user.findUnique({ 105 | where: { apiKey }, 106 | select: { id: true, apiKey: true }, 107 | }); 108 | 109 | if (!user || user.apiKey !== apiKey) { 110 | throw handleApiError( 111 | 401, 112 | `Batch API error: Invalid API key. Key: ${apiKey.substring(0, 4)}...` 113 | ); 114 | } 115 | 116 | const body = await readBody(event); 117 | const heartbeats = batchSchema.parse(body); 118 | 119 | const heartbeatsData = heartbeats.map((heartbeat) => { 120 | const timestamp = 121 | typeof heartbeat.timestamp === "number" 122 | ? new Date(heartbeat.timestamp) 123 | : new Date(heartbeat.timestamp); 124 | 125 | return { 126 | userId: user.id, 127 | timestamp, 128 | project: heartbeat.project, 129 | language: heartbeat.language, 130 | editor: heartbeat.editor, 131 | os: heartbeat.os, 132 | branch: heartbeat.branch, 133 | file: heartbeat.file, 134 | }; 135 | }); 136 | 137 | let insertCount = 0; 138 | const BATCH_SIZE = 1000; 139 | 140 | for (let i = 0; i < heartbeatsData.length; i += BATCH_SIZE) { 141 | const batch = heartbeatsData.slice(i, i + BATCH_SIZE); 142 | 143 | const result = await prisma.heartbeats.createMany({ 144 | data: batch, 145 | skipDuplicates: true, 146 | }); 147 | 148 | insertCount += result.count; 149 | } 150 | 151 | const result = { count: insertCount }; 152 | 153 | return { 154 | success: true, 155 | count: result.count, 156 | }; 157 | } catch (error: any) { 158 | if (error && typeof error === "object" && error.statusCode) throw error; 159 | if (error instanceof z.ZodError) { 160 | throw handleApiError( 161 | 400, 162 | `Batch API error: Validation error. Details: ${error.message}` 163 | ); 164 | } 165 | const detailedMessage = 166 | error instanceof Error 167 | ? error.message 168 | : "An unknown error occurred processing batch heartbeats."; 169 | const apiKeyPrefix = 170 | getHeader(event, "authorization")?.substring(7, 11) || "UNKNOWN"; 171 | throw handleApiError( 172 | 69, 173 | `Batch API error: Failed to process heartbeats. API Key prefix: ${apiKeyPrefix}... Error: ${detailedMessage}`, 174 | "Failed to process your request." 175 | ); 176 | } 177 | }); 178 | -------------------------------------------------------------------------------- /server/cron/summarize.ts: -------------------------------------------------------------------------------- 1 | import { defineCronHandler } from "#nuxt/cron"; 2 | 3 | import { processSummariesByDate } from "~~/server/utils/summarize"; 4 | import { handleLog } from "../utils/logging"; 5 | import { prisma } from "~~/prisma/db"; 6 | 7 | export default defineCronHandler( 8 | "daily", 9 | async () => { 10 | try { 11 | const now = new Date(); 12 | now.setHours(0, 0, 0, 0); 13 | 14 | const BATCH_SIZE = 5000; 15 | let processedCount = 0; 16 | let hasMore = true; 17 | 18 | while (hasMore) { 19 | const heartbeatsToSummarize = await prisma.$queryRaw< 20 | Array<{ 21 | id: string; 22 | timestamp: Date; 23 | userId: string; 24 | project: string | null; 25 | editor: string | null; 26 | language: string | null; 27 | os: string | null; 28 | file: string | null; 29 | branch: string | null; 30 | createdAt: Date; 31 | summariesId: string | null; 32 | keystrokeTimeout: number; 33 | }> 34 | >` 35 | SELECT DISTINCT ON (h."userId", h.timestamp, h.id) 36 | h.id, 37 | h.timestamp, 38 | h."userId", 39 | h.project, 40 | h.editor, 41 | h.language, 42 | h.os, 43 | h.file, 44 | h.branch, 45 | h."createdAt", 46 | h."summariesId", 47 | u."keystrokeTimeout" 48 | FROM "Heartbeats" h 49 | INNER JOIN "User" u ON h."userId" = u.id 50 | WHERE h.timestamp < ${now}::timestamptz 51 | AND h."summariesId" IS NULL 52 | ORDER BY h."userId", h.timestamp ASC, h.id 53 | LIMIT ${BATCH_SIZE} 54 | `; 55 | 56 | if (heartbeatsToSummarize.length === 0) { 57 | hasMore = false; 58 | break; 59 | } 60 | 61 | processedCount += heartbeatsToSummarize.length; 62 | 63 | const userHeartbeats: Record< 64 | string, 65 | Array<(typeof heartbeatsToSummarize)[0]> 66 | > = {}; 67 | 68 | heartbeatsToSummarize.forEach((heartbeat) => { 69 | const userId = heartbeat.userId; 70 | 71 | if (!userHeartbeats[userId]) { 72 | userHeartbeats[userId] = []; 73 | } 74 | 75 | userHeartbeats[userId].push(heartbeat); 76 | }); 77 | 78 | for (const userId in userHeartbeats) { 79 | await processSummariesByDate(userId, userHeartbeats[userId]); 80 | } 81 | 82 | if (heartbeatsToSummarize.length < BATCH_SIZE) { 83 | hasMore = false; 84 | } 85 | } 86 | 87 | await generatePublicStats(now); 88 | 89 | handleLog( 90 | `Summarization complete. Processed ${processedCount} heartbeats.` 91 | ); 92 | } catch (error) { 93 | console.error("Error in summarization cron job", error); 94 | } 95 | }, 96 | { 97 | timeZone: "UTC", 98 | runOnInit: true, 99 | } 100 | ); 101 | 102 | async function generatePublicStats(date: Date) { 103 | try { 104 | const statsDate = new Date(date); 105 | statsDate.setHours(0, 0, 0, 0); 106 | 107 | const existingStats = await prisma.stats.count({ 108 | where: { 109 | date: statsDate, 110 | }, 111 | }); 112 | 113 | if (existingStats > 0) { 114 | return; 115 | } 116 | 117 | const [ 118 | userCountResult, 119 | heartbeatCountResult, 120 | summariesAggregateResult, 121 | topEditorResult, 122 | topLanguageResult, 123 | topOSResult, 124 | ] = await Promise.all([ 125 | prisma.user.count(), 126 | 127 | prisma.heartbeats.count(), 128 | 129 | prisma.summaries.aggregate({ 130 | _sum: { 131 | totalMinutes: true, 132 | }, 133 | }), 134 | 135 | prisma.heartbeats.groupBy({ 136 | by: ["editor"], 137 | where: { 138 | editor: { not: null }, 139 | }, 140 | _count: { 141 | _all: true, 142 | }, 143 | orderBy: { 144 | _count: { 145 | editor: "desc", 146 | }, 147 | }, 148 | take: 1, 149 | }), 150 | 151 | prisma.heartbeats.groupBy({ 152 | by: ["language"], 153 | where: { 154 | language: { not: null }, 155 | }, 156 | _count: { 157 | _all: true, 158 | }, 159 | orderBy: { 160 | _count: { 161 | language: "desc", 162 | }, 163 | }, 164 | take: 1, 165 | }), 166 | 167 | prisma.heartbeats.groupBy({ 168 | by: ["os"], 169 | where: { 170 | os: { not: null }, 171 | }, 172 | _count: { 173 | _all: true, 174 | }, 175 | orderBy: { 176 | _count: { 177 | os: "desc", 178 | }, 179 | }, 180 | take: 1, 181 | }), 182 | ]); 183 | 184 | const totalUsers = userCountResult; 185 | const totalHeartbeats = heartbeatCountResult; 186 | const totalHours = Math.floor( 187 | (summariesAggregateResult._sum.totalMinutes || 0) / 60 188 | ); 189 | 190 | const topEditor = topEditorResult[0]?.editor || "Unknown"; 191 | const topLanguage = topLanguageResult[0]?.language || "Unknown"; 192 | const topOS = topOSResult[0]?.os || "Unknown"; 193 | 194 | await prisma.stats.create({ 195 | data: { 196 | date: statsDate, 197 | totalHours, 198 | totalUsers: BigInt(totalUsers), 199 | totalHeartbeats, 200 | topEditor, 201 | topLanguage, 202 | topOS, 203 | }, 204 | }); 205 | 206 | handleLog( 207 | `Generated public stats for ${statsDate.toISOString().split("T")[0]}: ${totalUsers} users, ${totalHeartbeats} heartbeats, ${totalHours} hours` 208 | ); 209 | } catch (error) { 210 | console.error("Error generating public stats:", error); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /app/pages/login.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 191 | 192 | 195 | -------------------------------------------------------------------------------- /prisma/migrations/20251002153924_migrate_to_real_timestampz/migration.sql: -------------------------------------------------------------------------------- 1 | -- Migration to convert Heartbeats timestamp from bigint to proper TIMESTAMPTZ 2 | -- and ensure full alignment with Prisma schema while preserving data 3 | 4 | -- 1. Ensure TimescaleDB extension is available 5 | CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; 6 | 7 | -- 2. Create backup table to preserve existing data 8 | DO $$ 9 | BEGIN 10 | IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Heartbeats' AND table_schema = 'public') THEN 11 | -- Create backup table with all data 12 | CREATE TABLE "Heartbeats_backup" AS SELECT * FROM "Heartbeats"; 13 | RAISE NOTICE 'Created backup table with % rows', (SELECT COUNT(*) FROM "Heartbeats_backup"); 14 | END IF; 15 | END $$; 16 | 17 | -- 3. Drop the old table/hypertable completely 18 | DO $$ 19 | BEGIN 20 | IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Heartbeats' AND table_schema = 'public') THEN 21 | BEGIN 22 | -- Try to drop as hypertable first 23 | PERFORM drop_hypertable('"Heartbeats"'); 24 | RAISE NOTICE 'Dropped hypertable Heartbeats'; 25 | EXCEPTION 26 | WHEN OTHERS THEN 27 | -- If that fails, drop as regular table 28 | DROP TABLE "Heartbeats" CASCADE; 29 | RAISE NOTICE 'Dropped regular table Heartbeats'; 30 | END; 31 | END IF; 32 | END $$; 33 | 34 | -- 4. Create the new table with exact schema matching Prisma model 35 | CREATE TABLE "Heartbeats" ( 36 | "id" TEXT NOT NULL, 37 | "timestamp" TIMESTAMPTZ NOT NULL, 38 | "userId" TEXT NOT NULL, 39 | "project" TEXT, 40 | "editor" TEXT, 41 | "language" TEXT, 42 | "os" TEXT, 43 | "file" TEXT, 44 | "branch" TEXT, 45 | "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), 46 | "summariesId" TEXT, 47 | 48 | CONSTRAINT "Heartbeats_pkey" PRIMARY KEY ("id", "timestamp") 49 | ); 50 | 51 | -- 5. Restore data from backup with proper timestamp conversion 52 | DO $$ 53 | DECLARE 54 | backup_exists boolean; 55 | timestamp_type text; 56 | BEGIN 57 | -- Check if backup table exists 58 | SELECT EXISTS ( 59 | SELECT 1 FROM information_schema.tables 60 | WHERE table_name = 'Heartbeats_backup' AND table_schema = 'public' 61 | ) INTO backup_exists; 62 | 63 | IF backup_exists THEN 64 | -- Check the data type of timestamp column in backup 65 | SELECT data_type INTO timestamp_type 66 | FROM information_schema.columns 67 | WHERE table_name = 'Heartbeats_backup' 68 | AND column_name = 'timestamp' 69 | AND table_schema = 'public'; 70 | 71 | RAISE NOTICE 'Backup timestamp column type: %', timestamp_type; 72 | 73 | -- Insert data with appropriate conversion based on column type 74 | IF timestamp_type = 'bigint' THEN 75 | -- Convert bigint milliseconds to timestamptz 76 | INSERT INTO "Heartbeats" ("id", "timestamp", "userId", "project", "editor", "language", "os", "file", "branch", "createdAt", "summariesId") 77 | SELECT 78 | "id", 79 | to_timestamp("timestamp"::double precision / 1000) AT TIME ZONE 'UTC', 80 | "userId", 81 | "project", 82 | "editor", 83 | "language", 84 | "os", 85 | "file", 86 | "branch", 87 | COALESCE("createdAt", now()), 88 | "summariesId" 89 | FROM "Heartbeats_backup"; 90 | ELSE 91 | -- Timestamp is already in timestamp/timestamptz format 92 | INSERT INTO "Heartbeats" ("id", "timestamp", "userId", "project", "editor", "language", "os", "file", "branch", "createdAt", "summariesId") 93 | SELECT 94 | "id", 95 | "timestamp"::timestamptz, 96 | "userId", 97 | "project", 98 | "editor", 99 | "language", 100 | "os", 101 | "file", 102 | "branch", 103 | COALESCE("createdAt", now()), 104 | "summariesId" 105 | FROM "Heartbeats_backup"; 106 | END IF; 107 | 108 | RAISE NOTICE 'Restored % rows to new Heartbeats table', (SELECT COUNT(*) FROM "Heartbeats"); 109 | END IF; 110 | END $$; 111 | 112 | -- 6. Create hypertable with migrate_data parameter 113 | SELECT create_hypertable('"Heartbeats"', 'timestamp', chunk_time_interval => INTERVAL '1 week', migrate_data => true); 114 | 115 | -- 7. Create all indexes as specified in Prisma schema (conditionally) 116 | DO $$ 117 | BEGIN 118 | -- Drop any existing indexes first to avoid conflicts 119 | DROP INDEX IF EXISTS "Heartbeats_userId_timestamp_idx"; 120 | DROP INDEX IF EXISTS "Heartbeats_timestamp_idx"; 121 | DROP INDEX IF EXISTS "Heartbeats_userId_project_timestamp_idx"; 122 | DROP INDEX IF EXISTS "Heartbeats_userId_language_timestamp_idx"; 123 | DROP INDEX IF EXISTS "Heartbeats_userId_editor_timestamp_idx"; 124 | DROP INDEX IF EXISTS "Heartbeats_userId_os_timestamp_idx"; 125 | DROP INDEX IF EXISTS "Heartbeats_branch_idx"; 126 | DROP INDEX IF EXISTS "Heartbeats_file_idx"; 127 | DROP INDEX IF EXISTS "Heartbeats_summariesId_idx"; 128 | 129 | -- Create all indexes 130 | CREATE INDEX "Heartbeats_userId_timestamp_idx" ON "Heartbeats"("userId", "timestamp" DESC); 131 | CREATE INDEX "Heartbeats_timestamp_idx" ON "Heartbeats"("timestamp" DESC); 132 | CREATE INDEX "Heartbeats_userId_project_timestamp_idx" ON "Heartbeats"("userId", "project", "timestamp" DESC); 133 | CREATE INDEX "Heartbeats_userId_language_timestamp_idx" ON "Heartbeats"("userId", "language", "timestamp" DESC); 134 | CREATE INDEX "Heartbeats_userId_editor_timestamp_idx" ON "Heartbeats"("userId", "editor", "timestamp" DESC); 135 | CREATE INDEX "Heartbeats_userId_os_timestamp_idx" ON "Heartbeats"("userId", "os", "timestamp" DESC); 136 | CREATE INDEX "Heartbeats_branch_idx" ON "Heartbeats"("branch"); 137 | CREATE INDEX "Heartbeats_file_idx" ON "Heartbeats"("file"); 138 | CREATE INDEX "Heartbeats_summariesId_idx" ON "Heartbeats"("summariesId"); 139 | 140 | RAISE NOTICE 'Created all indexes successfully'; 141 | END $$; 142 | 143 | -- 8. Add foreign key constraint 144 | ALTER TABLE "Heartbeats" ADD CONSTRAINT "Heartbeats_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 145 | 146 | -- 9. Clean up backup table 147 | DO $$ 148 | BEGIN 149 | IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Heartbeats_backup' AND table_schema = 'public') THEN 150 | DROP TABLE "Heartbeats_backup"; 151 | RAISE NOTICE 'Cleaned up backup table'; 152 | END IF; 153 | END $$; 154 | -------------------------------------------------------------------------------- /server/api/import/status.get.ts: -------------------------------------------------------------------------------- 1 | import { createEventStream, getRequestHeader } from "h3"; 2 | import type { User } from "@prisma/client"; 3 | 4 | import { handleLog } from "~~/server/utils/logging"; 5 | import { 6 | getAllJobStatuses, 7 | getQueueStatus, 8 | } from "~~/server/utils/import-queue"; 9 | 10 | function safeJSONStringify(obj: any): string { 11 | return JSON.stringify(obj, (key, value) => { 12 | if (typeof value === "bigint") { 13 | return value.toString(); 14 | } 15 | return value; 16 | }); 17 | } 18 | 19 | function isActiveJobStatus(status: string): boolean { 20 | if (!status || typeof status !== "string") return false; 21 | const inactiveKeywords = ["Completed", "Failed"]; 22 | return !inactiveKeywords.some((keyword) => status.includes(keyword)); 23 | } 24 | 25 | const recentlyCompletedJobs = new Map< 26 | string, 27 | { timestamp: number; sentFinalUpdate: boolean } 28 | >(); 29 | 30 | function cleanupOldCompletedJobs() { 31 | const now = Date.now(); 32 | for (const [jobId, data] of recentlyCompletedJobs.entries()) { 33 | if (now - data.timestamp > 5000) { 34 | recentlyCompletedJobs.delete(jobId); 35 | } 36 | } 37 | } 38 | 39 | export default defineEventHandler((event) => { 40 | const user = (event.context as any).user as User; 41 | if (!user) { 42 | throw createError({ 43 | statusCode: 401, 44 | statusMessage: "Unauthorized", 45 | }); 46 | } 47 | 48 | const userId = user.id; 49 | const userJobs = getAllJobStatuses(userId); 50 | const queueStatus = getQueueStatus(); 51 | 52 | let activeJob = userJobs.find((j) => isActiveJobStatus(j.status)); 53 | 54 | if (!activeJob) { 55 | const completedJob = userJobs.find( 56 | (j) => 57 | (j.status.includes("Completed") || j.status.includes("Failed")) && 58 | (!recentlyCompletedJobs.has(j.id) || 59 | !recentlyCompletedJobs.get(j.id)?.sentFinalUpdate), 60 | ); 61 | 62 | if (completedJob) { 63 | if (!recentlyCompletedJobs.has(completedJob.id)) { 64 | recentlyCompletedJobs.set(completedJob.id, { 65 | timestamp: Date.now(), 66 | sentFinalUpdate: false, 67 | }); 68 | } 69 | activeJob = completedJob; 70 | } 71 | } 72 | 73 | if (!activeJob) { 74 | activeJob = userJobs.sort((a, b) => b.id.localeCompare(a.id))[0]; 75 | } 76 | 77 | cleanupOldCompletedJobs(); 78 | 79 | const acceptHeader = getRequestHeader(event, "accept"); 80 | if (!acceptHeader?.includes("text/event-stream")) { 81 | return { 82 | activeJob, 83 | allJobs: userJobs.slice(0, 10), 84 | queueStatus: { 85 | queueLength: queueStatus.queueLength, 86 | busyWorkers: queueStatus.busyWorkers, 87 | availableWorkers: queueStatus.availableWorkers, 88 | }, 89 | hasActiveJobs: userJobs.some((j) => isActiveJobStatus(j.status)), 90 | }; 91 | } 92 | 93 | const eventStream = createEventStream(event); 94 | 95 | handleLog(`[sse] open for user ${userId}`); 96 | 97 | let completedMessagesSent = 0; 98 | let isCompleted = false; 99 | let heartbeatsSent = 0; 100 | let lastActiveJobState: any = null; 101 | let connectionStartTime = Date.now(); 102 | 103 | let currentInterval = 1000; 104 | const ACTIVE_INTERVAL = 500; 105 | const INACTIVE_INTERVAL = 5000; 106 | const MAX_CONNECTION_TIME = 30 * 60 * 1000; 107 | 108 | const sendUpdate = () => { 109 | try { 110 | if (Date.now() - connectionStartTime > MAX_CONNECTION_TIME) { 111 | handleLog(`[sse] Connection timeout for user ${userId}`); 112 | clearTimeout(timeoutId); 113 | eventStream.close(); 114 | return; 115 | } 116 | 117 | const currentUserJobs = getAllJobStatuses(userId); 118 | const currentQueueStatus = getQueueStatus(); 119 | 120 | let currentActiveJob = currentUserJobs.find((j) => 121 | isActiveJobStatus(j.status), 122 | ); 123 | 124 | if (!currentActiveJob) { 125 | const completedJob = currentUserJobs.find( 126 | (j) => 127 | (j.status.includes("Completed") || j.status.includes("Failed")) && 128 | (!recentlyCompletedJobs.has(j.id) || 129 | !recentlyCompletedJobs.get(j.id)?.sentFinalUpdate), 130 | ); 131 | 132 | if (completedJob) { 133 | currentActiveJob = completedJob; 134 | if (!recentlyCompletedJobs.has(completedJob.id)) { 135 | recentlyCompletedJobs.set(completedJob.id, { 136 | timestamp: Date.now(), 137 | sentFinalUpdate: false, 138 | }); 139 | } 140 | } 141 | } 142 | 143 | const hasActiveJobs = currentUserJobs.some((j) => 144 | isActiveJobStatus(j.status), 145 | ); 146 | 147 | const currentJobState = JSON.stringify(currentActiveJob); 148 | const shouldSendUpdate = 149 | currentJobState !== lastActiveJobState || 150 | heartbeatsSent % 10 === 0 || 151 | hasActiveJobs; 152 | 153 | if (shouldSendUpdate) { 154 | const response = { 155 | activeJob: currentActiveJob, 156 | queueStatus: { 157 | queueLength: currentQueueStatus.queueLength, 158 | busyWorkers: currentQueueStatus.busyWorkers, 159 | availableWorkers: currentQueueStatus.availableWorkers, 160 | }, 161 | hasActiveJobs, 162 | totalJobs: currentUserJobs.length, 163 | recentJobs: currentUserJobs.slice(0, 5), 164 | heartbeat: ++heartbeatsSent, 165 | }; 166 | 167 | eventStream.push(safeJSONStringify(response)); 168 | lastActiveJobState = currentJobState; 169 | 170 | if ( 171 | currentActiveJob && 172 | (currentActiveJob.status.includes("Completed") || 173 | currentActiveJob.status.includes("Failed")) && 174 | recentlyCompletedJobs.has(currentActiveJob.id) 175 | ) { 176 | const jobData = recentlyCompletedJobs.get(currentActiveJob.id); 177 | if (jobData) { 178 | jobData.sentFinalUpdate = true; 179 | } 180 | } 181 | } 182 | 183 | const newInterval = hasActiveJobs ? ACTIVE_INTERVAL : INACTIVE_INTERVAL; 184 | if (newInterval !== currentInterval) { 185 | currentInterval = newInterval; 186 | clearTimeout(timeoutId); 187 | timeoutId = setTimeout(sendUpdate, currentInterval); 188 | return; 189 | } 190 | 191 | if (!hasActiveJobs && !isCompleted) { 192 | isCompleted = true; 193 | completedMessagesSent = 0; 194 | } 195 | 196 | if (isCompleted) { 197 | completedMessagesSent++; 198 | if (completedMessagesSent >= 24) { 199 | clearTimeout(timeoutId); 200 | eventStream.close(); 201 | return; 202 | } 203 | } 204 | } catch (error) { 205 | handleLog(`[sse] Error sending message to user ${userId}: ${error}`); 206 | clearTimeout(timeoutId); 207 | eventStream.close(); 208 | return; 209 | } 210 | 211 | timeoutId = setTimeout(sendUpdate, currentInterval); 212 | }; 213 | 214 | let timeoutId = setTimeout(sendUpdate, 100); 215 | 216 | eventStream.onClosed(() => { 217 | handleLog(`[sse] close for user ${userId}`); 218 | clearTimeout(timeoutId); 219 | }); 220 | 221 | return eventStream.send(); 222 | }); 223 | -------------------------------------------------------------------------------- /server/api/auth/epilogue/callback.get.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~~/prisma/db"; 2 | import { decrypt, encrypt } from "paseto-ts/v4"; 3 | import { handleApiError } from "~~/server/utils/logging"; 4 | 5 | defineRouteMeta({ 6 | openAPI: { 7 | tags: ["Auth", "Epilogue"], 8 | summary: "Epilogue OAuth callback", 9 | description: 10 | "Handles Epilogue OAuth callback. Exchanges code for access token, signs in or links account, and redirects.", 11 | parameters: [ 12 | { in: "query", name: "code", required: true, schema: { type: "string" } }, 13 | { 14 | in: "query", 15 | name: "state", 16 | required: true, 17 | schema: { type: "string" }, 18 | }, 19 | ], 20 | responses: { 21 | 302: { description: "Redirect to application after login/link" }, 22 | 400: { description: "Missing or invalid parameters" }, 23 | 500: { description: "Authentication failure" }, 24 | }, 25 | operationId: "getEpilogueCallback", 26 | }, 27 | }); 28 | 29 | interface EpilogueUser { 30 | id: string; 31 | username?: string; 32 | email?: string; 33 | } 34 | 35 | export default defineEventHandler(async (event) => { 36 | const config = useRuntimeConfig(); 37 | const query = getQuery(event); 38 | const code = query.code as string; 39 | 40 | if (!code) { 41 | return sendRedirect(event, "/login?error=cancelled"); 42 | } 43 | 44 | const storedState = getCookie(event, "epilogue_oauth_state"); 45 | 46 | if (!storedState) { 47 | return sendRedirect(event, "/login?error=invalid_state"); 48 | } 49 | 50 | const isLinking = getCookie(event, "epilogue_link_account") === "true"; 51 | const linkSession = getCookie(event, "epilogue_link_session"); 52 | 53 | deleteCookie(event, "epilogue_oauth_state"); 54 | deleteCookie(event, "epilogue_link_account"); 55 | deleteCookie(event, "epilogue_link_session"); 56 | 57 | try { 58 | const tokenResponse = await $fetch<{ token: string }>( 59 | `https://auth.epilogue.team/api/v1/authorize/${config.epilogueAppId}`, 60 | { 61 | method: "POST", 62 | headers: { 63 | "Content-Type": "application/json", 64 | }, 65 | body: JSON.stringify({ 66 | authorizationCode: code, 67 | applicationSecret: config.epilogueAppSecret, 68 | }), 69 | } 70 | ); 71 | 72 | const accessToken = tokenResponse.token; 73 | 74 | const epilogueUser = await $fetch( 75 | "https://auth.epilogue.team/api/v1/app/me", 76 | { 77 | headers: { 78 | "Content-Type": "application/json", 79 | Authorization: `Bearer ${accessToken}`, 80 | }, 81 | } 82 | ); 83 | 84 | if (!epilogueUser || !epilogueUser.id) { 85 | throw handleApiError( 86 | 69, 87 | `Epilogue callback error: Invalid user data received`, 88 | "Could not retrieve user information from Epilogue" 89 | ); 90 | } 91 | 92 | if (isLinking && linkSession) { 93 | try { 94 | const { payload } = decrypt(config.pasetoKey, linkSession); 95 | 96 | if ( 97 | typeof payload === "object" && 98 | payload !== null && 99 | "userId" in payload 100 | ) { 101 | const userId = payload.userId; 102 | let redirectUrl = "/settings?success=epilogue_linked"; 103 | 104 | await prisma.$transaction(async (tx) => { 105 | const [existingEpilogueUser, currentUser] = await Promise.all([ 106 | tx.user.findFirst({ 107 | where: { epilogueId: epilogueUser.id }, 108 | }), 109 | tx.user.findUnique({ 110 | where: { id: userId }, 111 | }), 112 | ]); 113 | 114 | if (existingEpilogueUser && existingEpilogueUser.id !== userId) { 115 | await Promise.all([ 116 | tx.heartbeats.updateMany({ 117 | where: { userId: existingEpilogueUser.id }, 118 | data: { userId: userId }, 119 | }), 120 | tx.summaries.updateMany({ 121 | where: { userId: existingEpilogueUser.id }, 122 | data: { userId: userId }, 123 | }), 124 | tx.user.delete({ 125 | where: { id: existingEpilogueUser.id }, 126 | }), 127 | tx.user.update({ 128 | where: { id: userId }, 129 | data: { 130 | epilogueId: epilogueUser.id, 131 | epilogueUsername: epilogueUser.username, 132 | epilogueToken: accessToken, 133 | }, 134 | }), 135 | ]); 136 | 137 | redirectUrl = "/settings?success=accounts_merged"; 138 | } else if (currentUser?.epilogueId === epilogueUser.id) { 139 | await tx.user.update({ 140 | where: { id: userId }, 141 | data: { 142 | epilogueToken: accessToken, 143 | }, 144 | }); 145 | redirectUrl = "/settings?success=epilogue_updated"; 146 | } else { 147 | await tx.user.update({ 148 | where: { id: userId }, 149 | data: { 150 | epilogueId: epilogueUser.id, 151 | epilogueUsername: epilogueUser.username, 152 | epilogueToken: accessToken, 153 | }, 154 | }); 155 | } 156 | }); 157 | 158 | return sendRedirect(event, redirectUrl); 159 | } 160 | } catch { 161 | return sendRedirect(event, "/settings?error=link_failed"); 162 | } 163 | } 164 | 165 | await prisma.$transaction(async (tx) => { 166 | let user = await tx.user.findFirst({ 167 | where: { epilogueId: epilogueUser.id }, 168 | }); 169 | 170 | if (!user) { 171 | const userEmail = 172 | epilogueUser.email || `epilogue_${epilogueUser.id}@temp.ziit.app`; 173 | const username = 174 | epilogueUser.username || `epilogue_user_${epilogueUser.id}`; 175 | 176 | user = await tx.user.create({ 177 | data: { 178 | email: userEmail, 179 | passwordHash: null, 180 | epilogueId: epilogueUser.id, 181 | epilogueUsername: username, 182 | epilogueToken: accessToken, 183 | }, 184 | }); 185 | } else { 186 | const username = 187 | epilogueUser.username || 188 | user.epilogueUsername || 189 | `epilogue_user_${epilogueUser.id}`; 190 | 191 | user = await tx.user.update({ 192 | where: { id: user.id }, 193 | data: { 194 | epilogueToken: accessToken, 195 | epilogueUsername: username, 196 | }, 197 | }); 198 | } 199 | 200 | const token = encrypt(config.pasetoKey, { 201 | userId: user.id, 202 | exp: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), 203 | }); 204 | 205 | setCookie(event, "ziit_session", token, { 206 | httpOnly: true, 207 | secure: process.env.NODE_ENV === "production", 208 | maxAge: 60 * 60 * 24 * 7, 209 | path: "/", 210 | sameSite: "lax", 211 | }); 212 | return user; 213 | }); 214 | 215 | setHeader(event, "Cache-Control", "no-cache, no-store, must-revalidate"); 216 | setHeader(event, "Pragma", "no-cache"); 217 | setHeader(event, "Expires", "0"); 218 | 219 | return sendRedirect(event, "/"); 220 | } catch (error) { 221 | const detailedMessage = 222 | error instanceof Error 223 | ? error.message 224 | : "An unknown error occurred during Epilogue authentication."; 225 | throw handleApiError( 226 | 69, 227 | `Epilogue authentication failed: ${detailedMessage}`, 228 | "Epilogue authentication failed. Please try again." 229 | ); 230 | } 231 | }); 232 | --------------------------------------------------------------------------------