Files
speckle-server/packages/server/modules/acc/index.ts
T
2025-07-23 17:50:45 +01:00

342 lines
12 KiB
TypeScript

/* eslint-disable camelcase */
import { createAccOidcFlow } from '@/modules/acc/oidcHelper'
import { tryRegisterAccWebhook } from '@/modules/acc/webhook'
import { sessionMiddlewareFactory } from '@/modules/auth/middleware'
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { moduleLogger } from '@/observability/logging'
import { Express } from 'express'
import { db } from '@/db/knex'
import { queryAllPendingAccSyncItemsFactory } from '@/modules/acc/repositories/accSyncItems'
import { scheduleExecutionFactory } from '@/modules/core/services/taskScheduler'
import {
acquireTaskLockFactory,
releaseTaskLockFactory
} from '@/modules/core/repositories/scheduledTasks'
import { Scopes, TIME_MS } from '@speckle/shared'
import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations'
import { AccSyncItems } from '@/modules/acc/dbSchema'
import { AccSyncItem } from '@/modules/acc/domain/types'
import {
getAutomationTokenFactory,
getLatestAutomationRevisionFactory,
InsertableAutomationRun,
upsertAutomationRunFactory
} from '@/modules/automate/repositories/automations'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import cryptoRandomString from 'crypto-random-string'
import { triggerAutomationRun } from '@/modules/automate/clients/executionEngine'
import { DefaultAppIds } from '@/modules/auth/defaultApps'
import { createAppTokenFactory } from '@/modules/core/services/tokens'
import {
storeApiTokenFactory,
storeTokenScopesFactory,
storeTokenResourceAccessDefinitionsFactory,
storeUserServerAppTokenFactory
} from '@/modules/core/repositories/tokens'
import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql'
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
export default function accRestApi(app: Express) {
const sessionMiddleware = sessionMiddlewareFactory()
app.post('/auth/acc/login', sessionMiddleware, async (req, res) => {
const { projectId } = req.body
req.session.projectId = projectId
const accFlow = createAccOidcFlow()
const { codeVerifier, codeChallenge } = accFlow.generateCodeVerifier()
req.session.codeVerifier = codeVerifier
const redirectUri = `${getServerOrigin()}/auth/acc/callback`
console.log({ redirectUri })
const authorizeUrl = accFlow.buildAuthorizeUrl({
clientId: '5Y2LzxsL3usaD1xAMyElBY8mcN6XKyfHfulZDV3up0jfhN5Y',
redirectUri,
codeChallenge,
scopes: ['user-profile:read', 'data:read', 'viewables:read', 'openid']
})
return res.json({ authorizeUrl })
})
app.get('/auth/acc/callback', sessionMiddleware, async (req, res) => {
const { code } = req.query
const codeVerifier = req.session.codeVerifier
if (!code || !codeVerifier) {
return res.status(400).send({ error: 'Missing code or verifier' })
}
const accFlow = createAccOidcFlow()
try {
const tokens = await accFlow.exchangeCodeForTokens({
code: String(code),
codeVerifier,
clientId: '5Y2LzxsL3usaD1xAMyElBY8mcN6XKyfHfulZDV3up0jfhN5Y',
clientSecret:
'qHyGqaP4zCWLyS2lp04qBDOC1giIupPzJPmLFKGFHKZrPYYpan27zF8vlhQr1RYL',
redirectUri: `${getServerOrigin()}/auth/acc/callback`
})
req.session.accTokens = tokens
return res.redirect(`/projects/${req.session.projectId}/acc`)
} catch (error) {
console.error('Token exchange failed:', error)
return res.status(500).send({ error: 'Token exchange failed' })
}
})
app.get('/auth/acc/status', sessionMiddleware, (req, res) => {
if (!req.session.accTokens) {
return res.status(404).send({ error: 'No ACC tokens found' })
}
res.send(req.session.accTokens)
})
app.post('/auth/acc/refresh', sessionMiddleware, async (req, res) => {
const { refresh_token } = req.session.accTokens || {}
if (!refresh_token) {
return res.status(401).json({ error: 'No refresh token found' })
}
try {
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: '5Y2LzxsL3usaD1xAMyElBY8mcN6XKyfHfulZDV3up0jfhN5Y',
client_secret:
'qHyGqaP4zCWLyS2lp04qBDOC1giIupPzJPmLFKGFHKZrPYYpan27zF8vlhQr1RYL',
refresh_token
})
const response = await fetch(
'https://developer.api.autodesk.com/authentication/v2/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
}
)
if (!response.ok) {
console.error(await response.text())
return res.status(500).json({ error: 'Failed to refresh token' })
}
const newTokens = await response.json()
req.session.accTokens = newTokens
res.json(newTokens)
} catch (error) {
console.error('Error refreshing token:', error)
res.status(500).json({ error: 'Error refreshing token' })
}
})
app.post('/acc/sync-item-created', sessionMiddleware, async (req, res) => {
const { accHubUrn } = req.body
if (!req.session.accTokens) {
throw new Error('whatever')
}
const { access_token } = req.session.accTokens
await tryRegisterAccWebhook({
accessToken: access_token,
rootProjectId: accHubUrn,
region: 'EMEA',
event: ''
})
res.status(200)
})
// Registered ACC webhooks are handled here
// https://aps.autodesk.com/en/docs/webhooks/v1/reference/events/data_management_events/dm.version.added/
app.post('/acc/webhook/callback', sessionMiddleware, async (req, res) => {
const lineageUrn = req.body?.payload?.lineageUrn
if (!lineageUrn) {
console.warn('Webhook received without lineageUrn')
return res.status(400).send({ error: 'Missing lineageUrn' })
}
const accFileVersionIndex = Number.parseInt(req.body?.payload?.version ?? '0')
const accFileVersionUrn = req.body?.payload?.source
// TODO ACC: need to know when svf2 is generated, whether with timeout or a webhook that unknown for now
try {
// TODO: Multiple references to same item?
const affectedRows = await db<AccSyncItem>('acc_sync_items')
.where({ accFileLineageId: lineageUrn })
.andWhere(AccSyncItems.col.accFileVersionIndex, '<', accFileVersionIndex)
.update({
status: 'PENDING',
accFileVersionIndex,
accFileVersionUrn
})
.returning('*')
if (affectedRows.length > 0) {
console.log(
`✅ Updated ${affectedRows.length} item(s) with lineageUrn ${lineageUrn} to INITIALIZING`
// TODO ACC: trigger automation and update status of sync item (as in createAccSyncItemAndNotifyFactory)
)
} else {
console.log(`⚠️ No acc_sync_items matched lineageUrn ${lineageUrn}`)
}
res.status(200).send('OK')
} catch (err) {
console.error('❌ Failed to update acc_sync_items:', err)
res.status(500).send({ error: 'DB update failed' })
}
})
}
let scheduledTask: ReturnType<ScheduleExecution> | null = null
const schedulePendingAccSyncItemsPoll = () => {
const scheduleExecution = scheduleExecutionFactory({
acquireTaskLock: acquireTaskLockFactory({ db }),
releaseTaskLock: releaseTaskLockFactory({ db })
})
return scheduleExecution(
'*/1 * * * *', // Every minute
'pendingAccSyncItemPolling',
async (now: Date, { logger }) => {
logger.info('Checking for pending ACC Sync items')
for await (const items of queryAllPendingAccSyncItemsFactory({ db })()) {
for (const syncItem of items) {
console.log(`${syncItem.accFileVersionUrn} : ${syncItem.accFileName}`)
const projectDb = await getProjectDbClient({ projectId: syncItem.projectId })
const automationRevision = await getLatestAutomationRevisionFactory({
db: projectDb
})({ automationId: syncItem.automationId })
if (!automationRevision) continue
const runId = cryptoRandomString({ length: 15 })
const runData: InsertableAutomationRun = {
id: runId,
automationRevisionId: automationRevision.id,
createdAt: new Date(),
updatedAt: new Date(),
status: 'pending',
executionEngineRunId: null,
triggers: [
{
// TODO ACC: This is not meaningful until we integrate with fileUpload
triggeringId: '',
triggerType: 'versionCreation'
}
],
functionRuns: [
{
functionId: '2909d29a9d',
id: cryptoRandomString({ length: 15 }),
status: 'pending' as const,
elapsed: 0,
results: null,
contextView: null,
statusMessage: null,
functionReleaseId: 'd6947185f3',
createdAt: new Date(),
updatedAt: new Date()
}
]
}
await upsertAutomationRunFactory({ db: projectDb })(runData)
const projectScopedToken = await createAppTokenFactory({
storeApiToken: storeApiTokenFactory({ db }),
storeTokenScopes: storeTokenScopesFactory({ db }),
storeTokenResourceAccessDefinitions:
storeTokenResourceAccessDefinitionsFactory({
db
}),
storeUserServerAppToken: storeUserServerAppTokenFactory({ db })
})({
appId: DefaultAppIds.Automate,
name: `acct-${syncItem.id}`,
userId: syncItem.authorId,
// for now this is a baked in constant
// should rely on the function definitions requesting the needed scopes
scopes: [
Scopes.Profile.Read,
Scopes.Streams.Read,
Scopes.Streams.Write,
Scopes.Automate.ReportResults
],
limitResources: [
{
id: syncItem.projectId,
type: TokenResourceIdentifierType.Project
}
]
})
const automationToken = await getAutomationTokenFactory({ db: projectDb })(
syncItem.automationId
)
console.log({ automationToken })
if (!automationToken) continue
await triggerAutomationRun({
projectId: syncItem.projectId,
automationId: syncItem.automationId,
functionRuns: runData.functionRuns.map((r) => ({
...r,
runId: cryptoRandomString({ length: 15 }),
resultVersions: [],
functionInputs: {
projectId: syncItem.projectId,
modelId: syncItem.modelId,
autodeskUrn: btoa(syncItem.accFileVersionUrn)
.replaceAll('/', '_')
.replaceAll('==', ''),
autodeskRegion: 1,
autodeskClientId: '5Y2LzxsL3usaD1xAMyElBY8mcN6XKyfHfulZDV3up0jfhN5Y',
autodeskClientSecret:
'qHyGqaP4zCWLyS2lp04qBDOC1giIupPzJPmLFKGFHKZrPYYpan27zF8vlhQr1RYL'
}
})),
manifests: [
{
triggerType: 'versionCreation'
}
],
speckleToken: projectScopedToken,
automationToken: automationToken.automateToken
})
}
}
},
30 * TIME_MS.second
)
}
export const init: SpeckleModule['init'] = async ({ app }) => {
moduleLogger.info('🔑 Init acc module')
// Hoist rest
accRestApi(app)
scheduledTask = schedulePendingAccSyncItemsPoll()
}
export const shutdown: SpeckleModule['shutdown'] = async () => {
scheduledTask?.stop()
}
export const finalize: SpeckleModule['finalize'] = async () => {}