diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc new file mode 100644 index 0000000..9849553 --- /dev/null +++ b/.cursor/rules/ultracite.mdc @@ -0,0 +1,333 @@ +--- +description: Ultracite Rules - AI-Ready Formatter and Linter +globs: "**/*.{ts,tsx,js,jsx}" +alwaysApply: true +--- + +# Project Context +Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. + +## Key Principles +- Zero configuration required +- Subsecond performance +- Maximum type safety +- AI-friendly code generation + +## Before Writing Code +1. Analyze existing patterns in the codebase +2. Consider edge cases and error scenarios +3. Follow the rules below strictly +4. Validate accessibility requirements + +## Rules + +### Accessibility (a11y) +- Don't use `accessKey` attribute on any HTML element. +- Don't set `aria-hidden="true"` on focusable elements. +- Don't add ARIA roles, states, and properties to elements that don't support them. +- Don't use distracting elements like `` or ``. +- Only use the `scope` prop on `` elements. +- Don't assign non-interactive ARIA roles to interactive HTML elements. +- Make sure label elements have text content and are associated with an input. +- Don't assign interactive ARIA roles to non-interactive HTML elements. +- Don't assign `tabIndex` to non-interactive HTML elements. +- Don't use positive integers for `tabIndex` property. +- Don't include "image", "picture", or "photo" in img alt prop. +- Don't use explicit role property that's the same as the implicit/default role. +- Make static elements with click handlers use a valid role attribute. +- Always include a `title` element for SVG elements. +- Give all elements requiring alt text meaningful information for screen readers. +- Make sure anchors have content that's accessible to screen readers. +- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. +- Include all required ARIA attributes for elements with ARIA roles. +- Make sure ARIA properties are valid for the element's supported roles. +- Always include a `type` attribute for button elements. +- Make elements with interactive roles and handlers focusable. +- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). +- Always include a `lang` attribute on the html element. +- Always include a `title` attribute for iframe elements. +- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. +- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. +- Include caption tracks for audio and video elements. +- Use semantic elements instead of role attributes in JSX. +- Make sure all anchors are valid and navigable. +- Ensure all ARIA properties (`aria-*`) are valid. +- Use valid, non-abstract ARIA roles for elements with ARIA roles. +- Use valid ARIA state and property values. +- Use valid values for the `autocomplete` attribute on input elements. +- Use correct ISO language/country codes for the `lang` attribute. + +### Code Complexity and Quality +- Don't use consecutive spaces in regular expression literals. +- Don't use the `arguments` object. +- Don't use primitive type aliases or misleading types. +- Don't use the comma operator. +- Don't use empty type parameters in type aliases and interfaces. +- Don't write functions that exceed a given Cognitive Complexity score. +- Don't nest describe() blocks too deeply in test files. +- Don't use unnecessary boolean casts. +- Don't use unnecessary callbacks with flatMap. +- Use for...of statements instead of Array.forEach. +- Don't create classes that only have static members (like a static namespace). +- Don't use this and super in static contexts. +- Don't use unnecessary catch clauses. +- Don't use unnecessary constructors. +- Don't use unnecessary continue statements. +- Don't export empty modules that don't change anything. +- Don't use unnecessary escape sequences in regular expression literals. +- Don't use unnecessary fragments. +- Don't use unnecessary labels. +- Don't use unnecessary nested block statements. +- Don't rename imports, exports, and destructured assignments to the same name. +- Don't use unnecessary string or template literal concatenation. +- Don't use String.raw in template literals when there are no escape sequences. +- Don't use useless case statements in switch statements. +- Don't use ternary operators when simpler alternatives exist. +- Don't use useless `this` aliasing. +- Don't use any or unknown as type constraints. +- Don't initialize variables to undefined. +- Don't use the void operators (they're not familiar). +- Use arrow functions instead of function expressions. +- Use Date.now() to get milliseconds since the Unix Epoch. +- Use .flatMap() instead of map().flat() when possible. +- Use literal property access instead of computed property access. +- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. +- Use concise optional chaining instead of chained logical expressions. +- Use regular expression literals instead of the RegExp constructor when possible. +- Don't use number literal object member names that aren't base 10 or use underscore separators. +- Remove redundant terms from logical expressions. +- Use while loops instead of for loops when you don't need initializer and update expressions. +- Don't pass children as props. +- Don't reassign const variables. +- Don't use constant expressions in conditions. +- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. +- Don't return a value from a constructor. +- Don't use empty character classes in regular expression literals. +- Don't use empty destructuring patterns. +- Don't call global object properties as functions. +- Don't declare functions and vars that are accessible outside their block. +- Make sure builtins are correctly instantiated. +- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. +- Don't use variables and function parameters before they're declared. +- Don't use 8 and 9 escape sequences in string literals. +- Don't use literal numbers that lose precision. + +### React and JSX Best Practices +- Don't use the return value of React.render. +- Make sure all dependencies are correctly specified in React hooks. +- Make sure all React hooks are called from the top level of component functions. +- Don't forget key props in iterators and collection literals. +- Don't destructure props inside JSX components in Solid projects. +- Don't define React components inside other components. +- Don't use event handlers on non-interactive elements. +- Don't assign to React component props. +- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. +- Don't use dangerous JSX props. +- Don't use Array index in keys. +- Don't insert comments as text nodes. +- Don't assign JSX properties multiple times. +- Don't add extra closing tags for components without children. +- Use `<>...` instead of `...`. +- Watch out for possible "wrong" semicolons inside JSX elements. + +### Correctness and Safety +- Don't assign a value to itself. +- Don't return a value from a setter. +- Don't compare expressions that modify string case with non-compliant values. +- Don't use lexical declarations in switch clauses. +- Don't use variables that haven't been declared in the document. +- Don't write unreachable code. +- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. +- Don't use control flow statements in finally blocks. +- Don't use optional chaining where undefined values aren't allowed. +- Don't have unused function parameters. +- Don't have unused imports. +- Don't have unused labels. +- Don't have unused private class members. +- Don't have unused variables. +- Make sure void (self-closing) elements don't have children. +- Don't return a value from a function with the return type 'void' +- Use isNaN() when checking for NaN. +- Make sure "for" loop update clauses move the counter in the right direction. +- Make sure typeof expressions are compared to valid values. +- Make sure generator functions contain yield. +- Don't use await inside loops. +- Don't use bitwise operators. +- Don't use expressions where the operation doesn't change the value. +- Make sure Promise-like statements are handled appropriately. +- Don't use __dirname and __filename in the global scope. +- Prevent import cycles. +- Don't use configured elements. +- Don't hardcode sensitive data like API keys and tokens. +- Don't let variable declarations shadow variables from outer scopes. +- Don't use the TypeScript directive @ts-ignore. +- Prevent duplicate polyfills from Polyfill.io. +- Don't use useless backreferences in regular expressions that always match empty strings. +- Don't use unnecessary escapes in string literals. +- Don't use useless undefined. +- Make sure getters and setters for the same property are next to each other in class and object definitions. +- Make sure object literals are declared consistently (defaults to explicit definitions). +- Use static Response methods instead of new Response() constructor when possible. +- Make sure switch-case statements are exhaustive. +- Make sure the `preconnect` attribute is used when using Google Fonts. +- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. +- Make sure iterable callbacks return consistent values. +- Use `with { type: "json" }` for JSON module imports. +- Use numeric separators in numeric literals. +- Use object spread instead of `Object.assign()` when constructing new objects. +- Always use the radix argument when using `parseInt()`. +- Make sure JSDoc comment lines start with a single asterisk, except for the first one. +- Include a description parameter for `Symbol()`. +- Don't use spread (`...`) syntax on accumulators. +- Don't use the `delete` operator. +- Don't access namespace imports dynamically. +- Don't use namespace imports. +- Declare regex literals at the top level. +- Don't use `target="_blank"` without `rel="noopener"`. + +### TypeScript Best Practices +- Don't use TypeScript enums. +- Don't export imported variables. +- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. +- Don't use TypeScript namespaces. +- Don't use non-null assertions with the `!` postfix operator. +- Don't use parameter properties in class constructors. +- Don't use user-defined types. +- Use `as const` instead of literal types and type annotations. +- Use either `T[]` or `Array` consistently. +- Initialize each enum member value explicitly. +- Use `export type` for types. +- Use `import type` for types. +- Make sure all enum members are literal values. +- Don't use TypeScript const enum. +- Don't declare empty interfaces. +- Don't let variables evolve into any type through reassignments. +- Don't use the any type. +- Don't misuse the non-null assertion operator (!) in TypeScript files. +- Don't use implicit any type on variable declarations. +- Don't merge interfaces and classes unsafely. +- Don't use overload signatures that aren't next to each other. +- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. + +### Style and Consistency +- Don't use global `eval()`. +- Don't use callbacks in asynchronous tests and hooks. +- Don't use negation in `if` statements that have `else` clauses. +- Don't use nested ternary expressions. +- Don't reassign function parameters. +- This rule lets you specify global variable names you don't want to use in your application. +- Don't use specified modules when loaded by import or require. +- Don't use constants whose value is the upper-case version of their name. +- Use `String.slice()` instead of `String.substr()` and `String.substring()`. +- Don't use template literals if you don't need interpolation or special-character handling. +- Don't use `else` blocks when the `if` block breaks early. +- Don't use yoda expressions. +- Don't use Array constructors. +- Use `at()` instead of integer index access. +- Follow curly brace conventions. +- Use `else if` instead of nested `if` statements in `else` clauses. +- Use single `if` statements instead of nested `if` clauses. +- Use `new` for all builtins except `String`, `Number`, and `Boolean`. +- Use consistent accessibility modifiers on class properties and methods. +- Use `const` declarations for variables that are only assigned once. +- Put default function parameters and optional function parameters last. +- Include a `default` clause in switch statements. +- Use the `**` operator instead of `Math.pow`. +- Use `for-of` loops when you need the index to extract an item from the iterated array. +- Use `node:assert/strict` over `node:assert`. +- Use the `node:` protocol for Node.js builtin modules. +- Use Number properties instead of global ones. +- Use assignment operator shorthand where possible. +- Use function types instead of object types with call signatures. +- Use template literals over string concatenation. +- Use `new` when throwing an error. +- Don't throw non-Error values. +- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. +- Use standard constants instead of approximated literals. +- Don't assign values in expressions. +- Don't use async functions as Promise executors. +- Don't reassign exceptions in catch clauses. +- Don't reassign class members. +- Don't compare against -0. +- Don't use labeled statements that aren't loops. +- Don't use void type outside of generic or return types. +- Don't use console. +- Don't use control characters and escape sequences that match control characters in regular expression literals. +- Don't use debugger. +- Don't assign directly to document.cookie. +- Use `===` and `!==`. +- Don't use duplicate case labels. +- Don't use duplicate class members. +- Don't use duplicate conditions in if-else-if chains. +- Don't use two keys with the same name inside objects. +- Don't use duplicate function parameter names. +- Don't have duplicate hooks in describe blocks. +- Don't use empty block statements and static blocks. +- Don't let switch clauses fall through. +- Don't reassign function declarations. +- Don't allow assignments to native objects and read-only global variables. +- Use Number.isFinite instead of global isFinite. +- Use Number.isNaN instead of global isNaN. +- Don't assign to imported bindings. +- Don't use irregular whitespace characters. +- Don't use labels that share a name with a variable. +- Don't use characters made with multiple code points in character class syntax. +- Make sure to use new and constructor properly. +- Don't use shorthand assign when the variable appears on both sides. +- Don't use octal escape sequences in string literals. +- Don't use Object.prototype builtins directly. +- Don't redeclare variables, functions, classes, and types in the same scope. +- Don't have redundant "use strict". +- Don't compare things where both sides are exactly the same. +- Don't let identifiers shadow restricted names. +- Don't use sparse arrays (arrays with holes). +- Don't use template literal placeholder syntax in regular strings. +- Don't use the then property. +- Don't use unsafe negation. +- Don't use var. +- Don't use with statements in non-strict contexts. +- Make sure async functions actually use await. +- Make sure default clauses in switch statements come last. +- Make sure to pass a message value when creating a built-in error. +- Make sure get methods always return a value. +- Use a recommended display strategy with Google Fonts. +- Make sure for-in loops include an if statement. +- Use Array.isArray() instead of instanceof Array. +- Make sure to use the digits argument with Number#toFixed(). +- Make sure to use the "use strict" directive in script files. + +### Next.js Specific Rules +- Don't use `` elements in Next.js projects. +- Don't use `` elements in Next.js projects. +- Don't import next/document outside of pages/_document.jsx in Next.js projects. +- Don't use the next/head module in pages/_document.js on Next.js projects. + +### Testing Best Practices +- Don't use export or module.exports in test files. +- Don't use focused tests. +- Make sure the assertion function, like expect, is placed inside an it() function call. +- Don't use disabled tests. + +## Common Tasks +- `npx ultracite init` - Initialize Ultracite in your project +- `npx ultracite format` - Format and fix code automatically +- `npx ultracite lint` - Check for issues without fixing + +## Example: Error Handling +```typescript +// ✅ Good: Comprehensive error handling +try { + const result = await fetchData(); + return { success: true, data: result }; +} catch (error) { + console.error('API call failed:', error); + return { success: false, error: error.message }; +} + +// ❌ Bad: Swallowing errors +try { + return await fetchData(); +} catch (e) { + console.log(e); +} +``` \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..c598190 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,54 @@ +name: PR Check + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + api-checks: + name: API (Go) checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Install dependencies + run: go mod tidy + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.4.0 + working-directory: api + + + # app-checks: + # name: API (Next.js) checks + # runs-on: ubuntu-latest + # defaults: + # run: + # working-directory: app + # + # steps: + # - uses: actions/checkout@v5 + # - name: Setup Node + # uses: actions/setup-node@v4 + # with: + # node-version: '22' + # cache: 'pnpm' + # + # - name: Install dependencies + # run: pnpm install --frozen-lockfile + # + # - name: Lint + # run: pnpm lint diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..7994861 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,63 @@ +name: Push Check + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + api: + name: Build & Test API + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Install dependencies + run: go mod tidy + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.4.0 + working-directory: api + + + + - name: Build + run: | + go build -o cloudmesh cmd/main.go + go build -o worker cmd/worker/main.go + + # app: + # name: Build App + # runs-on: ubuntu-latest + # defaults: + # run: + # working-directory: app + # + # steps: + # - uses: actions/checkout@v5 + # - name: Setup Node + # uses: actions/setup-node@v4 + # with: + # node-version: '22' + # cache: 'pnpm' + # + # - name: Install dependencies + # run: pnpm install --frozen-lockfile + # + # - name: Lint + # run: pnpm lint + # + # - name: Build + # run: pnpm build diff --git a/.gitignore b/.gitignore index 2eea525..6ed48a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +node_modules diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..6835915 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +make lint +make format diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1043bea --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "emmet.showExpandedAbbreviation": "never", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + } +} \ No newline at end of file diff --git a/api/.golangci.yml b/api/.golangci.yml new file mode 100644 index 0000000..cfdfe1e --- /dev/null +++ b/api/.golangci.yml @@ -0,0 +1,44 @@ +# See the dedicated "version" documentation section. +version: "2" + +run: + timeout: 3m + concurrency: 2 + issues-exit-code: 1 + tests: false + +linters: + enable: + - errcheck + - govet + - staticcheck + - unused + - gosec + - ineffassign + - goconst + - misspell + - unconvert + - wastedassign + - whitespace + settings: + errcheck: + check-type-assertions: true + check-blank: true + disable-default-exclusions: true + exclude-functions: + - github.com/joho/godotenv.Load + - (io.ReadCloser).Close + - (*go.uber.org/zap.Logger).Sync + gosec: + excludes: + - G104 + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + - golines + - swaggo + diff --git a/api/cmd/api/api.go b/api/cmd/api/api.go index 0166b44..4bfea8b 100644 --- a/api/cmd/api/api.go +++ b/api/cmd/api/api.go @@ -82,7 +82,15 @@ func (s *APIServer) Run() error { zap.String("addr", s.addr), ) - return http.ListenAndServe(fmt.Sprintf("%s:%s", s.host, s.addr), r) + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%s", s.host, s.addr), + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return srv.ListenAndServe() } func (s *APIServer) registerRoutes() *chi.Mux { diff --git a/api/cmd/main.go b/api/cmd/main.go index 3a12f66..3f1f6f4 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -14,7 +14,12 @@ func main() { redisClient := db.GetRedisClient() - apiServer := api.NewAPIServer(config.APIConfig.HOST, config.APIConfig.PORT, connPool, redisClient) + apiServer := api.NewAPIServer( + config.APIConfig.HOST, + config.APIConfig.PORT, + connPool, + redisClient, + ) if err := apiServer.Run(); err != nil { config.LOGGER.Fatal("Application terminated", zap.Error(err)) diff --git a/api/cmd/worker/main.go b/api/cmd/worker/main.go index c5fc8ce..e99c797 100644 --- a/api/cmd/worker/main.go +++ b/api/cmd/worker/main.go @@ -11,16 +11,20 @@ import ( func main() { defer config.LOGGER.Sync() + redisAddr := fmt.Sprintf("%s:%s", config.RedisConfig.HOST, config.RedisConfig.PORT) - srv := asynq.NewServer(asynq.RedisClientOpt{Addr: redisAddr, Password: config.RedisConfig.PASS}, asynq.Config{ - Concurrency: config.AsynqConfig.CONCURRENCY, - Queues: map[string]int{ - "critical": 6, - "default": 3, - "low": 1, + srv := asynq.NewServer( + asynq.RedisClientOpt{Addr: redisAddr, Password: config.RedisConfig.PASS}, + asynq.Config{ + Concurrency: config.AsynqConfig.CONCURRENCY, + Queues: map[string]int{ + "critical": 6, + "default": 3, + "low": 1, + }, }, - }) + ) mux := asynq.NewServeMux() mux.HandleFunc(tasks.TypeFileSync, tasks.HandleFileSyncTask) diff --git a/api/go.mod b/api/go.mod index 9067d6c..606baed 100644 --- a/api/go.mod +++ b/api/go.mod @@ -9,12 +9,15 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 + github.com/hibiken/asynq v0.25.1 github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 github.com/kelseyhightower/envconfig v1.4.0 github.com/redis/go-redis/v9 v9.11.0 go.uber.org/zap v1.27.0 golang.org/x/oauth2 v0.30.0 + golang.org/x/sync v0.15.0 + google.golang.org/api v0.240.0 ) require ( @@ -33,7 +36,6 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/hibiken/asynq v0.25.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -48,11 +50,9 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/net v0.41.0 // indirect - golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/api v0.240.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/api/go.sum b/api/go.sum index cde17f2..d81d357 100644 --- a/api/go.sum +++ b/api/go.sum @@ -17,6 +17,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= @@ -38,6 +40,10 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -66,24 +72,27 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= -github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= @@ -92,6 +101,10 @@ go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -116,6 +129,10 @@ golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= +google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= diff --git a/api/pkg/config/env.go b/api/pkg/config/env.go index 512bd99..7ac6f99 100644 --- a/api/pkg/config/env.go +++ b/api/pkg/config/env.go @@ -15,7 +15,7 @@ type APIConfiguration struct { } type AESConfiguration struct { - MASTER_KEY string `envconfig:"AES_MASTER_KEY" required:"true"` + MASTER_KEY string `envconfig:"AES_MASTER_KEY" required:"true"` } type PostgresConfiguration struct { @@ -41,13 +41,13 @@ type RedisConfiguration struct { } type CacheConfiguration struct { - DEFAULT_GOOGLE_CACHE_EXPIRY int `envconfig:"GOOGLE_CACHE_EXPIRY" default:"15"` - DEFAULT_DROPBOX_CACHE_EXPIRY int `envconfig:"DROPBOX_CACHE_EXPIRY" default:"30"` + DEFAULT_GOOGLE_CACHE_EXPIRY int `envconfig:"GOOGLE_CACHE_EXPIRY" default:"15"` + DEFAULT_DROPBOX_CACHE_EXPIRY int `envconfig:"DROPBOX_CACHE_EXPIRY" default:"30"` DEFAULT_MICROSOFT_CACHE_EXPIRY int `envconfig:"MICROSOFT_CACHE_EXPIRY" default:"15"` } type AsynqConfiguration struct { - CONCURRENCY int `envconfig:"ASYNQ_CONCURRENCY" default:"10"` + CONCURRENCY int `envconfig:"ASYNQ_CONCURRENCY" default:"10"` FILE_SYNC_INTERVAL int `envconfig:"ASYNQ_FILE_SYNC_INTERVAL" default:"30"` } @@ -73,7 +73,7 @@ type OAuthConfiguration struct { } type CookieStoreConfiguration struct { - AUTH_KEY string `envconfig:"COOKIE_STORE_AUTH_KEY" required:"true"` + AUTH_KEY string `envconfig:"COOKIE_STORE_AUTH_KEY" required:"true"` ENCRYPTION_KEY string `envconfig:"COOKIE_STORE_ENCRYPTION_KEY" required:"true"` } @@ -93,37 +93,38 @@ func init() { } func loadEnv() { + //nolint:gosec godotenv.Load() if err := envconfig.Process("", &APIConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } if err := envconfig.Process("AES_", &AESConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } if err := envconfig.Process("POSTGRES_", &PostgresConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } if err := envconfig.Process("REDIS_", &RedisConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } if err := envconfig.Process("", &CacheConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } if err := envconfig.Process("ASYNQ_", &AsynqConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } if err := envconfig.Process("", &OAuthConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } if err := envconfig.Process("COOKIE_", &CookieStoreConfig); err != nil { - log.Fatalf("An error occured while loading environment variables: %v", err) + log.Fatalf("An error occurred while loading environment variables: %v", err) } } diff --git a/api/pkg/config/logger.go b/api/pkg/config/logger.go index 3f61740..5482efc 100644 --- a/api/pkg/config/logger.go +++ b/api/pkg/config/logger.go @@ -16,6 +16,7 @@ func init() { } func initializeLogger() { + //nolint:gosec godotenv.Load() encoderCfg := zap.NewProductionEncoderConfig() diff --git a/api/pkg/db/postgres.go b/api/pkg/db/postgres.go index c9b845d..672204e 100644 --- a/api/pkg/db/postgres.go +++ b/api/pkg/db/postgres.go @@ -52,6 +52,7 @@ func pingPostgresConnection(connPool *pgxpool.Pool) error { } defer conn.Release() + return conn.Ping(context.Background()) } diff --git a/api/pkg/db/redis.go b/api/pkg/db/redis.go index ad767a4..6362532 100644 --- a/api/pkg/db/redis.go +++ b/api/pkg/db/redis.go @@ -3,7 +3,6 @@ package db import ( "context" "fmt" - "sync" "github.com/blackmamoth/cloudmesh/pkg/config" @@ -26,15 +25,23 @@ func GetRedisClient() *redis.Client { Password: config.RedisConfig.PASS, DB: config.RedisConfig.DB, OnConnect: func(ctx context.Context, cn *redis.Conn) error { - config.LOGGER.Info("Application connected to Redis Server", zap.Int("db", config.RedisConfig.DB)) + config.LOGGER.Info( + "Application connected to Redis Server", + zap.Int("db", config.RedisConfig.DB), + ) + return nil }, }) if status := redisClient.Ping(context.Background()); status.Err() != nil { - config.LOGGER.Fatal("Application disconnected from Redis Server", zap.Error(status.Err())) + config.LOGGER.Fatal( + "Application disconnected from Redis Server", + zap.Error(status.Err()), + ) } }) + return redisClient } @@ -46,5 +53,6 @@ func GetAsynqClient() *asynq.Client { DB: config.RedisConfig.DB, }) }) + return asyncqclient } diff --git a/api/pkg/handlers/account.go b/api/pkg/handlers/account.go index cc01e7d..d2d53c2 100644 --- a/api/pkg/handlers/account.go +++ b/api/pkg/handlers/account.go @@ -1,7 +1,7 @@ package handlers import ( - "fmt" + "errors" "net/http" "github.com/blackmamoth/cloudmesh/pkg/config" @@ -31,7 +31,10 @@ type AccountDetail struct { UsedStorage int64 `json:"used_storage"` } -func NewAccountHandler(connPool *pgxpool.Pool, authMiddleware *middlewares.AuthMiddleware) *AccountHandler { +func NewAccountHandler( + connPool *pgxpool.Pool, + authMiddleware *middlewares.AuthMiddleware, +) *AccountHandler { return &AccountHandler{ connPool: connPool, authMiddleware: authMiddleware, @@ -49,12 +52,27 @@ func (h *AccountHandler) RegisterRoutes() *chi.Mux { } func (h *AccountHandler) getAccounts(w http.ResponseWriter, r *http.Request) { - userID := r.Context().Value(middlewares.UserKey).(string) + userID, ok := r.Context().Value(middlewares.UserKey).(string) + if !ok { + config.LOGGER.Error("invalid userid", zap.Any("user_id_received", userID)) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + errors.New("could not validate user credentials"), + ) + + return + } conn, err := h.connPool.Acquire(r.Context()) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to process your request, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("failed to process your request, please try again later"), + ) + return } defer conn.Release() @@ -63,8 +81,17 @@ func (h *AccountHandler) getAccounts(w http.ResponseWriter, r *http.Request) { accountDetails, err := queries.GetLinkedAccountsByUserID(r.Context(), userID) if err != nil { - config.LOGGER.Error("failed to fetch account details", zap.String("user_id", userID), zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to process your request, please try again later")) + config.LOGGER.Error( + "failed to fetch account details", + zap.String("user_id", userID), + zap.Error(err), + ) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("failed to process your request, please try again later"), + ) + return } @@ -73,10 +100,21 @@ func (h *AccountHandler) getAccounts(w http.ResponseWriter, r *http.Request) { for _, account := range accountDetails { provider := providers.OAuthProviders[string(account.Provider)] - storageQuota, err := provider.GetStorageQuota(r.Context(), userID, &account.ID, account.AccessToken, account.RefreshToken) + storageQuota, err := provider.GetStorageQuota( + r.Context(), + userID, + &account.ID, + account.AccessToken, + account.RefreshToken, + ) if err != nil { config.LOGGER.Error("failed to get storage quota", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to process your request, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("failed to process your request, please try again later"), + ) + return } @@ -94,8 +132,17 @@ func (h *AccountHandler) getAccounts(w http.ResponseWriter, r *http.Request) { lastSyncedTime, err := queries.GetLatestSyncTimeByUserID(r.Context(), userID) if err != nil { - config.LOGGER.Error("failed to fetch latest sync time", zap.String("user_id", userID), zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to process your request, please try again later")) + config.LOGGER.Error( + "failed to fetch latest sync time", + zap.String("user_id", userID), + zap.Error(err), + ) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("failed to process your request, please try again later"), + ) + return } @@ -112,7 +159,7 @@ func (h *AccountHandler) groupAccountsByProvider( grouped := make(map[string][]AccountDetail) for _, acc := range accounts { - grouped[string(acc.Provider)] = append(grouped[string(acc.Provider)], acc) + grouped[acc.Provider] = append(grouped[acc.Provider], acc) } return grouped diff --git a/api/pkg/handlers/files.go b/api/pkg/handlers/files.go index 3185d00..7be51ee 100644 --- a/api/pkg/handlers/files.go +++ b/api/pkg/handlers/files.go @@ -1,9 +1,8 @@ package handlers import ( + "context" "errors" - "fmt" - "io" "net/http" "github.com/blackmamoth/cloudmesh/pkg/config" @@ -35,13 +34,13 @@ type FilesHandler struct { type GetFilesValidation struct { Provider string `validate:"omitempty,oneof=google dropbox" json:"provider"` - ParentFolder string `validate:"omitempty" json:"parent_folder"` - Search string `validate:"omitempty" json:"search"` - SortOn string `validate:"omitempty" json:"sort_on"` - SortBy string `validate:"omitempty" json:"sort_by"` - Limit int32 `validate:"omitempty" json:"limit"` - Offset int32 `validate:"omitempty" json:"offset"` - ContentSearch bool `validate:"omitempty" json:"content_search"` + ParentFolder string `validate:"omitempty" json:"parent_folder"` + Search string `validate:"omitempty" json:"search"` + SortOn string `validate:"omitempty" json:"sort_on"` + SortBy string `validate:"omitempty" json:"sort_by"` + Limit int32 `validate:"omitempty" json:"limit"` + Offset int32 `validate:"omitempty" json:"offset"` + ContentSearch bool `validate:"omitempty" json:"content_search"` } type UploadFilesValidation struct { @@ -57,9 +56,9 @@ type PermanentDeleteValidation struct { } type CreateFolderValidation struct { - Name string `validate:"required" json:"name"` + Name string `validate:"required" json:"name"` AccountID string `validate:"required,uuid" json:"account_id"` - ParentFolderID string `validate:"omitempty" json:"parent_folder_id"` + ParentFolderID string `validate:"omitempty" json:"parent_folder_id"` } func (v *GetFilesValidation) setDefaults() { @@ -84,7 +83,11 @@ func (v *GetFilesValidation) setDefaults() { } } -func NewFilesHandler(connPool *pgxpool.Pool, authMiddleware *middlewares.AuthMiddleware, fileMiddleware *middlewares.FileMiddleware) *FilesHandler { +func NewFilesHandler( + connPool *pgxpool.Pool, + authMiddleware *middlewares.AuthMiddleware, + fileMiddleware *middlewares.FileMiddleware, +) *FilesHandler { return &FilesHandler{ connPool: connPool, authMiddleware: authMiddleware, @@ -114,19 +117,8 @@ func (h *FilesHandler) RegisterRoutes() *chi.Mux { } func (h *FilesHandler) getFiles(w http.ResponseWriter, r *http.Request) { - var payload GetFilesValidation - - defer r.Body.Close() - - if err := utils.ParseJSON(r, &payload); err != nil && !errors.Is(err, io.EOF) { - config.LOGGER.Error("could not parse json payload", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed")) - return - } - - if err := utils.Validate.Struct(payload); err != nil { - errs := utils.GenerateValidationErrorObject(err.(validator.ValidationErrors), payload) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, errs) + payload, ok := utils.ParseAndValidate[GetFilesValidation](w, r, true) + if !ok { return } @@ -135,12 +127,27 @@ func (h *FilesHandler) getFiles(w http.ResponseWriter, r *http.Request) { conn, err := h.connPool.Acquire(r.Context()) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } defer conn.Release() - userID := r.Context().Value(middlewares.UserKey).(string) + userID, ok := r.Context().Value(middlewares.UserKey).(string) + if !ok { + config.LOGGER.Error("invalid userid", zap.Any("user_id_received", userID)) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + errors.New("could not validate user credentials"), + ) + + return + } providerFileIDs := []string{} @@ -149,18 +156,44 @@ func (h *FilesHandler) getFiles(w http.ResponseWriter, r *http.Request) { if payload.ContentSearch && payload.Search != "" { accounts, err := queries.GetUserAccounts(r.Context(), userID) if err != nil { - config.LOGGER.Error("failed to fetch user accounts from db", zap.Error(err), zap.String("user_id", userID)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + config.LOGGER.Error( + "failed to fetch user accounts from db", + zap.Error(err), + zap.String("user_id", userID), + ) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } for _, account := range accounts { provider := providers.OAuthProviders[string(account.Provider)] - fileIDs, err := provider.SearchByContent(r.Context(), payload.Search, account, conn, queries) + fileIDs, err := provider.SearchByContent( + r.Context(), + payload.Search, + account, + conn, + queries, + ) if err != nil { - config.LOGGER.Error("failed to search for files by content", zap.String("provider", string(account.Provider)), zap.String("user_id", userID), zap.String("account_id", account.ID.String()), zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + config.LOGGER.Error( + "failed to search for files by content", + zap.String("provider", string(account.Provider)), + zap.String("user_id", userID), + zap.String("account_id", account.ID.String()), + zap.Error(err), + ) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -181,19 +214,36 @@ func (h *FilesHandler) getFiles(w http.ResponseWriter, r *http.Request) { }) if err != nil { config.LOGGER.Error("failed to fetch files", zap.String("user_id", userID), zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("we could not fetch your files details, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("we could not fetch your files details, please try again later"), + ) + return } - totalFileCount, err := queries.CountFilesWithFilters(r.Context(), repository.CountFilesWithFiltersParams{ - UserID: userID, - ParentFolder: db.PGTextField(payload.ParentFolder), - Provider: repository.ProviderEnum(payload.Provider), - Search: payload.Search, - }) + totalFileCount, err := queries.CountFilesWithFilters( + r.Context(), + repository.CountFilesWithFiltersParams{ + UserID: userID, + ParentFolder: db.PGTextField(payload.ParentFolder), + Provider: repository.ProviderEnum(payload.Provider), + Search: payload.Search, + }, + ) if err != nil { - config.LOGGER.Error("failed to fetch file counts", zap.String("user_id", userID), zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("we could not fetch your files details, please try again later")) + config.LOGGER.Error( + "failed to fetch file counts", + zap.String("user_id", userID), + zap.Error(err), + ) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("we could not fetch your files details, please try again later"), + ) + return } @@ -204,12 +254,13 @@ func (h *FilesHandler) getFiles(w http.ResponseWriter, r *http.Request) { }) } -func (h *FilesHandler) uploadFilesToProvider(w http.ResponseWriter, r *http.Request) { +func (h *FilesHandler) validateUploadPayload( + r *http.Request, +) (UploadFilesValidation, map[string]string, error) { defer r.Body.Close() if r.Form == nil { - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("`account_id` is required")) - return + return UploadFilesValidation{}, nil, errors.New("`account_id` is required") } payload := UploadFilesValidation{ @@ -217,37 +268,92 @@ func (h *FilesHandler) uploadFilesToProvider(w http.ResponseWriter, r *http.Requ } if err := utils.Validate.Struct(payload); err != nil { - errs := utils.GenerateValidationErrorObject(err.(validator.ValidationErrors), payload) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, errs) + errs := utils.GenerateValidationErrorObject(func() validator.ValidationErrors { + var target validator.ValidationErrors + + _ = errors.As(err, &target) + + return target + }(), payload) + + return UploadFilesValidation{}, errs, nil + } + + return payload, nil, nil +} + +func (h *FilesHandler) getUploadedFiles(ctx context.Context) ([]middlewares.UploadedFile, error) { + files, ok := h.fileMiddleware.GetUploadedFiles(ctx) + if !ok || len(files) == 0 { + return nil, errors.New("no files were found in request context") + } + + return files, nil +} + +func (h *FilesHandler) uploadFilesToProvider(w http.ResponseWriter, r *http.Request) { + payload, validationErrs, err := h.validateUploadPayload(r) + if validationErrs != nil { + utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, validationErrs) + return } - uploadedFiles, ok := h.fileMiddleware.GetUploadedFiles(r.Context()) - if !ok || len(uploadedFiles) == 0 { - config.LOGGER.Warn("no files were found or the request body was malformed") - utils.SendAPIErrorResponse(w, http.StatusBadRequest, fmt.Errorf("no files were found in request context")) + if err != nil { + utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, err) + + return + } + + uploadedFiles, err := h.getUploadedFiles(r.Context()) + if err != nil { + config.LOGGER.Warn(err.Error()) + utils.SendAPIErrorResponse(w, http.StatusBadRequest, err) + return } defer func() { for _, f := range uploadedFiles { - f.File.Close() + if err := f.File.Close(); err != nil { + config.LOGGER.Error("failed to close file", zap.Error(err)) + } } }() - userID := r.Context().Value(middlewares.UserKey).(string) + userID, ok := r.Context().Value(middlewares.UserKey).(string) + if !ok { + config.LOGGER.Error("invalid userid", zap.Any("user_id_received", userID)) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + errors.New("could not validate user credentials"), + ) + + return + } accountID, err := db.PGUUID(payload.AccountID) if err != nil { config.LOGGER.Error("failed to parse UUID", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusBadRequest, fmt.Errorf("invalid account id or UUID")) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + errors.New("invalid account id or UUID"), + ) + return } conn, err := h.connPool.Acquire(r.Context()) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -258,21 +364,46 @@ func (h *FilesHandler) uploadFilesToProvider(w http.ResponseWriter, r *http.Requ AccountID: *accountID, }) if err != nil { - config.LOGGER.Error("failed to fetch auth tokens from db", zap.Error(err), zap.String("user_id", userID), zap.String("account_id", accountID.String())) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + config.LOGGER.Error( + "failed to fetch auth tokens from db", + zap.Error(err), + zap.String("user_id", userID), + zap.String("account_id", accountID.String()), + ) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } provider, ok := providers.OAuthProviders[string(authTokens.Provider)] if !ok { - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, providers.ErrUnsupportedProvider) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + providers.ErrUnsupportedProvider, + ) + return } err = provider.UploadFiles(r.Context(), accountID, conn, queries, authTokens, uploadedFiles) if err != nil { - config.LOGGER.Error("failed to upload files", zap.Error(err), zap.String("user_id", userID), zap.String("account_id", accountID.String())) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("your files could not be uploaded, please try again later")) + config.LOGGER.Error( + "failed to upload files", + zap.Error(err), + zap.String("user_id", userID), + zap.String("account_id", accountID.String()), + ) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("your files could not be uploaded, please try again later"), + ) + return } @@ -280,28 +411,32 @@ func (h *FilesHandler) uploadFilesToProvider(w http.ResponseWriter, r *http.Requ } func (h *FilesHandler) createFolder(w http.ResponseWriter, r *http.Request) { - var payload CreateFolderValidation - - defer r.Body.Close() - - if err := utils.ParseJSON(r, &payload); err != nil && !errors.Is(err, io.EOF) { - config.LOGGER.Error("could not parse json payload", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed")) + payload, ok := utils.ParseAndValidate[CreateFolderValidation](w, r, false) + if !ok { return } - if err := utils.Validate.Struct(payload); err != nil { - errs := utils.GenerateValidationErrorObject(err.(validator.ValidationErrors), payload) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, errs) + userID, ok := r.Context().Value(middlewares.UserKey).(string) + if !ok { + config.LOGGER.Error("invalid userid", zap.Any("user_id_received", userID)) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + errors.New("could not validate user credentials"), + ) + return } - userID := r.Context().Value(middlewares.UserKey).(string) - conn, err := h.connPool.Acquire(r.Context()) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed"), + ) + return } defer conn.Release() @@ -311,16 +446,25 @@ func (h *FilesHandler) createFolder(w http.ResponseWriter, r *http.Request) { accountID, err := db.PGUUID(payload.AccountID) if err != nil { config.LOGGER.Error("failed to parse account uuid", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed")) - return + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed"), + ) + return } if payload.ParentFolderID != "" { parentFolderID, err = db.PGUUID(payload.ParentFolderID) if err != nil { config.LOGGER.Error("failed to parse account uuid", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed"), + ) + return } } @@ -332,23 +476,47 @@ func (h *FilesHandler) createFolder(w http.ResponseWriter, r *http.Request) { AccountID: *accountID, }) if err != nil { - config.LOGGER.Error("failed to retrieve user account", zap.String("account_id", payload.AccountID), zap.String("user_id", userID), zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("your request could not be processed")) + config.LOGGER.Error( + "failed to retrieve user account", + zap.String("account_id", payload.AccountID), + zap.String("user_id", userID), + zap.Error(err), + ) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("your request could not be processed"), + ) + return } parentFolder := providers.ParentFolder{} if parentFolderID.Valid { - syncedItem, err := queries.GetSyncedItemByID(r.Context(), repository.GetSyncedItemByIDParams{ - AccountID: *accountID, - ID: *parentFolderID, - }) + syncedItem, err := queries.GetSyncedItemByID( + r.Context(), + repository.GetSyncedItemByIDParams{ + AccountID: *accountID, + ID: *parentFolderID, + }, + ) if err != nil { - config.LOGGER.Error("failed to retrieve parent folder details", zap.String("account_id", payload.AccountID), zap.String("user_id", userID), zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("your request could not be processed")) + config.LOGGER.Error( + "failed to retrieve parent folder details", + zap.String("account_id", payload.AccountID), + zap.String("user_id", userID), + zap.Error(err), + ) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("your request could not be processed"), + ) + return } + parentFolder.ID = syncedItem.ProviderFileID parentFolder.Path = syncedItem.Path.String } @@ -358,39 +526,53 @@ func (h *FilesHandler) createFolder(w http.ResponseWriter, r *http.Request) { err = provider.CreateFolder(r.Context(), payload.Name, parentFolder, account, conn, *queries) if err != nil { config.LOGGER.Error("failed to create new folder", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusInternalServerError, fmt.Errorf("could not create new folder, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusInternalServerError, + errors.New("could not create new folder, please try again later"), + ) + return } - utils.SendAPIResponse(w, http.StatusOK, map[string]string{"message": "Your folder was successfully created"}) - + utils.SendAPIResponse( + w, + http.StatusOK, + map[string]string{"message": "Your folder was successfully created"}, + ) } +//nolint:dupl func (h *FilesHandler) moveFilesToTrash(w http.ResponseWriter, r *http.Request) { - var payload MoveToTrashValidation - - defer r.Body.Close() - - if err := utils.ParseJSON(r, &payload); err != nil && errors.Is(err, io.EOF) { - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("request body cannot be empty")) + payload, ok := utils.ParseAndValidate[MoveToTrashValidation](w, r, false) + if !ok { return } - if err := utils.Validate.Struct(payload); err != nil { - errs := utils.GenerateValidationErrorObject(err.(validator.ValidationErrors), payload) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, errs) + userID, ok := r.Context().Value(middlewares.UserKey).(string) + if !ok { + config.LOGGER.Error("invalid userid", zap.Any("user_id_received", userID)) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + errors.New("could not validate user credentials"), + ) + return } - userID := r.Context().Value(middlewares.UserKey).(string) - - var fileIds []pgtype.UUID + fileIds := []pgtype.UUID{} for _, fileID := range payload.FileIDs { fileUUID, err := db.PGUUID(fileID) if err != nil { config.LOGGER.Error("failed to parse string into UUID", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("we could not process your request, please try again")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("we could not process your request, please try again"), + ) + return } @@ -400,7 +582,12 @@ func (h *FilesHandler) moveFilesToTrash(w http.ResponseWriter, r *http.Request) conn, err := h.connPool.Acquire(r.Context()) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -413,7 +600,12 @@ func (h *FilesHandler) moveFilesToTrash(w http.ResponseWriter, r *http.Request) }) if err != nil { config.LOGGER.Error("failed to fetch file ids for move to trash action", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -426,7 +618,12 @@ func (h *FilesHandler) moveFilesToTrash(w http.ResponseWriter, r *http.Request) }) if err != nil { config.LOGGER.Error("failed to fetch auth tokens from db", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -436,8 +633,17 @@ func (h *FilesHandler) moveFilesToTrash(w http.ResponseWriter, r *http.Request) err = provider.MoveToTrash(r.Context(), &accountID, conn, queries, authTokens, items) if err != nil { - config.LOGGER.Error("failed to move file ids for move to trash action", zap.Error(err), zap.String("provider", string(providerName))) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + config.LOGGER.Error( + "failed to move file ids for move to trash action", + zap.Error(err), + zap.String("provider", string(providerName)), + ) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } } @@ -447,31 +653,37 @@ func (h *FilesHandler) moveFilesToTrash(w http.ResponseWriter, r *http.Request) }) } +//nolint:dupl func (h *FilesHandler) permanentlyDelete(w http.ResponseWriter, r *http.Request) { - var payload MoveToTrashValidation - - defer r.Body.Close() - - if err := utils.ParseJSON(r, &payload); err != nil && errors.Is(err, io.EOF) { - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("request body cannot be empty")) + payload, ok := utils.ParseAndValidate[PermanentDeleteValidation](w, r, false) + if !ok { return } - if err := utils.Validate.Struct(payload); err != nil { - errs := utils.GenerateValidationErrorObject(err.(validator.ValidationErrors), payload) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, errs) + userID, ok := r.Context().Value(middlewares.UserKey).(string) + if !ok { + config.LOGGER.Error("invalid userid", zap.Any("user_id_received", userID)) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + errors.New("could not validate user credentials"), + ) + return } - userID := r.Context().Value(middlewares.UserKey).(string) - - var fileIds []pgtype.UUID + fileIds := []pgtype.UUID{} for _, fileID := range payload.FileIDs { fileUUID, err := db.PGUUID(fileID) if err != nil { config.LOGGER.Error("failed to parse string into UUID", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("we could not process your request, please try again")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("we could not process your request, please try again"), + ) + return } @@ -481,7 +693,12 @@ func (h *FilesHandler) permanentlyDelete(w http.ResponseWriter, r *http.Request) conn, err := h.connPool.Acquire(r.Context()) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -494,7 +711,12 @@ func (h *FilesHandler) permanentlyDelete(w http.ResponseWriter, r *http.Request) }) if err != nil { config.LOGGER.Error("failed to fetch file ids for move to trash action", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -507,7 +729,12 @@ func (h *FilesHandler) permanentlyDelete(w http.ResponseWriter, r *http.Request) }) if err != nil { config.LOGGER.Error("failed to fetch auth tokens from db", zap.Error(err)) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } @@ -515,10 +742,26 @@ func (h *FilesHandler) permanentlyDelete(w http.ResponseWriter, r *http.Request) provider := providers.OAuthProviders[string(providerName)] - err = provider.PermanentlyDeleteFiles(r.Context(), &accountID, conn, queries, authTokens, items) + err = provider.PermanentlyDeleteFiles( + r.Context(), + &accountID, + conn, + queries, + authTokens, + items, + ) if err != nil { - config.LOGGER.Error("failed to move file ids for move to trash action", zap.Error(err), zap.String("provider", string(providerName))) - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, fmt.Errorf("your request could not be processed, please try again later")) + config.LOGGER.Error( + "failed to move file ids for move to trash action", + zap.Error(err), + zap.String("provider", string(providerName)), + ) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + errors.New("your request could not be processed, please try again later"), + ) + return } } @@ -528,7 +771,9 @@ func (h *FilesHandler) permanentlyDelete(w http.ResponseWriter, r *http.Request) }) } -func (h *FilesHandler) groupFilesByAccountID(files []repository.GetProviderFileIdsRow) map[pgtype.UUID][]repository.GetProviderFileIdsRow { +func (h *FilesHandler) groupFilesByAccountID( + files []repository.GetProviderFileIdsRow, +) map[pgtype.UUID][]repository.GetProviderFileIdsRow { grouped := make(map[pgtype.UUID][]repository.GetProviderFileIdsRow) for _, file := range files { diff --git a/api/pkg/handlers/link.go b/api/pkg/handlers/link.go index 3504fd5..dc18b23 100644 --- a/api/pkg/handlers/link.go +++ b/api/pkg/handlers/link.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" "go.uber.org/zap" + "golang.org/x/oauth2" ) type LinkHandler struct { @@ -73,6 +74,7 @@ func (h *LinkHandler) RegisterRoutes() *chi.Mux { r.Get("/{provider}", h.linkAccount) r.Get("/{provider}/callback", h.linkAccountCallback) + return r } @@ -84,6 +86,7 @@ func (h *LinkHandler) linkAccount(w http.ResponseWriter, r *http.Request) { provider, ok := providers.OAuthProviders[providerName] if !ok { h.errorRedirect(w, r) + return } @@ -91,6 +94,7 @@ func (h *LinkHandler) linkAccount(w http.ResponseWriter, r *http.Request) { if state == "" { h.errorRedirect(w, r) + return } @@ -98,74 +102,199 @@ func (h *LinkHandler) linkAccount(w http.ResponseWriter, r *http.Request) { if err != nil { config.LOGGER.Error("could not deocde base64 encoded state", zap.Error(err)) utils.SendAPIErrorResponse(w, http.StatusBadRequest, err) + return } var oauthState OAuthState + err = json.Unmarshal(decoded, &oauthState) if err != nil { config.LOGGER.Error("failed to unmarshal into OAuthState", zap.Error(err)) h.errorRedirect(w, r) + return } if oauthState.UserID == "" { h.errorRedirect(w, r) + return } if err = h.validateNonce(r.Context(), oauthState.Nonce); err != nil { h.errorRedirect(w, r) + return } consentPageURL, err := provider.GetConsentPageURL(w, r, store, oauthState.UserID) if err != nil { h.errorRedirect(w, r) + return } http.Redirect(w, r, consentPageURL, http.StatusFound) } -func (h *LinkHandler) linkAccountCallback(w http.ResponseWriter, r *http.Request) { - providerName := chi.URLParam(r, "provider") - - providerName = strings.ToLower(providerName) +func (h *LinkHandler) getProviderFromRequest( + w http.ResponseWriter, + r *http.Request, +) (string, providers.Provider, bool) { + providerName := strings.ToLower(chi.URLParam(r, "provider")) provider, ok := providers.OAuthProviders[providerName] if !ok { h.errorRedirect(w, r) + + return "", nil, false + } + + return providerName, provider, true +} + +func (h *LinkHandler) encryptTokens( + w http.ResponseWriter, + r *http.Request, + token *oauth2.Token, +) (string, string, error) { + encAccess, err := utils.Encrypt(token.AccessToken) + if err != nil { + h.errorRedirect(w, r) + + return "", "", err + } + + encRefresh, err := utils.Encrypt(token.RefreshToken) + if err != nil { + h.errorRedirect(w, r) + + return "", "", err + } + + return encAccess, encRefresh, nil +} + +func (h *LinkHandler) linkAccountCallback(w http.ResponseWriter, r *http.Request) { + providerName, provider, ok := h.getProviderFromRequest(w, r) + if !ok { return } token, userId, accountInfo, err := provider.GetToken(w, r, store) if err != nil { - config.LOGGER.Error("err", zap.Error(err)) - h.errorRedirect(w, r) + h.logAndRedirectError(w, r, "GetToken failed", providerName, err) + return } - encryptedAccessToken, err := utils.Encrypt(token.AccessToken) + encryptedAccessToken, encryptedRefreshToken, err := h.encryptTokens(w, r, token) if err != nil { - config.LOGGER.Error("failed to encrypt access token", zap.Error(err)) - h.errorRedirect(w, r) return } - encryptedRefreshToken, err := utils.Encrypt(token.RefreshToken) + conn, err := h.connPool.Acquire(r.Context()) if err != nil { - config.LOGGER.Error("failed to encrypt access token", zap.Error(err)) - h.errorRedirect(w, r) + h.logAndRedirectError(w, r, "failed to acquire connection", providerName, err) + + return + } + defer conn.Release() + + queries := repository.New(conn) + + accountID, successQuery, err := h.upsertAccount( + r.Context(), queries, conn, userId, providerName, accountInfo, + encryptedAccessToken, encryptedRefreshToken, token, + ) + if err != nil { + h.logAndRedirectError(w, r, "account upsert failed", providerName, err) + + return + } + + if err = h.scheduleBackgroundJobs(r.Context(), userId, providerName, accountID, token, queries); err != nil { + h.logAndRedirectError(w, r, "scheduling jobs failed", providerName, err) + return } + http.Redirect(w, r, fmt.Sprintf("%s/linked-accounts?successQuery=%s", + config.APIConfig.FRONTEND_HOST, successQuery), http.StatusFound) +} + +func (h *LinkHandler) errorRedirect(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, config.APIConfig.FRONTEND_HOST+"/error", http.StatusFound) +} + +func (h *LinkHandler) logAndRedirectError( + w http.ResponseWriter, + r *http.Request, + msg, provider string, + err error, +) { + if provider != "" { + config.LOGGER.Error(msg, zap.String("provider", provider), zap.Error(err)) + } else { + config.LOGGER.Error(msg, zap.Error(err)) + } + + h.errorRedirect(w, r) +} + +func (h *LinkHandler) scheduleBackgroundJobs( + ctx context.Context, + userID, providerName, accountID string, + token *oauth2.Token, + queries *repository.Queries, +) error { + asynqClient := db.GetAsynqClient() + + err := h.enqueueFileSyncTaskAndLog(ctx, userID, accountID, providerName, asynqClient, queries) + if err != nil { + config.LOGGER.Error("enqueueFileSyncTaskAndLog failed", zap.Error(err)) + + return err + } + + err = h.enqueueAuthTokenRenewalTaskAndLog( + ctx, + userID, + accountID, + providerName, + time.Duration(token.ExpiresIn), + asynqClient, + queries, + ) + if err != nil { + config.LOGGER.Error("enqueueAuthTokenRenewalTaskAndLog failed", zap.Error(err)) + + return err + } + + return nil +} + +func (h *LinkHandler) upsertAccount( + ctx context.Context, + queries *repository.Queries, + conn *pgxpool.Conn, + userID, providerName string, + accountInfo *providers.UserAccountInfo, + encAccessToken, encRefreshToken string, + token *oauth2.Token, +) (string, string, error) { + var accountID string + + successQuery := "newAccount" + addCountParams := repository.AddAccountDetailsParams{ - UserID: userId, + UserID: userID, Provider: repository.ProviderEnum(providerName), ProviderUserID: accountInfo.ProviderUserID, - AccessToken: encryptedAccessToken, - RefreshToken: encryptedRefreshToken, + AccessToken: encAccessToken, + RefreshToken: encRefreshToken, TokenType: db.PGTextField(token.TokenType), Expiry: db.PGTimestamptzField(token.Expiry), Email: accountInfo.Email, @@ -173,128 +302,83 @@ func (h *LinkHandler) linkAccountCallback(w http.ResponseWriter, r *http.Request AvatarUrl: db.PGTextField(accountInfo.AvatarURL), } - conn, err := h.connPool.Acquire(r.Context()) - if err != nil { - config.LOGGER.Error( - "failed to acquire new connection from connection pool", - zap.String("provider", providerName), - zap.Error(err), - ) - h.errorRedirect(w, r) - return - } - defer conn.Release() - - queries := repository.New(conn) - existingAccountID, err := queries.GetAccountByProviderID( - r.Context(), + ctx, repository.GetAccountByProviderIDParams{ - UserID: userId, + UserID: userID, Provider: repository.ProviderEnum(providerName), ProviderUserID: accountInfo.ProviderUserID, }, ) - var accountID string - successQuery := "newAccount" + if err != nil && !errors.Is(err, sql.ErrNoRows) { + config.LOGGER.Error("failed to fetch existing account details", zap.Error(err)) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - err = utils.WithTransaction(r.Context(), conn, func(tx pgx.Tx) error { - qx := queries.WithTx(tx) - - id, err := qx.AddAccountDetails(r.Context(), addCountParams) - if err != nil { - return err - } + return "", "", err + } - accountID = id.String() + if errors.Is(err, sql.ErrNoRows) { + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { + qx := queries.WithTx(tx) - return nil - }) + id, err := qx.AddAccountDetails(ctx, addCountParams) if err != nil { - config.LOGGER.Error( - "an error occured while inserting account details", - zap.String("provider", providerName), - zap.Error(err), - ) - h.errorRedirect(w, r) - return + return err } - } else { - config.LOGGER.Error("failed to fetch existing account details", zap.Error(err)) - h.errorRedirect(w, r) - return + + accountID = id.String() + + return nil + }) + if err != nil { + config.LOGGER.Error("error inserting account details", + zap.String("provider", providerName), + zap.Error(err)) + + return "", "", err } } else { accountID = existingAccountID.String() - err = utils.WithTransaction(r.Context(), conn, func(tx pgx.Tx) error { - qx := queries.WithTx(tx) - - err := qx.UpdateAuthTokens(r.Context(), repository.UpdateAuthTokensParams{ - AccessToken: encryptedAccessToken, - RefreshToken: encryptedRefreshToken, + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { + return queries.WithTx(tx).UpdateAuthTokens(ctx, repository.UpdateAuthTokensParams{ + AccessToken: encAccessToken, + RefreshToken: encRefreshToken, TokenType: db.PGTextField(token.TokenType), Expiry: db.PGTimestamptzField(token.Expiry), AccountID: existingAccountID, }) - - return err }) if err != nil { - config.LOGGER.Error("an error occured while updating auth tokens", zap.String("provider", providerName), zap.Error(err)) - h.errorRedirect(w, r) - return + config.LOGGER.Error("error updating auth tokens", + zap.String("provider", providerName), + zap.Error(err)) + + return "", "", err } successQuery = "existingAccount" } - asynqClient := db.GetAsynqClient() - - if err = h.enqueueFileSyncTaskAndLog(r.Context(), userId, accountID, providerName, asynqClient, queries); err != nil { - config.LOGGER.Error("enqueueFileSyncTaskAndLog failed", zap.Error(err)) - h.errorRedirect(w, r) - return - } - - if err = h.enqueueAuthTokenRenewalTaskAndLog(r.Context(), userId, accountID, providerName, time.Duration(token.ExpiresIn), asynqClient, queries); err != nil { - config.LOGGER.Error("enqueueAuthTokenRenewalTaskAndLog failed", zap.Error(err)) - h.errorRedirect(w, r) - return - } - - http.Redirect( - w, - r, - fmt.Sprintf( - "%s/linked-accounts?successQuery=%s", - config.APIConfig.FRONTEND_HOST, - successQuery, - ), - http.StatusFound, - ) -} - -func (h *LinkHandler) errorRedirect(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, fmt.Sprintf("%s/error", config.APIConfig.FRONTEND_HOST), http.StatusFound) + return accountID, successQuery, nil } func (h *LinkHandler) validateNonce(ctx context.Context, nonce string) error { - key := fmt.Sprintf("link-nonce:%s", nonce) + key := "link-nonce:" + nonce + val, err := h.redisClient.Get(ctx, key).Result() if err != nil { - if err == redis.Nil { - return fmt.Errorf("nonce not found or expired") + if errors.Is(err, redis.Nil) { + return errors.New("nonce not found or expired") } + config.LOGGER.Error("error querying redis for nonce", zap.Error(err)) + return err } if val != nonce { - return fmt.Errorf("nonce mismatch") + return errors.New("nonce mismatch") } if delErr := h.redisClient.Del(ctx, key).Err(); delErr != nil { @@ -318,6 +402,7 @@ func (h *LinkHandler) enqueueFileSyncTaskAndLog( zap.String("task_type", tasks.TypeFileSync), zap.Error(err), ) + return err } @@ -329,6 +414,7 @@ func (h *LinkHandler) enqueueFileSyncTaskAndLog( zap.String("task_type", tasks.TypeFileSync), zap.Error(err), ) + return err } @@ -347,6 +433,7 @@ func (h *LinkHandler) enqueueFileSyncTaskAndLog( zap.String("provider", providerName), zap.Error(err), ) + return nil } @@ -358,6 +445,7 @@ func (h *LinkHandler) enqueueFileSyncTaskAndLog( zap.String("accountID", accountID), zap.Error(err), ) + return nil } @@ -378,6 +466,7 @@ func (h *LinkHandler) enqueueFileSyncTaskAndLog( zap.String("queue", info.Queue), zap.Error(err), ) + return err } @@ -399,6 +488,7 @@ func (h *LinkHandler) enqueueAuthTokenRenewalTaskAndLog( zap.String("task_type", tasks.TypeAuthTokenRenewal), zap.Error(err), ) + return err } @@ -410,6 +500,7 @@ func (h *LinkHandler) enqueueAuthTokenRenewalTaskAndLog( zap.String("task_type", tasks.TypeAuthTokenRenewal), zap.Error(err), ) + return err } @@ -428,6 +519,7 @@ func (h *LinkHandler) enqueueAuthTokenRenewalTaskAndLog( zap.String("provider", providerName), zap.Error(err), ) + return nil } @@ -439,6 +531,7 @@ func (h *LinkHandler) enqueueAuthTokenRenewalTaskAndLog( zap.String("accountID", accountID), zap.Error(err), ) + return nil } @@ -459,6 +552,7 @@ func (h *LinkHandler) enqueueAuthTokenRenewalTaskAndLog( zap.String("queue", info.Queue), zap.Error(err), ) + return err } diff --git a/api/pkg/middlewares/auth.go b/api/pkg/middlewares/auth.go index cc70ca7..d0928a8 100644 --- a/api/pkg/middlewares/auth.go +++ b/api/pkg/middlewares/auth.go @@ -27,7 +27,7 @@ var UserKey userKey = userKey{} var ( ErrNoToken = errors.New("unauthorized, no token") ErrUnauthorized = errors.New("unauthorized") - ErrUnexpected = errors.New("an unexpected error occured, please try again later") + ErrUnexpected = errors.New("an unexpected error occurred, please try again later") ) func NewAuthMiddleware(connPool *pgxpool.Pool, redisClient *redis.Client) *AuthMiddleware { @@ -42,6 +42,7 @@ func (m *AuthMiddleware) VerifyAccessToken(next http.Handler) http.Handler { tokenString := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if tokenString == "" { utils.SendAPIErrorResponse(w, http.StatusUnauthorized, ErrNoToken) + return } @@ -49,15 +50,19 @@ func (m *AuthMiddleware) VerifyAccessToken(next http.Handler) http.Handler { conn, err := m.connPool.Acquire(r.Context()) if err != nil { - config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) + config.LOGGER.Error( + "failed to acquire new connection from connection pool", + zap.Error(err), + ) utils.SendAPIErrorResponse(w, http.StatusInternalServerError, ErrUnexpected) + return } defer conn.Release() publicKeyJWKCache := m.redisClient.Get(r.Context(), "public_key_jwk") if publicKeyJWKCache.Err() != nil { - if publicKeyJWKCache.Err() != redis.Nil { + if !errors.Is(publicKeyJWKCache.Err(), redis.Nil) { config.LOGGER.Warn("could not fetch public key from redis cache", zap.Error(err)) } @@ -65,6 +70,7 @@ func (m *AuthMiddleware) VerifyAccessToken(next http.Handler) http.Handler { if err != nil { config.LOGGER.Error("could not fetch public key from database", zap.Error(err)) utils.SendAPIErrorResponse(w, http.StatusInternalServerError, ErrUnexpected) + return } @@ -77,6 +83,7 @@ func (m *AuthMiddleware) VerifyAccessToken(next http.Handler) http.Handler { if err != nil { config.LOGGER.Error("could not parse jwt token", zap.Error(err)) utils.SendAPIErrorResponse(w, http.StatusUnauthorized, ErrUnauthorized) + return } @@ -84,6 +91,7 @@ func (m *AuthMiddleware) VerifyAccessToken(next http.Handler) http.Handler { if err := m.checkUserExists(r.Context(), conn, userID); err != nil { utils.SendAPIErrorResponse(w, http.StatusUnauthorized, ErrUnauthorized) + return } @@ -94,14 +102,18 @@ func (m *AuthMiddleware) VerifyAccessToken(next http.Handler) http.Handler { r = r.WithContext(ctx) next.ServeHTTP(w, r) - }) } -func (m *AuthMiddleware) checkUserExists(ctx context.Context, conn *pgxpool.Conn, userId string) error { +func (m *AuthMiddleware) checkUserExists( + ctx context.Context, + conn *pgxpool.Conn, + userId string, +) error { queries := repository.New(conn) _, err := queries.GetUserByID(ctx, userId) + return err } diff --git a/api/pkg/middlewares/file.go b/api/pkg/middlewares/file.go index 2260215..e68ee4c 100644 --- a/api/pkg/middlewares/file.go +++ b/api/pkg/middlewares/file.go @@ -2,6 +2,7 @@ package middlewares import ( "context" + "errors" "fmt" "io" "mime/multipart" @@ -37,39 +38,61 @@ func (m *FileMiddleware) CheckFilePayload(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") { utils.SendAPIErrorResponse(w, http.StatusUnsupportedMediaType, - fmt.Errorf("invalid content-type, expected \"multipart/form-data\"")) + errors.New("invalid content-type, expected \"multipart/form-data\"")) + return } r.Body = http.MaxBytesReader(w, r.Body, maxTotalUploadSize) if err := r.ParseMultipartForm(maxTotalUploadSize); err != nil { - utils.SendAPIErrorResponse(w, http.StatusBadRequest, - fmt.Errorf("total upload size too large. Max allowed is %dMB. Error: %v", maxTotalUploadSize/1024/1024, err)) + utils.SendAPIErrorResponse( + w, + http.StatusBadRequest, + fmt.Errorf( + "total upload size too large. Max allowed is %dMB. Error: %w", + maxTotalUploadSize/1024/1024, + err, + ), + ) + return } files := r.MultipartForm.File[fileFieldName] if len(files) == 0 { - utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, - fmt.Errorf("no files found for field '%s'. Please make sure you've uploaded a file(s)", fileFieldName)) + utils.SendAPIErrorResponse( + w, + http.StatusUnprocessableEntity, + fmt.Errorf( + "no files found for field '%s'. Please make sure you've uploaded a file(s)", + fileFieldName, + ), + ) + return } var uploadedFiles []UploadedFile + for _, fileHeader := range files { file, err := fileHeader.Open() if err != nil { config.LOGGER.Error("could not open uploaded file", zap.Error(err)) utils.SendAPIErrorResponse(w, http.StatusInternalServerError, - fmt.Errorf("error opening uploaded file '%s': %v", fileHeader.Filename, err)) + fmt.Errorf("error opening uploaded file '%s': %w", fileHeader.Filename, err)) + return } processedFile, err := m.processAndValidateFile(file, fileHeader) if err != nil { - file.Close() + if err := file.Close(); err != nil { + config.LOGGER.Error("failed to close a file", zap.Error(err)) + } + utils.SendAPIErrorResponse(w, http.StatusUnprocessableEntity, err) + return } @@ -79,6 +102,7 @@ func (m *FileMiddleware) CheckFilePayload(next http.Handler) http.Handler { ctx := context.WithValue(r.Context(), uploadedFilesKey{}, uploadedFiles) neededTime := m.estimateUploadTimeBySize(r) + ctx, cancel := context.WithTimeout(ctx, neededTime) defer cancel() @@ -88,19 +112,33 @@ func (m *FileMiddleware) CheckFilePayload(next http.Handler) http.Handler { }) } -func (m *FileMiddleware) processAndValidateFile(file multipart.File, fileHeader *multipart.FileHeader) (*UploadedFile, error) { +func (m *FileMiddleware) processAndValidateFile( + file multipart.File, + fileHeader *multipart.FileHeader, +) (*UploadedFile, error) { buffer := make([]byte, 512) + _, err := file.Read(buffer) - if err != nil && err != io.EOF { - config.LOGGER.Error("error reading file", zap.String("file_name", fileHeader.Filename), zap.Error(err)) - return nil, fmt.Errorf("error reading file '%s': %v", fileHeader.Filename, err) + if err != nil && !errors.Is(err, io.EOF) { + config.LOGGER.Error( + "error reading file", + zap.String("file_name", fileHeader.Filename), + zap.Error(err), + ) + + return nil, fmt.Errorf("error reading file '%s': %w", fileHeader.Filename, err) } contentType := http.DetectContentType(buffer) if _, err := file.Seek(0, 0); err != nil { - config.LOGGER.Error("error rewinding file", zap.String("file_name", fileHeader.Filename), zap.Error(err)) - return nil, fmt.Errorf("error rewinding file '%s': %v", fileHeader.Filename, err) + config.LOGGER.Error( + "error rewinding file", + zap.String("file_name", fileHeader.Filename), + zap.Error(err), + ) + + return nil, fmt.Errorf("error rewinding file '%s': %w", fileHeader.Filename, err) } return &UploadedFile{ @@ -112,6 +150,7 @@ func (m *FileMiddleware) processAndValidateFile(file multipart.File, fileHeader func (m *FileMiddleware) GetUploadedFiles(ctx context.Context) ([]UploadedFile, bool) { val, ok := ctx.Value(uploadedFilesKey{}).([]UploadedFile) + return val, ok } diff --git a/api/pkg/providers/dropbox.go b/api/pkg/providers/dropbox.go index c0b6002..3b3d454 100644 --- a/api/pkg/providers/dropbox.go +++ b/api/pkg/providers/dropbox.go @@ -125,6 +125,8 @@ type DropboxSpaceUsageResponse struct { const ( DROPBOX_SESSION_NAME = "cloudmesh-dropbox-oauth-session" + DROPBOX_VERIFIER_KEY = "pkce_verifier_dropbox" + DROPBOX_CSRF_KEY = "oauth_csrf_token_dropbox" DROPBOX_PROVIDER_NAME = string(repository.ProviderEnumDropbox) DROPBOX_CONTENT_API_BASE_URL = "https://content.dropboxapi.com" DROPBOX_API_BASE_URL = "https://api.dropboxapi.com" @@ -142,111 +144,69 @@ func NewDropboxProvider() *DropboxProvider { } } -func (p *DropboxProvider) GetConsentPageURL(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore, userID string) (string, error) { - - verifier := oauth2.GenerateVerifier() - - encodedState, oauthState, err := GenerateOauthState(userID) - if err != nil { - config.LOGGER.Error("failed to generated encoded oauthstate", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - session, err := store.Get(r, DROPBOX_SESSION_NAME) - if err != nil { - config.LOGGER.Error("could not get or create session from cookie store", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - session.Values["pkce_verifier_dropbox"] = verifier - session.Values["oauth_csrf_token_dropbox"] = oauthState.CsrfToken - - err = session.Save(r, w) - if err != nil { - config.LOGGER.Error("failed to save session in cookie store", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - url := p.Config.AuthCodeURL(encodedState, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier), oauth2.SetAuthURLParam("prompt", "consent"), oauth2.SetAuthURLParam("token_access_type", "offline")) - - return url, nil +func (p *DropboxProvider) GetConsentPageURL( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, + userID string, +) (string, error) { + return getConsentPageURL( + userID, + DROPBOX_PROVIDER_NAME, + DROPBOX_SESSION_NAME, + DROPBOX_VERIFIER_KEY, + DROPBOX_CSRF_KEY, + w, + r, + &p.Config, + store, + ) } -func (p *DropboxProvider) GetToken(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore) (*oauth2.Token, string, *UserAccountInfo, error) { - - code := r.URL.Query().Get("code") - if code == "" { - return nil, "", nil, ErrNoCode - } - - receivedEncodedState := r.URL.Query().Get("state") - if receivedEncodedState == "" { - return nil, "", nil, ErrNoState - } - - receivedOauthState, err := DecodeOauthState(receivedEncodedState) - if err != nil { - config.LOGGER.Error("failed to decode received state", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) - return nil, "", nil, fmt.Errorf("failed to decode received state") - } - - session, err := store.Get(r, DROPBOX_SESSION_NAME) - if err != nil { - return nil, "", nil, ErrNoSession - } - - storedVerifier, ok := session.Values["pkce_verifier_dropbox"].(string) - if !ok || storedVerifier == "" { - return nil, "", nil, ErrNoVerifier - } - - storedCsrfToken, ok := session.Values["oauth_csrf_token_dropbox"].(string) - if !ok || storedCsrfToken == "" { - return nil, "", nil, ErrNoState - } - - if receivedOauthState.CsrfToken != storedCsrfToken { - return nil, "", nil, ErrInvalidState - } - - delete(session.Values, "pkce_verifier_dropbox") - delete(session.Values, "oauth_csrf_token_dropbox") - err = session.Save(r, w) - if err != nil { - config.LOGGER.Error("failed to cleanup session details", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) - } - - tok, err := p.Config.Exchange(context.Background(), code, oauth2.VerifierOption(storedVerifier)) - if err != nil { - config.LOGGER.Error("token exchange failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) - return nil, "", nil, err - } - - accountInfo, err := p.GetAccountInfo(r.Context(), tok) - if err != nil { - return nil, "", nil, err - } - - return tok, receivedOauthState.UserID, accountInfo, nil +func (p *DropboxProvider) GetToken( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, +) (*oauth2.Token, string, *UserAccountInfo, error) { + return exchangeToken( + r.Context(), + r, + w, + store, + DROPBOX_SESSION_NAME, + DROPBOX_VERIFIER_KEY, + DROPBOX_CSRF_KEY, + &p.Config, + DROPBOX_PROVIDER_NAME, + p.GetAccountInfo, + ) } -func (p *DropboxProvider) GetAccountInfo(ctx context.Context, token *oauth2.Token) (*UserAccountInfo, error) { - +func (p *DropboxProvider) GetAccountInfo( + ctx context.Context, + token *oauth2.Token, +) (*UserAccountInfo, error) { httpClient := http.Client{} - url := fmt.Sprintf("%s/2/users/get_current_account", DROPBOX_API_BASE_URL) + url := DROPBOX_API_BASE_URL + "/2/users/get_current_account" - req, err := http.NewRequest(http.MethodPost, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { config.LOGGER.Error("failed to initiate new HTTP POST request", zap.Error(err)) + return nil, err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("dropbox /get_current_account request failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "dropbox /get_current_account request failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -254,16 +214,25 @@ func (p *DropboxProvider) GetAccountInfo(ctx context.Context, token *oauth2.Toke body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read response body for /get_current_account", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to read response body for /get_current_account", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } var response DropboxAccountInfo err = json.Unmarshal(body, &response) - if err != nil { - config.LOGGER.Error("failed to unmarshal response body for /get_current_account", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to unmarshal response body for /get_current_account", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -278,8 +247,12 @@ func (p *DropboxProvider) GetAccountInfo(ctx context.Context, token *oauth2.Toke return &userInfo, nil } -func (p *DropboxProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, authToken repository.GetAuthTokensRow) error { - +func (p *DropboxProvider) SyncFiles( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + authToken repository.GetAuthTokensRow, +) error { cursor := "" totalItemCount := 0 @@ -287,10 +260,14 @@ func (p *DropboxProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, acc queries := repository.New(conn) syncDetails, err := queries.GetLatestSyncTimeAndPagetoken(ctx, accountID) - if err != nil { if !errors.Is(err, sql.ErrNoRows) { - config.LOGGER.Error("could not fetch timestamp and page token for latest sync", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", accountID.String())) + config.LOGGER.Error( + "could not fetch timestamp and page token for latest sync", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + ) + return err } } @@ -301,37 +278,78 @@ func (p *DropboxProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, acc accessToken, err := utils.Decrypt(authToken.AccessToken) if err != nil { - config.LOGGER.Error("could not decrypt access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", accountID.String())) + config.LOGGER.Error( + "could not decrypt access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + ) + return err } refreshToken, err := utils.Decrypt(authToken.RefreshToken) if err != nil { - config.LOGGER.Error("could not decrypt refresh token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", accountID.String())) + config.LOGGER.Error( + "could not decrypt refresh token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + ) + return err } for { - - dropboxResponse, err := p.getDropboxFolderList(ctx, accountID, conn, accessToken, refreshToken, cursor) - + dropboxResponse, err := p.getDropboxFolderList( + ctx, + accountID, + conn, + accessToken, + refreshToken, + cursor, + ) if err != nil { - config.LOGGER.Error("request failed to fetch dropbox folder list", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "request failed to fetch dropbox folder list", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } - files, providerFileIDs := p.convertToSyncedItemSlice(dropboxResponse.Entries, accountID, syncDetails.LastSyncedAt.Valid) + files, providerFileIDs := p.convertToSyncedItemSlice( + dropboxResponse.Entries, + accountID, + syncDetails.LastSyncedAt.Valid, + ) var insertedRows int64 - insertedRows, err = p.bulkInsertSyncedItems(ctx, conn, *queries, providerFileIDs, accountID, files, dropboxResponse.Cursor) - + insertedRows, err = p.bulkInsertSyncedItems( + ctx, + conn, + *queries, + providerFileIDs, + accountID, + files, + dropboxResponse.Cursor, + ) if err != nil { - config.LOGGER.Error("failed to insert synced files", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to insert synced files", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } - config.LOGGER.Info("batch inserted", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Int64("item_count", insertedRows)) + config.LOGGER.Info( + "batch inserted", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Int64("item_count", insertedRows), + ) totalItemCount += int(insertedRows) @@ -347,53 +365,82 @@ func (p *DropboxProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, acc return nil } -func (p *DropboxProvider) getDropboxFolderList(ctx context.Context, accountID pgtype.UUID, conn *pgxpool.Conn, accessToken, refreshToken, cursor string) (*DropboxListFolderResponse, error) { - - url := fmt.Sprintf("%s/2/files/list_folder", DROPBOX_API_BASE_URL) - reqBody := []byte(`{"path": "", "recursive": true}`) +func (p *DropboxProvider) getDropboxFolderList( + ctx context.Context, + accountID pgtype.UUID, + conn *pgxpool.Conn, + accessToken, refreshToken, cursor string, +) (*DropboxListFolderResponse, error) { + url := DROPBOX_API_BASE_URL + "/2/files/list_folder" + reqBody := []byte(`{"path": "", "recursive": true, "include_deleted": true}`) if cursor != "" { - url = fmt.Sprintf("%s/continue", url) + url = url + "/continue" reqBody = fmt.Appendf(nil, "{\"cursor\": \"%s\"}", cursor) } httpClient := http.Client{} - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) if err != nil { - config.LOGGER.Error("an error occured while generating http request for dropbox sync task", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while generating http request for dropbox sync task", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request for dropbox sync task failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "http request for dropbox sync task failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http response body for dropbox sync task", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to read http response body for dropbox sync task", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } if res.StatusCode == http.StatusUnauthorized { - config.LOGGER.Warn("access token expired, attempting to renew", zap.String("provider", DROPBOX_PROVIDER_NAME)) + config.LOGGER.Warn( + "access token expired, attempting to renew", + zap.String("provider", DROPBOX_PROVIDER_NAME), + ) _, _, err := p.RenewOAuthTokens(ctx, conn, accountID, refreshToken) if err != nil { return nil, err } - return nil, fmt.Errorf("request has failed with status 401, failing task for it to fetch new token from db instead of using the stale token in next request") + return nil, errors.New( + "request has failed with status 401, failing task for it to fetch new token from db instead of using the stale token in next request", + ) } if res.StatusCode != http.StatusOK { - config.LOGGER.Error("http request for dropbox sync task did not return 200", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) + config.LOGGER.Error( + "http request for dropbox sync task did not return 200", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + return nil, fmt.Errorf("%s", string(body[:])) } @@ -404,7 +451,12 @@ func (p *DropboxProvider) getDropboxFolderList(ctx context.Context, accountID pg return &dropboxResponse, err } -func (p *DropboxProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, refreshToken string) (string, int64, error) { +func (p *DropboxProvider) RenewOAuthTokens( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + refreshToken string, +) (string, int64, error) { data := url.Values{} data.Add("grant_type", "refresh_token") @@ -412,37 +464,75 @@ func (p *DropboxProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool.Co data.Add("client_id", p.Config.ClientID) data.Add("client_secret", p.Config.ClientSecret) - url := fmt.Sprintf("%s/oauth2/token", DROPBOX_API_BASE_URL) + url := DROPBOX_API_BASE_URL + "/oauth2/token" - res, err := http.Post(url, "application/x-www-form-urlencoded", bytes.NewBufferString(data.Encode())) + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + url, + bytes.NewBufferString(data.Encode()), + ) + if err != nil { + config.LOGGER.Error( + "failed to create http request for dropbox oauth request", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + + return "", 0, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + httpClient := http.Client{} + + res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request for dropbox token renewal failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) + config.LOGGER.Error( + "http request for dropbox token renewal failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + return "", 0, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http response body for dropbox token renewal", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) + config.LOGGER.Error( + "failed to read http response body for dropbox token renewal", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + return "", 0, err } var dropboxResponse DropboxAuthResponse err = json.Unmarshal(body, &dropboxResponse) - if err != nil { - config.LOGGER.Error("failed to unmarshal dropbox token renew response", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to unmarshal dropbox token renew response", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return "", 0, err } expiresIn := time.Now().Add(time.Duration(dropboxResponse.ExpiresIn) * time.Second) - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { - + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { encryptedAccessToken, err := utils.Encrypt(dropboxResponse.AccessToken) if err != nil { - config.LOGGER.Error("failed to encrypt new access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to encrypt new access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -457,16 +547,25 @@ func (p *DropboxProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool.Co return err }) - if err != nil { - config.LOGGER.Error("failed to update dropbox oauth tokens in db", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to update dropbox oauth tokens in db", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return "", 0, err } return dropboxResponse.AccessToken, int64(dropboxResponse.ExpiresIn), nil } -func (p *DropboxProvider) GetStorageQuota(ctx context.Context, userID string, accountID *pgtype.UUID, encryptedAccessToken, encryptedRefreshToken string) (*StorageQuota, error) { +func (p *DropboxProvider) GetStorageQuota( + ctx context.Context, + userID string, + accountID *pgtype.UUID, + encryptedAccessToken, encryptedRefreshToken string, +) (*StorageQuota, error) { storageQuotaKey := fmt.Sprintf("storage:dropbox:%s:%s", userID, accountID.String()) redisClient := db.GetRedisClient() @@ -474,49 +573,69 @@ func (p *DropboxProvider) GetStorageQuota(ctx context.Context, userID string, ac cachedStorageQuota := redisClient.Get(ctx, storageQuotaKey) if cachedStorageQuota.Err() == nil { - val, err := cachedStorageQuota.Result() if err != nil { - config.LOGGER.Error("failed to get the result from redis cache", zap.String("user_id", userID), zap.String("account_id", accountID.String()), zap.String("provider", DROPBOX_PROVIDER_NAME)) + config.LOGGER.Error( + "failed to get the result from redis cache", + zap.String("user_id", userID), + zap.String("account_id", accountID.String()), + zap.String("provider", DROPBOX_PROVIDER_NAME), + ) } else { - var storageQuota StorageQuota + err = json.Unmarshal([]byte(val), &storageQuota) if err == nil { return &storageQuota, nil } + config.LOGGER.Error("failed to unmarshal storage quota", zap.String("user_id", userID), zap.String("account_id", accountID.String()), zap.String("provider", DROPBOX_PROVIDER_NAME)) } } accessToken, err := utils.Decrypt(encryptedAccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } - url := fmt.Sprintf("%s/2/users/get_space_usage", DROPBOX_API_BASE_URL) + url := DROPBOX_API_BASE_URL + "/2/users/get_space_usage" - req, err := http.NewRequest(http.MethodPost, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { - config.LOGGER.Error("failed create new http request for dropbox space usage endpoint", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed create new http request for dropbox space usage endpoint", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) client := http.Client{} resp, err := client.Do(req) if err != nil { config.LOGGER.Error("http request for dropbox space usage endpoint failed", zap.Error(err)) + return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - config.LOGGER.Error("failed to read response body for space usage request for dropbox", zap.Error(err)) + config.LOGGER.Error( + "failed to read response body for space usage request for dropbox", + zap.Error(err), + ) + return nil, err } @@ -524,7 +643,11 @@ func (p *DropboxProvider) GetStorageQuota(ctx context.Context, userID string, ac err = json.Unmarshal(body, &dropboxResponse) if err != nil { - config.LOGGER.Error("failed to unmarshal http response body for dropbox space usage endpoint", zap.Error(err)) + config.LOGGER.Error( + "failed to unmarshal http response body for dropbox space usage endpoint", + zap.Error(err), + ) + return nil, err } @@ -535,24 +658,46 @@ func (p *DropboxProvider) GetStorageQuota(ctx context.Context, userID string, ac storageQuotaCache, err := json.Marshal(storageQuota) if err != nil { - config.LOGGER.Error("failed to marshal storage quota for caching", zap.String("account_id", accountID.String()), zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to marshal storage quota for caching", + zap.String("account_id", accountID.String()), + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } storageCache := redisClient.Set(ctx, storageQuotaKey, storageQuotaCache, 15*time.Minute) if storageCache.Err() != nil { - config.LOGGER.Error("failed to cache storage quota", zap.String("account_id", accountID.String()), zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to cache storage quota", + zap.String("account_id", accountID.String()), + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) } return &storageQuota, nil } -func (p *DropboxProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, uploadedFiles []middlewares.UploadedFile) error { - +func (p *DropboxProvider) UploadFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + uploadedFiles []middlewares.UploadedFile, +) error { accessToken, err := utils.Decrypt(authTokens.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -565,17 +710,21 @@ func (p *DropboxProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUI for _, f := range uploadedFiles { file := f + g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() - uploadedFile, err := p.uploadToDropbox(accessToken, file) + uploadedFile, err := p.uploadToDropbox(ctx, accessToken, file) if err != nil { return err } mu.Lock() + results = append(results, *uploadedFile) + mu.Unlock() return nil @@ -589,22 +738,35 @@ func (p *DropboxProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUI files, _ := p.convertToSyncedItemSlice(results, *accountID, false) _, err = p.bulkInsertSyncedItems(ctx, conn, *queries, []string{}, *accountID, files, "") - if err != nil { - config.LOGGER.Error("failed to insert newly uploaded files", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to insert newly uploaded files", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } if err := utils.DeleteKeysByPattern(ctx, fmt.Sprintf("search_cache:%s:%s*", DROPBOX_PROVIDER_NAME, accountID.String())); err != nil { - config.LOGGER.Error("failed to delete cache for content search results", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Error(err)) + config.LOGGER.Error( + "failed to delete cache for content search results", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Error(err), + ) } return nil } -func (p *DropboxProvider) uploadToDropbox(accesstoken string, file middlewares.UploadedFile) (*DropboxListFolderEntries, error) { +func (p *DropboxProvider) uploadToDropbox( + ctx context.Context, + accesstoken string, + file middlewares.UploadedFile, +) (*DropboxListFolderEntries, error) { dropboxArgs := map[string]any{ - "path": fmt.Sprintf("/%s", file.FileHeader.Filename), + "path": "/" + file.FileHeader.Filename, "mode": "add", "autorename": false, "mute": false, @@ -612,29 +774,43 @@ func (p *DropboxProvider) uploadToDropbox(accesstoken string, file middlewares.U } argJSON, err := json.Marshal(dropboxArgs) - if err != nil { - config.LOGGER.Error("failed to marshal dropbox args", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to marshal dropbox args", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } - url := fmt.Sprintf("%s/2/files/upload", DROPBOX_CONTENT_API_BASE_URL) + url := DROPBOX_CONTENT_API_BASE_URL + "/2/files/upload" httpClient := http.Client{} - req, err := http.NewRequest("POST", url, file.File) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, file.File) if err != nil { - config.LOGGER.Error("failed to create new request to upload files to dropbox", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to create new request to upload files to dropbox", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accesstoken)) - req.Header.Set("Dropbox-API-Arg", string(argJSON)) + req.Header.Set("Authorization", "Bearer "+accesstoken) + req.Header.Set("Dropbox-Api-Arg", string(argJSON)) req.Header.Set("Content-Type", "application/octet-stream") res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request to upload file to dropbox failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "http request to upload file to dropbox failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -642,31 +818,56 @@ func (p *DropboxProvider) uploadToDropbox(accesstoken string, file middlewares.U body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http response body for dropbox file upload", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to read http response body for dropbox file upload", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } if res.StatusCode != http.StatusOK { - config.LOGGER.Error("http request to upload file to dropbox failed with non-200 status", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) - return nil, fmt.Errorf("http request to upload file to dropbox failed with non-200 status") + config.LOGGER.Error( + "http request to upload file to dropbox failed with non-200 status", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + + return nil, errors.New("http request to upload file to dropbox failed with non-200 status") } var response DropboxListFolderEntries err = json.Unmarshal(body, &response) - if err != nil { - config.LOGGER.Error("failed to unmarshal json response for dropbox file upload response", zap.Error(err)) + config.LOGGER.Error( + "failed to unmarshal json response for dropbox file upload response", + zap.Error(err), + ) + return nil, err } return &response, nil } -func (p *DropboxProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error { +func (p *DropboxProvider) MoveToTrash( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, +) error { accessToken, err := utils.Decrypt(authTokens.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -680,14 +881,17 @@ func (p *DropboxProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUI for _, f := range syncedItemIds { g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() - if err := p.moveToTrash(accessToken, f.Path.String); err != nil { + if err := p.moveToTrash(ctx, accessToken, f.Path.String); err != nil { return err } mu.Lock() + fileIDs = append(fileIDs, f.FileID) + mu.Unlock() return nil @@ -696,10 +900,11 @@ func (p *DropboxProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUI if err := g.Wait(); err != nil { config.LOGGER.Error("failed to move files to trash", zap.Error(err)) + return err } - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) return qx.SetFileTrashed(ctx, repository.SetFileTrashedParams{ @@ -707,19 +912,31 @@ func (p *DropboxProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUI AccountID: *accountID, }) }) - if err != nil { config.LOGGER.Error("failed to set is_trashed to true for file ids", zap.Error(err)) + return err } return nil } -func (p *DropboxProvider) PermanentlyDeleteFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error { +func (p *DropboxProvider) PermanentlyDeleteFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, +) error { accessToken, err := utils.Decrypt(authTokens.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -733,14 +950,17 @@ func (p *DropboxProvider) PermanentlyDeleteFiles(ctx context.Context, accountID for _, f := range syncedItemIds { g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() - if err := p.permanentlyDeleteFile(accessToken, f.Path.String); err != nil { + if err := p.permanentlyDeleteFile(ctx, accessToken, f.Path.String); err != nil { return err } mu.Lock() + fileIDs = append(fileIDs, f.FileID) + mu.Unlock() return nil @@ -749,10 +969,11 @@ func (p *DropboxProvider) PermanentlyDeleteFiles(ctx context.Context, accountID if err := g.Wait(); err != nil { config.LOGGER.Error("failed to move files to trash", zap.Error(err)) + return err } - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) return qx.DeleteSyncedItems(ctx, repository.DeleteSyncedItemsParams{ @@ -760,22 +981,36 @@ func (p *DropboxProvider) PermanentlyDeleteFiles(ctx context.Context, accountID AccountID: *accountID, }) }) - if err != nil { config.LOGGER.Error("failed to set is_trashed to true for file ids", zap.Error(err)) + return err } if err := utils.DeleteKeysByPattern(ctx, fmt.Sprintf("search_cache:%s:%s*", DROPBOX_PROVIDER_NAME, accountID.String())); err != nil { - config.LOGGER.Error("failed to delete cache for content search results", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Error(err)) + config.LOGGER.Error( + "failed to delete cache for content search results", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Error(err), + ) } return nil } -func (p *DropboxProvider) SearchByContent(ctx context.Context, searchText string, account repository.GetUserAccountsRow, conn *pgxpool.Conn, queries *repository.Queries) ([]string, error) { - - searchCacheKey := utils.BuildSearchCacheKey(string(account.Provider), account.ID.String(), searchText) +func (p *DropboxProvider) SearchByContent( + ctx context.Context, + searchText string, + account repository.GetUserAccountsRow, + conn *pgxpool.Conn, + queries *repository.Queries, +) ([]string, error) { + searchCacheKey := utils.BuildSearchCacheKey( + string(account.Provider), + account.ID.String(), + searchText, + ) cachedFileIds, err := utils.GetCachedProviderFileIDs(ctx, searchCacheKey) if err == nil { @@ -784,13 +1019,23 @@ func (p *DropboxProvider) SearchByContent(ctx context.Context, searchText string accessToken, err := utils.Decrypt(account.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } refreshToken, err := utils.Decrypt(account.RefreshToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -799,9 +1044,22 @@ func (p *DropboxProvider) SearchByContent(ctx context.Context, searchText string cursor := "" for { - fileIds, newCursor, err := p.searchContentResults(ctx, conn, account.ID, searchText, accessToken, refreshToken, cursor) + fileIds, newCursor, err := p.searchContentResults( + ctx, + conn, + account.ID, + searchText, + accessToken, + refreshToken, + cursor, + ) if err != nil { - config.LOGGER.Error("dropbox request for content search failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "dropbox request for content search failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -817,14 +1075,23 @@ func (p *DropboxProvider) SearchByContent(ctx context.Context, searchText string expiryTime := config.CacheConfig.DEFAULT_DROPBOX_CACHE_EXPIRY if err := utils.CacheProviderFileIDs(ctx, searchCacheKey, providerFileIDs, time.Duration(expiryTime)*time.Minute); err != nil { - config.LOGGER.Error("failed to cache search results", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", account.ID.String()), zap.Error(err)) + config.LOGGER.Error( + "failed to cache search results", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", account.ID.String()), + zap.Error(err), + ) } return providerFileIDs, nil } -func (p *DropboxProvider) searchContentResults(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, searchText, accessToken, refreshToken, cursor string) ([]string, string, error) { - +func (p *DropboxProvider) searchContentResults( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + searchText, accessToken, refreshToken, cursor string, +) ([]string, string, error) { reqBody := map[string]any{ "match_field_options": map[string]bool{ "include_highlights": false, @@ -838,68 +1105,102 @@ func (p *DropboxProvider) searchContentResults(ctx context.Context, conn *pgxpoo "query": searchText, } - reqUrl := fmt.Sprintf("%s/2/files/search_v2", DROPBOX_API_BASE_URL) + reqUrl := DROPBOX_API_BASE_URL + "/2/files/search_v2" if cursor != "" { reqBody = map[string]any{ "cursor": cursor, } - reqUrl = fmt.Sprintf("%s/2/files/search/continue_v2", DROPBOX_API_BASE_URL) + reqUrl = DROPBOX_API_BASE_URL + "/2/files/search/continue_v2" } jsonBody, err := json.Marshal(reqBody) if err != nil { - config.LOGGER.Error("failed to marshal request body", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to marshal request body", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, "", err } httpClient := http.Client{} - req, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewReader(jsonBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewReader(jsonBody)) if err != nil { - config.LOGGER.Error("failed to initialize new http post request for delete action", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to initialize new http post request for delete action", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, "", err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request for dropbox permanent delete action failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "http request for dropbox permanent delete action failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, "", err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http response body for dropbox search request", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to read http response body for dropbox search request", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, "", err } if res.StatusCode == http.StatusUnauthorized { - config.LOGGER.Warn("access token expired, attempting to renew", zap.String("provider", DROPBOX_PROVIDER_NAME)) + config.LOGGER.Warn( + "access token expired, attempting to renew", + zap.String("provider", DROPBOX_PROVIDER_NAME), + ) _, _, err := p.RenewOAuthTokens(ctx, conn, accountID, refreshToken) if err != nil { return nil, "", err } - return nil, "", fmt.Errorf("request has failed with status 401, failing task for it to fetch new token from db instead of using the stale token in next request") + return nil, "", errors.New( + "request has failed with status 401, failing task for it to fetch new token from db instead of using the stale token in next request", + ) } if res.StatusCode != http.StatusOK { - config.LOGGER.Error("http request for dropbox sync task did not return 200", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) + config.LOGGER.Error( + "http request for dropbox sync task did not return 200", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + return nil, "", fmt.Errorf("%s", string(body[:])) } var dropboxResponse DropboxSearchResponse err = json.Unmarshal(body, &dropboxResponse) - if err != nil { - config.LOGGER.Error("failed to unmarshal request body for http dropbox search request", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to unmarshal request body for http dropbox search request", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return nil, "", err } @@ -912,41 +1213,61 @@ func (p *DropboxProvider) searchContentResults(ctx context.Context, conn *pgxpoo return providerFileIds, dropboxResponse.Cursor, nil } -func (p *DropboxProvider) moveToTrash(accessToken, filePath string) error { +func (p *DropboxProvider) moveToTrash(ctx context.Context, accessToken, filePath string) error { reqBody := fmt.Appendf(nil, "{\"path\": \"%s\"}", filePath) httpClient := http.Client{} - url := fmt.Sprintf("%s/2/files/delete_v2", DROPBOX_API_BASE_URL) + url := DROPBOX_API_BASE_URL + "/2/files/delete_v2" - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) if err != nil { - config.LOGGER.Error("failed to initialize new http post request for delete action", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to initialize new http post request for delete action", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request for dropbox delete action failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "http request for dropbox delete action failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http response body for dropbox sync task", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to read http response body for dropbox sync task", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } if res.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("request has failed with status 401") + return errors.New("request has failed with status 401") } if res.StatusCode != http.StatusOK { - config.LOGGER.Error("http request for dropbox delete action did not return 200", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) + config.LOGGER.Error( + "http request for dropbox delete action did not return 200", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + return fmt.Errorf("%s", string(body[:])) } @@ -957,52 +1278,82 @@ func (p *DropboxProvider) moveToTrash(accessToken, filePath string) error { return err } -func (p *DropboxProvider) permanentlyDeleteFile(accessToken, filePath string) error { +func (p *DropboxProvider) permanentlyDeleteFile( + ctx context.Context, + accessToken, filePath string, +) error { reqBody := fmt.Appendf(nil, "{\"path\": \"%s\"}", filePath) httpClient := http.Client{} - url := fmt.Sprintf("%s/2/files/permanently_delete", DROPBOX_API_BASE_URL) + url := DROPBOX_API_BASE_URL + "/2/files/permanently_delete" - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) if err != nil { - config.LOGGER.Error("failed to initialize new http post request for delete action", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to initialize new http post request for delete action", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request for dropbox permanent delete action failed", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "http request for dropbox permanent delete action failed", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http response body for dropbox sync task", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to read http response body for dropbox sync task", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return err } if res.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("request has failed with status 401") + return errors.New("request has failed with status 401") } if res.StatusCode != http.StatusOK { - config.LOGGER.Error("http request for dropbox permanent delete action did not return 200", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) + config.LOGGER.Error( + "http request for dropbox permanent delete action did not return 200", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + return fmt.Errorf("%s", string(body[:])) } return nil } -func (p *DropboxProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgxpool.Conn, queries repository.Queries, providerFileIDs []string, accountID pgtype.UUID, files []repository.AddSyncedItemsParams, cursor string) (int64, error) { - +func (p *DropboxProvider) bulkInsertSyncedItems( + ctx context.Context, + conn *pgxpool.Conn, + queries repository.Queries, + providerFileIDs []string, + accountID pgtype.UUID, + files []repository.AddSyncedItemsParams, + cursor string, +) (int64, error) { var insertedRowCount int64 - err := utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err := utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) if len(providerFileIDs) > 0 { @@ -1010,15 +1361,19 @@ func (p *DropboxProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgxpo ProviderFileIds: providerFileIDs, AccountID: accountID, }) - if err != nil { - config.LOGGER.Error("an error occured while deleting conflicted files", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while deleting conflicted files", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Error(err), + ) + return err } } insertedRows, err := qx.AddSyncedItems(ctx, files) - if err != nil { return err } @@ -1030,28 +1385,38 @@ func (p *DropboxProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgxpo SyncPageToken: db.PGTextField(cursor), }) }) - if err != nil { - config.LOGGER.Error("failed to bulk insert synced items", zap.String("provider", DROPBOX_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to bulk insert synced items", + zap.String("provider", DROPBOX_PROVIDER_NAME), + zap.Error(err), + ) + return 0, err } return insertedRowCount, nil } -func (p *DropboxProvider) convertToSyncedItemSlice(entries []DropboxListFolderEntries, accountID pgtype.UUID, isValidLastSyncedData bool) ([]repository.AddSyncedItemsParams, []string) { - +func (p *DropboxProvider) convertToSyncedItemSlice( + entries []DropboxListFolderEntries, + accountID pgtype.UUID, + isValidLastSyncedData bool, +) ([]repository.AddSyncedItemsParams, []string) { syncedItems := []repository.AddSyncedItemsParams{} providerFileIDs := []string{} for _, entry := range entries { - ext := filepath.Ext(entry.Name) mimeType := mime.TypeByExtension(ext) parentFolder := path.Dir(entry.PathDisplay) + if entry.Name == "bgnet_usl_c_1.pdf" { + config.LOGGER.Info("is_deleted", zap.String("value", entry.Tag)) + } + syncedItems = append(syncedItems, repository.AddSyncedItemsParams{ AccountID: accountID, ProviderFileID: entry.ID, @@ -1063,6 +1428,7 @@ func (p *DropboxProvider) convertToSyncedItemSlice(entries []DropboxListFolderEn ParentFolder: db.PGTextField(parentFolder), IsFolder: entry.Tag == "folder", ContentHash: db.PGTextField(entry.ContentHash), + IsTrashed: db.PGBool(entry.Tag == "deleted"), CreatedTime: db.PGTimestamptzField(time.Time{}), ModifiedTime: db.PGTimestamptzField(entry.ClientModified), ThumbnailLink: db.PGTextField(""), @@ -1080,8 +1446,14 @@ func (p *DropboxProvider) convertToSyncedItemSlice(entries []DropboxListFolderEn return syncedItems, providerFileIDs } -func (p *DropboxProvider) CreateFolder(ctx context.Context, name string, parentFolder ParentFolder, account repository.GetLinkedAccountRow, conn *pgxpool.Conn, queries repository.Queries) error { - +func (p *DropboxProvider) CreateFolder( + ctx context.Context, + name string, + parentFolder ParentFolder, + account repository.GetLinkedAccountRow, + conn *pgxpool.Conn, + queries repository.Queries, +) error { logFields := []zap.Field{ zap.String("provider", DROPBOX_PROVIDER_NAME), } @@ -1090,31 +1462,40 @@ func (p *DropboxProvider) CreateFolder(ctx context.Context, name string, parentF if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to decrypt access token", logFields...) + return err } parentPath := parentFolder.Path - reqBody := fmt.Appendf(nil, "{\"autorename\":false,\"path\": \"%s\"}", fmt.Sprintf("%s/%s", parentPath, name)) + reqBody := fmt.Appendf( + nil, + "{\"autorename\":false,\"path\": \"%s\"}", + fmt.Sprintf("%s/%s", parentPath, name), + ) - url := fmt.Sprintf("%s/2/files/create_folder_v2", DROPBOX_API_BASE_URL) + url := DROPBOX_API_BASE_URL + "/2/files/create_folder_v2" httpClient := http.Client{} - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to create new request for creating new folder in dropbox", logFields...) + config.LOGGER.Error( + "failed to create new request for creating new folder in dropbox", + logFields...) + return err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") res, err := httpClient.Do(req) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("http request for creating new folder in dropbox failed", logFields...) + return err } defer res.Body.Close() @@ -1122,14 +1503,25 @@ func (p *DropboxProvider) CreateFolder(ctx context.Context, name string, parentF body, err := io.ReadAll(res.Body) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to read http response body for dropbox folder creation request", logFields...) + config.LOGGER.Error( + "failed to read http response body for dropbox folder creation request", + logFields...) + return err } if res.StatusCode != http.StatusOK { - err = fmt.Errorf("http request for creating new folder in dropbox returned a non-ok status code in response") - logFields = append(logFields, zap.Error(err), zap.String("body", string(body)), zap.Int("status_code", res.StatusCode)) + err = errors.New( + "http request for creating new folder in dropbox returned a non-ok status code in response", + ) + logFields = append( + logFields, + zap.Error(err), + zap.String("body", string(body)), + zap.Int("status_code", res.StatusCode), + ) config.LOGGER.Error("http request for creatin new folder in dropbox failed", logFields...) + return err } @@ -1139,6 +1531,7 @@ func (p *DropboxProvider) CreateFolder(ctx context.Context, name string, parentF if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to unmarshal http response body", logFields...) + return err } @@ -1148,8 +1541,7 @@ func (p *DropboxProvider) CreateFolder(ctx context.Context, name string, parentF parentFolderPath := path.Dir(newFolder.PathDisplay) - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { - + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) _, err := qx.AddSyncedItems(ctx, []repository.AddSyncedItemsParams{ @@ -1158,7 +1550,7 @@ func (p *DropboxProvider) CreateFolder(ctx context.Context, name string, parentF ProviderFileID: newFolder.ID, Name: newFolder.Name, Extension: ext, - Size: int64(newFolder.Size), + Size: newFolder.Size, Path: db.PGTextField(newFolder.PathDisplay), MimeType: db.PGTextField(mimeType), ParentFolder: db.PGTextField(parentFolderPath), @@ -1175,12 +1567,11 @@ func (p *DropboxProvider) CreateFolder(ctx context.Context, name string, parentF }) return err - }) - if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to insert metadata for newly created folder", logFields...) + return err } diff --git a/api/pkg/providers/google.go b/api/pkg/providers/google.go index 23625b9..7d6811a 100644 --- a/api/pkg/providers/google.go +++ b/api/pkg/providers/google.go @@ -37,6 +37,8 @@ type GoogleProvider struct { const ( GOOGLE_SESSION_NAME = "cloudmesh-google-oauth-session" + GOOGLE_VERIFIER_KEY = "pkce_verifier_google" + GOOGLE_CSRF_KEY = "oauth_csrf_token_google" GOOGLE_PROVIDER_NAME = string(repository.ProviderEnumGoogle) GOOGLE_AUTH_ENDPOINT = "https://oauth2.googleapis.com/token" ) @@ -60,106 +62,70 @@ func NewGoogleProvider() *GoogleProvider { } } -func (p *GoogleProvider) GetConsentPageURL(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore, userID string) (string, error) { - - verifier := oauth2.GenerateVerifier() - - encodedState, oauthState, err := GenerateOauthState(userID) - if err != nil { - config.LOGGER.Error("failed to generated encoded oauthstate", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - session, err := store.Get(r, GOOGLE_SESSION_NAME) - if err != nil { - config.LOGGER.Error("could not get or create session from cookie store", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - session.Values["pkce_verifier_google"] = verifier - session.Values["oauth_csrf_token_google"] = oauthState.CsrfToken - - err = session.Save(r, w) - if err != nil { - config.LOGGER.Error("failed to save session in cookie store", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - url := p.Config.AuthCodeURL(encodedState, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier), oauth2.SetAuthURLParam("prompt", "consent")) - - return url, nil +func (p *GoogleProvider) GetConsentPageURL( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, + userID string, +) (string, error) { + return getConsentPageURL( + userID, + GOOGLE_PROVIDER_NAME, + GOOGLE_SESSION_NAME, + GOOGLE_VERIFIER_KEY, + GOOGLE_CSRF_KEY, + w, + r, + &p.Config, + store, + ) } -func (p *GoogleProvider) GetToken(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore) (*oauth2.Token, string, *UserAccountInfo, error) { - - code := r.URL.Query().Get("code") - if code == "" { - return nil, "", nil, ErrNoCode - } - - receivedEncodedState := r.URL.Query().Get("state") - if receivedEncodedState == "" { - return nil, "", nil, ErrNoState - } - - receivedOauthState, err := DecodeOauthState(receivedEncodedState) - if err != nil { - config.LOGGER.Error("failed to decode received state", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return nil, "", nil, fmt.Errorf("failed to decode received state") - } - - session, err := store.Get(r, GOOGLE_SESSION_NAME) - if err != nil { - return nil, "", nil, ErrNoSession - } - - storedVerifier, ok := session.Values["pkce_verifier_google"].(string) - if !ok || storedVerifier == "" { - return nil, "", nil, ErrNoVerifier - } - - storedCsrfToken, ok := session.Values["oauth_csrf_token_google"].(string) - if !ok || storedCsrfToken == "" { - return nil, "", nil, ErrNoState - } - - if receivedOauthState.CsrfToken != storedCsrfToken { - return nil, "", nil, ErrInvalidState - } - - delete(session.Values, "pkce_verifier_google") - delete(session.Values, "oauth_csrf_token_google") - err = session.Save(r, w) - if err != nil { - config.LOGGER.Error("failed to cleanup session details", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - } - - tok, err := p.Config.Exchange(context.Background(), code, oauth2.VerifierOption(storedVerifier)) - if err != nil { - config.LOGGER.Error("token exchange failed", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return nil, "", nil, err - } - - accountInfo, err := p.GetAccountInfo(r.Context(), tok) - if err != nil { - return nil, "", nil, err - } - - return tok, receivedOauthState.UserID, accountInfo, nil +func (p *GoogleProvider) GetToken( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, +) (*oauth2.Token, string, *UserAccountInfo, error) { + return exchangeToken( + r.Context(), + r, + w, + store, + GOOGLE_SESSION_NAME, + GOOGLE_VERIFIER_KEY, + GOOGLE_CSRF_KEY, + &p.Config, + GOOGLE_PROVIDER_NAME, + p.GetAccountInfo, + ) } -func (p *GoogleProvider) GetAccountInfo(ctx context.Context, token *oauth2.Token) (*UserAccountInfo, error) { - httpClient := p.GetHTTPClient(token.AccessToken, token.RefreshToken) +func (p *GoogleProvider) GetAccountInfo( + ctx context.Context, + token *oauth2.Token, +) (*UserAccountInfo, error) { + httpClient := p.GetHTTPClient(ctx, token.AccessToken, token.RefreshToken) + svc, err := oauth2Google.NewService(ctx, option.WithHTTPClient(httpClient)) if err != nil { - config.LOGGER.Error("failed to create oauth2 service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return nil, fmt.Errorf("failed to create oauth2 service") + config.LOGGER.Error( + "failed to create oauth2 service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + + return nil, errors.New("failed to create oauth2 service") } userInfo, err := svc.Userinfo.Get().Do() if err != nil { - config.LOGGER.Error("failed to fetch user info", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return nil, fmt.Errorf("failed to fetch user info") + config.LOGGER.Error( + "failed to fetch user info", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + + return nil, errors.New("failed to fetch user info") } userAccountInfo := UserAccountInfo{ @@ -173,25 +139,44 @@ func (p *GoogleProvider) GetAccountInfo(ctx context.Context, token *oauth2.Token return &userAccountInfo, nil } -func (p *GoogleProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, authToken repository.GetAuthTokensRow) error { - +func (p *GoogleProvider) SyncFiles( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + authToken repository.GetAuthTokensRow, +) error { accessToken, err := utils.Decrypt(authToken.AccessToken) if err != nil { - config.LOGGER.Error("could not decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String())) + config.LOGGER.Error( + "could not decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + ) + return err } refreshToken, err := utils.Decrypt(authToken.RefreshToken) if err != nil { - config.LOGGER.Error("could not decrypt refresh token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String())) + config.LOGGER.Error( + "could not decrypt refresh token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + ) + return err } - httpClient := p.GetHTTPClient(accessToken, refreshToken) + httpClient := p.GetHTTPClient(ctx, accessToken, refreshToken) driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -202,22 +187,28 @@ func (p *GoogleProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, acco queries := repository.New(conn) syncDetails, err := queries.GetLatestSyncTimeAndPagetoken(ctx, accountID) - if err != nil { if !errors.Is(err, sql.ErrNoRows) { - config.LOGGER.Error("could not fetch timestamp and page token for latest sync", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String())) + config.LOGGER.Error( + "could not fetch timestamp and page token for latest sync", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + ) + return err } } if syncDetails.LastSyncedAt.Valid { - query = fmt.Sprintf("modifiedTime > '%s'", syncDetails.LastSyncedAt.Time.Format(time.RFC3339)) + query = fmt.Sprintf( + "modifiedTime > '%s'", + syncDetails.LastSyncedAt.Time.Format(time.RFC3339), + ) } totalItemCount := 0 for { - fileList, err := driveService.Files. List(). Q(query). @@ -225,43 +216,72 @@ func (p *GoogleProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, acco PageToken(pageToken). PageSize(1000). Do() - if err != nil { - if gErr, ok := err.(*googleapi.Error); ok { - if gErr.Code == http.StatusUnauthorized { - newAccessToken, _, err := p.RenewOAuthTokens(ctx, conn, accountID, refreshToken) - - if err != nil { - return err - } - - httpClient = p.GetHTTPClient(newAccessToken, refreshToken) - driveService, err = drive.NewService(ctx, option.WithHTTPClient(httpClient)) - if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return err - } - - continue - } + gErr := &googleapi.Error{} + if !errors.As(err, &gErr) || gErr.Code != http.StatusUnauthorized { + config.LOGGER.Error( + "an error occurred while synching google drive files", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + + return err } - config.LOGGER.Error("an error occured while synching google drive files", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return err + newAccessToken, _, err := p.RenewOAuthTokens(ctx, conn, accountID, refreshToken) + if err != nil { + return err + } + + httpClient = p.GetHTTPClient(ctx, newAccessToken, refreshToken) + + driveService, err = drive.NewService(ctx, option.WithHTTPClient(httpClient)) + if err != nil { + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + + return err + } + + continue } - files, providerFileIDs := p.convertToSyncedItemSlice(fileList.Files, accountID, syncDetails.LastSyncedAt.Valid) + files, providerFileIDs := p.convertToSyncedItemSlice( + fileList.Files, + accountID, + syncDetails.LastSyncedAt.Valid, + ) var insertedRows int64 - insertedRows, err = p.bulkInsertSyncedItems(ctx, conn, *queries, providerFileIDs, accountID, files) - + insertedRows, err = p.bulkInsertSyncedItems( + ctx, + conn, + *queries, + providerFileIDs, + accountID, + files, + ) if err != nil { - config.LOGGER.Error("failed to bulk insert synced items", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Error(err)) + config.LOGGER.Error( + "failed to bulk insert synced items", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Error(err), + ) + return err } - config.LOGGER.Info("batch inserted", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Int64("item_count", insertedRows)) + config.LOGGER.Info( + "batch inserted", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Int64("item_count", insertedRows), + ) totalItemCount += int(insertedRows) @@ -277,18 +297,56 @@ func (p *GoogleProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, acco return nil } -func (p *GoogleProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, refreshToken string) (string, int64, error) { - reqUrl := fmt.Sprintf("%s?grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s", GOOGLE_AUTH_ENDPOINT, p.Config.ClientID, p.Config.ClientSecret, refreshToken) +func (p *GoogleProvider) RenewOAuthTokens( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + refreshToken string, +) (string, int64, error) { + reqUrl := fmt.Sprintf( + "%s?grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s", + GOOGLE_AUTH_ENDPOINT, + p.Config.ClientID, + p.Config.ClientSecret, + refreshToken, + ) + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + reqUrl, + nil, + ) // #nosec G107 -- reqUrl is internal, not user-controlled + if err != nil { + config.LOGGER.Error( + "failed to create new http request for google oauth", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + } + + req.Header.Set("Content-Type", "application/json") - res, err := http.Post(reqUrl, "application/json", nil) + httpClient := http.Client{} + + res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request for google token renewal failed", zap.String("provider", GOOGLE_PROVIDER_NAME)) + config.LOGGER.Error( + "http request for google token renewal failed", + zap.String("provider", GOOGLE_PROVIDER_NAME), + ) + return "", 0, err } body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http response body for google token renewal", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Int("status_code", res.StatusCode)) + config.LOGGER.Error( + "failed to read http response body for google token renewal", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Int("status_code", res.StatusCode), + ) + return "", 0, err } @@ -297,15 +355,24 @@ func (p *GoogleProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool.Con var googleAuthResponse GoogleAuthResponse if err := json.Unmarshal(body, &googleAuthResponse); err != nil { - config.LOGGER.Error("failed to unmarshal dropbox token renew response", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to unmarshal dropbox token renew response", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return "", 0, err } - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { - + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { encryptedAccessToken, err := utils.Encrypt(googleAuthResponse.AccessToken) if err != nil { - config.LOGGER.Error("failed to encrypt new access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to encrypt new access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -319,23 +386,30 @@ func (p *GoogleProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool.Con return err }) - if err != nil { - config.LOGGER.Error("failed to update google oauth tokens in db", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to update google oauth tokens in db", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return "", 0, err } return googleAuthResponse.AccessToken, googleAuthResponse.ExpiresIn, nil } -func (p *GoogleProvider) GetHTTPClient(accessToken, refreshToken string) *http.Client { +func (p *GoogleProvider) GetHTTPClient( + ctx context.Context, + accessToken, refreshToken string, +) *http.Client { token := &oauth2.Token{AccessToken: accessToken, RefreshToken: refreshToken} - tokenSource := p.Config.TokenSource(context.Background(), token) + tokenSource := p.Config.TokenSource(ctx, token) reusableTokenSource := oauth2.ReuseTokenSource(token, tokenSource) - return oauth2.NewClient(context.Background(), reusableTokenSource) + return oauth2.NewClient(ctx, reusableTokenSource) } func (p *GoogleProvider) GetStorageQuota( @@ -351,44 +425,70 @@ func (p *GoogleProvider) GetStorageQuota( cachedStorageQuota := redisClient.Get(ctx, storageQuotaKey) if cachedStorageQuota.Err() == nil { - val, err := cachedStorageQuota.Result() if err != nil { - config.LOGGER.Error("failed to get the result from redis cache", zap.String("user_id", userID), zap.String("account_id", accountID.String()), zap.String("provider", GOOGLE_PROVIDER_NAME)) + config.LOGGER.Error( + "failed to get the result from redis cache", + zap.String("user_id", userID), + zap.String("account_id", accountID.String()), + zap.String("provider", GOOGLE_PROVIDER_NAME), + ) } else { - var storageQuota StorageQuota + err = json.Unmarshal([]byte(val), &storageQuota) if err == nil { return &storageQuota, nil } + config.LOGGER.Error("failed to unmarshal storage quota", zap.String("user_id", userID), zap.String("account_id", accountID.String()), zap.String("provider", GOOGLE_PROVIDER_NAME)) } } accessToken, err := utils.Decrypt(encryptedAccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } refreshToken, err := utils.Decrypt(encryptedRefreshToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } - httpClient := p.GetHTTPClient(accessToken, refreshToken) + httpClient := p.GetHTTPClient(ctx, accessToken, refreshToken) driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } about, err := driveService.About.Get().Fields("storageQuota").Do() if err != nil { - config.LOGGER.Error("failed to get storage quota from google drive service", zap.String("account_id", accountID.String()), zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to get storage quota from google drive service", + zap.String("account_id", accountID.String()), + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -399,38 +499,70 @@ func (p *GoogleProvider) GetStorageQuota( storageQuotaCache, err := json.Marshal(storageQuota) if err != nil { - config.LOGGER.Error("failed to marshal storage quota for caching", zap.String("account_id", accountID.String()), zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to marshal storage quota for caching", + zap.String("account_id", accountID.String()), + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } storageCache := redisClient.Set(ctx, storageQuotaKey, storageQuotaCache, 15*time.Minute) if storageCache.Err() != nil { - config.LOGGER.Error("failed to cache storage quota", zap.String("account_id", accountID.String()), zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to cache storage quota", + zap.String("account_id", accountID.String()), + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) } return &storageQuota, nil } -func (p *GoogleProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, uploadedFiles []middlewares.UploadedFile) error { - +func (p *GoogleProvider) UploadFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + uploadedFiles []middlewares.UploadedFile, +) error { accessToken, err := utils.Decrypt(authTokens.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } refreshToken, err := utils.Decrypt(authTokens.RefreshToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } - httpClient := p.GetHTTPClient(accessToken, refreshToken) + httpClient := p.GetHTTPClient(ctx, accessToken, refreshToken) driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -443,8 +575,10 @@ func (p *GoogleProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUID for _, f := range uploadedFiles { file := f + g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() uploadedFile, err := p.uploadToDrive(driveService, file) @@ -453,7 +587,9 @@ func (p *GoogleProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUID } mu.Lock() + results = append(results, uploadedFile) + mu.Unlock() return nil @@ -467,37 +603,68 @@ func (p *GoogleProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUID files, _ := p.convertToSyncedItemSlice(results, *accountID, false) _, err = p.bulkInsertSyncedItems(ctx, conn, *queries, []string{}, *accountID, files) - if err != nil { - config.LOGGER.Error("failed to insert newly uploaded files", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to insert newly uploaded files", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } if err := utils.DeleteKeysByPattern(ctx, fmt.Sprintf("search_cache:%s:%s*", GOOGLE_PROVIDER_NAME, accountID.String())); err != nil { - config.LOGGER.Error("failed to delete cache for content search results", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Error(err)) + config.LOGGER.Error( + "failed to delete cache for content search results", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Error(err), + ) } return nil } -func (p *GoogleProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error { +func (p *GoogleProvider) MoveToTrash( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, +) error { accessToken, err := utils.Decrypt(authTokens.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } refreshToken, err := utils.Decrypt(authTokens.RefreshToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } - httpClient := p.GetHTTPClient(accessToken, refreshToken) + httpClient := p.GetHTTPClient(ctx, accessToken, refreshToken) driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -511,6 +678,7 @@ func (p *GoogleProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUID for _, f := range syncedItemIds { g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() if err := p.moveToTrash(driveService, f.ProviderFileID); err != nil { @@ -518,7 +686,9 @@ func (p *GoogleProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUID } mu.Lock() + fileIDs = append(fileIDs, f.FileID) + mu.Unlock() return nil @@ -527,10 +697,11 @@ func (p *GoogleProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUID if err := g.Wait(); err != nil { config.LOGGER.Error("failed to move files to trash", zap.Error(err)) + return err } - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) return qx.SetFileTrashed(ctx, repository.SetFileTrashedParams{ @@ -538,33 +709,55 @@ func (p *GoogleProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUID AccountID: *accountID, }) }) - if err != nil { config.LOGGER.Error("failed to set is_trashed to true for file ids", zap.Error(err)) + return err } return nil } -func (p *GoogleProvider) PermanentlyDeleteFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error { +func (p *GoogleProvider) PermanentlyDeleteFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, +) error { accessToken, err := utils.Decrypt(authTokens.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } refreshToken, err := utils.Decrypt(authTokens.RefreshToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } - httpClient := p.GetHTTPClient(accessToken, refreshToken) + httpClient := p.GetHTTPClient(ctx, accessToken, refreshToken) driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return err } @@ -578,6 +771,7 @@ func (p *GoogleProvider) PermanentlyDeleteFiles(ctx context.Context, accountID * for _, f := range syncedItemIds { g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() if err := p.permanentlyDeleteFile(driveService, f.ProviderFileID); err != nil { @@ -585,7 +779,9 @@ func (p *GoogleProvider) PermanentlyDeleteFiles(ctx context.Context, accountID * } mu.Lock() + fileIDs = append(fileIDs, f.FileID) + mu.Unlock() return nil @@ -594,10 +790,11 @@ func (p *GoogleProvider) PermanentlyDeleteFiles(ctx context.Context, accountID * if err := g.Wait(); err != nil { config.LOGGER.Error("failed to move files to trash", zap.Error(err)) + return err } - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) return qx.DeleteSyncedItems(ctx, repository.DeleteSyncedItemsParams{ @@ -605,22 +802,36 @@ func (p *GoogleProvider) PermanentlyDeleteFiles(ctx context.Context, accountID * AccountID: *accountID, }) }) - if err != nil { config.LOGGER.Error("failed to set is_trashed to true for file ids", zap.Error(err)) + return err } if err := utils.DeleteKeysByPattern(ctx, fmt.Sprintf("search_cache:%s:%s*", GOOGLE_PROVIDER_NAME, accountID.String())); err != nil { - config.LOGGER.Error("failed to delete cache for content search results", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Error(err)) + config.LOGGER.Error( + "failed to delete cache for content search results", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Error(err), + ) } return nil } -func (p *GoogleProvider) SearchByContent(ctx context.Context, searchText string, account repository.GetUserAccountsRow, conn *pgxpool.Conn, queries *repository.Queries) ([]string, error) { - - searchCacheKey := utils.BuildSearchCacheKey(string(account.Provider), account.ID.String(), searchText) +func (p *GoogleProvider) SearchByContent( + ctx context.Context, + searchText string, + account repository.GetUserAccountsRow, + conn *pgxpool.Conn, + queries *repository.Queries, +) ([]string, error) { + searchCacheKey := utils.BuildSearchCacheKey( + string(account.Provider), + account.ID.String(), + searchText, + ) cachedFileIds, err := utils.GetCachedProviderFileIDs(ctx, searchCacheKey) if err == nil { @@ -629,22 +840,36 @@ func (p *GoogleProvider) SearchByContent(ctx context.Context, searchText string, accessToken, err := utils.Decrypt(account.AccessToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } refreshToken, err := utils.Decrypt(account.RefreshToken) if err != nil { - config.LOGGER.Error("failed to decrypt access token", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to decrypt access token", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } - httpClient := p.GetHTTPClient(accessToken, refreshToken) + httpClient := p.GetHTTPClient(ctx, accessToken, refreshToken) driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) - if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -662,24 +887,48 @@ func (p *GoogleProvider) SearchByContent(ctx context.Context, searchText string, PageToken(pageToken). PageSize(1000). Do() + if err != nil { + gErr := &googleapi.Error{} + if !errors.As(err, &gErr) { + config.LOGGER.Error( + "failed to fetch file metadata from google drive", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + + return nil, err + } + + if gErr.Code != http.StatusUnauthorized { + config.LOGGER.Error( + "failed to fetch file metadata from google drive", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Int("status_code", gErr.Code), + zap.Error(err), + ) - if gErr, ok := err.(*googleapi.Error); ok { - if gErr.Code == http.StatusUnauthorized { - newAccessToken, _, err := p.RenewOAuthTokens(ctx, conn, account.ID, refreshToken) + return nil, err + } + + newAccessToken, _, err := p.RenewOAuthTokens(ctx, conn, account.ID, refreshToken) + if err != nil { + return nil, err + } - if err != nil { - return nil, err - } + httpClient = p.GetHTTPClient(ctx, newAccessToken, refreshToken) - httpClient = p.GetHTTPClient(newAccessToken, refreshToken) - driveService, err = drive.NewService(ctx, option.WithHTTPClient(httpClient)) - if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return nil, err - } + driveService, err = drive.NewService(ctx, option.WithHTTPClient(httpClient)) + if err != nil { + config.LOGGER.Error( + "an error occurred while initializing google drive service", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) - continue + return nil, err } + + continue } for _, file := range fileList.Files { @@ -691,27 +940,42 @@ func (p *GoogleProvider) SearchByContent(ctx context.Context, searchText string, } pageToken = fileList.NextPageToken - } expiryTime := config.CacheConfig.DEFAULT_GOOGLE_CACHE_EXPIRY if err := utils.CacheProviderFileIDs(ctx, searchCacheKey, providerFileIDs, time.Duration(expiryTime)*time.Minute); err != nil { - config.LOGGER.Error("failed to cache search results", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", account.ID.String()), zap.Error(err)) + config.LOGGER.Error( + "failed to cache search results", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", account.ID.String()), + zap.Error(err), + ) } return providerFileIDs, nil } -func (p *GoogleProvider) uploadToDrive(service *drive.Service, file middlewares.UploadedFile) (*drive.File, error) { +func (p *GoogleProvider) uploadToDrive( + service *drive.Service, + file middlewares.UploadedFile, +) (*drive.File, error) { mimeType := file.ContentType fileMeta := &drive.File{Name: file.FileHeader.Filename} - uploadedFile, err := service.Files.Create(fileMeta).Media(file.File, googleapi.ContentType(mimeType)).Do() + uploadedFile, err := service.Files.Create(fileMeta). + Media(file.File, googleapi.ContentType(mimeType)). + Do() if err != nil { - config.LOGGER.Error("upload failed", zap.String("file", file.FileHeader.Filename), zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) - return nil, fmt.Errorf("upload failed for file '%s': %v", file.FileHeader.Filename, err) + config.LOGGER.Error( + "upload failed", + zap.String("file", file.FileHeader.Filename), + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + + return nil, fmt.Errorf("upload failed for file '%s': %w", file.FileHeader.Filename, err) } return uploadedFile, nil @@ -731,13 +995,15 @@ func (p *GoogleProvider) permanentlyDeleteFile(service *drive.Service, fileId st return service.Files.Delete(fileId).Do() } -func (p *GoogleProvider) convertToSyncedItemSlice(files []*drive.File, accountID pgtype.UUID, isValidLastSyncedData bool) ([]repository.AddSyncedItemsParams, []string) { - +func (p *GoogleProvider) convertToSyncedItemSlice( + files []*drive.File, + accountID pgtype.UUID, + isValidLastSyncedData bool, +) ([]repository.AddSyncedItemsParams, []string) { syncedItems := []repository.AddSyncedItemsParams{} providerFileIDs := []string{} for _, file := range files { - parsedCreatedTime, err := time.Parse(time.RFC3339, file.CreatedTime) if err != nil { parsedCreatedTime = time.Time{} @@ -788,13 +1054,19 @@ func (p *GoogleProvider) convertToSyncedItemSlice(files []*drive.File, accountID } return syncedItems, providerFileIDs - } -func (p *GoogleProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgxpool.Conn, queries repository.Queries, providerFileIDs []string, accountID pgtype.UUID, files []repository.AddSyncedItemsParams) (int64, error) { - +func (p *GoogleProvider) bulkInsertSyncedItems( + ctx context.Context, + conn *pgxpool.Conn, + queries repository.Queries, + providerFileIDs []string, + accountID pgtype.UUID, + files []repository.AddSyncedItemsParams, +) (int64, error) { var insertedRowCount int64 - err := utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + + err := utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) if len(providerFileIDs) > 0 { @@ -802,15 +1074,19 @@ func (p *GoogleProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgxpoo ProviderFileIds: providerFileIDs, AccountID: accountID, }) - if err != nil { - config.LOGGER.Error("an error occured while deleting conflicted files", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.String("account_id", accountID.String()), zap.Error(err)) + config.LOGGER.Error( + "an error occurred while deleting conflicted files", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.String("account_id", accountID.String()), + zap.Error(err), + ) + return err } } insertedRows, err := qx.AddSyncedItems(ctx, files) - if err != nil { return err } @@ -822,16 +1098,27 @@ func (p *GoogleProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgxpoo AccountID: accountID, }) }) - if err != nil { - config.LOGGER.Error("failed to bulk insert synced item", zap.String("provider", GOOGLE_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to bulk insert synced item", + zap.String("provider", GOOGLE_PROVIDER_NAME), + zap.Error(err), + ) + return 0, err } return insertedRowCount, nil } -func (p *GoogleProvider) CreateFolder(ctx context.Context, name string, parentFolder ParentFolder, account repository.GetLinkedAccountRow, conn *pgxpool.Conn, queries repository.Queries) error { +func (p *GoogleProvider) CreateFolder( + ctx context.Context, + name string, + parentFolder ParentFolder, + account repository.GetLinkedAccountRow, + conn *pgxpool.Conn, + queries repository.Queries, +) error { logFields := []zap.Field{ zap.String("provider", GOOGLE_PROVIDER_NAME), } @@ -839,20 +1126,25 @@ func (p *GoogleProvider) CreateFolder(ctx context.Context, name string, parentFo accessToken, err := utils.Decrypt(account.AccessToken) if err != nil { config.LOGGER.Error("failed to decrypt access token", logFields...) + return err } refreshToken, err := utils.Decrypt(account.RefreshToken) if err != nil { config.LOGGER.Error("failed to decrypt access token", logFields...) + return err } - httpClient := p.GetHTTPClient(accessToken, refreshToken) + httpClient := p.GetHTTPClient(ctx, accessToken, refreshToken) driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) if err != nil { - config.LOGGER.Error("an error occured while initializing google drive service", logFields...) + config.LOGGER.Error( + "an error occurred while initializing google drive service", + logFields...) + return err } @@ -866,10 +1158,13 @@ func (p *GoogleProvider) CreateFolder(ctx context.Context, name string, parentFo driveFolder.Parents = append(driveFolder.Parents, parentFolder.ID) } - folder, err := driveService.Files.Create(&driveFolder).Fields("id, name, size, mimeType, createdTime, modifiedTime, thumbnailLink, fullFileExtension, parents, webViewLink, webContentLink, iconLink, sha256Checksum, trashed").Do() + folder, err := driveService.Files.Create(&driveFolder). + Fields("id, name, size, mimeType, createdTime, modifiedTime, thumbnailLink, fullFileExtension, parents, webViewLink, webContentLink, iconLink, sha256Checksum, trashed"). + Do() if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to create new folder in google drive", logFields...) + return err } @@ -885,8 +1180,7 @@ func (p *GoogleProvider) CreateFolder(ctx context.Context, name string, parentFo parsedModifiedTime = time.Time{} } - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { - + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) _, err := qx.AddSyncedItems(ctx, []repository.AddSyncedItemsParams{ @@ -912,12 +1206,11 @@ func (p *GoogleProvider) CreateFolder(ctx context.Context, name string, parentFo }) return err - }) - if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to insert metadata for newly created folder", logFields...) + return err } diff --git a/api/pkg/providers/init.go b/api/pkg/providers/init.go index de60c12..3c270a9 100644 --- a/api/pkg/providers/init.go +++ b/api/pkg/providers/init.go @@ -8,12 +8,14 @@ import ( "fmt" "net/http" + "github.com/blackmamoth/cloudmesh/pkg/config" "github.com/blackmamoth/cloudmesh/pkg/middlewares" "github.com/blackmamoth/cloudmesh/repository" "github.com/google/uuid" "github.com/gorilla/sessions" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + "go.uber.org/zap" "golang.org/x/oauth2" ) @@ -41,17 +43,75 @@ type ParentFolder struct { } type Provider interface { - GetConsentPageURL(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore, userID string) (string, error) - GetToken(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore) (*oauth2.Token, string, *UserAccountInfo, error) + GetConsentPageURL( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, + userID string, + ) (string, error) + GetToken( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, + ) (*oauth2.Token, string, *UserAccountInfo, error) GetAccountInfo(ctx context.Context, token *oauth2.Token) (*UserAccountInfo, error) - SyncFiles(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, authToken repository.GetAuthTokensRow) error - RenewOAuthTokens(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, refreshToken string) (string, int64, error) - UploadFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, uploadedFiles []middlewares.UploadedFile) error - MoveToTrash(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error - PermanentlyDeleteFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error - SearchByContent(ctx context.Context, searchText string, account repository.GetUserAccountsRow, conn *pgxpool.Conn, queries *repository.Queries) ([]string, error) - GetStorageQuota(ctx context.Context, userID string, accountID *pgtype.UUID, encryptedAccessToken, encryptedRefreshToken string) (*StorageQuota, error) - CreateFolder(ctx context.Context, name string, parentFolder ParentFolder, account repository.GetLinkedAccountRow, conn *pgxpool.Conn, queries repository.Queries) error + SyncFiles( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + authToken repository.GetAuthTokensRow, + ) error + RenewOAuthTokens( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + refreshToken string, + ) (string, int64, error) + UploadFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + uploadedFiles []middlewares.UploadedFile, + ) error + MoveToTrash( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, + ) error + PermanentlyDeleteFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, + ) error + SearchByContent( + ctx context.Context, + searchText string, + account repository.GetUserAccountsRow, + conn *pgxpool.Conn, + queries *repository.Queries, + ) ([]string, error) + GetStorageQuota( + ctx context.Context, + userID string, + accountID *pgtype.UUID, + encryptedAccessToken, encryptedRefreshToken string, + ) (*StorageQuota, error) + CreateFolder( + ctx context.Context, + name string, + parentFolder ParentFolder, + account repository.GetLinkedAccountRow, + conn *pgxpool.Conn, + queries repository.Queries, + ) error } var ( @@ -82,22 +142,162 @@ func GenerateOauthState(userID string) (string, *OAuthState, error) { jsonData, err := json.Marshal(state) if err != nil { - return "", nil, fmt.Errorf("failed to marshal OAuth state: %v", err) + return "", nil, fmt.Errorf("failed to marshal OAuth state: %w", err) } encodedState := base64.URLEncoding.EncodeToString(jsonData) + return encodedState, state, nil } func DecodeOauthState(encodedState string) (*OAuthState, error) { decodedData, err := base64.URLEncoding.DecodeString(encodedState) if err != nil { - return nil, fmt.Errorf("failed to base64 decode OAuth state: %v", err) + return nil, fmt.Errorf("failed to base64 decode OAuth state: %w", err) } var state OAuthState if err := json.Unmarshal(decodedData, &state); err != nil { - return nil, fmt.Errorf("failed to unmarshal OAuth state JSON: %v", err) + return nil, fmt.Errorf("failed to unmarshal OAuth state JSON: %w", err) } + return &state, nil } + +func getConsentPageURL( + userID, providerName, sessionName, verifierKey, csrfTokenKey string, + w http.ResponseWriter, + r *http.Request, + oauthConfig *oauth2.Config, + store *sessions.CookieStore, +) (string, error) { + verifier := oauth2.GenerateVerifier() + + encodedState, oauthState, err := GenerateOauthState(userID) + if err != nil { + config.LOGGER.Error( + "failed to generated encoded oauthstate", + zap.String("provider", providerName), + zap.Error(err), + ) + + return "", err + } + + session, err := store.Get(r, sessionName) + if err != nil { + config.LOGGER.Error( + "could not get or create session from cookie store", + zap.String("provider", providerName), + zap.Error(err), + ) + + return "", err + } + + session.Values[verifierKey] = verifier + session.Values[csrfTokenKey] = oauthState.CsrfToken + + err = session.Save(r, w) + if err != nil { + config.LOGGER.Error( + "failed to save session in cookie store", + zap.String("provider", providerName), + zap.Error(err), + ) + + return "", err + } + + url := oauthConfig.AuthCodeURL( + encodedState, + oauth2.AccessTypeOffline, + oauth2.S256ChallengeOption(verifier), + oauth2.SetAuthURLParam("prompt", "consent"), + ) + + return url, nil +} + +func exchangeToken( + ctx context.Context, + r *http.Request, + w http.ResponseWriter, + store *sessions.CookieStore, + sessionName string, + verifierKey string, + csrfTokenKey string, + oauthConfig *oauth2.Config, + providerName string, + getAccountInfo func(ctx context.Context, tok *oauth2.Token) (*UserAccountInfo, error), +) (*oauth2.Token, string, *UserAccountInfo, error) { + code := r.URL.Query().Get("code") + if code == "" { + return nil, "", nil, ErrNoCode + } + + receivedEncodedState := r.URL.Query().Get("state") + if receivedEncodedState == "" { + return nil, "", nil, ErrNoState + } + + receivedOauthState, err := DecodeOauthState(receivedEncodedState) + if err != nil { + config.LOGGER.Error( + "failed to decode received state", + zap.String("provider", providerName), + zap.Error(err), + ) + + return nil, "", nil, errors.New("failed to decode received state") + } + + session, err := store.Get(r, sessionName) + if err != nil { + return nil, "", nil, ErrNoSession + } + + storedVerifier, ok := session.Values[verifierKey].(string) + if !ok || storedVerifier == "" { + return nil, "", nil, ErrNoVerifier + } + + storedCsrfToken, ok := session.Values[csrfTokenKey].(string) + if !ok || storedCsrfToken == "" { + return nil, "", nil, ErrNoState + } + + if receivedOauthState.CsrfToken != storedCsrfToken { + return nil, "", nil, ErrInvalidState + } + + delete(session.Values, verifierKey) + delete(session.Values, csrfTokenKey) + + err = session.Save(r, w) + if err != nil { + config.LOGGER.Error( + "failed to cleanup session details", + zap.String("provider", providerName), + zap.Error(err), + ) + } + + tok, err := oauthConfig.Exchange(ctx, code, oauth2.VerifierOption(storedVerifier)) + if err != nil { + config.LOGGER.Error( + "token exchange failed", + zap.String("provider", providerName), + zap.Error(err), + ) + + return nil, "", nil, err + } + + accountInfo, err := getAccountInfo(ctx, tok) + if err != nil { + return nil, "", nil, err + } + + return tok, receivedOauthState.UserID, accountInfo, nil +} diff --git a/api/pkg/providers/microsoft.go b/api/pkg/providers/microsoft.go index a95f0a3..324659f 100644 --- a/api/pkg/providers/microsoft.go +++ b/api/pkg/providers/microsoft.go @@ -181,6 +181,8 @@ type MicrosoftGetDriveItemsResponse struct { const ( MICROSOFT_SESSION_NAME = "cloudmesh-microsoft-oauth-session" + MICROSOFT_VERIFIER_KEY = "pkce_verifier_microsoft" + MICROSOFT_CSRF_KEY = "oauth_csrf_token_microsoft" MICROSOFT_PROVIDER_NAME = string(repository.ProviderEnumMicrosoft) MICROSOFT_GRAPH_API_BASE_URL = "https://graph.microsoft.com/v1.0" MICROSOFT_OAUTH_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/token" @@ -198,121 +200,97 @@ func NewMicrosoftProvider() *MicrosoftProvider { } } -func (p *MicrosoftProvider) GetConsentPageURL(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore, userID string) (string, error) { - verifier := oauth2.GenerateVerifier() - - encodedState, oauthState, err := GenerateOauthState(userID) - if err != nil { - config.LOGGER.Error("failed to generated encoded oauthstate", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - session, err := store.Get(r, MICROSOFT_SESSION_NAME) - if err != nil { - config.LOGGER.Error("could not get or create session from cookie store", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - session.Values["pkce_verifier_microsoft"] = verifier - session.Values["oauth_csrf_token_microsoft"] = oauthState.CsrfToken - - err = session.Save(r, w) - if err != nil { - config.LOGGER.Error("failed to save session in cookie store", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) - return "", err - } - - url := p.Config.AuthCodeURL(encodedState, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier), oauth2.SetAuthURLParam("prompt", "consent")) - - return url, nil +func (p *MicrosoftProvider) GetConsentPageURL( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, + userID string, +) (string, error) { + return getConsentPageURL( + userID, + MICROSOFT_PROVIDER_NAME, + MICROSOFT_SESSION_NAME, + MICROSOFT_VERIFIER_KEY, + MICROSOFT_CSRF_KEY, + w, + r, + &p.Config, + store, + ) } -func (p *MicrosoftProvider) GetToken(w http.ResponseWriter, r *http.Request, store *sessions.CookieStore) (*oauth2.Token, string, *UserAccountInfo, error) { - code := r.URL.Query().Get("code") - if code == "" { - return nil, "", nil, ErrNoCode - } - - receivedEncodedState := r.URL.Query().Get("state") - if receivedEncodedState == "" { - return nil, "", nil, ErrNoState - } - - receivedOauthState, err := DecodeOauthState(receivedEncodedState) - if err != nil { - config.LOGGER.Error("failed to decode received state", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) - return nil, "", nil, fmt.Errorf("failed to decode received state") - } - - session, err := store.Get(r, MICROSOFT_SESSION_NAME) - if err != nil { - return nil, "", nil, ErrNoSession - } - - storedVerifier, ok := session.Values["pkce_verifier_microsoft"].(string) - if !ok || storedVerifier == "" { - return nil, "", nil, ErrNoVerifier - } - - storedCsrfToken, ok := session.Values["oauth_csrf_token_microsoft"].(string) - if !ok || storedCsrfToken == "" { - return nil, "", nil, ErrNoState - } - - if receivedOauthState.CsrfToken != storedCsrfToken { - return nil, "", nil, ErrInvalidState - } - - delete(session.Values, "pkce_verifier_microsoft") - delete(session.Values, "oauth_csrf_token_microsoft") - err = session.Save(r, w) - if err != nil { - config.LOGGER.Error("failed to cleanup session details", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) - } - - tok, err := p.Config.Exchange(context.Background(), code, oauth2.VerifierOption(storedVerifier)) - if err != nil { - config.LOGGER.Error("token exchange failed", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) - return nil, "", nil, err - } - - accountInfo, err := p.GetAccountInfo(r.Context(), tok) - if err != nil { - return nil, "", nil, err - } - - return tok, receivedOauthState.UserID, accountInfo, nil +func (p *MicrosoftProvider) GetToken( + w http.ResponseWriter, + r *http.Request, + store *sessions.CookieStore, +) (*oauth2.Token, string, *UserAccountInfo, error) { + return exchangeToken( + r.Context(), + r, + w, + store, + MICROSOFT_SESSION_NAME, + MICROSOFT_VERIFIER_KEY, + MICROSOFT_CSRF_KEY, + &p.Config, + MICROSOFT_PROVIDER_NAME, + p.GetAccountInfo, + ) } -func (p *MicrosoftProvider) GetAccountInfo(ctx context.Context, token *oauth2.Token) (*UserAccountInfo, error) { - url := fmt.Sprintf("%s/me", MICROSOFT_GRAPH_API_BASE_URL) +func (p *MicrosoftProvider) GetAccountInfo( + ctx context.Context, + token *oauth2.Token, +) (*UserAccountInfo, error) { + url := MICROSOFT_GRAPH_API_BASE_URL + "/me" - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - config.LOGGER.Error("failed to create request body for microsoft account info request", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to create request body for microsoft account info request", + zap.String("provider", MICROSOFT_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } httpClient := http.Client{} - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) res, err := httpClient.Do(req) if err != nil { - config.LOGGER.Error("http request for getting microsoft account details failed", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "http request for getting microsoft account details failed", + zap.String("provider", MICROSOFT_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - config.LOGGER.Error("failed to read http resonse body for getting microsoft account details", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to read http resonse body for getting microsoft account details", + zap.String("provider", MICROSOFT_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } if res.StatusCode != http.StatusOK { err = fmt.Errorf("%s", string(body)) - config.LOGGER.Error("http resonse for getting microsoft account details returned a non 200 resonse", zap.Int("status_code", res.StatusCode), zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "http resonse for getting microsoft account details returned a non 200 resonse", + zap.Int("status_code", res.StatusCode), + zap.String("provider", MICROSOFT_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -320,7 +298,12 @@ func (p *MicrosoftProvider) GetAccountInfo(ctx context.Context, token *oauth2.To err = json.Unmarshal(body, &user) if err != nil { - config.LOGGER.Error("failed to unmarshal microsoft user details", zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.Error(err)) + config.LOGGER.Error( + "failed to unmarshal microsoft user details", + zap.String("provider", MICROSOFT_PROVIDER_NAME), + zap.Error(err), + ) + return nil, err } @@ -335,7 +318,12 @@ func (p *MicrosoftProvider) GetAccountInfo(ctx context.Context, token *oauth2.To return &userAccountInfo, nil } -func (p *MicrosoftProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, authToken repository.GetAuthTokensRow) error { +func (p *MicrosoftProvider) SyncFiles( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + authToken repository.GetAuthTokensRow, +) error { logFields := []zap.Field{ zap.String("account_id", accountID.String()), zap.String("provider", MICROSOFT_PROVIDER_NAME), @@ -351,7 +339,10 @@ func (p *MicrosoftProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, a if err != nil { if !errors.Is(err, sql.ErrNoRows) { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("could not fetch timestamp and page token for latest sync", logFields...) + config.LOGGER.Error( + "could not fetch timestamp and page token for latest sync", + logFields...) + return err } } @@ -364,6 +355,7 @@ func (p *MicrosoftProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, a if err != nil { zap.Error(err) config.LOGGER.Error("could not decrypt access token", logFields...) + return err } @@ -371,14 +363,23 @@ func (p *MicrosoftProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, a if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("could not decrypt refresh token", logFields...) + return err } for { - oneDriveResponse, err := p.getOneDriveDeltaFiles(ctx, accountID, conn, accessToken, refreshToken, deltaToken) + oneDriveResponse, err := p.getOneDriveDeltaFiles( + ctx, + accountID, + conn, + accessToken, + refreshToken, + deltaToken, + ) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to retrieve files for onedrive", logFields...) + return err } @@ -386,21 +387,36 @@ func (p *MicrosoftProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, a deltaLink, err := url.Parse(oneDriveResponse.DataDeltaLink) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("could not parse delta link from onedrive get file request", logFields...) + config.LOGGER.Error( + "could not parse delta link from onedrive get file request", + logFields...) logFields = logFields[:len(logFields)-1] } else { deltaToken = deltaLink.Query().Get("token") } } - files, providerFileIDs := p.convertToSyncedItemSlice(oneDriveResponse.Value, accountID, syncDetails.LastSyncedAt.Valid) + files, providerFileIDs := p.convertToSyncedItemSlice( + oneDriveResponse.Value, + accountID, + syncDetails.LastSyncedAt.Valid, + ) var insertedRows int64 - insertedRows, err = p.bulkInsertSyncedItems(ctx, conn, *queries, providerFileIDs, accountID, files, deltaToken) + insertedRows, err = p.bulkInsertSyncedItems( + ctx, + conn, + *queries, + providerFileIDs, + accountID, + files, + deltaToken, + ) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to insert synced files", logFields...) + return err } @@ -413,7 +429,6 @@ func (p *MicrosoftProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, a if oneDriveResponse.DataNextLink == "" { break } - } logFields = append(logFields, zap.Int("item_count", totalItemCount)) @@ -422,13 +437,18 @@ func (p *MicrosoftProvider) SyncFiles(ctx context.Context, conn *pgxpool.Conn, a return nil } -func (p *MicrosoftProvider) getOneDriveDeltaFiles(ctx context.Context, accountID pgtype.UUID, conn *pgxpool.Conn, accessToken, refreshToken, deltaToken string) (*MicrosoftGetDriveItemsResponse, error) { +func (p *MicrosoftProvider) getOneDriveDeltaFiles( + ctx context.Context, + accountID pgtype.UUID, + conn *pgxpool.Conn, + accessToken, refreshToken, deltaToken string, +) (*MicrosoftGetDriveItemsResponse, error) { logFields := []zap.Field{ zap.String("account_id", accountID.String()), zap.String("provider", MICROSOFT_PROVIDER_NAME), } - oneDriveApiURL := fmt.Sprintf("%s/me/drive/root/delta?$top=1000", MICROSOFT_GRAPH_API_BASE_URL) + oneDriveApiURL := MICROSOFT_GRAPH_API_BASE_URL + "/me/drive/root/delta?$top=1000" if deltaToken != "" { oneDriveApiURL = fmt.Sprintf("%s&token=%s", oneDriveApiURL, deltaToken) @@ -436,19 +456,23 @@ func (p *MicrosoftProvider) getOneDriveDeltaFiles(ctx context.Context, accountID httpClient := http.Client{} - req, err := http.NewRequest(http.MethodGet, oneDriveApiURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, oneDriveApiURL, nil) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to create new http request to get one drive files", logFields...) + config.LOGGER.Error( + "failed to create new http request to get one drive files", + logFields...) + return nil, err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) res, err := httpClient.Do(req) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("http request to get onedrive files failed", logFields...) + return nil, err } defer res.Body.Close() @@ -456,7 +480,10 @@ func (p *MicrosoftProvider) getOneDriveDeltaFiles(ctx context.Context, accountID body, err := io.ReadAll(res.Body) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to read http response body for one drive file request", logFields...) + config.LOGGER.Error( + "failed to read http response body for one drive file request", + logFields...) + return nil, err } @@ -464,6 +491,7 @@ func (p *MicrosoftProvider) getOneDriveDeltaFiles(ctx context.Context, accountID err = fmt.Errorf("%s", string(body)) logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("access token expired or invalid", logFields...) + return nil, err } @@ -471,6 +499,7 @@ func (p *MicrosoftProvider) getOneDriveDeltaFiles(ctx context.Context, accountID err = fmt.Errorf("%s", string(body)) logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("access token expired or invalid", logFields...) + return nil, err } @@ -481,7 +510,11 @@ func (p *MicrosoftProvider) getOneDriveDeltaFiles(ctx context.Context, accountID return &oneDriveResponse, err } -func (p *MicrosoftProvider) convertToSyncedItemSlice(items []OneDriveItem, accountID pgtype.UUID, isValidLastSyncedData bool) ([]repository.AddSyncedItemsParams, []string) { +func (p *MicrosoftProvider) convertToSyncedItemSlice( + items []OneDriveItem, + accountID pgtype.UUID, + isValidLastSyncedData bool, +) ([]repository.AddSyncedItemsParams, []string) { syncedItems := []repository.AddSyncedItemsParams{} providerFileIDs := []string{} @@ -503,13 +536,18 @@ func (p *MicrosoftProvider) convertToSyncedItemSlice(items []OneDriveItem, accou parentFolder = item.ParentReference.ID } + path := strings.TrimPrefix(item.ParentReference.Path, "/drive/root:") + if path == "" { + path = "/" + } + syncedItems = append(syncedItems, repository.AddSyncedItemsParams{ AccountID: accountID, ProviderFileID: item.ID, Name: item.Name, Extension: ext, Size: item.Size, - Path: db.PGTextField(item.ParentReference.Path), + Path: db.PGTextField(path), MimeType: db.PGTextField(mimeType), ParentFolder: db.PGTextField(parentFolder), IsFolder: item.Folder != nil, @@ -532,7 +570,15 @@ func (p *MicrosoftProvider) convertToSyncedItemSlice(items []OneDriveItem, accou return syncedItems, providerFileIDs } -func (p *MicrosoftProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgxpool.Conn, queries repository.Queries, providerFileIDs []string, accountID pgtype.UUID, files []repository.AddSyncedItemsParams, cursor string) (int64, error) { +func (p *MicrosoftProvider) bulkInsertSyncedItems( + ctx context.Context, + conn *pgxpool.Conn, + queries repository.Queries, + providerFileIDs []string, + accountID pgtype.UUID, + files []repository.AddSyncedItemsParams, + cursor string, +) (int64, error) { logFields := []zap.Field{ zap.String("provider", MICROSOFT_PROVIDER_NAME), zap.String("account_id", accountID.String()), @@ -540,7 +586,7 @@ func (p *MicrosoftProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgx var insertedRowCount int64 - err := utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err := utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) if len(providerFileIDs) > 0 { err := qx.DeleteConflictingItems(ctx, repository.DeleteConflictingItemsParams{ @@ -549,7 +595,10 @@ func (p *MicrosoftProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgx }) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("an error occured while deleting conflicted files", logFields...) + config.LOGGER.Error( + "an error occurred while deleting conflicted files", + logFields...) + return err } } @@ -569,13 +618,19 @@ func (p *MicrosoftProvider) bulkInsertSyncedItems(ctx context.Context, conn *pgx if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to bulk insert synced items", logFields...) + return 0, err } return insertedRowCount, nil } -func (p *MicrosoftProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool.Conn, accountID pgtype.UUID, refreshToken string) (string, int64, error) { +func (p *MicrosoftProvider) RenewOAuthTokens( + ctx context.Context, + conn *pgxpool.Conn, + accountID pgtype.UUID, + refreshToken string, +) (string, int64, error) { logFields := []zap.Field{ zap.String("account_id", accountID.String()), zap.String("provider", MICROSOFT_PROVIDER_NAME), @@ -588,25 +643,52 @@ func (p *MicrosoftProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool. data.Add("client_id", p.Config.ClientID) data.Add("client_secret", p.Config.ClientSecret) - res, err := http.Post(MICROSOFT_OAUTH_ENDPOINT, "application/x-www-form-urlencoded", bytes.NewBufferString(data.Encode())) + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + MICROSOFT_OAUTH_ENDPOINT, + bytes.NewBufferString(data.Encode()), + ) + if err != nil { + logFields = append(logFields, zap.Error(err)) + config.LOGGER.Error( + "failed to create new request with context for microsoft oauth request", + logFields...) + + return "", 0, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + httpClient := http.Client{} + + res, err := httpClient.Do(req) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("http request for onedrive token renewal failed", logFields...) + return "", 0, err } + defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to read http response body for onedrive token renewal", logFields...) + config.LOGGER.Error( + "failed to read http response body for onedrive token renewal", + logFields...) + return "", 0, err } if res.StatusCode != http.StatusOK { err = fmt.Errorf("%s", string(body)) logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("http request for onedrive token renewal retuned a non-ok response", logFields...) + config.LOGGER.Error( + "http request for onedrive token renewal retuned a non-ok response", + logFields...) + return "", 0, err } @@ -615,17 +697,21 @@ func (p *MicrosoftProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool. err = json.Unmarshal(body, &oneDriveResponse) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to unmarshal onedrive token renewal response body", logFields...) + config.LOGGER.Error( + "failed to unmarshal onedrive token renewal response body", + logFields...) + return "", 0, err } expiresIn := time.Now().Add(time.Duration(oneDriveResponse.ExpiresIn) * time.Second) - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { encryptedAccessToken, err := utils.Encrypt(oneDriveResponse.AccessToken) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to encrypte new access token", logFields...) + return err } @@ -633,6 +719,7 @@ func (p *MicrosoftProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool. if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to encrypte refresh token", logFields...) + return err } @@ -646,17 +733,24 @@ func (p *MicrosoftProvider) RenewOAuthTokens(ctx context.Context, conn *pgxpool. AccountID: accountID, }) }) - if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to update new oauth tokens", logFields...) + return "", 0, nil } return oneDriveResponse.AccessToken, oneDriveResponse.ExpiresIn, nil } -func (p *MicrosoftProvider) UploadFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, uploadedFiles []middlewares.UploadedFile) error { +func (p *MicrosoftProvider) UploadFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + uploadedFiles []middlewares.UploadedFile, +) error { logFields := []zap.Field{ zap.String("account_id", accountID.String()), zap.String("provider", MICROSOFT_PROVIDER_NAME), @@ -666,6 +760,7 @@ func (p *MicrosoftProvider) UploadFiles(ctx context.Context, accountID *pgtype.U if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to decrypt access token", logFields...) + return err } @@ -678,17 +773,21 @@ func (p *MicrosoftProvider) UploadFiles(ctx context.Context, accountID *pgtype.U for _, f := range uploadedFiles { file := f + g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() - uploadedFile, err := p.uploadFiles(accessToken, file) + uploadedFile, err := p.uploadFiles(ctx, accessToken, file) if err != nil { return err } mu.Lock() + results = append(results, *uploadedFile) + mu.Unlock() return nil @@ -701,37 +800,60 @@ func (p *MicrosoftProvider) UploadFiles(ctx context.Context, accountID *pgtype.U files, _ := p.convertToSyncedItemSlice(results, *accountID, false) - insertRowCount, err := p.bulkInsertSyncedItems(ctx, conn, *queries, []string{}, *accountID, files, "") - + insertRowCount, err := p.bulkInsertSyncedItems( + ctx, + conn, + *queries, + []string{}, + *accountID, + files, + "", + ) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to insert newly uploaded files", logFields...) + return err } - config.LOGGER.Info("batch inserted uploaded file entries", zap.Int64("insert_row_count", insertRowCount)) + config.LOGGER.Info( + "batch inserted uploaded file entries", + zap.Int64("insert_row_count", insertRowCount), + ) if err := utils.DeleteKeysByPattern(ctx, fmt.Sprintf("search_cache:%s:%s*", MICROSOFT_GRAPH_API_BASE_URL, accountID.String())); err != nil { config.LOGGER.Error("failed to delete cache for content search results", logFields...) + return err } return nil } -func (p *MicrosoftProvider) uploadFiles(accessToken string, file middlewares.UploadedFile) (*OneDriveItem, error) { +func (p *MicrosoftProvider) uploadFiles( + ctx context.Context, + accessToken string, + file middlewares.UploadedFile, +) (*OneDriveItem, error) { logFields := []zap.Field{ zap.String("provider", MICROSOFT_PROVIDER_NAME), } - url := fmt.Sprintf("%s/me/drive/root:/%s:/content", MICROSOFT_GRAPH_API_BASE_URL, file.FileHeader.Filename) + url := fmt.Sprintf( + "%s/me/drive/root:/%s:/content", + MICROSOFT_GRAPH_API_BASE_URL, + file.FileHeader.Filename, + ) httpClient := http.Client{} - req, err := http.NewRequest(http.MethodPut, url, file.File) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file.File) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to create new request for uploading files to onedrive", logFields...) + config.LOGGER.Error( + "failed to create new request for uploading files to onedrive", + logFields...) + return nil, err } @@ -747,6 +869,7 @@ func (p *MicrosoftProvider) uploadFiles(accessToken string, file middlewares.Upl if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("http request for uploading file to onedrive failed", logFields...) + return nil, err } defer res.Body.Close() @@ -754,31 +877,45 @@ func (p *MicrosoftProvider) uploadFiles(accessToken string, file middlewares.Upl body, err := io.ReadAll(res.Body) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to read http response body for onedrive file upload", logFields...) + config.LOGGER.Error( + "failed to read http response body for onedrive file upload", + logFields...) + return nil, err } if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated { logFields = append(logFields, zap.Int("status_code", res.StatusCode), zap.Error(err)) - config.LOGGER.Error("http request to upload file to onedrive failed with non-ok status", logFields...) - return nil, fmt.Errorf("http request to upload file to onedrive failed with non-ok status") + config.LOGGER.Error( + "http request to upload file to onedrive failed with non-ok status", + logFields...) + + return nil, errors.New("http request to upload file to onedrive failed with non-ok status") } var response OneDriveItem err = json.Unmarshal(body, &response) - if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to unmarshal json response for onedrive file upload response", logFields...) + config.LOGGER.Error( + "failed to unmarshal json response for onedrive file upload response", + logFields...) + return nil, err } return &response, nil } -func (p *MicrosoftProvider) MoveToTrash(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error { - +func (p *MicrosoftProvider) MoveToTrash( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, +) error { logFields := []zap.Field{ zap.String("account_id", accountID.String()), zap.String("provider", MICROSOFT_PROVIDER_NAME), @@ -788,6 +925,7 @@ func (p *MicrosoftProvider) MoveToTrash(ctx context.Context, accountID *pgtype.U if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to decrypt access token", logFields...) + return err } @@ -801,14 +939,17 @@ func (p *MicrosoftProvider) MoveToTrash(ctx context.Context, accountID *pgtype.U for _, f := range syncedItemIds { g.Go(func() error { sem <- struct{}{} + defer func() { <-sem }() - if err := p.moveToTrash(accessToken, f.ProviderFileID); err != nil { + if err := p.moveToTrash(ctx, accessToken, f.ProviderFileID); err != nil { return err } mu.Lock() + fileIDs = append(fileIDs, f.FileID) + mu.Unlock() return nil @@ -818,10 +959,11 @@ func (p *MicrosoftProvider) MoveToTrash(ctx context.Context, accountID *pgtype.U if err := g.Wait(); err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to move files to trash", logFields...) + return err } - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) return qx.SetFileTrashed(ctx, repository.SetFileTrashedParams{ @@ -829,16 +971,17 @@ func (p *MicrosoftProvider) MoveToTrash(ctx context.Context, accountID *pgtype.U AccountID: *accountID, }) }) - if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to set is_trashed to true for file ids", logFields...) + return err } + return nil } -func (p *MicrosoftProvider) moveToTrash(accessToken, fileID string) error { +func (p *MicrosoftProvider) moveToTrash(ctx context.Context, accessToken, fileID string) error { logFields := []zap.Field{ zap.String("provider", MICROSOFT_PROVIDER_NAME), } @@ -847,19 +990,21 @@ func (p *MicrosoftProvider) moveToTrash(accessToken, fileID string) error { url := fmt.Sprintf("%s/me/drive/items/%s", MICROSOFT_GRAPH_API_BASE_URL, fileID) - req, err := http.NewRequest(http.MethodDelete, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("could not create http request to delete onedrive item", logFields...) + return err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) res, err := httpClient.Do(req) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("http request for deleting onedrive item failed", logFields...) + return err } defer res.Body.Close() @@ -867,32 +1012,59 @@ func (p *MicrosoftProvider) moveToTrash(accessToken, fileID string) error { body, err := io.ReadAll(res.Body) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to read http response body for deleting onedrive item request", logFields...) + config.LOGGER.Error( + "failed to read http response body for deleting onedrive item request", + logFields...) + return err } if res.StatusCode != http.StatusNoContent { - err = fmt.Errorf("http request for deleting onedrive item returned a non-no-content response") - logFields = append(logFields, zap.Error(err), zap.Int("status_code", res.StatusCode), zap.String("body", string(body))) + err = errors.New( + "http request for deleting onedrive item returned a non-no-content response", + ) + logFields = append( + logFields, + zap.Error(err), + zap.Int("status_code", res.StatusCode), + zap.String("body", string(body)), + ) config.LOGGER.Error("http request for deleting onedrive item failed", logFields...) + return err } return nil } -func (p *MicrosoftProvider) PermanentlyDeleteFiles(ctx context.Context, accountID *pgtype.UUID, conn *pgxpool.Conn, queries *repository.Queries, authTokens repository.GetAuthTokensRow, syncedItemIds []repository.GetProviderFileIdsRow) error { +func (p *MicrosoftProvider) PermanentlyDeleteFiles( + ctx context.Context, + accountID *pgtype.UUID, + conn *pgxpool.Conn, + queries *repository.Queries, + authTokens repository.GetAuthTokensRow, + syncedItemIds []repository.GetProviderFileIdsRow, +) error { return nil } -func (p *MicrosoftProvider) SearchByContent(ctx context.Context, searchText string, account repository.GetUserAccountsRow, conn *pgxpool.Conn, queries *repository.Queries) ([]string, error) { - +func (p *MicrosoftProvider) SearchByContent( + ctx context.Context, + searchText string, + account repository.GetUserAccountsRow, + conn *pgxpool.Conn, + queries *repository.Queries, +) ([]string, error) { logFields := []zap.Field{ zap.String("account_id", account.ID.String()), zap.String("provider", MICROSOFT_PROVIDER_NAME), } - searchCacheKey := utils.BuildSearchCacheKey(string(account.Provider), account.ID.String(), searchText) + searchCacheKey := utils.BuildSearchCacheKey( + string(account.Provider), + account.ID.String(), + searchText, + ) cachedFileIds, err := utils.GetCachedProviderFileIDs(ctx, searchCacheKey) if err == nil { @@ -903,6 +1075,7 @@ func (p *MicrosoftProvider) SearchByContent(ctx context.Context, searchText stri if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to decrypt access token", logFields...) + return nil, err } @@ -911,10 +1084,11 @@ func (p *MicrosoftProvider) SearchByContent(ctx context.Context, searchText stri nextLink := "" for { - fileIDs, newNextLink, err := p.searchByContent(accessToken, searchText, nextLink) + fileIDs, newNextLink, err := p.searchByContent(ctx, accessToken, searchText, nextLink) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("onedrive content search request failed", logFields...) + return nil, err } @@ -936,8 +1110,9 @@ func (p *MicrosoftProvider) SearchByContent(ctx context.Context, searchText stri return providerFileIDs, nil } -func (p *MicrosoftProvider) searchByContent(accessToken, searchText, nextLink string) ([]string, string, error) { - +func (p *MicrosoftProvider) searchByContent( + ctx context.Context, accessToken, searchText, nextLink string, +) ([]string, string, error) { logFields := []zap.Field{ zap.String("provider", MICROSOFT_PROVIDER_NAME), } @@ -950,19 +1125,23 @@ func (p *MicrosoftProvider) searchByContent(accessToken, searchText, nextLink st httpClient := http.Client{} - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("could not create http request for onedrive content search", logFields...) + config.LOGGER.Error( + "could not create http request for onedrive content search", + logFields...) + return nil, "", err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) res, err := httpClient.Do(req) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("http request for onedrive content search failed", logFields...) + return nil, "", err } defer res.Body.Close() @@ -970,14 +1149,18 @@ func (p *MicrosoftProvider) searchByContent(accessToken, searchText, nextLink st body, err := io.ReadAll(res.Body) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to read http response body of onedrive content search request", logFields...) + config.LOGGER.Error( + "failed to read http response body of onedrive content search request", + logFields...) + return nil, "", err } if res.StatusCode != http.StatusOK { - err = fmt.Errorf("http request for onedrive content search returned a non-ok status code") + err = errors.New("http request for onedrive content search returned a non-ok status code") logFields = append(logFields, zap.Error(err), zap.Int("status_code", res.StatusCode)) config.LOGGER.Error("http request for onedrive content search failed", logFields...) + return nil, "", err } @@ -986,7 +1169,10 @@ func (p *MicrosoftProvider) searchByContent(accessToken, searchText, nextLink st err = json.Unmarshal(body, &response) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to unmarshal response body of onedrive content search request", logFields...) + config.LOGGER.Error( + "failed to unmarshal response body of onedrive content search request", + logFields...) + return nil, "", err } @@ -999,7 +1185,12 @@ func (p *MicrosoftProvider) searchByContent(accessToken, searchText, nextLink st return providerFileIDs, response.NextLink, nil } -func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, accountID *pgtype.UUID, encryptedAccessToken, encryptedRefreshToken string) (*StorageQuota, error) { +func (p *MicrosoftProvider) GetStorageQuota( + ctx context.Context, + userID string, + accountID *pgtype.UUID, + encryptedAccessToken, encryptedRefreshToken string, +) (*StorageQuota, error) { logFields := []zap.Field{ zap.String("user_id", userID), zap.String("account_id", accountID.String()), @@ -1013,18 +1204,18 @@ func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, cachedStorageQuota := redisClient.Get(ctx, storageQuotaKey) if cachedStorageQuota.Err() == nil { - val, err := cachedStorageQuota.Result() if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to get the result from redis cache", logFields...) } else { - var storageQuota StorageQuota + err = json.Unmarshal([]byte(val), &storageQuota) if err == nil { return &storageQuota, nil } + logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to unmarshal storage quota", logFields...) } @@ -1034,18 +1225,19 @@ func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to decrypt access token", logFields...) + return nil, err } - url := fmt.Sprintf("%s/me/drive", MICROSOFT_GRAPH_API_BASE_URL) + url := MICROSOFT_GRAPH_API_BASE_URL + "/me/drive" - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to create new http request to get storage quota", logFields...) } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) httpClient := http.Client{} @@ -1053,6 +1245,7 @@ func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("http request for getting onedrive storage quota failed", logFields...) + return nil, err } defer res.Body.Close() @@ -1061,12 +1254,16 @@ func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to read resonse body of onedrive storage request", logFields...) + return nil, err } if res.StatusCode != http.StatusOK { logFields = append(logFields, zap.Error(fmt.Errorf("%s", string(body)))) - config.LOGGER.Error("http resonse for getting onedrive storage quota returned a non-200 resonse", logFields...) + config.LOGGER.Error( + "http resonse for getting onedrive storage quota returned a non-200 resonse", + logFields...) + return nil, err } @@ -1075,7 +1272,10 @@ func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, err = json.Unmarshal(body, &resonse) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to unmarshal http resonse body for onedrive storage quota request", logFields...) + config.LOGGER.Error( + "failed to unmarshal http resonse body for onedrive storage quota request", + logFields...) + return nil, err } @@ -1088,6 +1288,7 @@ func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to marshal storage quota for caching", logFields...) + return nil, err } @@ -1101,7 +1302,14 @@ func (p *MicrosoftProvider) GetStorageQuota(ctx context.Context, userID string, return &storageQuota, nil } -func (p *MicrosoftProvider) CreateFolder(ctx context.Context, name string, parentFolder ParentFolder, account repository.GetLinkedAccountRow, conn *pgxpool.Conn, queries repository.Queries) error { +func (p *MicrosoftProvider) CreateFolder( + ctx context.Context, + name string, + parentFolder ParentFolder, + account repository.GetLinkedAccountRow, + conn *pgxpool.Conn, + queries repository.Queries, +) error { logFields := []zap.Field{ zap.String("provider", MICROSOFT_PROVIDER_NAME), } @@ -1109,33 +1317,44 @@ func (p *MicrosoftProvider) CreateFolder(ctx context.Context, name string, paren accessToken, err := utils.Decrypt(account.AccessToken) if err != nil { config.LOGGER.Error("failed to decrypt access token", logFields...) + return err } - url := fmt.Sprintf("%s/me/drive/root/children", MICROSOFT_GRAPH_API_BASE_URL) + url := MICROSOFT_GRAPH_API_BASE_URL + "/me/drive/root/children" if parentFolder.ID != "" { - url = fmt.Sprintf("%s/me/drive/items/%s/children", MICROSOFT_GRAPH_API_BASE_URL, parentFolder.ID) + url = fmt.Sprintf( + "%s/me/drive/items/%s/children", + MICROSOFT_GRAPH_API_BASE_URL, + parentFolder.ID, + ) } httpClient := http.Client{} reqBody := fmt.Appendf(nil, "{\"name\": \"%s\", \"folder\": {}}", name) - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to create a new http request for creating a new folder", logFields...) + config.LOGGER.Error( + "failed to create a new http request for creating a new folder", + logFields...) + return err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/json") res, err := httpClient.Do(req) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("http request for creating a new folder in onedrive failed", logFields...) + config.LOGGER.Error( + "http request for creating a new folder in onedrive failed", + logFields...) + return err } defer res.Body.Close() @@ -1143,14 +1362,23 @@ func (p *MicrosoftProvider) CreateFolder(ctx context.Context, name string, paren body, err := io.ReadAll(res.Body) if err != nil { logFields = append(logFields, zap.Error(err)) - config.LOGGER.Error("failed to read http response body for creating a new folder", logFields...) + config.LOGGER.Error( + "failed to read http response body for creating a new folder", + logFields...) + return err } if res.StatusCode != http.StatusCreated { - err = fmt.Errorf("received non-ok status code for creating a new onedrive folder") - logFields = append(logFields, zap.Error(err), zap.String("body", string(body)), zap.Int("status_code", res.StatusCode)) + err = errors.New("received non-ok status code for creating a new onedrive folder") + logFields = append( + logFields, + zap.Error(err), + zap.String("body", string(body)), + zap.Int("status_code", res.StatusCode), + ) config.LOGGER.Error("http request for creating new onedrive folder failed", logFields...) + return err } @@ -1160,6 +1388,7 @@ func (p *MicrosoftProvider) CreateFolder(ctx context.Context, name string, paren if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to unmarshal one drive item from request body", logFields...) + return err } @@ -1167,8 +1396,7 @@ func (p *MicrosoftProvider) CreateFolder(ctx context.Context, name string, paren mimeType := mime.TypeByExtension(ext) - err = utils.WithTransaction(ctx, conn, func(tx pgx.Tx) error { - + err = utils.WithTransaction(ctx, conn, func(ctx context.Context, tx pgx.Tx) error { qx := queries.WithTx(tx) _, err := qx.AddSyncedItems(ctx, []repository.AddSyncedItemsParams{ @@ -1177,7 +1405,7 @@ func (p *MicrosoftProvider) CreateFolder(ctx context.Context, name string, paren ProviderFileID: newFolder.ID, Name: newFolder.Name, Extension: ext, - Size: int64(newFolder.Size), + Size: newFolder.Size, Path: db.PGTextField(newFolder.ParentReference.Path), MimeType: db.PGTextField(mimeType), ParentFolder: db.PGTextField(newFolder.ParentReference.ID), @@ -1195,12 +1423,11 @@ func (p *MicrosoftProvider) CreateFolder(ctx context.Context, name string, paren }) return err - }) - if err != nil { logFields = append(logFields, zap.Error(err)) config.LOGGER.Error("failed to insert metadata for newly created folder", logFields...) + return err } diff --git a/api/pkg/tasks/auth.go b/api/pkg/tasks/auth.go index fce408a..e52d542 100644 --- a/api/pkg/tasks/auth.go +++ b/api/pkg/tasks/auth.go @@ -3,6 +3,7 @@ package tasks import ( "context" "encoding/json" + "errors" "fmt" "time" @@ -15,6 +16,7 @@ import ( "go.uber.org/zap" ) +// #nosec G101 -- this is a static task type name, not a credential const ( TypeAuthTokenRenewal = "file:auth-token-renewal" ) @@ -29,14 +31,14 @@ func NewAuthTokenRenewalTask(userID, accountID string) (*asynq.Task, error) { if err != nil { return nil, err } + return asynq.NewTask(TypeAuthTokenRenewal, payload), nil } func HandleAuthTokenRenewalTask(ctx context.Context, t *asynq.Task) error { - var p AuthTokenRenewalPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { - return fmt.Errorf("file to unmarshal task payload: %v", err) + return fmt.Errorf("file to unmarshal task payload: %w", err) } _, connPool := db.GetPGClient() @@ -44,7 +46,8 @@ func HandleAuthTokenRenewalTask(ctx context.Context, t *asynq.Task) error { conn, err := connPool.Acquire(ctx) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - return fmt.Errorf("failed to acquire new connection from connection pool: %v", err) + + return fmt.Errorf("failed to acquire new connection from connection pool: %w", err) } defer conn.Release() @@ -59,36 +62,44 @@ func HandleAuthTokenRenewalTask(ctx context.Context, t *asynq.Task) error { StartedAt: db.PGTimestamptzField(time.Now()), JobID: jobID, }) - if err != nil { - config.LOGGER.Error("failed to insert start log for job", zap.String("job_id", jobID), zap.Error(err)) + config.LOGGER.Error( + "failed to insert start log for job", + zap.String("job_id", jobID), + zap.Error(err), + ) } } else { err = queries.UpdateJobLogRetryCount(ctx, repository.UpdateJobLogRetryCountParams{ + // #nosec G115 -- retryCount is bounded and will never exceed int32 Retries: db.PGInt4Field(int32(retryCount)), JobID: jobID, }) - if err != nil { config.LOGGER.Error("failed to insert retry count log for job", zap.String("job_id", jobID), zap.Int("retry_count", retryCount), zap.Error(err)) } } accountID, err := db.PGUUID(p.AccountID) - if err != nil { config.LOGGER.Error("failed to parse UUID string", zap.Error(err)) - return fmt.Errorf("failed to parse UUID string") + + return errors.New("failed to parse UUID string") } authToken, err := queries.GetAuthTokens(ctx, repository.GetAuthTokensParams{ UserID: p.UserID, AccountID: *accountID, }) - if err != nil { - config.LOGGER.Error("failed to fetch auth tokens from db", zap.Error(err), zap.String("user_id", p.UserID), zap.String("account_id", p.AccountID)) - return fmt.Errorf("failed to fetch auth tokens from db: %v", err) + config.LOGGER.Error( + "failed to fetch auth tokens from db", + zap.Error(err), + zap.String("user_id", p.UserID), + zap.String("account_id", p.AccountID), + ) + + return fmt.Errorf("failed to fetch auth tokens from db: %w", err) } provider, ok := providers.OAuthProviders[string(authToken.Provider)] @@ -99,21 +110,27 @@ func HandleAuthTokenRenewalTask(ctx context.Context, t *asynq.Task) error { refreshToken, err := utils.Decrypt(authToken.RefreshToken) if err != nil { - config.LOGGER.Error("could not decrypt refresh token", zap.String("provider", string(authToken.Provider)), zap.String("account_id", accountID.String())) + config.LOGGER.Error( + "could not decrypt refresh token", + zap.String("provider", string(authToken.Provider)), + zap.String("account_id", accountID.String()), + ) + return err } _, expiresIn, err := provider.RenewOAuthTokens(ctx, conn, *accountID, refreshToken) - if err != nil { - dbErr := queries.UpdateJobLogFailed(ctx, repository.UpdateJobLogFailedParams{ Error: db.PGTextField(err.Error()), JobID: jobID, }) - if dbErr != nil { - config.LOGGER.Error("failed to insert failed log for job", zap.String("job_id", jobID), zap.Error(err)) + config.LOGGER.Error( + "failed to insert failed log for job", + zap.String("job_id", jobID), + zap.Error(err), + ) } return err @@ -123,19 +140,32 @@ func HandleAuthTokenRenewalTask(ctx context.Context, t *asynq.Task) error { FinishedAt: db.PGTimestamptzField(time.Now()), JobID: jobID, }) - if err != nil { - config.LOGGER.Error("failed to insert finish log for job", zap.String("job_id", jobID), zap.Error(err)) + config.LOGGER.Error( + "failed to insert finish log for job", + zap.String("job_id", jobID), + zap.Error(err), + ) } newTask, err := NewAuthTokenRenewalTask(p.UserID, p.AccountID) - if err == nil { asynqclient := db.GetAsynqClient() - asynqclient.Enqueue(newTask, asynq.ProcessIn(time.Duration(expiresIn)), asynq.Unique(6*time.Minute)) + if _, err := asynqclient.Enqueue( + newTask, + asynq.ProcessIn(time.Duration(expiresIn)), + asynq.Unique(6*time.Minute), + ); err != nil { + config.LOGGER.Error("failed to enqueue new auth token renewal task", zap.Error(err)) + } } - config.LOGGER.Info("worker completed token renewal task and saved new token to db", zap.String("user_id", p.UserID), zap.String("account_id", p.AccountID)) + config.LOGGER.Info( + "worker completed token renewal task and saved new token to db", + zap.String("user_id", p.UserID), + zap.String("account_id", p.AccountID), + ) + return nil } diff --git a/api/pkg/tasks/file.go b/api/pkg/tasks/file.go index fd2c060..e22fa4b 100644 --- a/api/pkg/tasks/file.go +++ b/api/pkg/tasks/file.go @@ -3,6 +3,7 @@ package tasks import ( "context" "encoding/json" + "errors" "fmt" "time" @@ -29,14 +30,14 @@ func NewFileSyncTask(userID, accountID string) (*asynq.Task, error) { if err != nil { return nil, err } + return asynq.NewTask(TypeFileSync, payload), nil } func HandleFileSyncTask(ctx context.Context, t *asynq.Task) error { - var p FileSyncPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { - return fmt.Errorf("file to unmarshal task payload: %v", err) + return fmt.Errorf("file to unmarshal task payload: %w", err) } _, connPool := db.GetPGClient() @@ -44,7 +45,8 @@ func HandleFileSyncTask(ctx context.Context, t *asynq.Task) error { conn, err := connPool.Acquire(ctx) if err != nil { config.LOGGER.Error("failed to acquire new connection from connection pool", zap.Error(err)) - return fmt.Errorf("failed to acquire new connection from connection pool: %v", err) + + return fmt.Errorf("failed to acquire new connection from connection pool: %w", err) } defer conn.Release() @@ -59,36 +61,44 @@ func HandleFileSyncTask(ctx context.Context, t *asynq.Task) error { StartedAt: db.PGTimestamptzField(time.Now()), JobID: jobID, }) - if err != nil { - config.LOGGER.Error("failed to insert start log for job", zap.String("job_id", jobID), zap.Error(err)) + config.LOGGER.Error( + "failed to insert start log for job", + zap.String("job_id", jobID), + zap.Error(err), + ) } } else { err = queries.UpdateJobLogRetryCount(ctx, repository.UpdateJobLogRetryCountParams{ + // #nosec G115 -- retryCount is bounded and will never exceed int32 Retries: db.PGInt4Field(int32(retryCount)), JobID: jobID, }) - if err != nil { config.LOGGER.Error("failed to insert retry count log for job", zap.String("job_id", jobID), zap.Int("retry_count", retryCount), zap.Error(err)) } } accountID, err := db.PGUUID(p.AccountID) - if err != nil { config.LOGGER.Error("failed to parse UUID string", zap.Error(err)) - return fmt.Errorf("failed to parse UUID string") + + return errors.New("failed to parse UUID string") } authToken, err := queries.GetAuthTokens(ctx, repository.GetAuthTokensParams{ UserID: p.UserID, AccountID: *accountID, }) - if err != nil { - config.LOGGER.Error("failed to fetch auth tokens from db", zap.Error(err), zap.String("user_id", p.UserID), zap.String("account_id", p.AccountID)) - return fmt.Errorf("failed to fetch auth tokens from db: %v", err) + config.LOGGER.Error( + "failed to fetch auth tokens from db", + zap.Error(err), + zap.String("user_id", p.UserID), + zap.String("account_id", p.AccountID), + ) + + return fmt.Errorf("failed to fetch auth tokens from db: %w", err) } provider, ok := providers.OAuthProviders[string(authToken.Provider)] @@ -98,16 +108,17 @@ func HandleFileSyncTask(ctx context.Context, t *asynq.Task) error { } err = provider.SyncFiles(ctx, conn, *accountID, authToken) - if err != nil { - dbErr := queries.UpdateJobLogFailed(ctx, repository.UpdateJobLogFailedParams{ Error: db.PGTextField(err.Error()), JobID: jobID, }) - if dbErr != nil { - config.LOGGER.Error("failed to insert failed log for job", zap.String("job_id", jobID), zap.Error(err)) + config.LOGGER.Error( + "failed to insert failed log for job", + zap.String("job_id", jobID), + zap.Error(err), + ) } return err @@ -117,25 +128,40 @@ func HandleFileSyncTask(ctx context.Context, t *asynq.Task) error { FinishedAt: db.PGTimestamptzField(time.Now()), JobID: jobID, }) - if err != nil { - config.LOGGER.Error("failed to insert finish log for job", zap.String("job_id", jobID), zap.Error(err)) + config.LOGGER.Error( + "failed to insert finish log for job", + zap.String("job_id", jobID), + zap.Error(err), + ) } newTask, err := NewFileSyncTask(p.UserID, p.AccountID) - if err == nil { asynqclient := db.GetAsynqClient() - processAt := time.Now().Add(time.Duration(config.AsynqConfig.FILE_SYNC_INTERVAL) * time.Minute) + processAt := time.Now(). + Add(time.Duration(config.AsynqConfig.FILE_SYNC_INTERVAL) * time.Minute) - asynqclient.Enqueue(newTask, asynq.ProcessAt(processAt), asynq.Unique(6*time.Minute)) + if _, err := asynqclient.Enqueue(newTask, asynq.ProcessAt(processAt), asynq.Unique(6*time.Minute)); err != nil { + config.LOGGER.Error("failed to enqueue new file sync task", zap.Error(err)) + } } if err := utils.DeleteKeysByPattern(ctx, fmt.Sprintf("search_cache:%s:%s*", authToken.Provider, p.AccountID)); err != nil { - config.LOGGER.Error("failed to delete cache for content search results", zap.String("provider", string(authToken.Provider)), zap.String("account_id", p.AccountID), zap.Error(err)) + config.LOGGER.Error( + "failed to delete cache for content search results", + zap.String("provider", string(authToken.Provider)), + zap.String("account_id", p.AccountID), + zap.Error(err), + ) } - config.LOGGER.Info("worker completed synching files to the db", zap.String("user_id", p.UserID), zap.String("account_id", p.AccountID)) + config.LOGGER.Info( + "worker completed synching files to the db", + zap.String("user_id", p.UserID), + zap.String("account_id", p.AccountID), + ) + return nil } diff --git a/api/pkg/utils/aes.go b/api/pkg/utils/aes.go index e976d71..867a940 100644 --- a/api/pkg/utils/aes.go +++ b/api/pkg/utils/aes.go @@ -5,6 +5,7 @@ import ( "crypto/cipher" "crypto/rand" "encoding/hex" + "errors" "fmt" "io" @@ -15,20 +16,18 @@ func GetAESKey() ([]byte, error) { keyHex := config.AESConfig.MASTER_KEY key, err := hex.DecodeString(keyHex) - if err != nil { - return nil, fmt.Errorf("invalid hex string: %v", err) + return nil, fmt.Errorf("invalid hex string: %w", err) } if len(key) != 32 { - return nil, fmt.Errorf("aes master key must be 32 bytes (64 hex characters) for AES-256") + return nil, errors.New("aes master key must be 32 bytes (64 hex characters) for AES-256") } return key, nil } func Encrypt(plainText string) (string, error) { - plainTextByte := []byte(plainText) key, err := GetAESKey() @@ -82,10 +81,11 @@ func Decrypt(encryptedText string) (string, error) { nonceSize := aesGCM.NonceSize() if len(encryptedText) < nonceSize { - return "", fmt.Errorf("cipherText too short") + return "", errors.New("cipherText too short") } nonce, cipherText := decodedHex[:nonceSize], decodedHex[nonceSize:] + decrypted, err := aesGCM.Open(nil, nonce, cipherText, nil) if err != nil { return "", err diff --git a/api/pkg/utils/db.go b/api/pkg/utils/db.go index fcc4363..1b77341 100644 --- a/api/pkg/utils/db.go +++ b/api/pkg/utils/db.go @@ -9,23 +9,37 @@ import ( "go.uber.org/zap" ) -func WithTransaction(ctx context.Context, conn *pgxpool.Conn, fn func(pgx.Tx) error) error { +func WithTransaction( + ctx context.Context, + conn *pgxpool.Conn, + fn func(context.Context, pgx.Tx) error, +) error { tx, err := conn.Begin(ctx) if err != nil { config.LOGGER.Error("failed to begin transactoin", zap.Error(err)) + return err } - defer func() { + defer func(ctx context.Context) { if r := recover(); r != nil { - tx.Rollback(context.Background()) + if rollbackErr := tx.Rollback(ctx); rollbackErr != nil { + config.LOGGER.Error("failed to rollback transaction after panic", + zap.Error(rollbackErr), + zap.Any("panic", r)) + } + panic(r) } else if err != nil { - tx.Rollback(context.Background()) + if rollbackErr := tx.Rollback(ctx); rollbackErr != nil { + config.LOGGER.Error("failed to rollback transaction", + zap.Error(rollbackErr), + zap.NamedError("original_error", err)) + } } - }() + }(ctx) - err = fn(tx) + err = fn(ctx, tx) if err != nil { return err } diff --git a/api/pkg/utils/jwt.go b/api/pkg/utils/jwt.go index d12e3c9..9f5eb14 100644 --- a/api/pkg/utils/jwt.go +++ b/api/pkg/utils/jwt.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "encoding/base64" "encoding/json" + "errors" "fmt" "time" @@ -12,6 +13,7 @@ import ( type JWTClaims struct { jwt.RegisteredClaims + UserID string `json:"id"` Name string `json:"name"` Email string `json:"email"` @@ -32,8 +34,12 @@ type JWK struct { KTY string `json:"kty"` } -func ParseJWT(tokenString, publicKeyJWK string) (*JWTClaims, error) { +var ( + ErrUnexpectedSigningMethod = errors.New("unexpected signing method") + ErrInvalidToken = errors.New("invalid token") +) +func ParseJWT(tokenString, publicKeyJWK string) (*JWTClaims, error) { var jwk JWK if err := json.Unmarshal([]byte(publicKeyJWK), &jwk); err != nil { @@ -49,8 +55,9 @@ func ParseJWT(tokenString, publicKeyJWK string) (*JWTClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(t *jwt.Token) (any, error) { if t.Method.Alg() != jwt.SigningMethodEdDSA.Alg() { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + return nil, fmt.Errorf("%w: %v", ErrUnexpectedSigningMethod, t.Header["alg"]) } + return publicKey, nil }) if err != nil { @@ -60,5 +67,6 @@ func ParseJWT(tokenString, publicKeyJWK string) (*JWTClaims, error) { if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { return claims, nil } - return nil, fmt.Errorf("invalid token") + + return nil, ErrInvalidToken } diff --git a/api/pkg/utils/redis.go b/api/pkg/utils/redis.go index 7bb2a36..f819ac1 100644 --- a/api/pkg/utils/redis.go +++ b/api/pkg/utils/redis.go @@ -13,13 +13,19 @@ import ( func BuildSearchCacheKey(provider, accountID, searchText string) string { h := sha256.New() + //nolint:errcheck h.Write([]byte(searchText)) hash := hex.EncodeToString(h.Sum(nil))[:10] return fmt.Sprintf("search_cache:%s:%s:%s", provider, accountID, hash) } -func CacheProviderFileIDs(ctx context.Context, key string, values []string, ttl time.Duration) error { +func CacheProviderFileIDs( + ctx context.Context, + key string, + values []string, + ttl time.Duration, +) error { data, err := json.Marshal(values) if err != nil { return err @@ -31,7 +37,6 @@ func CacheProviderFileIDs(ctx context.Context, key string, values []string, ttl } func GetCachedProviderFileIDs(ctx context.Context, key string) ([]string, error) { - redisClient := db.GetRedisClient() val, err := redisClient.Get(ctx, key).Result() @@ -40,19 +45,22 @@ func GetCachedProviderFileIDs(ctx context.Context, key string) ([]string, error) } var result []string + err = json.Unmarshal([]byte(val), &result) return result, err } func DeleteKeysByPattern(ctx context.Context, pattern string) error { - redisClient := db.GetRedisClient() var cursor uint64 + for { - var keys []string - var err error + var ( + keys []string + err error + ) keys, cursor, err = redisClient.Scan(ctx, cursor, pattern, 100).Result() if err != nil { @@ -69,5 +77,6 @@ func DeleteKeysByPattern(ctx context.Context, pattern string) error { break } } + return nil } diff --git a/api/pkg/utils/response.go b/api/pkg/utils/response.go index 70f8486..419f930 100644 --- a/api/pkg/utils/response.go +++ b/api/pkg/utils/response.go @@ -2,11 +2,13 @@ package utils import ( "encoding/json" - "fmt" "net/http" + + "github.com/blackmamoth/cloudmesh/pkg/config" + "go.uber.org/zap" ) -func SendAPIResponse(w http.ResponseWriter, status int, data any, cookies ...*http.Cookie) error { +func SendAPIResponse(w http.ResponseWriter, status int, data any, cookies ...*http.Cookie) { if len(cookies) > 0 { for _, cookie := range cookies { http.SetCookie(w, cookie) @@ -15,27 +17,33 @@ func SendAPIResponse(w http.ResponseWriter, status int, data any, cookies ...*ht w.WriteHeader(status) w.Header().Add("Content-Type", "application/json") - return json.NewEncoder(w).Encode(generateAPIResponseBody(status, data)) + + if resErr := json.NewEncoder(w).Encode(generateAPIResponseBody(status, data)); resErr != nil { + config.LOGGER.Error("failed to send api response", zap.Error(resErr)) + } } func SendAPIErrorResponse(w http.ResponseWriter, status int, err any) { + message := map[string]any{"message": err} if e, ok := err.(error); ok { - SendAPIResponse(w, status, map[string]any{"message": e.Error()}) - } else { - SendAPIResponse(w, status, map[string]any{"message": err}) + message["message"] = e.Error() } + + SendAPIResponse(w, status, message) } func generateAPIResponseBody(status int, data any) map[string]any { if status >= 400 { return map[string]any{"status": status, "error": data} } + return map[string]any{"status": status, "data": data} } func ParseJSON(r *http.Request, v any) error { if r.Body == nil { - return fmt.Errorf("request body should not be empty") + return ErrNoEmptyReqBody } + return json.NewDecoder(r.Body).Decode(v) } diff --git a/api/pkg/utils/validation.go b/api/pkg/utils/validation.go index d77a3c0..c614765 100644 --- a/api/pkg/utils/validation.go +++ b/api/pkg/utils/validation.go @@ -1,54 +1,56 @@ package utils import ( + "errors" "fmt" + "io" + "net/http" "reflect" "strings" "github.com/go-playground/validator/v10" ) +var ErrNoEmptyReqBody = errors.New("request body cannot be empty") + var Validate = validator.New() func generateMsgForField(fe validator.FieldError, v any) (string, string) { - t := reflect.TypeOf(v) + jsonTag := extractJSONTag(fe, v) + fieldName, message := generateValidationMessage(fe, jsonTag) + + return fieldName, message +} +func extractJSONTag(fe validator.FieldError, v any) string { + t := reflect.TypeOf(v) if t.Kind() == reflect.Ptr { t = t.Elem() } - field, _ := t.FieldByName(fe.StructField()) + field, ok := t.FieldByName(fe.StructField()) + if !ok { + return fe.StructField() + } - jsonTag := field.Tag.Get("json") - if field, ok := t.FieldByName(fe.StructField()); ok { - if tag := field.Tag.Get("json"); tag != "" && tag != "-" { - jsonTag = strings.Split(tag, ",")[0] - } + tag := field.Tag.Get("json") + if tag == "" || tag == "-" { + return fe.StructField() } + return strings.Split(tag, ",")[0] +} + +func generateValidationMessage(fe validator.FieldError, jsonTag string) (string, string) { switch fe.Tag() { case "required": return jsonTag, fmt.Sprintf("`%s` is required", jsonTag) case "email": return jsonTag, fmt.Sprintf("`%s` must be a valid email address", jsonTag) case "min": - switch fe.Kind() { - case reflect.String: - return jsonTag, fmt.Sprintf("`%s` should contain at least %s characters", jsonTag, fe.Param()) - case reflect.Slice, reflect.Array: - return jsonTag, fmt.Sprintf("`%s` should contain at least %s item(s)", jsonTag, fe.Param()) - default: - return jsonTag, fmt.Sprintf("`%s` should be at least %s", jsonTag, fe.Param()) - } + return jsonTag, generateMinMessage(fe, jsonTag) case "max": - switch fe.Kind() { - case reflect.String: - return jsonTag, fmt.Sprintf("`%s` should contain at most %s characters", jsonTag, fe.Param()) - case reflect.Slice, reflect.Array: - return jsonTag, fmt.Sprintf("`%s` should contain at most %s item(s)", jsonTag, fe.Param()) - default: - return jsonTag, fmt.Sprintf("`%s` should be at most %s", jsonTag, fe.Param()) - } + return jsonTag, generateMaxMessage(fe, jsonTag) case "dive": return jsonTag, fmt.Sprintf("`%s` should be in an array", jsonTag) case "oneof": @@ -59,16 +61,77 @@ func generateMsgForField(fe validator.FieldError, v any) (string, string) { return jsonTag, fmt.Sprintf("`%s` should be all lower case", jsonTag) case "uuid", "uuid4": return fe.StructField(), fmt.Sprintf("`%s` should be a valid UUID", fe.StructField()) + default: + return fe.Field(), fe.Error() + } +} + +func generateMinMessage(fe validator.FieldError, jsonTag string) string { + //nolint:exhaustive // handled by default case + switch fe.Kind() { + case reflect.String: + return fmt.Sprintf("`%s` should contain at least %s characters", jsonTag, fe.Param()) + case reflect.Slice, reflect.Array: + return fmt.Sprintf("`%s` should contain at least %s item(s)", jsonTag, fe.Param()) + default: + return fmt.Sprintf("`%s` should be at least %s", jsonTag, fe.Param()) } +} - return fe.Field(), fe.Error() +func generateMaxMessage(fe validator.FieldError, jsonTag string) string { + //nolint:exhaustive // handled by default case + switch fe.Kind() { + case reflect.String: + return fmt.Sprintf("`%s` should contain at most %s characters", jsonTag, fe.Param()) + case reflect.Slice, reflect.Array: + return fmt.Sprintf("`%s` should contain at most %s item(s)", jsonTag, fe.Param()) + default: + return fmt.Sprintf("`%s` should be at most %s", jsonTag, fe.Param()) + } } func GenerateValidationErrorObject(ve validator.ValidationErrors, v any) map[string]string { errs := map[string]string{} + for _, fe := range ve { key, value := generateMsgForField(fe, v) errs[key] = value } + return errs } + +func ParseAndValidate[T any](w http.ResponseWriter, r *http.Request, allowEmpty bool) (T, bool) { + var payload T + + defer r.Body.Close() + + if err := ParseJSON(r, &payload); err != nil { + if errors.Is(err, io.EOF) && !allowEmpty { + SendAPIErrorResponse( + w, + http.StatusBadRequest, + ErrNoEmptyReqBody, + ) + + return payload, false + } + + return payload, true + } + + if err := Validate.Struct(payload); err != nil { + errs := GenerateValidationErrorObject(func() validator.ValidationErrors { + var target validator.ValidationErrors + + _ = errors.As(err, &target) + + return target + }(), payload) + SendAPIErrorResponse(w, http.StatusUnprocessableEntity, errs) + + return payload, false + } + + return payload, true +} diff --git a/app/biome.jsonc b/app/biome.jsonc new file mode 100644 index 0000000..e6240ba --- /dev/null +++ b/app/biome.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", + "extends": [ + "ultracite" + ] +} \ No newline at end of file diff --git a/app/package.json b/app/package.json index e681a43..36e0409 100644 --- a/app/package.json +++ b/app/package.json @@ -6,9 +6,11 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "ultracite lint", + "format": "ultracite format", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate" + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" }, "dependencies": { "@hookform/resolvers": "^5.2.1", @@ -52,6 +54,7 @@ "zustand": "^5.0.7" }, "devDependencies": { + "@biomejs/biome": "2.1.2", "@tailwindcss/postcss": "^4", "@types/http-errors": "^2.0.5", "@types/node": "^20", @@ -62,6 +65,12 @@ "tailwindcss": "^4", "tsx": "^4.20.3", "tw-animate-css": "^1.3.6", - "typescript": "^5" + "typescript": "^5", + "ultracite": "5.1.2" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,jsonc,css,scss,md,mdx}": [ + "npx ultracite format" + ] } } diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index a907b38..6c8bb71 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: specifier: ^5.0.7 version: 5.0.7(@types/react@19.1.9)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)) devDependencies: + '@biomejs/biome': + specifier: 2.1.2 + version: 2.1.2 '@tailwindcss/postcss': specifier: ^4 version: 4.1.11 @@ -159,6 +162,9 @@ importers: typescript: specifier: ^5 version: 5.9.2 + ultracite: + specifier: 5.1.2 + version: 5.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.1) packages: @@ -180,6 +186,65 @@ packages: '@better-fetch/fetch@1.1.18': resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@biomejs/biome@2.1.2': + resolution: {integrity: sha512-yq8ZZuKuBVDgAS76LWCfFKHSYIAgqkxVB3mGVVpOe2vSkUTs7xG46zXZeNPRNVjiJuw0SZ3+J2rXiYx0RUpfGg==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.1.2': + resolution: {integrity: sha512-leFAks64PEIjc7MY/cLjE8u5OcfBKkcDB0szxsWUB4aDfemBep1WVKt0qrEyqZBOW8LPHzrFMyDl3FhuuA0E7g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.1.2': + resolution: {integrity: sha512-Nmmv7wRX5Nj7lGmz0FjnWdflJg4zii8Ivruas6PBKzw5SJX/q+Zh2RfnO+bBnuKLXpj8kiI2x2X12otpH6a32A==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.1.2': + resolution: {integrity: sha512-qgHvafhjH7Oca114FdOScmIKf1DlXT1LqbOrrbR30kQDLFPEOpBG0uzx6MhmsrmhGiCFCr2obDamu+czk+X0HQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.1.2': + resolution: {integrity: sha512-NWNy2Diocav61HZiv2enTQykbPP/KrA/baS7JsLSojC7Xxh2nl9IczuvE5UID7+ksRy2e7yH7klm/WkA72G1dw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.1.2': + resolution: {integrity: sha512-xlB3mU14ZUa3wzLtXfmk2IMOGL+S0aHFhSix/nssWS/2XlD27q+S6f0dlQ8WOCbYoXcuz8BCM7rCn2lxdTrlQA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.1.2': + resolution: {integrity: sha512-Km/UYeVowygTjpX6sGBzlizjakLoMQkxWbruVZSNE6osuSI63i4uCeIL+6q2AJlD3dxoiBJX70dn1enjQnQqwA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.1.2': + resolution: {integrity: sha512-G8KWZli5ASOXA3yUQgx+M4pZRv3ND16h77UsdunUL17uYpcL/UC7RkWTdkfvMQvogVsAuz5JUcBDjgZHXxlKoA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.1.2': + resolution: {integrity: sha512-9zajnk59PMpjBkty3bK2IrjUsUHvqe9HWwyAWQBjGLE7MIBjbX2vwv1XPEhmO2RRuGoTkVx3WCanHrjAytICLA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1171,6 +1236,106 @@ packages: peerDependencies: react: '>=18.2.0' + '@rollup/rollup-android-arm-eabi@4.46.2': + resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.46.2': + resolution: {integrity: sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.46.2': + resolution: {integrity: sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.46.2': + resolution: {integrity: sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.46.2': + resolution: {integrity: sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.46.2': + resolution: {integrity: sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.46.2': + resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.46.2': + resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.46.2': + resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.46.2': + resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.46.2': + resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.46.2': + resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.46.2': + resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.46.2': + resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.46.2': + resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.46.2': + resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.46.2': + resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.46.2': + resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.46.2': + resolution: {integrity: sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.46.2': + resolution: {integrity: sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==} + cpu: [x64] + os: [win32] + '@simplewebauthn/browser@13.1.2': resolution: {integrity: sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw==} @@ -1325,6 +1490,14 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@trpc/server@11.4.4': + resolution: {integrity: sha512-VkJb2xnb4rCynuwlCvgPBh5aM+Dco6fBBIo6lWAdJJRYVwtyE5bxNZBgUvRRz/cFSEAy0vmzLxF7aABDJfK5Rg==} + peerDependencies: + typescript: '>=5.7.2' + + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -1352,12 +1525,21 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} '@types/node@20.19.9': resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} + '@types/omelette@0.4.5': + resolution: {integrity: sha512-zUCJpVRwfMcZfkxSCGp73mgd3/xesvPz5tQJIORlfP/zkYEyp9KUfF7IP3RRjyZR3DwxkPs96/IFf70GmYZYHQ==} + '@types/pg@8.15.5': resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} @@ -1369,6 +1551,35 @@ packages: '@types/react@19.1.9': resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1377,6 +1588,10 @@ packages: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + better-auth@1.3.4: resolution: {integrity: sha512-JbZYam6Cs3Eu5CSoMK120zSshfaKvrCftSo/+v7524H1RvhryQ7UtMbzagBcXj0Digjj8hZtVkkR4tTZD/wK2g==} peerDependencies: @@ -1394,9 +1609,21 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + caniuse-lite@1.0.30001731: resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + chai@5.2.1: + resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} + engines: {node: '>=18'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1429,6 +1656,10 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + commander@14.0.0: + resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1488,6 +1719,14 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -1613,6 +1852,9 @@ packages: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -1628,13 +1870,28 @@ packages: engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + fast-equals@5.2.2: resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==} engines: {node: '>=6.0.0'} + fdir@6.4.6: + resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1684,6 +1941,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + kysely@0.28.4: resolution: {integrity: sha512-pfQj8/Bo3KSzC1HIZB5MeeYRWcDmx1ZZv8H25LsyeygqXE+gfsbUAgPT1GSYZFctB1cdOVlv+OifuCls2mQSnw==} engines: {node: '>=20.0.0'} @@ -1765,6 +2028,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.0: + resolution: {integrity: sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==} + lucide-react@0.536.0: resolution: {integrity: sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==} peerDependencies: @@ -1829,6 +2095,13 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} @@ -1866,6 +2139,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -1984,6 +2261,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.46.2: + resolution: {integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rou3@0.5.1: resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==} @@ -2005,9 +2287,15 @@ packages: resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -2029,6 +2317,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -2036,6 +2327,12 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -2066,10 +2363,45 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + trpc-cli@0.10.2: + resolution: {integrity: sha512-zBkL88AeX0vQLXwEAcX6WUoT4Sopr97nFDFeD1zmW33wHQwBKbszylplNVk6BO/cuhgm/iq8/cG27NokqKA1mw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@inquirer/prompts': '*' + omelette: '*' + peerDependenciesMeta: + '@inquirer/prompts': + optional: true + omelette: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2086,6 +2418,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ultracite@5.1.2: + resolution: {integrity: sha512-Jemg+mm0cLnZhUcuIaREkt3T81e1YrBoowzrzMvyGE4tgNrokd2FAJrAyic/y9CLbVejYwk3kCUVBTAd0hPDvg==} + hasBin: true + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -2126,6 +2462,84 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.1.2: + resolution: {integrity: sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -2134,6 +2548,19 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + peerDependencies: + zod: ^3.24.1 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.0.14: resolution: {integrity: sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==} @@ -2173,6 +2600,52 @@ snapshots: '@better-fetch/fetch@1.1.18': {} + '@biomejs/biome@2.1.2': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.1.2 + '@biomejs/cli-darwin-x64': 2.1.2 + '@biomejs/cli-linux-arm64': 2.1.2 + '@biomejs/cli-linux-arm64-musl': 2.1.2 + '@biomejs/cli-linux-x64': 2.1.2 + '@biomejs/cli-linux-x64-musl': 2.1.2 + '@biomejs/cli-win32-arm64': 2.1.2 + '@biomejs/cli-win32-x64': 2.1.2 + + '@biomejs/cli-darwin-arm64@2.1.2': + optional: true + + '@biomejs/cli-darwin-x64@2.1.2': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.1.2': + optional: true + + '@biomejs/cli-linux-arm64@2.1.2': + optional: true + + '@biomejs/cli-linux-x64-musl@2.1.2': + optional: true + + '@biomejs/cli-linux-x64@2.1.2': + optional: true + + '@biomejs/cli-win32-arm64@2.1.2': + optional: true + + '@biomejs/cli-win32-x64@2.1.2': + optional: true + + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/runtime@1.4.5': @@ -2978,6 +3451,66 @@ snapshots: dependencies: react: 19.1.0 + '@rollup/rollup-android-arm-eabi@4.46.2': + optional: true + + '@rollup/rollup-android-arm64@4.46.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.46.2': + optional: true + + '@rollup/rollup-darwin-x64@4.46.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.46.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.46.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.46.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.46.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.46.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.46.2': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.46.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.46.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.46.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.46.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.46.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.46.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.46.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.46.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.46.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.46.2': + optional: true + '@simplewebauthn/browser@13.1.2': {} '@simplewebauthn/server@13.1.2': @@ -3095,6 +3628,14 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@trpc/server@11.4.4(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -3119,12 +3660,18 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/http-errors@2.0.5': {} '@types/node@20.19.9': dependencies: undici-types: 6.21.0 + '@types/omelette@0.4.5': {} + '@types/pg@8.15.5': dependencies: '@types/node': 20.19.9 @@ -3139,6 +3686,48 @@ snapshots: dependencies: csstype: 3.1.3 + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 7.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.3 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.0 + tinyrainbow: 2.0.0 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -3149,6 +3738,8 @@ snapshots: pvutils: 1.1.3 tslib: 2.8.1 + assertion-error@2.0.1: {} + better-auth@1.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@better-auth/utils': 0.2.5 @@ -3176,8 +3767,20 @@ snapshots: buffer-from@1.1.2: {} + cac@6.7.14: {} + caniuse-lite@1.0.30001731: {} + chai@5.2.1: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.0 + pathval: 2.0.1 + + check-error@2.1.1: {} + chownr@3.0.0: {} class-variance-authority@0.7.1: @@ -3210,6 +3813,8 @@ snapshots: color-string: 1.9.1 optional: true + commander@14.0.0: {} + csstype@3.1.3: {} d3-array@3.2.4: @@ -3256,6 +3861,10 @@ snapshots: decimal.js-light@2.5.1: {} + deep-eql@5.0.2: {} + + deepmerge@4.3.1: {} + defu@6.1.4: {} denque@2.1.0: {} @@ -3293,6 +3902,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.2 + es-module-lexer@1.7.0: {} + esbuild-register@3.6.0(esbuild@0.25.8): dependencies: debug: 4.4.1 @@ -3354,10 +3965,20 @@ snapshots: '@esbuild/win32-ia32': 0.25.8 '@esbuild/win32-x64': 0.25.8 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + eventemitter3@4.0.7: {} + expect-type@1.2.2: {} + fast-equals@5.2.2: {} + fdir@6.4.6(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fsevents@2.3.3: optional: true @@ -3409,6 +4030,10 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + + jsonc-parser@3.3.1: {} + kysely@0.28.4: {} lightningcss-darwin-arm64@1.30.1: @@ -3466,6 +4091,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.0: {} + lucide-react@0.536.0(react@19.1.0): dependencies: react: 19.1.0 @@ -3518,6 +4145,10 @@ snapshots: object-assign@4.1.1: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + pg-cloudflare@1.2.7: optional: true @@ -3555,6 +4186,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@4.0.3: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -3673,6 +4306,32 @@ snapshots: resolve-pkg-maps@1.0.0: {} + rollup@4.46.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.46.2 + '@rollup/rollup-android-arm64': 4.46.2 + '@rollup/rollup-darwin-arm64': 4.46.2 + '@rollup/rollup-darwin-x64': 4.46.2 + '@rollup/rollup-freebsd-arm64': 4.46.2 + '@rollup/rollup-freebsd-x64': 4.46.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.46.2 + '@rollup/rollup-linux-arm-musleabihf': 4.46.2 + '@rollup/rollup-linux-arm64-gnu': 4.46.2 + '@rollup/rollup-linux-arm64-musl': 4.46.2 + '@rollup/rollup-linux-loongarch64-gnu': 4.46.2 + '@rollup/rollup-linux-ppc64-gnu': 4.46.2 + '@rollup/rollup-linux-riscv64-gnu': 4.46.2 + '@rollup/rollup-linux-riscv64-musl': 4.46.2 + '@rollup/rollup-linux-s390x-gnu': 4.46.2 + '@rollup/rollup-linux-x64-gnu': 4.46.2 + '@rollup/rollup-linux-x64-musl': 4.46.2 + '@rollup/rollup-win32-arm64-msvc': 4.46.2 + '@rollup/rollup-win32-ia32-msvc': 4.46.2 + '@rollup/rollup-win32-x64-msvc': 4.46.2 + fsevents: 2.3.3 + rou3@0.5.1: {} scheduler@0.26.0: {} @@ -3714,11 +4373,15 @@ snapshots: '@img/sharp-win32-x64': 0.34.3 optional: true + siginfo@2.0.0: {} + simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 optional: true + sisteransi@1.0.5: {} + sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -3735,10 +4398,18 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.1: {} + std-env@3.9.0: {} + + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + styled-jsx@5.1.6(react@19.1.0): dependencies: client-only: 0.0.1 @@ -3761,8 +4432,34 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} + toidentifier@1.0.1: {} + trpc-cli@0.10.2(typescript@5.9.2): + dependencies: + '@trpc/server': 11.4.4(typescript@5.9.2) + '@types/omelette': 0.4.5 + commander: 14.0.0 + picocolors: 1.1.1 + zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) + transitivePeerDependencies: + - typescript + tslib@2.8.1: {} tsx@4.20.3: @@ -3776,6 +4473,38 @@ snapshots: typescript@5.9.2: {} + ultracite@5.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(typescript@5.9.2)(yaml@2.8.1): + dependencies: + '@clack/prompts': 0.11.0 + deepmerge: 4.3.1 + jsonc-parser: 3.3.1 + trpc-cli: 0.10.2(typescript@5.9.2) + vitest: 3.2.4(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) + zod: 4.0.14 + transitivePeerDependencies: + - '@edge-runtime/vm' + - '@inquirer/prompts' + - '@types/debug' + - '@types/node' + - '@vitest/browser' + - '@vitest/ui' + - happy-dom + - jiti + - jsdom + - less + - lightningcss + - msw + - omelette + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - yaml + uncrypto@0.1.3: {} undici-types@6.21.0: {} @@ -3820,10 +4549,102 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1): + dependencies: + esbuild: 0.25.8 + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.46.2 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 20.19.9 + fsevents: 2.3.3 + jiti: 2.5.1 + lightningcss: 1.30.1 + tsx: 4.20.3 + yaml: 2.8.1 + + vitest@3.2.4(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + debug: 4.4.1 + expect-type: 1.2.2 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.2(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.9 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + xtend@4.0.2: {} yallist@5.0.0: {} + yaml@2.8.1: + optional: true + + zod-to-json-schema@3.24.6(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + zod@4.0.14: {} zustand@5.0.7(@types/react@19.1.9)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)): diff --git a/app/tsconfig.json b/app/tsconfig.json index d8b9323..c94fc66 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,9 +23,19 @@ } ], "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "strictNullChecks": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/makefile b/makefile index 4f02905..9846952 100644 --- a/makefile +++ b/makefile @@ -35,3 +35,11 @@ dev: @pnpm -C app db:generate @pnpm -C app db:migrate $(MAKE) migration-up + +lint: + @cd api && golangci-lint run --fix + # @pnpm -C app lint + +format: + @cd api && golangci-lint fmt + # @pnpm -C app format diff --git a/package.json b/package.json new file mode 100644 index 0000000..8bdb5cc --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "devDependencies": { + "husky": "^9.1.7", + "lint-staged": "^16.1.5" + }, + "scripts": { + "prepare": "husky" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8c6e6fd --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,337 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.1.5 + version: 16.1.5 + +packages: + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + chalk@5.6.0: + resolution: {integrity: sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@14.0.0: + resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} + engines: {node: '>=20'} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lint-staged@16.1.5: + resolution: {integrity: sha512-uAeQQwByI6dfV7wpt/gVqg+jAPaSp8WwOA8kKC/dv1qw14oGpnpAisY65ibGHUGDUv0rYaZ8CAJZ/1U8hUvC2A==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.1: + resolution: {integrity: sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==} + engines: {node: '>=20.0.0'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nano-spawn@1.0.2: + resolution: {integrity: sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==} + engines: {node: '>=20.17'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@6.1.0: {} + + ansi-styles@6.2.1: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + chalk@5.6.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + colorette@2.0.20: {} + + commander@14.0.0: {} + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + emoji-regex@10.4.0: {} + + environment@1.1.0: {} + + eventemitter3@5.0.1: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + get-east-asian-width@1.3.0: {} + + husky@9.1.7: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + + is-number@7.0.0: {} + + lilconfig@3.1.3: {} + + lint-staged@16.1.5: + dependencies: + chalk: 5.6.0 + commander: 14.0.0 + debug: 4.4.1 + lilconfig: 3.1.3 + listr2: 9.0.1 + micromatch: 4.0.8 + nano-spawn: 1.0.2 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.8.1 + transitivePeerDependencies: + - supports-color + + listr2@9.0.1: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-function@5.0.1: {} + + ms@2.1.3: {} + + nano-spawn@1.0.2: {} + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + picomatch@2.3.1: {} + + pidtree@0.6.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rfdc@1.4.1: {} + + signal-exit@4.1.0: {} + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + string-argv@0.3.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + yaml@2.8.1: {}