diff --git a/app/Controllers/Http/Admin/AdminuserController.ts b/app/Controllers/Http/Admin/AdminuserController.ts index 2962cbd..05f25a4 100644 --- a/app/Controllers/Http/Admin/AdminuserController.ts +++ b/app/Controllers/Http/Admin/AdminuserController.ts @@ -85,9 +85,9 @@ export default class AdminuserController { // return response.badRequest(error.messages); throw error; } - - const input: Record = request.only(['login', 'email','first_name', 'last_name']); - input.password = request.input('new_password'); + + const input: Record = request.only(['login', 'email', 'first_name', 'last_name']); + input.password = request.input('new_password'); const user = await User.create(input); if (request.input('roles')) { const roles: Array = request.input('roles'); diff --git a/app/Controllers/Http/Admin/MimetypeController.ts b/app/Controllers/Http/Admin/MimetypeController.ts index 561c0ad..932fb55 100644 --- a/app/Controllers/Http/Admin/MimetypeController.ts +++ b/app/Controllers/Http/Admin/MimetypeController.ts @@ -33,7 +33,7 @@ export default class MimetypeController { // Step 2 - Validate request body against the schema // await request.validate({ schema: newDatasetSchema, messages: this.messages }); const validator = vine.compile(newDatasetSchema); - validator.messagesProvider = new SimpleMessagesProvider(this.messages); + validator.messagesProvider = new SimpleMessagesProvider(this.messages); await request.validateUsing(validator, { messagesProvider: new SimpleMessagesProvider(this.messages) }); } catch (error) { // Step 3 - Handle errors @@ -168,7 +168,7 @@ export default class MimetypeController { mimetype, }); } catch (error) { - if (error.code == 'E_ROW_NOT_FOUND') { + if (error.code === 'E_ROW_NOT_FOUND') { session.flash({ warning: 'Mimetype is not found in database' }); } else { session.flash({ warning: 'general error occured, you cannot delete the mimetype' }); diff --git a/app/Controllers/Http/Admin/mailsettings_controller.ts b/app/Controllers/Http/Admin/mailsettings_controller.ts index 628a9fc..332fd05 100644 --- a/app/Controllers/Http/Admin/mailsettings_controller.ts +++ b/app/Controllers/Http/Admin/mailsettings_controller.ts @@ -85,15 +85,14 @@ export default class MailSettingsController { } try { - await mail.send( - (message) => { - message - // .from(Config.get('mail.from.address')) - .from('tethys@geosphere.at') - .to(userEmail) - .subject('Test Email') - .html('

If you received this email, the email configuration seems to be correct.

'); - }); + await mail.send((message) => { + message + // .from(Config.get('mail.from.address')) + .from('tethys@geosphere.at') + .to(userEmail) + .subject('Test Email') + .html('

If you received this email, the email configuration seems to be correct.

'); + }); return response.json({ success: true, message: 'Test email sent successfully' }); // return response.flash('Test email sent successfully!', 'message').redirect().back(); diff --git a/app/Controllers/Http/Api/DatasetController.ts b/app/Controllers/Http/Api/DatasetController.ts index 9106521..8cb17b6 100644 --- a/app/Controllers/Http/Api/DatasetController.ts +++ b/app/Controllers/Http/Api/DatasetController.ts @@ -210,13 +210,13 @@ export default class DatasetController { */ private async buildVersionChain(dataset: Dataset) { const versionChain = { - current: { - id: dataset.id, - publish_id: dataset.publish_id, - doi: dataset.identifier?.value || null, - main_title: dataset.mainTitle || null, - server_date_published: dataset.server_date_published, - }, + // current: { + // id: dataset.id, + // publish_id: dataset.publish_id, + // doi: dataset.identifier?.value || null, + // main_title: dataset.mainTitle || null, + // server_date_published: dataset.server_date_published, + // }, previousVersions: [] as any[], newerVersions: [] as any[], }; @@ -233,92 +233,181 @@ export default class DatasetController { /** * Recursively get all previous versions */ + // private async getPreviousVersions(datasetId: number, visited: Set = new Set()): Promise { + // // Prevent infinite loops + // if (visited.has(datasetId)) { + // return []; + // } + // visited.add(datasetId); + + // const previousVersions: any[] = []; + + // // Find references where this dataset "IsNewVersionOf" another dataset + // const previousRefs = await DatasetReference.query() + // .where('document_id', datasetId) + // .where('relation', 'IsNewVersionOf') + // .whereNotNull('related_document_id'); + + // for (const ref of previousRefs) { + // if (!ref.related_document_id) continue; + + // const previousDataset = await Dataset.query() + // .where('id', ref.related_document_id) + // .preload('identifier') + // .preload('titles') + // .first(); + + // if (previousDataset) { + // const versionInfo = { + // id: previousDataset.id, + // publish_id: previousDataset.publish_id, + // doi: previousDataset.identifier?.value || null, + // main_title: previousDataset.mainTitle || null, + // server_date_published: previousDataset.server_date_published, + // relation: 'IsPreviousVersionOf', // From perspective of current dataset + // }; + + // previousVersions.push(versionInfo); + + // // Recursively get even older versions + // const olderVersions = await this.getPreviousVersions(previousDataset.id, visited); + // previousVersions.push(...olderVersions); + // } + // } + + // return previousVersions; + // } + private async getPreviousVersions(datasetId: number, visited: Set = new Set()): Promise { - // Prevent infinite loops - if (visited.has(datasetId)) { - return []; - } + if (visited.has(datasetId)) return []; visited.add(datasetId); - const previousVersions: any[] = []; + const result: any[] = []; - // Find references where this dataset "IsNewVersionOf" another dataset - const previousRefs = await DatasetReference.query() + // A dataset points to its OLDER version via relation 'IsNewVersionOf' + const refs = await DatasetReference.query() .where('document_id', datasetId) - .where('relation', 'IsNewVersionOf') - .whereNotNull('related_document_id'); + .where('relation', 'IsNewVersionOf'); // ← removed .whereNotNull('related_document_id') - for (const ref of previousRefs) { - if (!ref.related_document_id) continue; + for (const ref of refs) { + const related = await this.resolveReferencedDataset(ref, datasetId); + if (!related) continue; - const previousDataset = await Dataset.query() - .where('id', ref.related_document_id) - .preload('identifier') - .preload('titles') - .first(); + result.push({ + id: related.id, + publish_id: related.publish_id, + doi: related.identifier?.value || null, + main_title: related.mainTitle || null, + server_date_published: related.server_date_published, + relation: 'IsPreviousVersionOf', + }); - if (previousDataset) { - const versionInfo = { - id: previousDataset.id, - publish_id: previousDataset.publish_id, - doi: previousDataset.identifier?.value || null, - main_title: previousDataset.mainTitle || null, - server_date_published: previousDataset.server_date_published, - relation: 'IsPreviousVersionOf', // From perspective of current dataset - }; - - previousVersions.push(versionInfo); - - // Recursively get even older versions - const olderVersions = await this.getPreviousVersions(previousDataset.id, visited); - previousVersions.push(...olderVersions); - } + result.push(...(await this.getPreviousVersions(related.id, visited))); } - return previousVersions; + return result; } /** * Recursively get all newer versions */ + // private async getNewerVersions(datasetId: number, visited: Set = new Set()): Promise { + // // Prevent infinite loops + // if (visited.has(datasetId)) { + // return []; + // } + // visited.add(datasetId); + + // const newerVersions: any[] = []; + + // // Find references where this dataset "IsPreviousVersionOf" another dataset + // const newerRefs = await DatasetReference.query() + // .where('document_id', datasetId) + // .where('relation', 'IsPreviousVersionOf') + // .whereNotNull('related_document_id'); + + // for (const ref of newerRefs) { + // if (!ref.related_document_id) continue; + + // const newerDataset = await Dataset.query().where('id', ref.related_document_id).preload('identifier').preload('titles').first(); + + // if (newerDataset) { + // const versionInfo = { + // id: newerDataset.id, + // publish_id: newerDataset.publish_id, + // doi: newerDataset.identifier?.value || null, + // main_title: newerDataset.mainTitle || null, + // server_date_published: newerDataset.server_date_published, + // relation: 'IsNewVersionOf', // From perspective of current dataset + // }; + + // newerVersions.push(versionInfo); + + // // Recursively get even newer versions + // const evenNewerVersions = await this.getNewerVersions(newerDataset.id, visited); + // newerVersions.push(...evenNewerVersions); + // } + // } + + // return newerVersions; + // } private async getNewerVersions(datasetId: number, visited: Set = new Set()): Promise { - // Prevent infinite loops - if (visited.has(datasetId)) { - return []; - } + if (visited.has(datasetId)) return []; visited.add(datasetId); - const newerVersions: any[] = []; + const result: any[] = []; - // Find references where this dataset "IsPreviousVersionOf" another dataset - const newerRefs = await DatasetReference.query() + // A dataset points to its NEWER version via relation 'IsPreviousVersionOf' + const refs = await DatasetReference.query() .where('document_id', datasetId) - .where('relation', 'IsPreviousVersionOf') - .whereNotNull('related_document_id'); + .where('relation', 'IsPreviousVersionOf'); // ← removed .whereNotNull(...) - for (const ref of newerRefs) { - if (!ref.related_document_id) continue; + for (const ref of refs) { + const related = await this.resolveReferencedDataset(ref, datasetId); + if (!related) continue; - const newerDataset = await Dataset.query().where('id', ref.related_document_id).preload('identifier').preload('titles').first(); + result.push({ + id: related.id, + publish_id: related.publish_id, + doi: related.identifier?.value || null, + main_title: related.mainTitle || null, + server_date_published: related.server_date_published, + relation: 'IsNewVersionOf', + }); - if (newerDataset) { - const versionInfo = { - id: newerDataset.id, - publish_id: newerDataset.publish_id, - doi: newerDataset.identifier?.value || null, - main_title: newerDataset.mainTitle || null, - server_date_published: newerDataset.server_date_published, - relation: 'IsNewVersionOf', // From perspective of current dataset - }; - - newerVersions.push(versionInfo); - - // Recursively get even newer versions - const evenNewerVersions = await this.getNewerVersions(newerDataset.id, visited); - newerVersions.push(...evenNewerVersions); - } + result.push(...(await this.getNewerVersions(related.id, visited))); } - return newerVersions; + return result; + } + + private async resolveReferencedDataset(ref: DatasetReference, currentDatasetId: number) { + const doi = this.normalizeDoi(ref.value); + + if (doi) { + const byDoi = await Dataset.query() + .whereHas('identifier', (q) => q.where('value', doi)) + .preload('identifier') + .preload('titles') // needed so mainTitle computes + .first(); + if (byDoi) return byDoi; + } + + if (ref.related_document_id && ref.related_document_id !== currentDatasetId) { + return await Dataset.query() + .where('id', ref.related_document_id) + .preload('identifier') + .preload('titles') + .first(); + } + + return null; + } + private normalizeDoi(value: string | null): string | null { + if (!value) return null; + return value + .trim() + .replace(/^https?:\/\/(dx\.)?doi\.org\//i, '') + .replace(/^doi:/i, ''); } } diff --git a/app/Controllers/Http/Auth/AuthController.ts b/app/Controllers/Http/Auth/AuthController.ts index 08b1c05..90a02be 100644 --- a/app/Controllers/Http/Auth/AuthController.ts +++ b/app/Controllers/Http/Auth/AuthController.ts @@ -1,100 +1,68 @@ import type { HttpContext } from '@adonisjs/core/http'; import User from '#models/user'; import BackupCode from '#models/backup_code'; -// import Hash from '@ioc:Adonis/Core/Hash'; -// import InvalidCredentialException from 'App/Exceptions/InvalidCredentialException'; import { authValidator } from '#validators/auth'; import hash from '@adonisjs/core/services/hash'; import db from '@adonisjs/lucid/services/db'; import TwoFactorAuthProvider from '#app/services/TwoFactorAuthProvider'; -// import { Authenticator } from '@adonisjs/auth'; -// import { LoginState } from 'Contracts/enums'; -// import { StatusCodes } from 'http-status-codes'; +import ActivityLogger from '#services/activity_logger'; +import logger from '@adonisjs/core/services/logger'; -// interface MyHttpsContext extends HttpContext { -// auth: Authenticator -// } export default class AuthController { - // login function{ request, auth, response }:HttpContext public async login({ request, response, auth, session }: HttpContext) { - // console.log({ - // registerBody: request.body(), - // }); - // await request.validate(AuthValidator); await request.validateUsing(authValidator); - // const plainPassword = await request.input('password'); - // const email = await request.input('email'); - // grab uid and password values off request body const { email, password } = request.only(['email', 'password']); try { - - await db.connection().rawQuery('SELECT 1') - - - // // attempt to verify credential and login user - // await auth.use('web').attempt(email, plainPassword); + await db.connection().rawQuery('SELECT 1'); - // const user = await auth.use('web').verifyCredentials(email, password); const user = await User.verifyCredentials(email, password); if (user.isTwoFactorEnabled) { - // session.put("login.id", user.id); - // return view.render("pages/two-factor-challenge"); - + // Noch KEIN abgeschlossenes Login -> nicht loggen. session.flash('user_id', user.id); return response.redirect().back(); - - // let state = LoginState.STATE_VALIDATED; - // return response.status(StatusCodes.OK).json({ - // state: state, - // new_user_id: user.id, - // }); } await auth.use('web').login(user); - } catch (error) { + this.recordAuthEvent('auth.login', { user, ip: request.ip() }); + } catch (error: any) { + // DB nicht erreichbar -> kein fehlgeschlagener Login-Versuch, weiterwerfen if (error.code === 'ECONNREFUSED') { - throw error + throw error; } - // if login fails, return vague form message and redirect back + + // Echter Credential-Fehler -> als fehlgeschlagenen Versuch protokollieren + this.recordAuthEvent('auth.login_failed', { email, ip: request.ip() }); + session.flash('message', 'Your username, email, or password is incorrect'); return response.redirect().back(); } - // otherwise, redirect todashboard - response.redirect('/apps/dashboard'); + return response.redirect('/apps/dashboard'); } public async twoFactorChallenge({ request, session, auth, response }: HttpContext) { - const { code, backup_code, login_id } = request.only(['code', 'backup_code', 'login_id']); + const { code, backup_code, login_id } = request.only(['code', 'backup_code', 'login_id']); const user = await User.query().where('id', login_id).firstOrFail(); if (code) { const isValid = await TwoFactorAuthProvider.validate(user, code); if (isValid) { - // login user and redirect to dashboard await auth.use('web').login(user); - response.redirect('/apps/dashboard'); - } else { - session.flash('message', 'Your two-factor code is incorrect'); - return response.redirect().back(); + this.recordAuthEvent('auth.login', { user, email: user.email, ip: request.ip(), method: '2fa_totp' }); + return response.redirect('/apps/dashboard'); } - } else if (backup_code) { - const codes: BackupCode[] = await user.getBackupCodes(); - - // const verifiedBackupCodes = await Promise.all( - // codes.map(async (backupCode) => { - // let isVerified = await hash.verify(backupCode.code, backup_code); - // if (isVerified) { - // return backupCode; - // } - // }), - // ); - // const backupCodeToDelete = verifiedBackupCodes.find(Boolean); - let backupCodeToDelete = null; + session.flash('message', 'Your two-factor code is incorrect'); + return response.redirect().back(); + } + + if (backup_code) { + const codes: BackupCode[] = await user.getBackupCodes(); + + let backupCodeToDelete: BackupCode | null = null; for (const backupCode of codes) { const isVerified = await hash.verify(backupCode.code, backup_code); if (isVerified) { @@ -103,29 +71,68 @@ export default class AuthController { } } - if (backupCodeToDelete) { - if (backupCodeToDelete.used === false) { - backupCodeToDelete.used = true; - await backupCodeToDelete.save(); - console.log(`BackupCode with id ${backupCodeToDelete.id} has been marked as used.`); - await auth.use('web').login(user); - response.redirect('/apps/dashboard'); - } else { - session.flash('message', 'BackupCode already used'); - return response.redirect().back(); - } - } else { + if (!backupCodeToDelete) { session.flash('message', 'BackupCode not found'); return response.redirect().back(); - } + } + + if (backupCodeToDelete.used) { + session.flash('message', 'BackupCode already used'); + return response.redirect().back(); + } + + backupCodeToDelete.used = true; + await backupCodeToDelete.save(); + + await auth.use('web').login(user); + this.recordAuthEvent('auth.login', { user, email: user.email, ip: request.ip(), method: '2fa_backup_code' }); + + return response.redirect('/apps/dashboard'); } + + // Weder code noch backup_code übergeben + session.flash('message', 'No verification code provided'); + return response.redirect().back(); } - // logout function - public async logout({ auth, response }: HttpContext) { - // await auth.logout(); + public async logout({ auth, request, response }: HttpContext) { + // Session auflösen -> füllt auth.user, falls eingeloggt + await auth.use('web').check(); + const user = auth.use('web').user; + await auth.use('web').logout(); + + if (user) { + this.recordAuthEvent('auth.logout', { user, email: user.email, ip: request.ip() }); + } + return response.redirect('/app/login'); - // return response.status(200); + } + + /** + * Zentraler Audit-Logger für Auth-Events. + * Fire-and-forget: ein Fehler beim Schreiben darf Login/Logout nie blockieren. + */ + private recordAuthEvent( + type: 'auth.login' | 'auth.logout' | 'auth.login_failed', + opts: { user?: User; email?: string; ip: string; method?: string }, + ) { + const { user, email, ip, method } = opts; + + const description = + type === 'auth.login' + ? `${user!.firstName} ${user!.lastName} signed in` + : type === 'auth.logout' + ? `${user!.firstName} ${user!.lastName} signed out` + : `Failed login attempt for ${email ?? 'unknown'}`; + + void ActivityLogger.log({ + type, + description, + userId: user?.id ?? null, + subjectType: user ? 'User' : null, + subjectId: user?.id ?? null, + properties: { ip, ...(method ? { method } : {}) }, + }).catch((err) => logger.error({ err }, `failed to record ${type} activity`)); } } diff --git a/app/Controllers/Http/Oai/OaiController.ts b/app/Controllers/Http/Oai/OaiController.ts index 47d1708..e00836f 100644 --- a/app/Controllers/Http/Oai/OaiController.ts +++ b/app/Controllers/Http/Oai/OaiController.ts @@ -1,5 +1,4 @@ import type { HttpContext } from '@adonisjs/core/http'; -// import { RequestContract } from '@ioc:Adonis/Core/Request'; import { Request } from '@adonisjs/core/http'; import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js'; import { create } from 'xmlbuilder2'; @@ -18,11 +17,8 @@ import { getDomain, preg_match } from '#app/utils/utility-functions'; import DatasetXmlSerializer from '#app/Library/DatasetXmlSerializer'; import logger from '@adonisjs/core/services/logger'; import ResumptionToken from '#app/Library/Oai/ResumptionToken'; -// import Config from '@ioc:Adonis/Core/Config'; import config from '@adonisjs/core/services/config'; -// import { inject } from '@adonisjs/fold'; import { inject } from '@adonisjs/core'; -// import { TokenWorkerContract } from "MyApp/Models/TokenWorker"; import TokenWorkerContract from '#library/Oai/TokenWorkerContract'; import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'; @@ -83,13 +79,13 @@ export default class OaiController { xsltParameter['oai_error_message'] = 'Only POST and GET methods are allowed for OAI-PMH.'; } - let earliestDateFromDb; // const oaiRequest: OaiParameter = request.body; try { this.firstPublishedDataset = await Dataset.earliestPublicationDate(); - this.firstPublishedDataset != null && - (earliestDateFromDb = this.firstPublishedDataset.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")); - this.xsltParameter['earliestDatestamp'] = earliestDateFromDb; + // Pflichtfeld laut OAI-PMH: auch bei leerem Repository einen validen + // UTCdatetime liefern, sonst entsteht ein ungültiges leeres Element. + this.xsltParameter['earliestDatestamp'] = + this.firstPublishedDataset?.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") ?? '1970-01-01T00:00:00Z'; // start the request await this.handleRequest(oaiRequest, request); } catch (error) { @@ -122,7 +118,7 @@ export default class OaiController { // logLevel: 10, }); xmlOutput = result.principalResult; - } catch (error) { + } catch (error: any) { return response.status(500).json({ message: 'An error occurred while creating the user', error: error.message, @@ -157,7 +153,7 @@ export default class OaiController { const verb = oaiRequest['verb']; this.xsltParameter['oai_verb'] = verb; if (verb === 'Identify') { - this.handleIdentify(); + this.handleIdentify(oaiRequest); } else if (verb === 'ListMetadataFormats') { this.handleListMetadataFormats(); } else if (verb == 'GetRecord') { @@ -184,7 +180,10 @@ export default class OaiController { } } - protected handleIdentify() { + protected handleIdentify(oaiRequest: Dictionary) { + // OAI-PMH: Identify akzeptiert außer `verb` keine Argumente. + this.assertOnlyVerb(oaiRequest); + // Get configuration values from environment or a dedicated configuration service const email = process.env.OAI_EMAIL ?? 'repository@geosphere.at'; const repositoryName = process.env.OAI_REPOSITORY_NAME ?? 'Tethys RDR'; @@ -203,6 +202,21 @@ export default class OaiController { this.xml.root().ele('Datasets'); } + /** + * Wirft badArgument, wenn der Request andere Parameter als `verb` enthält. + * Für Verben ohne zusätzliche Argumente (Identify, ListSets, ListMetadataFormats). + */ + private assertOnlyVerb(oaiRequest: Dictionary) { + const illegalKeys = Object.keys(oaiRequest).filter((key) => key !== 'verb'); + if (illegalKeys.length > 0) { + throw new OaiModelException( + StatusCodes.BAD_REQUEST, + `The request includes illegal arguments: ${illegalKeys.join(', ')}.`, + OaiErrorCodes.BADARGUMENT, + ); + } + } + protected handleListMetadataFormats() { this.xml.root().ele('Datasets'); } diff --git a/app/Controllers/Http/Submitter/DatasetController.ts b/app/Controllers/Http/Submitter/DatasetController.ts index 23789b9..a5b484c 100644 --- a/app/Controllers/Http/Submitter/DatasetController.ts +++ b/app/Controllers/Http/Submitter/DatasetController.ts @@ -40,6 +40,8 @@ import type { Multipart } from '@adonisjs/bodyparser'; import * as fs from 'fs'; import { parseBytesSize, getConfigFor, getTmpPath, formatBytes, errorMessage } from '#app/utils/utility-functions'; import validation from '#services/validation_service'; +import ActivityLogger from '#services/activity_logger'; +import logger from '@adonisjs/core/services/logger'; interface Dictionary { [index: string]: string; @@ -469,9 +471,7 @@ export default class DatasetController { console.error('Error cleaning up temporary file:', cleanupError); } }); - request.multipart.abort( - validation.make('files', `Upload limit of ${formatBytes(aggregatedLimit)} exceeded.`, 'limit') - ); + request.multipart.abort(validation.make('files', `Upload limit of ${formatBytes(aggregatedLimit)} exceeded.`, 'limit')); } }); @@ -513,10 +513,21 @@ export default class DatasetController { trx = await db.transaction(); const user = (await User.find(auth.user?.id)) as User; - await this.createDatasetAndAssociations(user, request, trx); + // await this.createDatasetAndAssociations(user, request, trx); + const { dataset, mainTitle } = await this.createDatasetAndAssociations(user, request, trx); await trx.commit(); console.log('Dataset and related models created successfully'); + + // NACH dem Commit: Dataset ist garantiert persistiert, keine Waisen-Gefahr. + // Fire-and-forget, damit ein Log-Fehler den bereits erfolgreichen Upload nicht kippt. + void ActivityLogger.log({ + type: 'dataset.uploaded', + description: `New publication uploaded: ${mainTitle ?? 'Untitled'}`, + userId: user.id, + subjectType: 'Dataset', + subjectId: dataset.id, + }).catch((err) => logger.error({ err }, 'failed to record dataset.uploaded activity')); } catch (error) { // Clean up temporary files if validation or later steps fail uploadedTmpFiles.forEach((tmpPath) => { @@ -564,6 +575,15 @@ export default class DatasetController { await this.savePersons(dataset, request.input('contributors', []), 'contributor', trx); //save main and additional titles + // const titles = request.input('titles', []); + // for (const titleData of titles) { + // const title = new Title(); + // title.value = titleData.value; + // title.language = titleData.language; + // title.type = titleData.type; + // await dataset.useTransaction(trx).related('titles').save(title); + // } + let mainTitle: string | null = null; const titles = request.input('titles', []); for (const titleData of titles) { const title = new Title(); @@ -571,6 +591,11 @@ export default class DatasetController { title.language = titleData.language; title.type = titleData.type; await dataset.useTransaction(trx).related('titles').save(title); + + if (titleData.type === 'Main') { + // <-- an eure Typ-Konvention anpassen + mainTitle = titleData.value; + } } // save descriptions @@ -664,6 +689,8 @@ export default class DatasetController { await dataset.useTransaction(trx).related('files').save(newFile); await newFile.createHashValues(trx); } + + return { dataset, mainTitle }; // <-- statt void } private generateRandomString(length: number): string { @@ -1095,7 +1122,7 @@ export default class DatasetController { } }); const error = new errors.E_VALIDATION_ERROR({ - 'files': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`, + files: `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`, }); request.multipart.abort(error); } diff --git a/app/controllers/activities_controller.ts b/app/controllers/activities_controller.ts new file mode 100644 index 0000000..6526108 --- /dev/null +++ b/app/controllers/activities_controller.ts @@ -0,0 +1,43 @@ +// app/controllers/activities_controller.ts +import type { HttpContext } from '@adonisjs/core/http'; +import Activity from '#models/activity'; + +export default class ActivitiesController { + async index({ response }: HttpContext) { + // const activities = await Activity.query() + // .preload('user', (q) => q.select('id', 'login')) + // .orderBy('created_at', 'desc') + // .limit(10); + + // return response.json( + // activities.map((a) => ({ + // id: a.id, + // type: a.type, + // description: a.description, + // user: a.user?.login ?? null, + // created_at: a.createdAt.toISO(), // relativeTime() expects ISO + // })), + // ); + try { + const activities = await Activity.query() + .preload('user', (q) => q.select('id', 'login')) + .orderBy('created_at', 'desc') + .limit(10); + + return response.json( + activities.map((a) => ({ + id: a.id, + type: a.type, + description: a.description, + user: a.user?.login ?? null, + created_at: a.createdAt.toISO(), // relativeTime() expects ISO + })), + ); + } catch (error) { + console.error('Error fetching activities:', error); + return response.status(500).json({ error: 'Internal Server Error' }); + } + } + + +} diff --git a/app/models/activity.ts b/app/models/activity.ts new file mode 100644 index 0000000..a2b81c3 --- /dev/null +++ b/app/models/activity.ts @@ -0,0 +1,57 @@ +// app/models/activity.ts +import { DateTime } from 'luxon'; +import { belongsTo, column } from '@adonisjs/lucid/orm'; +import BaseModel from './base_model.js'; +import type { BelongsTo } from '@adonisjs/lucid/types/relations'; +import User from '#models/user'; +import { SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'; + +export default class Activity extends BaseModel { + public static namingStrategy = new SnakeCaseNamingStrategy(); + public static primaryKey = 'id'; + public static table = 'activities'; + + @column({ isPrimary: true }) + declare id: number; + + @column() + declare type: string; + + @column() + declare userId: number | null; + + @column() + declare subjectType: string | null; + + @column() + declare subjectId: number | null; + + @column() + declare description: string; + + // Manual JSON (de)serialization keeps this working on SQLite/MySQL. + // On Postgres json/jsonb the driver already parses — drop the `consume` + // JSON.parse there to avoid double-handling. + // @column({ + // prepare: (value: Record | null) => (value ? JSON.stringify(value) : null), + // consume: (value: string | null) => (value ? JSON.parse(value) : null), + // }) + // declare properties: Record | null; + + @column() + declare properties: Record | null; + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime; + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime; + + // @belongsTo(() => User) + // declare user: BelongsTo; + + @belongsTo(() => User, { + foreignKey: 'userId', + }) + declare user: BelongsTo; +} diff --git a/app/models/dataset.ts b/app/models/dataset.ts index 1233a28..1bf772b 100644 --- a/app/models/dataset.ts +++ b/app/models/dataset.ts @@ -5,7 +5,10 @@ import { belongsTo, hasMany, computed, - hasOne + hasOne, + afterCreate, + beforeUpdate, + afterUpdate, } from '@adonisjs/lucid/orm'; import { DateTime } from 'luxon'; import dayjs from 'dayjs'; @@ -23,10 +26,11 @@ import DatasetIdentifier from './dataset_identifier.js'; import Project from './project.js'; import DocumentXmlCache from './DocumentXmlCache.js'; import DatasetExtension from '#models/traits/dataset_extension'; -import type { ManyToMany } from "@adonisjs/lucid/types/relations"; -import type { BelongsTo } from "@adonisjs/lucid/types/relations"; -import type { HasMany } from "@adonisjs/lucid/types/relations"; -import type { HasOne } from "@adonisjs/lucid/types/relations"; +import type { ManyToMany } from '@adonisjs/lucid/types/relations'; +import type { BelongsTo } from '@adonisjs/lucid/types/relations'; +import type { HasMany } from '@adonisjs/lucid/types/relations'; +import type { HasOne } from '@adonisjs/lucid/types/relations'; +import ActivityLogger from '#services/activity_logger'; export default class Dataset extends DatasetExtension { public static namingStrategy = new SnakeCaseNamingStrategy(); @@ -46,7 +50,7 @@ export default class Dataset extends DatasetExtension { @column({ columnName: 'creating_corporation' }) public creating_corporation: string; - @column.dateTime({ + @column.dateTime({ columnName: 'embargo_date', serialize: (value: Date | null) => { return value ? dayjs(value).format('YYYY-MM-DD') : value; @@ -60,7 +64,7 @@ export default class Dataset extends DatasetExtension { @column({}) public language: string; - @column({columnName: 'publish_id'}) + @column({ columnName: 'publish_id' }) public publish_id: number | null = null; @column({}) @@ -266,10 +270,12 @@ export default class Dataset extends DatasetExtension { return model || null; } - static async getMax (column: string) { - let dataset = await this.query().max(column + ' as max_publish_id').firstOrFail(); + static async getMax(column: string) { + let dataset = await this.query() + .max(column + ' as max_publish_id') + .firstOrFail(); return dataset.$extras.max_publish_id; - } + } @computed({ serializeAs: 'remaining_time', @@ -284,4 +290,34 @@ export default class Dataset extends DatasetExtension { return 0; } } + + // @afterCreate() + // static async logUploaded(dataset: Dataset) { + // await dataset.preload('titles'); + + // await ActivityLogger.log({ + // type: 'dataset.uploaded', + // description: `New publication uploaded: ${dataset.mainTitle ?? 'Untitled'}`, + // subjectType: 'Dataset', + // subjectId: dataset.id, + // }); + // } + + @beforeUpdate() + static capturePublish(dataset: Dataset) { + // $dirty is populated here, before persistence + (dataset as any).$becamePublished = dataset.$dirty.status !== undefined && dataset.status === 'published'; + } + + @afterUpdate() + static async logPublished(dataset: Dataset) { + if ((dataset as any).$becamePublished) { + await ActivityLogger.log({ + type: 'dataset.published', + description: `Publication published: ${dataset.mainTitle}`, + subjectType: 'Dataset', + subjectId: dataset.id, + }); + } + } } diff --git a/app/models/user.ts b/app/models/user.ts index 7be02b9..038b569 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -14,6 +14,7 @@ import type { ManyToMany } from '@adonisjs/lucid/types/relations'; import type { HasMany } from '@adonisjs/lucid/types/relations'; import { compose } from '@adonisjs/core/helpers'; import BackupCode from './backup_code.js'; +import Activity from './activity.js'; const AuthFinder = withAuthFinder(() => hash.use('laravel'), { uids: ['email'], @@ -111,6 +112,11 @@ export default class User extends compose(BaseModel, AuthFinder) { }) public backupcodes: HasMany; + @hasMany(() => Activity, { + foreignKey: 'user_id', + }) + public activities: HasMany; + @computed({ serializeAs: 'is_admin', }) diff --git a/app/services/activity_logger.ts b/app/services/activity_logger.ts new file mode 100644 index 0000000..c81b4e5 --- /dev/null +++ b/app/services/activity_logger.ts @@ -0,0 +1,27 @@ +// app/services/activity_logger.ts +import Activity from '#models/activity' + +interface LogOptions { + type: string + description: string + userId?: number | null + subjectType?: string | null + subjectId?: number | string | null + properties?: Record +} + +export default class ActivityLogger { + static async log(options: LogOptions): Promise { + await Activity.create({ + type: options.type, + description: options.description, + userId: options.userId ?? null, + subjectType: options.subjectType ?? null, + subjectId: options.subjectId != null ? Number(options.subjectId) : null, + properties: options.properties ?? null, + }) + + // Invalidate the cache if you add one (see Redis section). + // await redis.del('activities:recent') + } +} \ No newline at end of file diff --git a/commands/fix_version_related_ids.ts b/commands/fix_version_related_ids.ts new file mode 100644 index 0000000..40afc13 --- /dev/null +++ b/commands/fix_version_related_ids.ts @@ -0,0 +1,189 @@ +/* +|-------------------------------------------------------------------------- +| node ace make:command fix-version-related-ids +| DONE: create commands/fix_version_related_ids.ts +|-------------------------------------------------------------------------- +| Repairs the `related_document_id` foreign key on version references +| (IsNewVersionOf / IsPreviousVersionOf, both directions). +| +| The DOI stored in `value` is the reliable link; `related_document_id` +| is frequently NULL or self-referential. This command resolves the target +| dataset via its DOI and sets `related_document_id` accordingly, correcting +| both NULL and wrong-but-non-null values. +| +| Examples: +| node ace fix:version-related-ids // dry run, all datasets +| node ace fix:version-related-ids --verbose // dry run with per-row detail +| node ace fix:version-related-ids --fix // apply changes +| node ace fix:version-related-ids --fix -p 226 // apply, only refs owned by publish_id 226 +*/ +import { BaseCommand, flags } from '@adonisjs/core/ace'; +import type { CommandOptions } from '@adonisjs/core/types/ace'; +import Dataset from '#models/dataset'; +import DatasetReference from '#models/dataset_reference'; + +export default class FixVersionRelatedIds extends BaseCommand { + static commandName = 'fix:version-related-ids'; + static description = + 'Backfill/repair related_document_id on IsNewVersionOf / IsPreviousVersionOf references by resolving the target dataset via its DOI'; + + public static needsApplication = true; + + @flags.boolean({ alias: 'f', description: 'Apply changes. Without this flag the command runs as a dry run.' }) + public fix: boolean = false; + + @flags.boolean({ alias: 'v', description: 'Verbose output (per-reference detail)' }) + public verbose: boolean = false; + + @flags.number({ alias: 'p', description: 'Only process references owned by this publish_id' }) + public publish_id?: number; + + public static options: CommandOptions = { + startApp: true, + staysAlive: false, + }; + + // Only the version relations, both directions. + private readonly VERSION_RELATIONS = ['IsNewVersionOf', 'IsPreviousVersionOf']; + + async run() { + this.logger.info(`🔍 Scanning ${this.VERSION_RELATIONS.join(' / ')} references...`); + this.logger.info(this.fix ? '✏️ Mode: APPLY (changes will be written)' : '👀 Mode: DRY RUN (no changes written)'); + if (typeof this.publish_id === 'number') { + this.logger.info(`🎯 Filtering by owning publish_id: ${this.publish_id}`); + } + + try { + const query = DatasetReference.query() + .whereIn('relation', this.VERSION_RELATIONS) + .whereIn('type', ['DOI', 'URL']) + .where((q) => { + q.where('value', 'like', '%doi.org/10.24341/tethys.%').orWhere('value', 'like', '%tethys.at/dataset/%'); + }); + + // Restrict to references owned by a specific dataset (by publish_id), if requested. + if (typeof this.publish_id === 'number') { + query.whereHas('dataset', (d) => d.where('publish_id', this.publish_id as number)); + } + + const refs = await query.exec(); + this.logger.info(`🔗 Found ${refs.length} version reference(s) to inspect`); + + let alreadyCorrect = 0; + let filledFromNull = 0; + let correctedWrong = 0; + let unresolved = 0; + + for (const ref of refs) { + const target = await this.resolveTarget(ref); + + if (!target) { + unresolved++; + if (this.verbose) { + this.logger.warning(`⚠️ Reference ${ref.id}: could not resolve target (value: ${ref.value})`); + } + continue; + } + + // Never let a reference point at its own owning document. + if (target.id === ref.document_id) { + unresolved++; + if (this.verbose) { + this.logger.warning( + `⚠️ Reference ${ref.id}: target resolves to its own document (${ref.document_id}); skipping self-link`, + ); + } + continue; + } + + if (ref.related_document_id === target.id) { + alreadyCorrect++; + continue; + } + + const previous = ref.related_document_id; + const wasNull = previous === null || previous === undefined; + + if (this.fix) { + ref.related_document_id = target.id; + await ref.save(); + } + + if (wasNull) { + filledFromNull++; + } else { + correctedWrong++; + } + + if (this.verbose) { + const action = this.fix ? 'Updated' : '📝 Would update'; + this.logger.info( + `${action} reference ${ref.id} (doc ${ref.document_id}, ${ref.relation}): ` + + `related_document_id ${previous ?? 'NULL'} → ${target.id} (publish_id ${target.publish_id})`, + ); + } + } + + this.logger.info('────────────────────────────────────────'); + this.logger.info(`✔️ Already correct: ${alreadyCorrect}`); + this.logger.info(`➕ Filled from NULL: ${filledFromNull}`); + this.logger.info(`🔧 Corrected wrong value: ${correctedWrong}`); + this.logger.info(`⚠️ Unresolved/skipped: ${unresolved}`); + this.logger.info('────────────────────────────────────────'); + + const changes = filledFromNull + correctedWrong; + if (!this.fix && changes > 0) { + this.logger.info(`💡 Dry run only. Re-run with --fix to write ${changes} change(s).`); + } else if (this.fix) { + this.logger.success(`Done. ${changes} reference(s) updated.`); + } else { + this.logger.success('Nothing to change — all version references already linked correctly.'); + } + } catch (error) { + this.logger.error('Error fixing version related_document_id values:', error); + process.exit(1); + } + } + + /** + * Resolve the dataset a version reference points to. + * Prefers the DOI in `value` (reliable); falls back to a tethys publish_id URL. + */ + private async resolveTarget(ref: DatasetReference): Promise { + const doi = this.normalizeDoi(ref.value); + if (doi) { + const byDoi = await Dataset.query() + .whereHas('identifier', (q) => q.where('value', doi)) + .first(); + if (byDoi) return byDoi; + } + + const publishId = this.extractPublishId(ref.value); + if (publishId) { + const byPublishId = await Dataset.query().where('publish_id', publishId).first(); + if (byPublishId) return byPublishId; + } + + return null; + } + + /** + * Strip the resolver prefix so a reference value like + * "https://doi.org/10.24341/tethys.108.2" matches the identifier + * table value "10.24341/tethys.108.2". Returns null if it isn't a DOI. + */ + private normalizeDoi(value: string | null): string | null { + if (!value) return null; + const cleaned = value + .trim() + .replace(/^https?:\/\/(dx\.)?doi\.org\//i, '') + .replace(/^doi:/i, ''); + return /^10\.\d{4,}\//.test(cleaned) ? cleaned : null; + } + + private extractPublishId(value: string | null): number | null { + if (!value) return null; + const urlMatch = value.match(/tethys\.at\/dataset\/(\d+)/); + return urlMatch ? parseInt(urlMatch[1], 10) : null; + } +} \ No newline at end of file diff --git a/config/inertia.ts b/config/inertia.ts index 7a13efc..1bbab4b 100644 --- a/config/inertia.ts +++ b/config/inertia.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@adonisjs/inertia'; import type { HttpContext } from '@adonisjs/core/http'; -import type { InferSharedProps } from '@adonisjs/inertia/types' +import type { InferSharedProps } from '@adonisjs/inertia/types'; +import env from '#start/env'; const inertiaConfig = defineConfig({ /** @@ -21,6 +22,8 @@ const inertiaConfig = defineConfig({ return ctx.session?.flashMessages.get('user_id'); }, + opensearch_host: env.get('OPENSEARCH_HOST'), + flash: (ctx) => { return { message: ctx.session?.flashMessages.get('message'), diff --git a/database/migrations/1782294801884_create_activities_table.ts b/database/migrations/1782294801884_create_activities_table.ts new file mode 100644 index 0000000..7b497be --- /dev/null +++ b/database/migrations/1782294801884_create_activities_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema'; + +export default class extends BaseSchema { + protected tableName = 'activities'; + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id'); + table.string('type').notNullable().index(); // 'dataset.uploaded', 'auth.login' + table.integer('user_id').unsigned().nullable().references('id').inTable('accounts').onDelete('SET NULL'); + table.string('subject_type').nullable(); // manual morph: model name + table.bigInteger('subject_id').unsigned().nullable(); + table.string('description').notNullable(); + table.json('properties').nullable(); + table.timestamp('created_at'); + table.timestamp('updated_at'); + table.index(['subject_type', 'subject_id']) + table.index('created_at') + }); + } + + async down() { + this.schema.dropTable(this.tableName); + } +} diff --git a/resources/js/Components/Map/SearchMap.vue b/resources/js/Components/Map/SearchMap.vue index 25ce75d..efe8b02 100644 --- a/resources/js/Components/Map/SearchMap.vue +++ b/resources/js/Components/Map/SearchMap.vue @@ -1,5 +1,5 @@ @@ -210,4 +440,4 @@ const today = computed(() => animation: none; } } - \ No newline at end of file + diff --git a/resources/js/Stores/main.ts b/resources/js/Stores/main.ts index 3a31057..aa1e84c 100644 --- a/resources/js/Stores/main.ts +++ b/resources/js/Stores/main.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import axios from 'axios'; -import { Dataset } from '@/Dataset'; +import { Activity, Dataset } from '@/Dataset'; import menu from '@/menu'; // import type Person from '#models/person'; @@ -133,6 +133,8 @@ export const MainService = defineStore('main', { used: 0, codes: [], + activities: [] as Array, // <-- neu + graphData: {}, }), actions: { @@ -203,6 +205,17 @@ export const MainService = defineStore('main', { }); }, + // async fetchApi(resource: string) { + // try { + // const { data } = await axios.get(`/api/${resource}`); + // // @ts-ignore – dynamischer Key + // this[resource] = data; + // } catch (error) { + // console.error(`Failed to fetch ${resource}`, error); + // } + // }, + + setState(state: any) { this.totpState = state; }, diff --git a/start/env.ts b/start/env.ts index 371f1cf..767c6ec 100644 --- a/start/env.ts +++ b/start/env.ts @@ -35,6 +35,7 @@ export default await Env.create(new URL('../', import.meta.url), { HASH_DRIVER: Env.schema.enum(['scrypt', 'argon', 'bcrypt', 'laravel', undefined] as const), OAI_LIST_SIZE: Env.schema.number(), + OPENSEARCH_HOST: Env.schema.string(), /* |---------------------------------------------------------- diff --git a/start/routes/api.ts b/start/routes/api.ts index ca3b079..5586b16 100644 --- a/start/routes/api.ts +++ b/start/routes/api.ts @@ -8,12 +8,15 @@ import AvatarController from '#controllers/Http/Api/AvatarController'; import UserController from '#controllers/Http/Api/UserController'; import CollectionsController from '#controllers/Http/Api/collections_controller'; import { middleware } from '../kernel.js'; +import ActivitiesController from '#app/controllers/activities_controller'; // Clean DOI URL routes (no /api prefix) // API routes with /api prefix router .group(() => { + router.get('activities', [ActivitiesController, 'index']).as('activities.index'); + router.get('clients', [UserController, 'getSubmitters']).as('client.index').use(middleware.auth()); router.get('authors', [AuthorsController, 'index']).as('author.index').use(middleware.auth()); router.get('datasets', [DatasetController, 'index']).as('dataset.index');