@igstack/app-catalog-backend-core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1934 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2539 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
- package/prisma/migrations/20250526183023_init/migration.sql +71 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +149 -0
- package/src/__tests__/dummy.test.ts +7 -0
- package/src/db/client.ts +42 -0
- package/src/db/index.ts +21 -0
- package/src/db/syncAppCatalog.ts +312 -0
- package/src/db/tableSyncMagazine.ts +32 -0
- package/src/db/tableSyncPrismaAdapter.ts +203 -0
- package/src/index.ts +126 -0
- package/src/middleware/backendResolver.ts +42 -0
- package/src/middleware/createEhMiddleware.ts +171 -0
- package/src/middleware/database.ts +62 -0
- package/src/middleware/featureRegistry.ts +173 -0
- package/src/middleware/index.ts +43 -0
- package/src/middleware/types.ts +202 -0
- package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
- package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
- package/src/modules/appCatalog/service.ts +130 -0
- package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
- package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
- package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
- package/src/modules/approvalMethod/slugUtils.ts +17 -0
- package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
- package/src/modules/assets/assetRestController.ts +271 -0
- package/src/modules/assets/assetUtils.ts +114 -0
- package/src/modules/assets/screenshotRestController.ts +195 -0
- package/src/modules/assets/screenshotRouter.ts +112 -0
- package/src/modules/assets/syncAssets.ts +277 -0
- package/src/modules/assets/upsertAsset.ts +46 -0
- package/src/modules/auth/auth.ts +51 -0
- package/src/modules/auth/authProviders.ts +40 -0
- package/src/modules/auth/authRouter.ts +75 -0
- package/src/modules/auth/authorizationUtils.ts +132 -0
- package/src/modules/auth/devMockUserUtils.ts +49 -0
- package/src/modules/auth/registerAuthRoutes.ts +33 -0
- package/src/modules/icons/iconRestController.ts +171 -0
- package/src/modules/icons/iconRouter.ts +180 -0
- package/src/modules/icons/iconService.ts +73 -0
- package/src/modules/icons/iconUtils.ts +46 -0
- package/src/prisma-json-types.d.ts +34 -0
- package/src/server/controller.ts +47 -0
- package/src/server/ehStaticControllerContract.ts +19 -0
- package/src/server/ehTrpcContext.ts +26 -0
- package/src/server/trpcSetup.ts +89 -0
- package/src/types/backend/api.ts +73 -0
- package/src/types/backend/common.ts +10 -0
- package/src/types/backend/companySpecificBackend.ts +5 -0
- package/src/types/backend/dataSources.ts +25 -0
- package/src/types/backend/deployments.ts +40 -0
- package/src/types/common/app/appTypes.ts +13 -0
- package/src/types/common/app/ui/appUiTypes.ts +12 -0
- package/src/types/common/appCatalogTypes.ts +65 -0
- package/src/types/common/approvalMethodTypes.ts +149 -0
- package/src/types/common/env/envTypes.ts +7 -0
- package/src/types/common/resourceTypes.ts +8 -0
- package/src/types/common/sharedTypes.ts +5 -0
- package/src/types/index.ts +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { parseAssetMeta } from './assetUtils'
|
|
2
|
+
import type { AssetType, PrismaClient } from '@prisma/client'
|
|
3
|
+
|
|
4
|
+
export interface UpsertAssetParams {
|
|
5
|
+
prisma: PrismaClient
|
|
6
|
+
buffer: Buffer
|
|
7
|
+
name: string
|
|
8
|
+
originalFilename: string
|
|
9
|
+
assetType: AssetType
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function upsertAsset({
|
|
13
|
+
prisma,
|
|
14
|
+
buffer,
|
|
15
|
+
name,
|
|
16
|
+
originalFilename,
|
|
17
|
+
assetType,
|
|
18
|
+
}: UpsertAssetParams) {
|
|
19
|
+
const { checksum, fileSize, width, height, mimeType } = await parseAssetMeta({
|
|
20
|
+
buffer,
|
|
21
|
+
originalFilename,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// If an asset with the same checksum already exists, reuse it instead of storing duplicate binary.
|
|
25
|
+
const existing = await prisma.dbAsset.findUnique({
|
|
26
|
+
where: { name },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (existing) {
|
|
30
|
+
return existing.id
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const asset = await prisma.dbAsset.create({
|
|
34
|
+
data: {
|
|
35
|
+
name,
|
|
36
|
+
checksum,
|
|
37
|
+
assetType,
|
|
38
|
+
content: new Uint8Array(buffer),
|
|
39
|
+
mimeType,
|
|
40
|
+
fileSize,
|
|
41
|
+
width,
|
|
42
|
+
height,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
return asset.id
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
|
|
2
|
+
import { betterAuth } from 'better-auth'
|
|
3
|
+
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
|
4
|
+
import { getDbClient } from '../../db'
|
|
5
|
+
|
|
6
|
+
export interface AuthConfig {
|
|
7
|
+
appName?: string
|
|
8
|
+
baseURL: string
|
|
9
|
+
secret: string
|
|
10
|
+
providers?: BetterAuthOptions['socialProviders']
|
|
11
|
+
plugins?: Array<BetterAuthPlugin>
|
|
12
|
+
/** Session expiration in seconds. Default: 7 days (604800) */
|
|
13
|
+
sessionExpiresIn?: number
|
|
14
|
+
/** Session update age in seconds. Default: 1 day (86400) */
|
|
15
|
+
sessionUpdateAge?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createAuth(config: AuthConfig) {
|
|
19
|
+
const prisma = getDbClient()
|
|
20
|
+
const isProduction = process.env.NODE_ENV === 'production'
|
|
21
|
+
|
|
22
|
+
const auth = betterAuth({
|
|
23
|
+
appName: config.appName || 'EnvHopper',
|
|
24
|
+
baseURL: config.baseURL,
|
|
25
|
+
basePath: '/api/auth',
|
|
26
|
+
secret: config.secret,
|
|
27
|
+
database: prismaAdapter(prisma, {
|
|
28
|
+
provider: 'postgresql',
|
|
29
|
+
}),
|
|
30
|
+
socialProviders: config.providers || {},
|
|
31
|
+
plugins: config.plugins || [],
|
|
32
|
+
emailAndPassword: {
|
|
33
|
+
enabled: true,
|
|
34
|
+
},
|
|
35
|
+
session: {
|
|
36
|
+
expiresIn: config.sessionExpiresIn ?? 60 * 60 * 24 * 30,
|
|
37
|
+
updateAge: config.sessionUpdateAge ?? 60 * 60 * 24,
|
|
38
|
+
cookieCache: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
maxAge: 300,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
advanced: {
|
|
44
|
+
useSecureCookies: isProduction,
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return auth
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type BetterAuth = ReturnType<typeof createAuth>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth provider types and utilities
|
|
3
|
+
*
|
|
4
|
+
* Note: app-catalog backend-core does not read environment variables directly.
|
|
5
|
+
* Auth configuration (providers, plugins, admin groups) should be passed via
|
|
6
|
+
* middleware parameters by the client application.
|
|
7
|
+
*
|
|
8
|
+
* Example client-side configuration:
|
|
9
|
+
*
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { genericOAuth, okta } from 'better-auth/plugins'
|
|
12
|
+
*
|
|
13
|
+
* const authConfig = {
|
|
14
|
+
* baseURL: process.env.BETTER_AUTH_URL,
|
|
15
|
+
* secret: process.env.BETTER_AUTH_SECRET,
|
|
16
|
+
* providers: {
|
|
17
|
+
* github: {
|
|
18
|
+
* clientId: process.env.AUTH_GITHUB_CLIENT_ID,
|
|
19
|
+
* clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET,
|
|
20
|
+
* },
|
|
21
|
+
* google: {
|
|
22
|
+
* clientId: process.env.AUTH_GOOGLE_CLIENT_ID,
|
|
23
|
+
* clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET,
|
|
24
|
+
* },
|
|
25
|
+
* },
|
|
26
|
+
* plugins: [
|
|
27
|
+
* genericOAuth({
|
|
28
|
+
* config: [
|
|
29
|
+
* okta({
|
|
30
|
+
* clientId: process.env.AUTH_OKTA_CLIENT_ID,
|
|
31
|
+
* clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET,
|
|
32
|
+
* issuer: process.env.AUTH_OKTA_ISSUER,
|
|
33
|
+
* }),
|
|
34
|
+
* ],
|
|
35
|
+
* }),
|
|
36
|
+
* ],
|
|
37
|
+
* adminGroups: ['env_hopper_ui_super_admins'],
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { BetterAuthPlugin } from 'better-auth'
|
|
2
|
+
import type { TRPCRootObject } from '@trpc/server'
|
|
3
|
+
import type { EhTrpcContext } from '../../server/ehTrpcContext'
|
|
4
|
+
import type { BetterAuth } from './auth'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create auth tRPC procedures
|
|
8
|
+
* @param t - tRPC instance
|
|
9
|
+
* @param auth - Better Auth instance (optional, for future extensions)
|
|
10
|
+
* @returns tRPC router with auth procedures
|
|
11
|
+
*/
|
|
12
|
+
export function createAuthRouter(
|
|
13
|
+
t: TRPCRootObject<EhTrpcContext, {}, {}>,
|
|
14
|
+
auth?: BetterAuth,
|
|
15
|
+
) {
|
|
16
|
+
const router = t.router
|
|
17
|
+
const publicProcedure = t.procedure
|
|
18
|
+
|
|
19
|
+
return router({
|
|
20
|
+
getSession: publicProcedure.query(async ({ ctx }) => {
|
|
21
|
+
// User is now extracted in the tRPC context creation
|
|
22
|
+
return {
|
|
23
|
+
user: ctx.user ?? null,
|
|
24
|
+
isAuthenticated: !!ctx.user,
|
|
25
|
+
}
|
|
26
|
+
}),
|
|
27
|
+
getProviders: publicProcedure.query(() => {
|
|
28
|
+
// Return configured social providers and OAuth providers from plugins
|
|
29
|
+
const providers: Array<string> = []
|
|
30
|
+
const authOptions = auth?.options
|
|
31
|
+
|
|
32
|
+
// Add built-in social providers (github, google, etc.)
|
|
33
|
+
if (authOptions?.socialProviders) {
|
|
34
|
+
const socialProviders = authOptions.socialProviders as Record<
|
|
35
|
+
string,
|
|
36
|
+
unknown
|
|
37
|
+
>
|
|
38
|
+
Object.keys(socialProviders).forEach((key) => {
|
|
39
|
+
if (socialProviders[key]) {
|
|
40
|
+
providers.push(key)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Add OAuth providers from plugins (like Okta via genericOAuth)
|
|
46
|
+
if (authOptions?.plugins) {
|
|
47
|
+
const plugins = authOptions.plugins
|
|
48
|
+
plugins.forEach((plugin) => {
|
|
49
|
+
const pluginWithConfig = plugin as BetterAuthPlugin & {
|
|
50
|
+
options?: {
|
|
51
|
+
config?: Array<{ providerId?: string }>
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
pluginWithConfig.id === 'generic-oauth' &&
|
|
56
|
+
pluginWithConfig.options?.config
|
|
57
|
+
) {
|
|
58
|
+
const configs = Array.isArray(pluginWithConfig.options.config)
|
|
59
|
+
? pluginWithConfig.options.config
|
|
60
|
+
: [pluginWithConfig.options.config]
|
|
61
|
+
configs.forEach((config) => {
|
|
62
|
+
if (config.providerId) {
|
|
63
|
+
providers.push(config.providerId)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { providers }
|
|
71
|
+
}),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type AuthRouter = ReturnType<typeof createAuthRouter>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization utilities for checking user permissions based on groups
|
|
3
|
+
*
|
|
4
|
+
* Groups are automatically included in the user session when:
|
|
5
|
+
* 1. Okta auth server has a "groups" claim configured
|
|
6
|
+
* 2. The auth policy rule includes "groups" in scope_whitelist
|
|
7
|
+
*
|
|
8
|
+
* Example usage in tRPC procedures:
|
|
9
|
+
* ```typescript
|
|
10
|
+
* myProcedure: protectedProcedure.query(async ({ ctx }) => {
|
|
11
|
+
* if (requireAdmin(ctx.user)) {
|
|
12
|
+
* // Admin-only logic
|
|
13
|
+
* }
|
|
14
|
+
* // Regular user logic
|
|
15
|
+
* })
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface UserWithGroups {
|
|
20
|
+
id: string
|
|
21
|
+
email: string
|
|
22
|
+
name?: string
|
|
23
|
+
// Groups from Okta (or other identity provider)
|
|
24
|
+
// This will be populated if groups claim is configured
|
|
25
|
+
[key: string]: any
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract groups from user object
|
|
30
|
+
* Groups can be stored in different locations depending on the OAuth provider
|
|
31
|
+
*/
|
|
32
|
+
export function getUserGroups(
|
|
33
|
+
user: UserWithGroups | null | undefined,
|
|
34
|
+
): Array<string> {
|
|
35
|
+
if (!user) {
|
|
36
|
+
console.log('[getUserGroups] No user provided')
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Debug: Log all user properties to see what's available
|
|
41
|
+
console.log('[getUserGroups] === USER OBJECT DEBUG ===')
|
|
42
|
+
console.log('[getUserGroups] User ID:', user.id)
|
|
43
|
+
console.log('[getUserGroups] User email:', user.email)
|
|
44
|
+
console.log(
|
|
45
|
+
'[getUserGroups] User.env_hopper_groups:',
|
|
46
|
+
(user as any).env_hopper_groups,
|
|
47
|
+
)
|
|
48
|
+
console.log('[getUserGroups] User.groups:', user.groups)
|
|
49
|
+
console.log('[getUserGroups] User.oktaGroups:', (user as any).oktaGroups)
|
|
50
|
+
console.log('[getUserGroups] User.roles:', (user as any).roles)
|
|
51
|
+
console.log('[getUserGroups] All user keys:', Object.keys(user))
|
|
52
|
+
console.log(
|
|
53
|
+
'[getUserGroups] Full user object:',
|
|
54
|
+
JSON.stringify(user, null, 2),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// Check common locations for group information
|
|
58
|
+
// Order of preference: custom env_hopper_groups first
|
|
59
|
+
const groups =
|
|
60
|
+
(user as any).env_hopper_groups || // Custom env_hopper_groups claim (stores natera.env_hopper_ui.groups)
|
|
61
|
+
user.groups || // Standard "groups" claim
|
|
62
|
+
(user as any).oktaGroups || // Okta-specific
|
|
63
|
+
(user as any).roles || // Some providers use "roles"
|
|
64
|
+
[]
|
|
65
|
+
|
|
66
|
+
const result = Array.isArray(groups) ? groups : []
|
|
67
|
+
console.log('[getUserGroups] Final groups result:', result)
|
|
68
|
+
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if user is a member of any of the specified groups
|
|
74
|
+
*/
|
|
75
|
+
export function isMemberOfAnyGroup(
|
|
76
|
+
user: UserWithGroups | null | undefined,
|
|
77
|
+
allowedGroups: Array<string>,
|
|
78
|
+
): boolean {
|
|
79
|
+
const userGroups = getUserGroups(user)
|
|
80
|
+
return allowedGroups.some((group) => userGroups.includes(group))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if user is a member of all specified groups
|
|
85
|
+
*/
|
|
86
|
+
export function isMemberOfAllGroups(
|
|
87
|
+
user: UserWithGroups | null | undefined,
|
|
88
|
+
requiredGroups: Array<string>,
|
|
89
|
+
): boolean {
|
|
90
|
+
const userGroups = getUserGroups(user)
|
|
91
|
+
return requiredGroups.every((group) => userGroups.includes(group))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if user has admin permissions
|
|
96
|
+
* @param user User object with groups
|
|
97
|
+
* @param adminGroups List of admin group names (default: ['env_hopper_ui_super_admins'])
|
|
98
|
+
*/
|
|
99
|
+
export function isAdmin(
|
|
100
|
+
user: UserWithGroups | null | undefined,
|
|
101
|
+
adminGroups: Array<string> = ['env_hopper_ui_super_admins'],
|
|
102
|
+
): boolean {
|
|
103
|
+
return isMemberOfAnyGroup(user, adminGroups)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Require admin permissions - throws error if not admin
|
|
108
|
+
* @param user User object with groups
|
|
109
|
+
* @param adminGroups List of admin group names (default: ['env_hopper_ui_super_admins'])
|
|
110
|
+
*/
|
|
111
|
+
export function requireAdmin(
|
|
112
|
+
user: UserWithGroups | null | undefined,
|
|
113
|
+
adminGroups: Array<string> = ['env_hopper_ui_super_admins'],
|
|
114
|
+
): void {
|
|
115
|
+
if (!isAdmin(user, adminGroups)) {
|
|
116
|
+
throw new Error('Forbidden: Admin access required')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Require membership in specific groups - throws error if not member
|
|
122
|
+
*/
|
|
123
|
+
export function requireGroups(
|
|
124
|
+
user: UserWithGroups | null | undefined,
|
|
125
|
+
groups: Array<string>,
|
|
126
|
+
): void {
|
|
127
|
+
if (!isMemberOfAnyGroup(user, groups)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Forbidden: Membership in one of these groups required: ${groups.join(', ')}`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { EhDevMockUser } from '../../middleware/types'
|
|
2
|
+
import type { User } from 'better-auth/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extended User type with app-catalog specific fields
|
|
6
|
+
*/
|
|
7
|
+
type EhUser = User & {
|
|
8
|
+
env_hopper_groups?: Array<string>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a complete User object from basic dev mock user details
|
|
13
|
+
*/
|
|
14
|
+
export function createMockUserFromDevConfig(devUser: EhDevMockUser): EhUser {
|
|
15
|
+
return {
|
|
16
|
+
id: devUser.id,
|
|
17
|
+
email: devUser.email,
|
|
18
|
+
name: devUser.name,
|
|
19
|
+
emailVerified: true,
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
updatedAt: new Date(),
|
|
22
|
+
env_hopper_groups: devUser.groups,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a mock session response for /api/auth/session endpoint
|
|
28
|
+
*/
|
|
29
|
+
export function createMockSessionResponse(devUser: EhDevMockUser) {
|
|
30
|
+
return {
|
|
31
|
+
user: {
|
|
32
|
+
id: devUser.id,
|
|
33
|
+
email: devUser.email,
|
|
34
|
+
name: devUser.name,
|
|
35
|
+
emailVerified: true,
|
|
36
|
+
createdAt: new Date().toISOString(),
|
|
37
|
+
updatedAt: new Date().toISOString(),
|
|
38
|
+
env_hopper_groups: devUser.groups,
|
|
39
|
+
},
|
|
40
|
+
session: {
|
|
41
|
+
id: `${devUser.id}-session`,
|
|
42
|
+
userId: devUser.id,
|
|
43
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), // 30 days
|
|
44
|
+
token: `${devUser.id}-token`,
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
updatedAt: new Date().toISOString(),
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { toNodeHandler } from 'better-auth/node'
|
|
2
|
+
import type { Express, Request, Response } from 'express'
|
|
3
|
+
import type { BetterAuth } from './auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register Better Auth routes with Express
|
|
7
|
+
* @param app - Express application instance
|
|
8
|
+
* @param auth - Better Auth instance
|
|
9
|
+
*/
|
|
10
|
+
export function registerAuthRoutes(app: Express, auth: BetterAuth) {
|
|
11
|
+
// Explicit session endpoint handler
|
|
12
|
+
// Better Auth's toNodeHandler doesn't expose a direct /session endpoint
|
|
13
|
+
app.get('/api/auth/session', async (req: Request, res: Response) => {
|
|
14
|
+
try {
|
|
15
|
+
const session = await auth.api.getSession({
|
|
16
|
+
headers: req.headers as HeadersInit,
|
|
17
|
+
})
|
|
18
|
+
if (session) {
|
|
19
|
+
res.json(session)
|
|
20
|
+
} else {
|
|
21
|
+
res.status(401).json({ error: 'Not authenticated' })
|
|
22
|
+
}
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('[Auth Session Error]', error)
|
|
25
|
+
res.status(500).json({ error: 'Internal server error' })
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Use toNodeHandler to adapt better-auth for Express/Node.js
|
|
30
|
+
// Express v5 wildcard syntax: /{*any} (also works with Express v4)
|
|
31
|
+
const authHandler = toNodeHandler(auth)
|
|
32
|
+
app.all('/api/auth/{*any}', authHandler)
|
|
33
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Request, Response, Router } from 'express'
|
|
2
|
+
import multer from 'multer'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { getDbClient } from '../../db'
|
|
5
|
+
import { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'
|
|
6
|
+
|
|
7
|
+
// Configure multer for memory storage
|
|
8
|
+
const upload = multer({
|
|
9
|
+
storage: multer.memoryStorage(),
|
|
10
|
+
limits: {
|
|
11
|
+
fileSize: 10 * 1024 * 1024, // 10MB limit
|
|
12
|
+
},
|
|
13
|
+
fileFilter: (_req, file, cb) => {
|
|
14
|
+
// Accept images only
|
|
15
|
+
if (!file.mimetype.startsWith('image/')) {
|
|
16
|
+
cb(new Error('Only image files are allowed'))
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
cb(null, true)
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export interface IconRestControllerConfig {
|
|
24
|
+
/**
|
|
25
|
+
* Base path for icon endpoints (e.g., '/api/icons')
|
|
26
|
+
*/
|
|
27
|
+
basePath: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Registers REST endpoints for icon upload and retrieval
|
|
32
|
+
*
|
|
33
|
+
* Endpoints:
|
|
34
|
+
* - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)
|
|
35
|
+
* - GET {basePath}/:id - Get icon binary by ID
|
|
36
|
+
* - GET {basePath}/:id/metadata - Get icon metadata only
|
|
37
|
+
*/
|
|
38
|
+
export function registerIconRestController(
|
|
39
|
+
router: Router,
|
|
40
|
+
config: IconRestControllerConfig,
|
|
41
|
+
): void {
|
|
42
|
+
const { basePath } = config
|
|
43
|
+
|
|
44
|
+
// Upload endpoint - accepts multipart/form-data
|
|
45
|
+
router.post(
|
|
46
|
+
`${basePath}/upload`,
|
|
47
|
+
upload.single('icon'),
|
|
48
|
+
async (req: Request, res: Response) => {
|
|
49
|
+
try {
|
|
50
|
+
if (!req.file) {
|
|
51
|
+
res.status(400).json({ error: 'No file uploaded' })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let name = req.body['name'] as string
|
|
56
|
+
if (!name) {
|
|
57
|
+
res.status(400).json({ error: 'Name is required' })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Extract extension from original filename or derive from MIME type
|
|
62
|
+
const extension =
|
|
63
|
+
getExtensionFromFilename(req.file.originalname) ||
|
|
64
|
+
getExtensionFromMimeType(req.file.mimetype)
|
|
65
|
+
|
|
66
|
+
// Add extension to name if not already present
|
|
67
|
+
if (!name.includes('.')) {
|
|
68
|
+
name = `${name}.${extension}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const prisma = getDbClient()
|
|
72
|
+
const checksum = createHash('sha256')
|
|
73
|
+
.update(req.file.buffer)
|
|
74
|
+
.digest('hex')
|
|
75
|
+
const icon = await prisma.dbAsset.create({
|
|
76
|
+
data: {
|
|
77
|
+
name,
|
|
78
|
+
assetType: 'icon',
|
|
79
|
+
content: new Uint8Array(req.file.buffer),
|
|
80
|
+
mimeType: req.file.mimetype,
|
|
81
|
+
fileSize: req.file.size,
|
|
82
|
+
checksum,
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
res.status(201).json({
|
|
87
|
+
id: icon.id,
|
|
88
|
+
name: icon.name,
|
|
89
|
+
mimeType: icon.mimeType,
|
|
90
|
+
fileSize: icon.fileSize,
|
|
91
|
+
createdAt: icon.createdAt,
|
|
92
|
+
})
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('Error uploading icon:', error)
|
|
95
|
+
res.status(500).json({ error: 'Failed to upload icon' })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Get icon binary by name (e.g., /api/icons/jira.svg)
|
|
101
|
+
router.get(`${basePath}/:name`, async (req: Request, res: Response) => {
|
|
102
|
+
try {
|
|
103
|
+
const { name } = req.params
|
|
104
|
+
|
|
105
|
+
const prisma = getDbClient()
|
|
106
|
+
const icon = await prisma.dbAsset.findFirst({
|
|
107
|
+
where: {
|
|
108
|
+
name,
|
|
109
|
+
assetType: 'icon',
|
|
110
|
+
},
|
|
111
|
+
select: {
|
|
112
|
+
content: true,
|
|
113
|
+
mimeType: true,
|
|
114
|
+
name: true,
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
if (!icon) {
|
|
119
|
+
res.status(404).json({ error: 'Icon not found' })
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Set appropriate headers
|
|
124
|
+
res.setHeader('Content-Type', icon.mimeType)
|
|
125
|
+
res.setHeader('Content-Disposition', `inline; filename="${icon.name}"`)
|
|
126
|
+
res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
|
|
127
|
+
|
|
128
|
+
// Send binary content
|
|
129
|
+
res.send(icon.content)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Error fetching icon:', error)
|
|
132
|
+
res.status(500).json({ error: 'Failed to fetch icon' })
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// Get icon metadata only (no binary content)
|
|
137
|
+
router.get(
|
|
138
|
+
`${basePath}/:name/metadata`,
|
|
139
|
+
async (req: Request, res: Response) => {
|
|
140
|
+
try {
|
|
141
|
+
const { name } = req.params
|
|
142
|
+
|
|
143
|
+
const prisma = getDbClient()
|
|
144
|
+
const icon = await prisma.dbAsset.findFirst({
|
|
145
|
+
where: {
|
|
146
|
+
name,
|
|
147
|
+
assetType: 'icon',
|
|
148
|
+
},
|
|
149
|
+
select: {
|
|
150
|
+
id: true,
|
|
151
|
+
name: true,
|
|
152
|
+
mimeType: true,
|
|
153
|
+
fileSize: true,
|
|
154
|
+
createdAt: true,
|
|
155
|
+
updatedAt: true,
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (!icon) {
|
|
160
|
+
res.status(404).json({ error: 'Icon not found' })
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
res.json(icon)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error('Error fetching icon metadata:', error)
|
|
167
|
+
res.status(500).json({ error: 'Failed to fetch icon metadata' })
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
}
|