From cb16385b334ec215e0ebdce696ae04580061d6ab Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 20 Jul 2026 02:11:44 +0100 Subject: [PATCH] Added printer and filament profile schemas, routes, and associated logic for managing profiles. Enhanced authentication for slicer uploads and integrated new routes for handling slicer-related operations. Updated database utilities and services to support new features, improving overall functionality and user experience. --- src/__tests__/keycloak.apppassword.test.js | 133 ++++++++ src/database/database.js | 17 +- src/database/redis.js | 19 ++ src/database/schemas/models.js | 16 + .../production/filamentprofile.schema.js | 165 ++++++++++ .../schemas/production/printer.schema.js | 13 + .../production/printerprofile.schema.js | 111 +++++++ src/database/utils.js | 55 +++- src/index.js | 6 + src/keycloak.js | 23 +- src/routes/index.js | 6 + src/routes/misc/slicer.js | 23 ++ src/routes/production/filamentprofiles.js | 48 +++ src/routes/production/printerprofiles.js | 48 +++ .../management/__tests__/files.test.js | 14 +- src/services/management/filamentskus.js | 4 +- src/services/management/files.js | 102 +++++- .../misc/__tests__/sessionStore.test.js | 90 ++++++ src/services/misc/__tests__/slicer.test.js | 141 ++++++++ src/services/misc/auth.js | 11 + src/services/misc/sessionStore.js | 73 +++++ src/services/misc/slicer.js | 229 +++++++++++++ .../__tests__/filamentprofiles.test.js | 137 ++++++++ .../__tests__/printerprofiles.test.js | 158 +++++++++ .../production/__tests__/printers.test.js | 27 +- src/services/production/filamentprofiles.js | 305 ++++++++++++++++++ src/services/production/printerprofiles.js | 193 +++++++++++ src/services/production/printers.js | 10 +- 28 files changed, 2127 insertions(+), 50 deletions(-) create mode 100644 src/__tests__/keycloak.apppassword.test.js create mode 100644 src/database/schemas/production/filamentprofile.schema.js create mode 100644 src/database/schemas/production/printerprofile.schema.js create mode 100644 src/routes/misc/slicer.js create mode 100644 src/routes/production/filamentprofiles.js create mode 100644 src/routes/production/printerprofiles.js create mode 100644 src/services/misc/__tests__/sessionStore.test.js create mode 100644 src/services/misc/__tests__/slicer.test.js create mode 100644 src/services/misc/slicer.js create mode 100644 src/services/production/__tests__/filamentprofiles.test.js create mode 100644 src/services/production/__tests__/printerprofiles.test.js create mode 100644 src/services/production/filamentprofiles.js create mode 100644 src/services/production/printerprofiles.js diff --git a/src/__tests__/keycloak.apppassword.test.js b/src/__tests__/keycloak.apppassword.test.js new file mode 100644 index 0000000..64c621f --- /dev/null +++ b/src/__tests__/keycloak.apppassword.test.js @@ -0,0 +1,133 @@ +import { jest } from '@jest/globals'; + +const userModelMock = { + findOne: jest.fn(), +}; +const appPasswordQueryMock = { + select: jest.fn(), + lean: jest.fn(), +}; +const appPasswordModelMock = { + find: jest.fn(() => appPasswordQueryMock), +}; +const bcryptCompareMock = jest.fn(); + +jest.unstable_mockModule('../config.js', () => ({ + default: { server: { logLevel: 'info' } }, + getEnvironment: jest.fn(), +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), + }, +})); + +jest.unstable_mockModule('node-cache', () => ({ + default: class { + get() {} + set() {} + on() {} + flushAll() {} + getStats() {} + del() {} + }, +})); + +jest.unstable_mockModule('bcrypt', () => ({ + default: { compare: bcryptCompareMock }, +})); + +jest.unstable_mockModule('../database/schemas/management/user.schema.js', () => ({ + userModel: userModelMock, +})); + +jest.unstable_mockModule('../database/schemas/management/apppassword.schema.js', () => ({ + appPasswordModel: appPasswordModelMock, +})); + +jest.unstable_mockModule('../database/database.js', () => ({ + getObject: jest.fn(), +})); + +jest.unstable_mockModule('../database/schemas/management/host.schema.js', () => ({ + hostModel: {}, +})); + +jest.unstable_mockModule('../services/misc/auth.js', () => ({ + getSession: jest.fn(), + lookupUserByToken: jest.fn(), +})); + +const { isAppAuthenticated } = await import('../keycloak.js'); + +describe('app-password authentication', () => { + let req; + let res; + let next; + + beforeEach(() => { + jest.clearAllMocks(); + appPasswordQueryMock.select.mockReturnValue(appPasswordQueryMock); + req = { + headers: { + authorization: `Basic ${Buffer.from('slicer-user:app-secret').toString('base64')}`, + }, + params: { username: 'slicer-user' }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + }); + + it('accepts a valid username and app password', async () => { + const user = { _id: 'user-1', username: 'slicer-user' }; + userModelMock.findOne.mockResolvedValue(user); + appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]); + bcryptCompareMock.mockResolvedValue(true); + + await isAppAuthenticated(req, res, next); + + expect(req.user).toBe(user); + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('accepts the app password from the password route parameter', async () => { + const user = { _id: 'user-1', username: 'slicer-user' }; + req.headers = {}; + req.params.password = 'app-secret'; + userModelMock.findOne.mockResolvedValue(user); + appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]); + bcryptCompareMock.mockResolvedValue(true); + + await isAppAuthenticated(req, res, next); + + expect(bcryptCompareMock).toHaveBeenCalledWith('app-secret', 'stored-hash'); + expect(req.user).toBe(user); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('rejects an invalid app password', async () => { + userModelMock.findOne.mockResolvedValue({ _id: 'user-1', username: 'slicer-user' }); + appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]); + bcryptCompareMock.mockResolvedValue(false); + + await isAppAuthenticated(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Not Authenticated', + code: 'UNAUTHORIZED', + }); + }); +}); diff --git a/src/database/database.js b/src/database/database.js index 04dfde9..3634450 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -556,6 +556,15 @@ export const listObjectsByProperties = async ({ // Build aggregation pipeline const pipeline = []; + logger.debug('Master filter:', masterFilter); + + // Match before populate so reference fields (e.g. filament) are still ObjectIds + if (Object.keys(masterFilter).length > 0) { + const convertedFilter = convertObjectIdStringsInFilter(masterFilter); + logger.debug('Converted filter:', convertedFilter); + pipeline.push({ $match: convertedFilter }); + } + // Handle populate (array or single value) if (populate) { const populates = Array.isArray(populate) ? populate : [populate]; @@ -599,14 +608,6 @@ export const listObjectsByProperties = async ({ } } - logger.debug('Master filter:', masterFilter); - - if (Object.keys(masterFilter).length > 0) { - const convertedFilter = convertObjectIdStringsInFilter(masterFilter); - logger.debug('Converted filter:', convertedFilter); - pipeline.push({ $match: convertedFilter }); - } - if (propertiesPresent) { // Build the $group _id object for all properties const groupId = {}; diff --git a/src/database/redis.js b/src/database/redis.js index 4a9f7a1..9a8c060 100644 --- a/src/database/redis.js +++ b/src/database/redis.js @@ -55,6 +55,25 @@ class RedisServer { await this.client.del(key); } + async getAndDeleteKey(key) { + await this.connect(); + const value = await this.client.getDel(key); + if (value == null) return null; + try { + return JSON.parse(value); + } catch { + return value; + } + } + + async eval(script, keys = [], args = []) { + await this.connect(); + return this.client.eval(script, { + keys, + arguments: args.map((arg) => String(arg)), + }); + } + async getKeysByPattern(pattern) { await this.connect(); const keys = []; diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 44b07a0..b55486a 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -1,6 +1,8 @@ import { jobModel } from './production/job.schema.js'; import { subJobModel } from './production/subjob.schema.js'; import { printerModel } from './production/printer.schema.js'; +import { printerProfileModel } from './production/printerprofile.schema.js'; +import { filamentProfileModel } from './production/filamentprofile.schema.js'; import { filamentModel } from './management/filament.schema.js'; import { filamentSkuModel } from './management/filamentsku.schema.js'; import { gcodeFileModel } from './production/gcodefile.schema.js'; @@ -55,6 +57,20 @@ export const models = { referenceField: '_reference', label: 'Printer', }, + PPF: { + model: printerProfileModel, + idField: '_id', + type: 'printerProfile', + referenceField: '_reference', + label: 'Printer Profile', + }, + FPF: { + model: filamentProfileModel, + idField: '_id', + type: 'filamentProfile', + referenceField: '_reference', + label: 'Filament Profile', + }, FIL: { model: filamentModel, idField: '_id', diff --git a/src/database/schemas/production/filamentprofile.schema.js b/src/database/schemas/production/filamentprofile.schema.js new file mode 100644 index 0000000..91b893b --- /dev/null +++ b/src/database/schemas/production/filamentprofile.schema.js @@ -0,0 +1,165 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; + +const filamentProfileSchema = new mongoose.Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + filamentType: { type: String, required: true }, + filament: { + type: mongoose.Schema.Types.ObjectId, + refPath: 'filamentType', + required: true, + }, + filamentIsSupport: { type: Boolean }, + filamentSoluble: { type: Boolean }, + filamentPrintable: { type: Number }, + filamentAdhesivenessCategory: { type: Number }, + temperatureVitrification: { type: Number }, + idleTemperature: { type: Number }, + pelletFlowCoefficient: { type: Number }, + requiredNozzleHrc: { type: Number }, + filamentFlowRatio: { type: Number }, + enablePressureAdvance: { type: Boolean }, + pressureAdvance: { type: Number }, + adaptivePressureAdvance: { type: Boolean }, + adaptivePressureAdvanceBridges: { type: Boolean }, + adaptivePressureAdvanceOverhangs: { type: Boolean }, + adaptivePressureAdvanceModel: { type: String }, + activateChamberTempControl: { type: Boolean }, + chamberTemperature: { type: Number }, + chamberMinimalTemperature: { type: Number }, + nozzleTemperatureInitialLayer: { type: Number }, + nozzleTemperature: { type: Number }, + nozzleTemperatureRangeLow: { type: Number }, + nozzleTemperatureRangeHigh: { type: Number }, + hotPlateTempInitialLayer: { type: Number }, + hotPlateTemp: { type: Number }, + coolPlateTempInitialLayer: { type: Number }, + coolPlateTemp: { type: Number }, + engPlateTempInitialLayer: { type: Number }, + engPlateTemp: { type: Number }, + texturedPlateTempInitialLayer: { type: Number }, + texturedPlateTemp: { type: Number }, + texturedCoolPlateTempInitialLayer: { type: Number }, + texturedCoolPlateTemp: { type: Number }, + supertackPlateTempInitialLayer: { type: Number }, + supertackPlateTemp: { type: Number }, + filamentAdaptiveVolumetricSpeed: { type: Boolean }, + filamentMaxVolumetricSpeed: { type: Number }, + volumetricSpeedCoefficients: { type: String }, + closeFanTheFirstXLayers: { type: Number }, + fullFanSpeedLayer: { type: Number }, + fanMinSpeed: { type: Number }, + fanMaxSpeed: { type: Number }, + reduceFanStopStartFreq: { type: Boolean }, + slowDownForLayerCooling: { type: Boolean }, + dontSlowDownOuterWall: { type: Boolean }, + slowDownMinSpeed: { type: Number }, + slowDownLayerTime: { type: Number }, + fanCoolingLayerTime: { type: Number }, + enableOverhangBridgeFan: { type: Boolean }, + overhangFanThreshold: { type: String }, + overhangFanSpeed: { type: Number }, + internalBridgeFanSpeed: { type: Number }, + supportMaterialInterfaceFanSpeed: { type: Number }, + ironingFanSpeed: { type: Number }, + initialLayerFanSpeed: { type: Number }, + firstXLayerFanSpeed: { type: Number }, + additionalCoolingFanSpeed: { type: Number }, + additionalFanFullSpeedLayer: { type: Number }, + closeAdditionalFanFirstXLayers: { type: Boolean }, + activateAirFiltration: { type: Boolean }, + activateAirFiltrationDuringPrint: { type: Boolean }, + activateAirFiltrationOnCompletion: { type: Boolean }, + duringPrintExhaustFanSpeed: { type: Number }, + completePrintExhaustFanSpeed: { type: Number }, + filamentRetractionLength: { type: Number }, + filamentRetractionSpeed: { type: Number }, + filamentDeretractionSpeed: { type: Number }, + filamentRetractionMinimumTravel: { type: Number }, + filamentRetractWhenChangingLayer: { type: Boolean }, + filamentRetractBeforeWipe: { type: Number }, + filamentRetractAfterWipe: { type: Number }, + filamentRetractRestartExtra: { type: Number }, + filamentRetractLiftAbove: { type: Number }, + filamentRetractLiftBelow: { type: Number }, + filamentRetractLiftEnforce: { type: String }, + filamentWipe: { type: Boolean }, + filamentWipeDistance: { type: Number }, + filamentZHop: { type: Number }, + filamentZHopTypes: { type: String }, + filamentLongRetractionsWhenCut: { type: Boolean }, + filamentRetractionDistancesWhenCut: { type: Number }, + longRetractionsWhenEc: { type: Boolean }, + retractionDistancesWhenEc: { type: Number }, + filamentLoadingSpeed: { type: Number }, + filamentLoadingSpeedStart: { type: Number }, + filamentUnloadingSpeed: { type: Number }, + filamentUnloadingSpeedStart: { type: Number }, + filamentChangeLength: { type: Number }, + filamentChangeLengthNc: { type: Number }, + filamentToolchangeDelay: { type: Number }, + filamentExtruderCompatibility: { type: Number }, + filamentExtruderVariant: { type: String }, + filamentMultitoolRamming: { type: Boolean }, + filamentMultitoolRammingFlow: { type: Number }, + filamentMultitoolRammingVolume: { type: Number }, + filamentRammingParameters: { type: String }, + filamentRammingTravelTime: { type: Number }, + filamentRammingTravelTimeNc: { type: Number }, + filamentRammingVolumetricSpeed: { type: Number }, + filamentRammingVolumetricSpeedNc: { type: Number }, + filamentMinimalPurgeOnWipeTower: { type: Number }, + filamentTowerInterfacePreExtrusionDist: { type: Number }, + filamentTowerInterfacePreExtrusionLength: { type: Number }, + filamentTowerInterfacePrintTemp: { type: Number }, + filamentTowerInterfacePurgeVolume: { type: Number }, + filamentTowerIroningArea: { type: Number }, + filamentCoolingBeforeTower: { type: Number }, + filamentCoolingInitialSpeed: { type: Number }, + filamentCoolingFinalSpeed: { type: Number }, + filamentCoolingMoves: { type: Number }, + filamentFlushTemp: { type: Number }, + filamentFlushTempFast: { type: Number }, + filamentFlushVolumetricSpeed: { type: Number }, + filamentPreCoolingTemperature: { type: Number }, + filamentPreCoolingTemperatureNc: { type: Number }, + filamentPreheatTemperatureDelta: { type: Number }, + filamentPrimeVolumeNc: { type: Number }, + filamentRetractLengthNc: { type: Number }, + filamentStampingDistance: { type: Number }, + filamentStampingLoadingSpeed: { type: Number }, + filamentStartGcode: { type: String }, + filamentEndGcode: { type: String }, + filamentChangeExtrusionRoleGcode: { type: String }, + filamentShrink: { type: String }, + filamentShrinkageCompensationZ: { type: String }, + filamentDevAmsDryingTemperature: { type: Number }, + filamentDevAmsDryingTime: { type: Number }, + filamentDevAmsDryingHeatDistortionTemperature: { type: Number }, + filamentDevAmsDryingAmsLimitations: { type: String }, + filamentDevChamberDryingBedTemperature: { type: Number }, + filamentDevChamberDryingTime: { type: Number }, + filamentDevDryingCoolingTemperature: { type: Number }, + filamentDevDryingSofteningTemperature: { type: Number }, + compatiblePrinters: [ + { type: mongoose.Schema.Types.ObjectId, ref: 'printerProfile' }, + ], + compatiblePrintersCondition: { type: String }, + compatiblePrints: [{ type: String }], + compatiblePrintsCondition: { type: String }, + filamentNotes: { type: String }, + }, + { timestamps: true } +); + +filamentProfileSchema.index({ name: 'text', filamentNotes: 'text' }); + +filamentProfileSchema.virtual('id').get(function () { + return this._id; +}); + +filamentProfileSchema.set('toJSON', { virtuals: true }); + +export const filamentProfileModel = mongoose.model('filamentProfile', filamentProfileSchema); diff --git a/src/database/schemas/production/printer.schema.js b/src/database/schemas/production/printer.schema.js index 35b8398..89c7962 100644 --- a/src/database/schemas/production/printer.schema.js +++ b/src/database/schemas/production/printer.schema.js @@ -25,6 +25,18 @@ const alertSchema = new Schema( { timestamps: true, _id: false } ); +const pendingSlicerUploadSchema = new Schema( + { + file: { type: Schema.Types.ObjectId, ref: 'file', required: true }, + gcodeFile: { type: Schema.Types.ObjectId, ref: 'gcodeFile', default: null }, + job: { type: Schema.Types.ObjectId, ref: 'job', default: null }, + shouldPrint: { type: Boolean, required: true, default: false }, + new: { type: Boolean, required: true, default: true }, + properties: { type: Object, required: true, default: () => ({}) }, + }, + { timestamps: true } +); + // Define the main printer schema const printerSchema = new Schema( { @@ -53,6 +65,7 @@ const printerSchema = new Schema( vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null }, host: { type: Schema.Types.ObjectId, ref: 'host', default: null }, alerts: [alertSchema], + pendingSlicerUploads: { type: [pendingSlicerUploadSchema], default: [] }, }, { timestamps: true } ); diff --git a/src/database/schemas/production/printerprofile.schema.js b/src/database/schemas/production/printerprofile.schema.js new file mode 100644 index 0000000..eea6ea7 --- /dev/null +++ b/src/database/schemas/production/printerprofile.schema.js @@ -0,0 +1,111 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; + +const minMaxNumberSchema = { + min: { type: Number }, + max: { type: Number } +}; + +const thumbnailSchema = new mongoose.Schema( + { + width: { type: Number }, + height: { type: Number } + }, + { _id: true } +); + +const bedExcludeAreaSchema = new mongoose.Schema( + { + x: { type: Number }, + y: { type: Number } + }, + { _id: true } +); + +const extruderSchema = new mongoose.Schema( + { + defaultNozzleVolumeType: { type: String }, + nozzleDiameter: { type: Number }, + nozzleVolume: { type: Number }, + nozzleType: { type: String }, + nozzleFlushDataset: { type: String }, + minLayerHeight: { type: Number }, + maxLayerHeight: { type: Number }, + extruderColour: { type: String }, + extruderType: { type: String }, + extruderVariantList: { type: String }, + extruderPrintableHeight: { type: Number }, + printerExtruderId: { type: String }, + printerExtruderVariant: { type: String }, + positionOffsetX: { type: Number }, + positionOffsetY: { type: Number }, + retractionLength: { type: Number }, + retractionExtraLengthOnRestart: { type: Number }, + retractionSpeed: { type: Number }, + deretractionSpeed: { type: Number }, + retractionTravelDistanceThreshold: { type: Number }, + retractOnLayerChange: { type: Boolean }, + wipeWhileRetracting: { type: Boolean }, + wipeDistance: { type: Number }, + retractAmountBeforeWipe: { type: Number }, + retractAmountAfterWipe: { type: Number }, + zHopOnSurfaces: { type: String }, + zHopType: { type: String }, + zHopHeight: { type: Number }, + zHopTravelingAngle: { type: Number }, + zHopOnlyLiftZAbove: { type: Number }, + zHopOnlyLiftZBelow: { type: Number }, + materialSwitchRetractionLength: { type: Number }, + materialSwitchExtraLengthOnRestart: { type: Number }, + longRetractionWhenCut: { type: Boolean }, + retractionDistancesWhenCut: { type: Number }, + }, + { _id: true } +); + +const printerProfileSchema = new mongoose.Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + printer: { type: mongoose.Schema.Types.ObjectId, ref: 'printer', default: null }, + extruders: [extruderSchema], + printBedWidth: { type: Number }, + printBedHeight: { type: Number }, + originX: { type: Number }, + originY: { type: Number }, + bedExcludeArea: [bedExcludeAreaSchema], + printableHeight: { type: Number }, + supportMultiBedTypes: { type: Boolean }, + bestObjectPositionX: { type: Number }, + bestObjectPositionY: { type: Number }, + zOffset: { type: Number }, + preferredOrientation: { type: Number }, + machineMaxAccelerationRetracting: minMaxNumberSchema, + machineMaxSpeedE: minMaxNumberSchema, + machineMaxSpeedX: minMaxNumberSchema, + machineMaxSpeedY: minMaxNumberSchema, + machinePauseGcode: { type: String }, + machineStartGcode: { type: String }, + machineEndGcode: { type: String }, + layerChangeGcode: { type: String }, + beforeLayerChangeGcode: { type: String }, + printerNotes: { type: String }, + scanFirstLayer: { type: Boolean }, + machineLoadFilamentTime: { type: Number }, + machineUnloadFilamentTime: { type: Number }, + thumbnails: [thumbnailSchema], + auxiliaryFan: { type: Boolean }, + machineMaxJunctionDeviation: minMaxNumberSchema, + }, + { timestamps: true } +); + +printerProfileSchema.index({ name: 'text', printerNotes: 'text' }); + +printerProfileSchema.virtual('id').get(function () { + return this._id; +}); + +printerProfileSchema.set('toJSON', { virtuals: true }); + +export const printerProfileModel = mongoose.model('printerProfile', printerProfileSchema); diff --git a/src/database/utils.js b/src/database/utils.js index 6c9fb95..d79fe58 100644 --- a/src/database/utils.js +++ b/src/database/utils.js @@ -20,6 +20,32 @@ export function toObjectIdIfValid(value) { return value; } +/** Extract an ObjectId from a populated reference object, if applicable. */ +function extractReferenceId(value) { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + value instanceof mongoose.Types.ObjectId || + value instanceof Date || + value._id == null + ) { + return null; + } + + const keys = Object.keys(value); + const looksLikeReference = keys.every((k) => k === '_id' || k === '__v'); + if (!looksLikeReference) { + return null; + } + + const id = value._id; + if (id instanceof mongoose.Types.ObjectId) { + return id; + } + return toObjectIdIfValid(id?.toString?.() ?? id); +} + /** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */ export function convertObjectIdStringsInFilter(filter) { if (!filter || typeof filter !== 'object') return filter; @@ -28,22 +54,31 @@ export function convertObjectIdStringsInFilter(filter) { for (const [key, value] of Object.entries(filter)) { if (key.startsWith('$')) { if ((key === '$in' || key === '$nin') && Array.isArray(value)) { - result[key] = value.map((v) => (isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v)); + result[key] = value.map((v) => { + const refId = extractReferenceId(v); + if (refId !== null) return refId; + return isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v; + }); } else if (value && typeof value === 'object' && !Array.isArray(value)) { result[key] = convertObjectIdStringsInFilter(value); } else { result[key] = toObjectIdIfValid(value); } - } else if ( - value && - typeof value === 'object' && - !Array.isArray(value) && - !(value instanceof mongoose.Types.ObjectId) && - !(value instanceof Date) - ) { - result[key] = convertObjectIdStringsInFilter(value); } else { - result[key] = toObjectIdIfValid(value); + const refId = extractReferenceId(value); + if (refId !== null) { + result[key] = refId; + } else if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof mongoose.Types.ObjectId) && + !(value instanceof Date) + ) { + result[key] = convertObjectIdStringsInFilter(value); + } else { + result[key] = toObjectIdIfValid(value); + } } } return result; diff --git a/src/index.js b/src/index.js index 0f89a23..946b59c 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,8 @@ import { appPasswordRoutes, fileRoutes, printerRoutes, + printerProfileRoutes, + filamentProfileRoutes, jobRoutes, subJobRoutes, gcodeFileRoutes, @@ -61,6 +63,7 @@ import { appLaunchRoutes, appUpdateRoutes, serverRoutes, + slicerRoutes, } from './routes/index.js'; import path from 'path'; import * as fs from 'fs'; @@ -139,6 +142,8 @@ app.use('/apppasswords', appPasswordRoutes); app.use('/files', fileRoutes); app.use('/spotlight', spotlightRoutes); app.use('/printers', printerRoutes); +app.use('/printerprofiles', printerProfileRoutes); +app.use('/filamentprofiles', filamentProfileRoutes); app.use('/hosts', hostRoutes); app.use('/jobs', jobRoutes); app.use('/subjobs', subJobRoutes); @@ -189,6 +194,7 @@ app.use('/csv', csvRoutes); app.use('/applaunch', appLaunchRoutes); app.use('/appupdate', appUpdateRoutes); app.use('/server', serverRoutes); +app.use('/slicer', slicerRoutes); // Start the application if (process.env.NODE_ENV !== 'test') { diff --git a/src/keycloak.js b/src/keycloak.js index 3b79eb5..9dc5112 100644 --- a/src/keycloak.js +++ b/src/keycloak.js @@ -95,7 +95,7 @@ const isAuthenticated = async (req, res, next) => { const authenticateWithAppPassword = async (username, secret) => { if (!username || !secret) return null; - const user = await userModel.findOne({ username }); + const user = await userModel.findOne({ username }).lean(); if (!user) return null; const appPasswords = await appPasswordModel @@ -115,6 +115,17 @@ const authenticateWithAppPassword = async (username, secret) => { const isAppAuthenticated = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; + const apiKey = req.headers['x-api-key']; + const userParam = req.params.username; + const passwordParam = req.params.password; + + logger.debug('App authentication request', { + hasBasicAuth: authHeader?.startsWith('Basic ') === true, + hasApiKey: Boolean(apiKey), + hasPasswordParam: Boolean(passwordParam), + userParam, + }); + // Supports HTTP Basic Auth (username + app password secret) if (authHeader?.startsWith('Basic ')) { try { @@ -134,6 +145,16 @@ const isAppAuthenticated = async (req, res, next) => { logger.error('Basic auth error:', error.message); } } + const appPassword = apiKey || passwordParam; + if (appPassword && userParam) { + logger.debug('App password and username present', { userParam }); + const user = await authenticateWithAppPassword(userParam, appPassword); + if (user) { + req.user = user; + req.session = { user }; + return next(); + } + } return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; diff --git a/src/routes/index.js b/src/routes/index.js index 5c26e89..508f56b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -3,6 +3,8 @@ import appPasswordRoutes from './management/apppasswords.js'; import fileRoutes from './management/files.js'; import authRoutes from './misc/auth.js'; import printerRoutes from './production/printers.js'; +import printerProfileRoutes from './production/printerprofiles.js'; +import filamentProfileRoutes from './production/filamentprofiles.js'; import hostRoutes from './management/hosts.js'; import jobRoutes from './production/jobs.js'; import subJobRoutes from './production/subjobs.js'; @@ -54,6 +56,7 @@ import csvRoutes from './misc/csv.js'; import appLaunchRoutes from './misc/applaunch.js'; import appUpdateRoutes from './misc/appupdate.js'; import serverRoutes from './misc/server.js'; +import slicerRoutes from './misc/slicer.js'; export { userRoutes, @@ -61,6 +64,8 @@ export { fileRoutes, authRoutes, printerRoutes, + printerProfileRoutes, + filamentProfileRoutes, hostRoutes, jobRoutes, subJobRoutes, @@ -112,4 +117,5 @@ export { appLaunchRoutes, appUpdateRoutes, serverRoutes, + slicerRoutes, }; diff --git a/src/routes/misc/slicer.js b/src/routes/misc/slicer.js new file mode 100644 index 0000000..1be463d --- /dev/null +++ b/src/routes/misc/slicer.js @@ -0,0 +1,23 @@ +import express from 'express'; +import { isAppAuthenticated } from '../../keycloak.js'; +import { + slicerUploadRouteHandler, + slicerUIRouteHandler, + slicerVersionRouteHandler, +} from '../../services/misc/slicer.js'; + +const router = express.Router(); + +router.get('/:printerId/:username/:password', isAppAuthenticated, slicerUIRouteHandler); +router.get( + '/:printerId/:username/:password/api/version', + isAppAuthenticated, + slicerVersionRouteHandler +); +router.post( + '/:printerId/:username/:password/api/files/local', + isAppAuthenticated, + slicerUploadRouteHandler +); + +export default router; diff --git a/src/routes/production/filamentprofiles.js b/src/routes/production/filamentprofiles.js new file mode 100644 index 0000000..d89113f --- /dev/null +++ b/src/routes/production/filamentprofiles.js @@ -0,0 +1,48 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { convertPropertiesString, getFilter } from '../../utils.js'; +import { + deleteFilamentProfileRouteHandler, + editFilamentProfileRouteHandler, + getFilamentProfileRouteHandler, + listFilamentProfilesByPropertiesRouteHandler, + listFilamentProfilesRouteHandler, + newFilamentProfileRouteHandler, + searchFilamentProfilesRouteHandler, +} from '../../services/production/filamentprofiles.js'; + +const router = express.Router(); + +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const filter = getFilter(req.query, ['_id', 'name']); + listFilamentProfilesRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = getFilter(req.query, ['name'], false); + listFilamentProfilesByPropertiesRouteHandler(req, res, properties, filter); +}); + +router.get('/search', isAuthenticated, (req, res) => { + searchFilamentProfilesRouteHandler(req, res, req.query.search); +}); + +router.post('/', isAuthenticated, (req, res) => { + newFilamentProfileRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getFilamentProfileRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, (req, res) => { + editFilamentProfileRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, (req, res) => { + deleteFilamentProfileRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/production/printerprofiles.js b/src/routes/production/printerprofiles.js new file mode 100644 index 0000000..a8536e0 --- /dev/null +++ b/src/routes/production/printerprofiles.js @@ -0,0 +1,48 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { convertPropertiesString, getFilter } from '../../utils.js'; +import { + deletePrinterProfileRouteHandler, + editPrinterProfileRouteHandler, + getPrinterProfileRouteHandler, + listPrinterProfilesByPropertiesRouteHandler, + listPrinterProfilesRouteHandler, + newPrinterProfileRouteHandler, + searchPrinterProfilesRouteHandler, +} from '../../services/production/printerprofiles.js'; + +const router = express.Router(); + +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const filter = getFilter(req.query, ['_id', 'name']); + listPrinterProfilesRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = getFilter(req.query, ['name'], false); + listPrinterProfilesByPropertiesRouteHandler(req, res, properties, filter); +}); + +router.get('/search', isAuthenticated, (req, res) => { + searchPrinterProfilesRouteHandler(req, res, req.query.search); +}); + +router.post('/', isAuthenticated, (req, res) => { + newPrinterProfileRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getPrinterProfileRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, (req, res) => { + editPrinterProfileRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, (req, res) => { + deletePrinterProfileRouteHandler(req, res); +}); + +export default router; diff --git a/src/services/management/__tests__/files.test.js b/src/services/management/__tests__/files.test.js index 829a52c..d40b0a6 100644 --- a/src/services/management/__tests__/files.test.js +++ b/src/services/management/__tests__/files.test.js @@ -52,16 +52,11 @@ jest.unstable_mockModule('log4js', () => ({ }, })); -const { - listFilesRouteHandler, - getFileRouteHandler, - editFileRouteHandler, - flushFileRouteHandler, -} = await import('../files.js'); +const { listFilesRouteHandler, getFileRouteHandler, editFileRouteHandler, flushFileRouteHandler } = + await import('../files.js'); -const { listObjects, getObject, editObject, flushFile } = await import( - '../../../database/database.js' -); +const { listObjects, getObject, editObject, flushFile } = + await import('../../../database/database.js'); const { fileModel } = await import('../../../database/schemas/management/file.schema.js'); describe('File Service Route Handlers', () => { @@ -121,4 +116,3 @@ describe('File Service Route Handlers', () => { }); }); }); - diff --git a/src/services/management/filamentskus.js b/src/services/management/filamentskus.js index 304da7d..9407cb6 100644 --- a/src/services/management/filamentskus.js +++ b/src/services/management/filamentskus.js @@ -11,7 +11,7 @@ import { listObjectsByProperties, getModelStats, getModelHistory, - searchObjects + searchObjects, } from '../../database/database.js'; const logger = log4js.getLogger('Filament SKUs'); logger.level = config.server.logLevel; @@ -60,7 +60,7 @@ export const listFilamentSkusByPropertiesRouteHandler = async ( model: filamentSkuModel, properties, filter, - populate: ['costTaxRate'], + populate: ['costTaxRate', 'filament'], masterFilter, }); diff --git a/src/services/management/files.js b/src/services/management/files.js index 07ce4d4..71322e8 100644 --- a/src/services/management/files.js +++ b/src/services/management/files.js @@ -15,7 +15,7 @@ import { flushFile, getModelStats, getModelHistory, - searchObjects + searchObjects, } from '../../database/database.js'; import { uploadFile, @@ -28,6 +28,17 @@ import { getFileMeta } from '../../utils.js'; const logger = log4js.getLogger('Files'); logger.level = config.server.logLevel; +const getDownloadContentType = (file) => { + // G-code is text, but it must be transferred byte-for-byte. Some slicers + // report text/plain, causing proxies to compress an otherwise fixed-length + // response and browsers to reject it with a content-length network error. + if (file.extension?.toLowerCase() === '.gcode') { + return 'application/octet-stream'; + } + + return file.type || 'application/octet-stream'; +}; + // Set storage engine to memory for Ceph upload const fileStorage = multer.memoryStorage(); @@ -288,9 +299,31 @@ function checkFileType(file, cb) { } export const getFileContentRouteHandler = async (req, res) => { - try { - const id = req.params.id; + const id = req.params.id; + let bytesSent = 0; + let sourceEnded = false; + logger.info(`File download requested: ${id}`); + + req.once('aborted', () => { + logger.warn(`File download request aborted by client: ${id} (${bytesSent} bytes sent)`); + }); + + res.once('finish', () => { + logger.info( + `File download response finished: ${id} (status ${res.statusCode}, ${bytesSent} bytes sent)` + ); + }); + + res.once('close', () => { + if (!res.writableFinished) { + logger.warn( + `File download response closed early: ${id} (${bytesSent} bytes sent, source ended: ${sourceEnded})` + ); + } + }); + + try { const file = await getObject({ model: fileModel, id, @@ -302,27 +335,64 @@ export const getFileContentRouteHandler = async (req, res) => { } logger.trace(`Returning file contents with ID: ${id}:`); + logger.debug(`File record loaded for download: ${id}`, { + name: file.name, + extension: file.extension, + recordedSize: file.size, + type: file.type, + }); // Check if file is stored in Ceph if (file._id && file.extension) { const cephKey = `files/${id}${file.extension}`; + logger.info(`Starting Ceph download: ${cephKey} (recorded size: ${file.size ?? 'unknown'})`); + try { const body = await downloadFile(BUCKETS.FILES, cephKey); + logger.debug(`Ceph response stream opened: ${cephKey}`, { + readable: body?.readable, + destroyed: body?.destroyed, + }); - // Set appropriate content type and disposition - res.set('Content-Type', file.type || 'application/octet-stream'); + // Preserve the stored bytes across reverse proxies. This also repairs + // downloads of existing slicer uploads recorded as text/plain. + res.set('Content-Type', getDownloadContentType(file)); res.set('Content-Disposition', `attachment; filename="${file.name}${file.extension}"`); - - // Expose file size so clients can compute download progress - if (typeof file.size === 'number' && !Number.isNaN(file.size)) { - res.set('Content-Length', String(file.size)); - } + res.set('Cache-Control', 'private, no-transform'); // Stream or send buffer if (body && typeof body.pipe === 'function') { + body.on('data', (chunk) => { + bytesSent += chunk.length; + }); + + body.on('end', () => { + sourceEnded = true; + logger.info(`Ceph stream ended: ${cephKey} (${bytesSent} bytes read)`); + + if ( + typeof file.size === 'number' && + !Number.isNaN(file.size) && + bytesSent !== file.size + ) { + logger.warn( + `File size mismatch for ${cephKey}: database=${file.size}, streamed=${bytesSent}` + ); + } + }); + + body.on('close', () => { + logger.debug( + `Ceph stream closed: ${cephKey} (${bytesSent} bytes read, ended: ${sourceEnded})` + ); + }); + // Handle stream errors body.on('error', (err) => { - logger.error('Error streaming file from Ceph:', err); + logger.error( + `Error streaming file from Ceph: ${cephKey} (${bytesSent} bytes read)`, + err + ); // If headers not sent, send a 500; otherwise destroy the response if (!res.headersSent) { try { @@ -343,7 +413,7 @@ export const getFileContentRouteHandler = async (req, res) => { }); body.pipe(res); - logger.debug('Retrieved:', cephKey); + logger.debug(`Ceph stream piped to response: ${cephKey}`); return; } @@ -357,6 +427,7 @@ export const getFileContentRouteHandler = async (req, res) => { // Fallback to local file system for backward compatibility const filePath = path.join(config.storage.fileStorage, file.fileName || file.name); + logger.info(`Falling back to local file download: ${filePath}`); // Read the file fs.readFile(filePath, (err, data) => { @@ -367,8 +438,13 @@ export const getFileContentRouteHandler = async (req, res) => { return res.status(500).send({ error: 'Error reading file.' }); } - res.set('Content-Type', file.type || 'application/octet-stream'); + bytesSent = data.length; + sourceEnded = true; + logger.info(`Local file read complete: ${filePath} (${data.length} bytes)`); + + res.set('Content-Type', getDownloadContentType(file)); res.set('Content-Disposition', `inline; filename="${file.name}${file.extension || ''}"`); + res.set('Cache-Control', 'private, no-transform'); // Ensure Content-Length is set for progress events if possible. const length = diff --git a/src/services/misc/__tests__/sessionStore.test.js b/src/services/misc/__tests__/sessionStore.test.js new file mode 100644 index 0000000..f42b657 --- /dev/null +++ b/src/services/misc/__tests__/sessionStore.test.js @@ -0,0 +1,90 @@ +import { jest } from '@jest/globals'; + +const redisMock = { + setKey: jest.fn(), + getKey: jest.fn(), + deleteKey: jest.fn(), + getAndDeleteKey: jest.fn(), + eval: jest.fn(), +}; + +jest.unstable_mockModule('../../../config.js', () => ({ + default: { server: { logLevel: 'info' } }, +})); + +jest.unstable_mockModule('../../../database/redis.js', () => ({ + redisServer: redisMock, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + }), + }, +})); + +const { + createSlicerSession, + createSlicerAuthCode, + getAndConsumeSlicerAuthCode, + isSlicerAuthCode, +} = await import('../sessionStore.js'); + +describe('slicer sessions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('atomically replaces the current slicer session with a 99-year session', async () => { + const now = Date.now(); + const user = { _id: 'user-1', username: 'slicer-user', roles: ['user'] }; + + const result = await createSlicerSession(user); + + expect(result.sessionToken).toMatch(/^[a-f0-9]{64}$/); + expect(result.expiresAt).toBeGreaterThan(now + 98 * 365 * 24 * 60 * 60 * 1000); + expect(result.expiresAt).toBeLessThan(now + 100 * 366 * 24 * 60 * 60 * 1000); + expect(redisMock.eval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('DEL'"), + ['slicer-session:user-1', `session:${result.sessionToken}`], + [ + expect.stringContaining('"source":"slicer"'), + expect.any(Number), + result.sessionToken, + 'session:', + ] + ); + }); + + it('creates a short-lived one-time auth code containing the session response', async () => { + const session = { + sessionToken: 'session-token', + expiresAt: Date.now() + 1000, + user: { _id: 'user-1', username: 'slicer-user' }, + }; + + const code = await createSlicerAuthCode(session); + + expect(isSlicerAuthCode(code)).toBe(true); + expect(redisMock.setKey).toHaveBeenCalledWith( + `slicer-auth-code:${code}`, + { + access_token: session.sessionToken, + expires_at: session.expiresAt, + ...session.user, + }, + 60 + ); + }); + + it('consumes an auth code through the atomic Redis operation', async () => { + const tokenData = { access_token: 'session-token' }; + redisMock.getAndDeleteKey.mockResolvedValueOnce(tokenData).mockResolvedValueOnce(null); + + await expect(getAndConsumeSlicerAuthCode('slicer_code')).resolves.toEqual(tokenData); + await expect(getAndConsumeSlicerAuthCode('slicer_code')).resolves.toBeNull(); + expect(redisMock.getAndDeleteKey).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/services/misc/__tests__/slicer.test.js b/src/services/misc/__tests__/slicer.test.js new file mode 100644 index 0000000..e9be769 --- /dev/null +++ b/src/services/misc/__tests__/slicer.test.js @@ -0,0 +1,141 @@ +import { jest } from '@jest/globals'; +import express from 'express'; +import request from 'supertest'; + +const printerId = '507f1f77bcf86cd799439011'; +const storedFileId = '507f1f77bcf86cd799439012'; +const printer = { _id: printerId, pendingSlicerUploads: [] }; + +const findById = jest.fn(() => ({ + select: jest.fn(() => ({ + lean: jest.fn().mockResolvedValue(printer), + })), +})); +const newObject = jest.fn(); +const editObject = jest.fn(); +const deleteObject = jest.fn(); +const uploadFile = jest.fn(); +const deleteFile = jest.fn(); +const getFileMeta = jest.fn(); + +jest.unstable_mockModule('../../../config.js', () => ({ + default: { + server: { logLevel: 'info' }, + app: { urlClient: 'http://localhost:3000' }, + }, +})); + +jest.unstable_mockModule('../../../database/schemas/management/file.schema.js', () => ({ + fileModel: { modelName: 'file' }, +})); + +jest.unstable_mockModule('../../../database/schemas/production/printer.schema.js', () => ({ + printerModel: { modelName: 'printer', findById }, +})); + +jest.unstable_mockModule('../../../database/database.js', () => ({ + deleteObject, + editObject, + newObject, +})); + +jest.unstable_mockModule('../../../database/ceph.js', () => ({ + BUCKETS: { FILES: 'files' }, + deleteFile, + uploadFile, +})); + +jest.unstable_mockModule('../../../utils.js', () => ({ + getFileMeta, +})); + +jest.unstable_mockModule('../sessionStore.js', () => ({ + createSlicerAuthCode: jest.fn(), + createSlicerSession: jest.fn(), +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }), + }, +})); + +const { slicerUploadRouteHandler } = await import('../slicer.js'); + +describe('slicer upload', () => { + beforeEach(() => { + jest.clearAllMocks(); + newObject.mockResolvedValue({ _id: storedFileId }); + editObject.mockResolvedValue({ _id: printerId }); + getFileMeta.mockResolvedValue({ generator: 'test-slicer' }); + }); + + it('stores the multipart file bytes and parses OctoPrint boolean fields', async () => { + const app = express(); + app.post( + '/slicer/:printerId/user/pass/api/files/local', + (req, _res, next) => { + req.user = { username: 'slicer-user' }; + next(); + }, + slicerUploadRouteHandler + ); + + const fileContents = Buffer.from('G28\nG1 X10 Y20\n'); + const response = await request(app) + .post(`/slicer/${printerId}/user/pass/api/files/local`) + .field('select', 'true') + .field('print', 'false') + .attach('file', fileContents, { + filename: 'part.gcode', + // Cura and other slicers may identify G-code as compressible text. + contentType: 'text/plain', + }); + + expect(response.status).toBe(201); + expect(uploadFile).toHaveBeenCalledWith( + 'files', + `files/${storedFileId}.gcode`, + fileContents, + 'application/octet-stream', + { + originalName: 'part.gcode', + uploadedBy: 'slicer-user', + } + ); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ + name: 'part', + extension: '.gcode', + size: fileContents.length, + }), + }) + ); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: { + pendingSlicerUploads: [ + expect.objectContaining({ + file: storedFileId, + shouldPrint: false, + }), + ], + }, + }) + ); + expect(response.body).toEqual( + expect.objectContaining({ + done: true, + effectiveSelect: true, + effectivePrint: false, + }) + ); + expect(response.headers.location).toMatch(/\/part\.gcode$/); + }); +}); diff --git a/src/services/misc/auth.js b/src/services/misc/auth.js index d2face8..4ef5dc1 100644 --- a/src/services/misc/auth.js +++ b/src/services/misc/auth.js @@ -12,6 +12,8 @@ import { getSession, updateSessionKeycloakTokens, deleteSession, + getAndConsumeSlicerAuthCode, + isSlicerAuthCode, } from './sessionStore.js'; import { expandObjectIds } from '../../utils.js'; @@ -128,6 +130,15 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => } try { + if (isSlicerAuthCode(code)) { + const slicerTokenData = await getAndConsumeSlicerAuthCode(code); + if (!slicerTokenData) { + return res.status(401).json({ error: 'Authorization code invalid or expired' }); + } + logger.debug('Exchanged slicer auth code for session'); + return res.status(200).json(slicerTokenData); + } + // Check for temporary email render auth code (30s TTL for Puppeteer) const emailRenderData = getAndConsumeEmailRenderTokenData(code); if (emailRenderData) { diff --git a/src/services/misc/sessionStore.js b/src/services/misc/sessionStore.js index 4a6d55e..1d26cac 100644 --- a/src/services/misc/sessionStore.js +++ b/src/services/misc/sessionStore.js @@ -12,6 +12,20 @@ const logger = log4js.getLogger('SessionStore'); logger.level = config.server.logLevel; const SESSION_KEY_PREFIX = 'session:'; +const SLICER_SESSION_KEY_PREFIX = 'slicer-session:'; +const SLICER_AUTH_CODE_KEY_PREFIX = 'slicer-auth-code:'; +const SLICER_AUTH_CODE_PREFIX = 'slicer_'; +const SLICER_AUTH_CODE_TTL_SECONDS = 60; + +const REPLACE_SLICER_SESSION_SCRIPT = ` + local previousToken = redis.call('GET', KEYS[1]) + redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2]) + redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[2]) + if previousToken and previousToken ~= ARGV[3] then + redis.call('DEL', ARGV[4] .. previousToken) + end + return previousToken +`; /** * Generate a cryptographically secure session token @@ -61,6 +75,65 @@ export async function createSession({ user, keycloakTokens }) { return { sessionToken, expiresAt }; } +/** + * Create a long-lived slicer UI session, replacing only the user's previous slicer session. + */ +export async function createSlicerSession(user) { + const sessionToken = generateSessionToken(); + const expiresAtDate = new Date(); + expiresAtDate.setFullYear(expiresAtDate.getFullYear() + 99); + const expiresAt = expiresAtDate.getTime(); + const ttlSeconds = getTtlSeconds(expiresAt); + const sessionData = { + sessionToken, + user: userToSessionUser(user), + keycloakTokens: null, + expiresAt, + source: 'slicer', + }; + console.log('sessionData', sessionData); + const userId = sessionData.user?._id; + + if (!userId) { + throw new Error('Cannot create slicer session without a user id'); + } + + await redisServer.eval( + REPLACE_SLICER_SESSION_SCRIPT, + [SLICER_SESSION_KEY_PREFIX + userId, SESSION_KEY_PREFIX + sessionToken], + [JSON.stringify(sessionData), ttlSeconds, sessionToken, SESSION_KEY_PREFIX] + ); + + logger.debug(`Created replacement slicer session for user ${user.username}`); + return { sessionToken, expiresAt, user: sessionData.user }; +} + +/** + * Create a short-lived, one-time code which the UI can exchange for a slicer session. + */ +export async function createSlicerAuthCode({ sessionToken, expiresAt, user }) { + const authCode = SLICER_AUTH_CODE_PREFIX + generateSessionToken(); + await redisServer.setKey( + SLICER_AUTH_CODE_KEY_PREFIX + authCode, + { + access_token: sessionToken, + expires_at: expiresAt, + ...user, + }, + SLICER_AUTH_CODE_TTL_SECONDS + ); + return authCode; +} + +export function isSlicerAuthCode(code) { + return typeof code === 'string' && code.startsWith(SLICER_AUTH_CODE_PREFIX); +} + +export async function getAndConsumeSlicerAuthCode(code) { + if (!isSlicerAuthCode(code)) return null; + return redisServer.getAndDeleteKey(SLICER_AUTH_CODE_KEY_PREFIX + code); +} + /** * Get session by token. Returns null if not found or expired. */ diff --git a/src/services/misc/slicer.js b/src/services/misc/slicer.js new file mode 100644 index 0000000..44f7e41 --- /dev/null +++ b/src/services/misc/slicer.js @@ -0,0 +1,229 @@ +import mongoose from 'mongoose'; +import log4js from 'log4js'; +import multer from 'multer'; +import path from 'path'; +import config from '../../config.js'; +import { fileModel } from '../../database/schemas/management/file.schema.js'; +import { printerModel } from '../../database/schemas/production/printer.schema.js'; +import { deleteObject, editObject, newObject } from '../../database/database.js'; +import { BUCKETS, deleteFile as deleteCephFile, uploadFile } from '../../database/ceph.js'; +import { getFileMeta } from '../../utils.js'; +import { createSlicerAuthCode, createSlicerSession } from './sessionStore.js'; + +const logger = log4js.getLogger('Slicer'); +logger.level = config.server.logLevel; + +const slicerFileUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 500000000 }, +}).single('file'); + +const receiveSlicerFile = (req, res) => + new Promise((resolve, reject) => { + slicerFileUpload(req, res, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + +const parseFormBoolean = (value) => + value === true || (typeof value === 'string' && value.toLowerCase() === 'true'); + +const getSlicerContentType = (extension, reportedContentType) => { + // Slicers commonly label G-code as text/plain. That allows reverse proxies + // to compress the response later, which can invalidate a known byte length. + if (extension.toLowerCase() === '.gcode') { + return 'application/octet-stream'; + } + + return reportedContentType || 'application/octet-stream'; +}; + +const SLICER_VERSION = { + server: '1.5.0', + api: '0.1', + text: 'OctoPrint (Moonraker v0.9.3-72-g7cdcca3)', +}; + +export const slicerUIRouteHandler = async (req, res) => { + try { + const { printerId } = req.params; + console.log('req.user', req.user); + const session = await createSlicerSession(req.user); + const authCode = await createSlicerAuthCode(session); + const uiUrl = new URL('/slicer', config.app.urlClient); + uiUrl.searchParams.set('printerId', printerId); + uiUrl.searchParams.set('authCode', authCode); + return res.redirect(uiUrl.toString()); + } catch (error) { + logger.error('Failed to create slicer UI session:', error); + return res.status(500).json({ error: 'Failed to authenticate slicer UI' }); + } +}; + +export const slicerVersionRouteHandler = async (req, res) => { + const { printerId } = req.params; + + if (!mongoose.isValidObjectId(printerId)) { + return res.status(404).send({ error: 'Printer not found.', code: 404 }); + } + + const printer = await mongoose.connection + .collection('printers') + .findOne({ _id: new mongoose.Types.ObjectId(printerId) }, { projection: { _id: 1 } }); + + if (!printer) { + return res.status(404).send({ error: 'Printer not found.', code: 404 }); + } + + return res.json(SLICER_VERSION); +}; + +export const slicerUploadRouteHandler = async (req, res) => { + const { printerId } = req.params; + + if (!mongoose.isValidObjectId(printerId)) { + return res.status(404).send({ error: 'Printer not found.', code: 404 }); + } + + const printer = await printerModel.findById(printerId).select('_id pendingSlicerUploads').lean(); + if (!printer) { + return res.status(404).send({ error: 'Printer not found.', code: 404 }); + } + + try { + await receiveSlicerFile(req, res); + } catch (uploadError) { + logger.error('Error receiving slicer upload:', uploadError); + const status = uploadError.code === 'LIMIT_FILE_SIZE' ? 413 : 400; + return res.status(status).send({ error: uploadError.message }); + } + + // OctoPrint uploads use a multipart `file` part. Multer places the binary + // part on req.file and the accompanying select/print fields on req.body. + const file = req.file; + if (!file?.buffer || !file.originalname) { + return res.status(400).send({ error: 'No file selected.' }); + } + + const originalName = path.basename(file.originalname); + const extension = path.extname(originalName); + const baseName = path.parse(originalName).name; + + if (!extension || !baseName) { + return res.status(400).send({ error: 'The uploaded file must have a valid filename.' }); + } + + const shouldPrint = parseFormBoolean(req.body?.print); + const shouldSelect = shouldPrint || parseFormBoolean(req.body?.select); + const contentType = getSlicerContentType(extension, file.mimetype); + let createdFile; + let cephKey; + + try { + const meta = await getFileMeta({ ...file, originalname: originalName }); + + createdFile = await newObject({ + model: fileModel, + newData: { + name: baseName, + type: contentType, + extension, + size: file.buffer.length, + metaData: { + originalName, + ...meta, + }, + }, + user: req.user, + }); + + if (createdFile.error) { + logger.error('Error creating slicer file:', createdFile.error); + return res.status(createdFile.code || 500).send(createdFile); + } + + cephKey = `files/${createdFile._id}${extension}`; + await uploadFile(BUCKETS.FILES, cephKey, file.buffer, contentType, { + originalName, + uploadedBy: req.user?.username || 'slicer', + }); + + const now = new Date(); + const updatedPrinter = await editObject({ + model: printerModel, + id: printerId, + updateData: { + pendingSlicerUploads: [ + ...(printer.pendingSlicerUploads || []), + { + file: createdFile, + gcodeFile: null, + shouldPrint, + new: true, + properties: {}, + createdAt: now, + updatedAt: now, + }, + ], + }, + user: req.user, + }); + + if (updatedPrinter.error) { + throw new Error(updatedPrinter.error); + } + + logger.debug(`Slicer file ${createdFile._id} uploaded and queued for printer ${printerId}`); + + const uploadPath = (req.originalUrl || req.url).split('?')[0].replace(/\/$/, ''); + const resource = `${uploadPath}/${encodeURIComponent(originalName)}`; + res.location(resource); + + return res.status(201).send({ + files: { + local: { + name: originalName, + origin: 'local', + path: originalName, + type: 'machinecode', + typePath: ['machinecode', extension.slice(1).toLowerCase()], + refs: { + resource, + download: resource, + }, + }, + }, + done: true, + effectiveSelect: shouldSelect, + effectivePrint: shouldPrint, + }); + } catch (error) { + logger.error('Error processing slicer upload:', error); + + if (cephKey) { + try { + await deleteCephFile(BUCKETS.FILES, cephKey); + } catch (cleanupError) { + logger.error('Error cleaning up slicer file from storage:', cleanupError); + } + } + + if (createdFile?._id) { + try { + await deleteObject({ + model: fileModel, + id: createdFile._id, + user: req.user, + }); + } catch (cleanupError) { + logger.error('Error cleaning up slicer file record:', cleanupError); + } + } + + return res.status(500).send({ error: error.message }); + } +}; diff --git a/src/services/production/__tests__/filamentprofiles.test.js b/src/services/production/__tests__/filamentprofiles.test.js new file mode 100644 index 0000000..1541267 --- /dev/null +++ b/src/services/production/__tests__/filamentprofiles.test.js @@ -0,0 +1,137 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../database/database.js', () => ({ + deleteObject: jest.fn(), + editObject: jest.fn(), + getObject: jest.fn(), + listObjects: jest.fn(), + listObjectsByProperties: jest.fn(), + newObject: jest.fn(), + searchObjects: jest.fn(), +})); + +jest.unstable_mockModule('../../../database/schemas/production/filamentprofile.schema.js', () => ({ + filamentProfileModel: { modelName: 'filamentProfile' }, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }), + }, +})); + +const { + deleteFilamentProfileRouteHandler, + editFilamentProfileRouteHandler, + listFilamentProfilesRouteHandler, + newFilamentProfileRouteHandler, +} = await import('../filamentprofiles.js'); +const { deleteObject, editObject, listObjects, newObject } = + await import('../../../database/database.js'); +const { filamentProfileModel } = + await import('../../../database/schemas/production/filamentprofile.schema.js'); + +describe('Filament profile service route handlers', () => { + let req; + let res; + + beforeEach(() => { + req = { + params: {}, + body: {}, + user: { id: 'test-user-id' }, + }; + res = { + send: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + jest.clearAllMocks(); + }); + + it('lists filament profiles', async () => { + const result = [{ _id: '1', name: 'PETG profile' }]; + listObjects.mockResolvedValue(result); + + await listFilamentProfilesRouteHandler(req, res); + + expect(listObjects).toHaveBeenCalledWith( + expect.objectContaining({ + model: filamentProfileModel, + populate: ['filament', 'compatiblePrinters'], + }) + ); + expect(res.send).toHaveBeenCalledWith(result); + }); + + it('creates profiles using only supported Orca fields', async () => { + const profileBody = { + name: 'Prusa Generic PETG', + filamentType: 'filament', + filament: '507f1f77bcf86cd799439011', + filamentFlowRatio: 0.98, + enablePressureAdvance: false, + pressureAdvance: 0.02, + adaptivePressureAdvance: false, + nozzleTemperatureInitialLayer: 230, + nozzleTemperature: 240, + hotPlateTempInitialLayer: 85, + hotPlateTemp: 85, + filamentAdaptiveVolumetricSpeed: false, + filamentMaxVolumetricSpeed: 8, + closeFanTheFirstXLayers: 3, + fanMinSpeed: 40, + fanMaxSpeed: 90, + activateAirFiltration: false, + compatiblePrinters: ['507f1f77bcf86cd799439012'], + }; + req.body = { ...profileBody, unexpected: 'ignored' }; + newObject.mockImplementation(async ({ newData }) => ({ _id: 'profile-id', ...newData })); + + await newFilamentProfileRouteHandler(req, res); + + const { newData } = newObject.mock.calls[0][0]; + expect(newData).toEqual(profileBody); + expect(newData).not.toHaveProperty('unexpected'); + }); + + it('edits only supplied supported Orca fields', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + req.body = { + name: 'Updated PETG profile', + filamentFlowRatio: 1, + unexpected: 'ignored', + }; + editObject.mockResolvedValue({ _id: req.params.id, name: req.body.name }); + + await editFilamentProfileRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: filamentProfileModel, + updateData: { + updatedAt: expect.any(Date), + name: 'Updated PETG profile', + filamentFlowRatio: 1, + }, + }) + ); + }); + + it('deletes profiles', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + const result = { _id: req.params.id }; + deleteObject.mockResolvedValue(result); + + await deleteFilamentProfileRouteHandler(req, res); + + expect(deleteObject).toHaveBeenCalledWith( + expect.objectContaining({ model: filamentProfileModel, user: req.user }) + ); + expect(res.send).toHaveBeenCalledWith(result); + }); +}); diff --git a/src/services/production/__tests__/printerprofiles.test.js b/src/services/production/__tests__/printerprofiles.test.js new file mode 100644 index 0000000..9a5c895 --- /dev/null +++ b/src/services/production/__tests__/printerprofiles.test.js @@ -0,0 +1,158 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../database/database.js', () => ({ + deleteObject: jest.fn(), + editObject: jest.fn(), + getObject: jest.fn(), + listObjects: jest.fn(), + listObjectsByProperties: jest.fn(), + newObject: jest.fn(), + searchObjects: jest.fn(), +})); + +jest.unstable_mockModule('../../../database/schemas/production/printerprofile.schema.js', () => ({ + printerProfileModel: { modelName: 'printerProfile' }, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }), + }, +})); + +const { + deletePrinterProfileRouteHandler, + editPrinterProfileRouteHandler, + listPrinterProfilesRouteHandler, + newPrinterProfileRouteHandler, +} = await import('../printerprofiles.js'); +const { deleteObject, editObject, listObjects, newObject } = + await import('../../../database/database.js'); +const { printerProfileModel } = + await import('../../../database/schemas/production/printerprofile.schema.js'); + +describe('Printer profile service route handlers', () => { + let req; + let res; + + beforeEach(() => { + req = { + params: {}, + body: {}, + user: { id: 'test-user-id' }, + }; + res = { + send: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + jest.clearAllMocks(); + }); + + it('lists printer profiles', async () => { + const result = [{ _id: '1', name: 'Orca profile' }]; + listObjects.mockResolvedValue(result); + + await listPrinterProfilesRouteHandler(req, res); + + expect(listObjects).toHaveBeenCalledWith( + expect.objectContaining({ model: printerProfileModel, populate: ['printer'] }) + ); + expect(res.send).toHaveBeenCalledWith(result); + }); + + it('creates profiles using only supported Orca fields', async () => { + const profileBody = { + name: 'Orca profile', + printer: '507f1f77bcf86cd799439011', + extruders: [ + { + nozzleDiameter: 0.4, + nozzleType: 'hardened_steel', + minLayerHeight: 0.08, + maxLayerHeight: 0.3, + positionOffsetX: 0, + positionOffsetY: 0, + }, + ], + printBedWidth: 220, + printBedHeight: 220, + originX: 0, + originY: 0, + bedExcludeArea: [{ x: 0, y: 0 }], + printableHeight: 250, + supportMultiBedTypes: false, + bestObjectPositionX: 110, + bestObjectPositionY: 110, + zOffset: 0, + preferredOrientation: 0, + machineMaxAccelerationRetracting: { min: 5000, max: 5000 }, + machineMaxSpeedE: { min: 30, max: 30 }, + machineMaxSpeedX: { min: 500, max: 500 }, + machineMaxSpeedY: { min: 500, max: 500 }, + machinePauseGcode: 'PAUSE', + machineStartGcode: 'START_PRINT', + machineEndGcode: 'END_PRINT', + layerChangeGcode: 'LAYER_CHANGE', + beforeLayerChangeGcode: 'BEFORE_LAYER_CHANGE', + printerNotes: 'Test printer', + scanFirstLayer: true, + machineLoadFilamentTime: 20, + machineUnloadFilamentTime: 15, + thumbnails: [ + { width: 32, height: 32 }, + { width: 300, height: 300 } + ], + auxiliaryFan: true, + machineMaxJunctionDeviation: { min: 0.02, max: 0.02 }, + }; + req.body = { ...profileBody, unexpected: 'ignored' }; + newObject.mockImplementation(async ({ newData }) => ({ _id: 'profile-id', ...newData })); + + await newPrinterProfileRouteHandler(req, res); + + const { newData } = newObject.mock.calls[0][0]; + expect(newData).toEqual(profileBody); + expect(newData).not.toHaveProperty('unexpected'); + }); + + it('edits only supplied supported Orca fields', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + req.body = { + name: 'Updated profile', + extruders: [{ nozzleDiameter: 0.4 }], + unexpected: 'ignored', + }; + editObject.mockResolvedValue({ _id: req.params.id, name: req.body.name }); + + await editPrinterProfileRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: printerProfileModel, + updateData: { + updatedAt: expect.any(Date), + name: 'Updated profile', + extruders: [{ nozzleDiameter: 0.4 }], + }, + }) + ); + }); + + it('deletes profiles', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + const result = { _id: req.params.id }; + deleteObject.mockResolvedValue(result); + + await deletePrinterProfileRouteHandler(req, res); + + expect(deleteObject).toHaveBeenCalledWith( + expect.objectContaining({ model: printerProfileModel, user: req.user }) + ); + expect(res.send).toHaveBeenCalledWith(result); + }); +}); diff --git a/src/services/production/__tests__/printers.test.js b/src/services/production/__tests__/printers.test.js index c972f7e..fcbe725 100644 --- a/src/services/production/__tests__/printers.test.js +++ b/src/services/production/__tests__/printers.test.js @@ -65,20 +65,32 @@ describe('Printer Service Route Handlers', () => { await listPrintersRouteHandler(req, res); - expect(listObjects).toHaveBeenCalledWith(expect.objectContaining({ model: printerModel })); + expect(listObjects).toHaveBeenCalledWith( + expect.objectContaining({ + model: printerModel, + populate: ['host'], + }) + ); expect(res.send).toHaveBeenCalledWith(mockResult); }); }); describe('newPrinterRouteHandler', () => { it('should create a new printer', async () => { - req.body = { name: 'New Printer', host: 'host123' }; + req.body = { + name: 'New Printer', + host: 'host123', + }; const mockPrinter = { _id: '456', ...req.body }; newObject.mockResolvedValue(mockPrinter); await newPrinterRouteHandler(req, res); - expect(newObject).toHaveBeenCalled(); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ host: 'host123' }), + }) + ); expect(res.send).toHaveBeenCalledWith(mockPrinter); }); }); @@ -86,13 +98,18 @@ describe('Printer Service Route Handlers', () => { describe('editPrinterRouteHandler', () => { it('should update a printer', async () => { req.params.id = '507f1f77bcf86cd799439011'; - req.body = { name: 'Updated Printer' }; + req.body = { name: 'Updated Printer', host: 'host456' }; const mockResult = { _id: '507f1f77bcf86cd799439011', ...req.body }; editObject.mockResolvedValue(mockResult); await editPrinterRouteHandler(req, res); - expect(editObject).toHaveBeenCalled(); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ host: 'host456' }), + populate: ['vendor', 'host'], + }) + ); expect(res.send).toHaveBeenCalledWith(mockResult); }); }); diff --git a/src/services/production/filamentprofiles.js b/src/services/production/filamentprofiles.js new file mode 100644 index 0000000..83f321d --- /dev/null +++ b/src/services/production/filamentprofiles.js @@ -0,0 +1,305 @@ +import config from '../../config.js'; +import { filamentProfileModel } from '../../database/schemas/production/filamentprofile.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + editObject, + getObject, + listObjects, + listObjectsByProperties, + newObject, + searchObjects, +} from '../../database/database.js'; + +const logger = log4js.getLogger('FilamentProfiles'); +logger.level = config.server.logLevel; + +const FILAMENT_PROFILE_POPULATE = ['filament', 'compatiblePrinters']; + +export const filamentProfileFields = [ + 'name', + 'filamentType', + 'filament', + 'filamentIsSupport', + 'filamentSoluble', + 'filamentPrintable', + 'filamentAdhesivenessCategory', + 'temperatureVitrification', + 'idleTemperature', + 'pelletFlowCoefficient', + 'requiredNozzleHrc', + 'filamentFlowRatio', + 'enablePressureAdvance', + 'pressureAdvance', + 'adaptivePressureAdvance', + 'adaptivePressureAdvanceBridges', + 'adaptivePressureAdvanceOverhangs', + 'adaptivePressureAdvanceModel', + 'activateChamberTempControl', + 'chamberTemperature', + 'chamberMinimalTemperature', + 'nozzleTemperatureInitialLayer', + 'nozzleTemperature', + 'nozzleTemperatureRangeLow', + 'nozzleTemperatureRangeHigh', + 'hotPlateTempInitialLayer', + 'hotPlateTemp', + 'coolPlateTempInitialLayer', + 'coolPlateTemp', + 'engPlateTempInitialLayer', + 'engPlateTemp', + 'texturedPlateTempInitialLayer', + 'texturedPlateTemp', + 'texturedCoolPlateTempInitialLayer', + 'texturedCoolPlateTemp', + 'supertackPlateTempInitialLayer', + 'supertackPlateTemp', + 'filamentAdaptiveVolumetricSpeed', + 'filamentMaxVolumetricSpeed', + 'volumetricSpeedCoefficients', + 'closeFanTheFirstXLayers', + 'fullFanSpeedLayer', + 'fanMinSpeed', + 'fanMaxSpeed', + 'reduceFanStopStartFreq', + 'slowDownForLayerCooling', + 'dontSlowDownOuterWall', + 'slowDownMinSpeed', + 'slowDownLayerTime', + 'fanCoolingLayerTime', + 'enableOverhangBridgeFan', + 'overhangFanThreshold', + 'overhangFanSpeed', + 'internalBridgeFanSpeed', + 'supportMaterialInterfaceFanSpeed', + 'ironingFanSpeed', + 'initialLayerFanSpeed', + 'firstXLayerFanSpeed', + 'additionalCoolingFanSpeed', + 'additionalFanFullSpeedLayer', + 'closeAdditionalFanFirstXLayers', + 'activateAirFiltration', + 'activateAirFiltrationDuringPrint', + 'activateAirFiltrationOnCompletion', + 'duringPrintExhaustFanSpeed', + 'completePrintExhaustFanSpeed', + 'filamentRetractionLength', + 'filamentRetractionSpeed', + 'filamentDeretractionSpeed', + 'filamentRetractionMinimumTravel', + 'filamentRetractWhenChangingLayer', + 'filamentRetractBeforeWipe', + 'filamentRetractAfterWipe', + 'filamentRetractRestartExtra', + 'filamentRetractLiftAbove', + 'filamentRetractLiftBelow', + 'filamentRetractLiftEnforce', + 'filamentWipe', + 'filamentWipeDistance', + 'filamentZHop', + 'filamentZHopTypes', + 'filamentLongRetractionsWhenCut', + 'filamentRetractionDistancesWhenCut', + 'longRetractionsWhenEc', + 'retractionDistancesWhenEc', + 'filamentLoadingSpeed', + 'filamentLoadingSpeedStart', + 'filamentUnloadingSpeed', + 'filamentUnloadingSpeedStart', + 'filamentChangeLength', + 'filamentChangeLengthNc', + 'filamentToolchangeDelay', + 'filamentExtruderCompatibility', + 'filamentExtruderVariant', + 'filamentMultitoolRamming', + 'filamentMultitoolRammingFlow', + 'filamentMultitoolRammingVolume', + 'filamentRammingParameters', + 'filamentRammingTravelTime', + 'filamentRammingTravelTimeNc', + 'filamentRammingVolumetricSpeed', + 'filamentRammingVolumetricSpeedNc', + 'filamentMinimalPurgeOnWipeTower', + 'filamentTowerInterfacePreExtrusionDist', + 'filamentTowerInterfacePreExtrusionLength', + 'filamentTowerInterfacePrintTemp', + 'filamentTowerInterfacePurgeVolume', + 'filamentTowerIroningArea', + 'filamentCoolingBeforeTower', + 'filamentCoolingInitialSpeed', + 'filamentCoolingFinalSpeed', + 'filamentCoolingMoves', + 'filamentFlushTemp', + 'filamentFlushTempFast', + 'filamentFlushVolumetricSpeed', + 'filamentPreCoolingTemperature', + 'filamentPreCoolingTemperatureNc', + 'filamentPreheatTemperatureDelta', + 'filamentPrimeVolumeNc', + 'filamentRetractLengthNc', + 'filamentStampingDistance', + 'filamentStampingLoadingSpeed', + 'filamentStartGcode', + 'filamentEndGcode', + 'filamentChangeExtrusionRoleGcode', + 'filamentShrink', + 'filamentShrinkageCompensationZ', + 'filamentDevAmsDryingTemperature', + 'filamentDevAmsDryingTime', + 'filamentDevAmsDryingHeatDistortionTemperature', + 'filamentDevAmsDryingAmsLimitations', + 'filamentDevChamberDryingBedTemperature', + 'filamentDevChamberDryingTime', + 'filamentDevDryingCoolingTemperature', + 'filamentDevDryingSofteningTemperature', + 'compatiblePrinters', + 'compatiblePrintersCondition', + 'compatiblePrints', + 'compatiblePrintsCondition', + 'filamentNotes', +]; + +const pickFilamentProfileFields = (body = {}) => + Object.fromEntries( + filamentProfileFields + .filter((field) => Object.hasOwn(body, field)) + .map((field) => [field, body[field]]) + ); + +export const listFilamentProfilesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: filamentProfileModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: FILAMENT_PROFILE_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing filament profiles.'); + return res.status(result.code).send(result); + } + + logger.debug(`List of filament profiles (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listFilamentProfilesByPropertiesRouteHandler = async ( + req, + res, + properties = [], + filter = {} +) => { + const result = await listObjectsByProperties({ + model: filamentProfileModel, + properties, + filter, + populate: FILAMENT_PROFILE_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing filament profiles.'); + return res.status(result.code).send(result); + } + + logger.debug(`List of filament profiles. Count: ${result.length}`); + res.send(result); +}; + +export const searchFilamentProfilesRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: filamentProfileModel, + search, + }); + res.send(result); +}; + +export const getFilamentProfileRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: filamentProfileModel, + id, + populate: FILAMENT_PROFILE_POPULATE, + }); + + if (result?.error) { + logger.warn('Filament profile not found with supplied id.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved filament profile with ID: ${id}`); + res.send(result); +}; + +export const editFilamentProfileRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const updateData = { + updatedAt: new Date(), + ...pickFilamentProfileFields(req.body), + }; + const result = await editObject({ + model: filamentProfileModel, + id, + updateData, + user: req.user, + }); + + if (result?.error) { + logger.error('Error editing filament profile:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Edited filament profile with ID: ${id}`); + res.send(result); +}; + +export const newFilamentProfileRouteHandler = async (req, res) => { + const newData = { + ...pickFilamentProfileFields(req.body), + }; + const result = await newObject({ + model: filamentProfileModel, + newData, + user: req.user, + }); + + if (result?.error) { + logger.error('No filament profile created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New filament profile with ID: ${result._id}`); + res.send(result); +}; + +export const deleteFilamentProfileRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await deleteObject({ + model: filamentProfileModel, + id, + user: req.user, + }); + + if (result?.error) { + logger.error('No filament profile deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted filament profile with ID: ${result._id}`); + res.send(result); +}; diff --git a/src/services/production/printerprofiles.js b/src/services/production/printerprofiles.js new file mode 100644 index 0000000..ecb85f3 --- /dev/null +++ b/src/services/production/printerprofiles.js @@ -0,0 +1,193 @@ +import config from '../../config.js'; +import { printerProfileModel } from '../../database/schemas/production/printerprofile.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + editObject, + getObject, + listObjects, + listObjectsByProperties, + newObject, + searchObjects, +} from '../../database/database.js'; + +const logger = log4js.getLogger('PrinterProfiles'); +logger.level = config.server.logLevel; + +export const printerProfileFields = [ + 'name', + 'printer', + 'extruders', + 'printBedWidth', + 'printBedHeight', + 'originX', + 'originY', + 'bedExcludeArea', + 'printableHeight', + 'supportMultiBedTypes', + 'bestObjectPositionX', + 'bestObjectPositionY', + 'zOffset', + 'preferredOrientation', + 'machineMaxAccelerationRetracting', + 'machineMaxSpeedE', + 'machineMaxSpeedX', + 'machineMaxSpeedY', + 'machinePauseGcode', + 'machineStartGcode', + 'machineEndGcode', + 'layerChangeGcode', + 'beforeLayerChangeGcode', + 'printerNotes', + 'scanFirstLayer', + 'machineLoadFilamentTime', + 'machineUnloadFilamentTime', + 'thumbnails', + 'auxiliaryFan', + 'machineMaxJunctionDeviation', +]; + +const pickPrinterProfileFields = (body = {}) => + Object.fromEntries( + printerProfileFields + .filter((field) => Object.hasOwn(body, field)) + .map((field) => [field, body[field]]) + ); + +export const listPrinterProfilesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: printerProfileModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: ['printer'], + }); + + if (result?.error) { + logger.error('Error listing printer profiles.'); + return res.status(result.code).send(result); + } + + logger.debug(`List of printer profiles (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listPrinterProfilesByPropertiesRouteHandler = async ( + req, + res, + properties = [], + filter = {} +) => { + const result = await listObjectsByProperties({ + model: printerProfileModel, + properties, + filter, + populate: ['printer'], + }); + + if (result?.error) { + logger.error('Error listing printer profiles.'); + return res.status(result.code).send(result); + } + + logger.debug(`List of printer profiles. Count: ${result.length}`); + res.send(result); +}; + +export const searchPrinterProfilesRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: printerProfileModel, + search, + }); + res.send(result); +}; + +export const getPrinterProfileRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: printerProfileModel, + id, + populate: ['printer'], + }); + + if (result?.error) { + logger.warn('Printer profile not found with supplied id.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved printer profile with ID: ${id}`); + res.send(result); +}; + +export const editPrinterProfileRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const updateData = { + updatedAt: new Date(), + ...pickPrinterProfileFields(req.body), + }; + const result = await editObject({ + model: printerProfileModel, + id, + updateData, + user: req.user, + }); + + if (result?.error) { + logger.error('Error editing printer profile:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Edited printer profile with ID: ${id}`); + res.send(result); +}; + +export const newPrinterProfileRouteHandler = async (req, res) => { + const newData = { + ...pickPrinterProfileFields(req.body), + }; + const result = await newObject({ + model: printerProfileModel, + newData, + user: req.user, + }); + + if (result?.error) { + logger.error('No printer profile created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New printer profile with ID: ${result._id}`); + res.send(result); +}; + +export const deletePrinterProfileRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + const result = await deleteObject({ + model: printerProfileModel, + id, + user: req.user, + }); + + if (result?.error) { + logger.error('No printer profile deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted printer profile with ID: ${result._id}`); + res.send(result); +}; diff --git a/src/services/production/printers.js b/src/services/production/printers.js index c6c28fa..302c54e 100644 --- a/src/services/production/printers.js +++ b/src/services/production/printers.js @@ -86,9 +86,16 @@ export const getPrinterRouteHandler = async (req, res) => { const result = await getObject({ model: printerModel, id, - populate: ['vendor', 'host'], + populate: [ + 'vendor', + 'host', + { path: 'pendingSlicerUploads.file', strictPopulate: false }, + { path: 'pendingSlicerUploads.gcodeFile', strictPopulate: false }, + { path: 'pendingSlicerUploads.job', strictPopulate: false }, + ], }); if (result?.error) { + console.log('result', result.error); logger.warn(`Printer not found with supplied id.`); return res.status(result.code).send(result); } @@ -109,6 +116,7 @@ export const editPrinterRouteHandler = async (req, res) => { tags: req.body.tags, vendor: req.body.vendor, host: req.body.host, + pendingSlicerUploads: req.body.pendingSlicerUploads, active: req.body.active, }; // Create audit log before updating