Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,13 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

# CMS (ChemInformatics Microservice) API Configuration
# API_URL: Internal/Docker network URL for backend requests
# CM_PUBLIC_API: Public URL for frontend/proxy requests
# CMS_INTERNAL_AUTH_TOKEN: Authentication token to bypass rate limits
# CMS_RATE_LIMIT: Maximum requests per minute to /cms/depict2d endpoint (default: 200, set to 0 to disable)
API_URL=https://api.cheminf.studio/latest/
CM_PUBLIC_API=https://api.cheminf.studio/latest/
CMS_INTERNAL_AUTH_TOKEN=
CMS_RATE_LIMIT=200
24 changes: 12 additions & 12 deletions app/Console/Commands/GenerateCoordinates.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
use App\Models\Collection;
use App\Models\Molecule;
use App\Models\Structure;
use App\Services\CmsClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

Expand Down Expand Up @@ -63,21 +63,18 @@ public function handle()
$progressBar->start();

// Process molecules in chunks using the static list of IDs.
$moleculeIds->chunk(10000)->each(function ($idsChunk) use ($progressBar) {
$cmsClient = app(CmsClient::class);

$moleculeIds->chunk(10000)->each(function ($idsChunk) use ($progressBar, $cmsClient) {
$mols = Molecule::whereIn('id', $idsChunk)->select('id', 'canonical_smiles')->get();
$data = [];
foreach ($mols as $mol) {
$id = $mol->id;
$canonical_smiles = $mol->canonical_smiles;

// Build endpoints.
$apiUrl = env('API_URL', 'https://api.cheminf.studio/latest/');
$d2Endpoint = $apiUrl.'convert/mol2D?smiles='.urlencode($canonical_smiles).'&toolkit=rdkit';
$d3Endpoint = $apiUrl.'convert/mol3D?smiles='.urlencode($canonical_smiles).'&toolkit=rdkit';

// Fetch coordinates from API.
$d2 = $this->fetchFromApi($d2Endpoint, $canonical_smiles);
$d3 = $this->fetchFromApi($d3Endpoint, $canonical_smiles);
$d2 = $this->fetchFromApi($cmsClient, 'convert/mol2D', $canonical_smiles);
$d3 = $this->fetchFromApi($cmsClient, 'convert/mol3D', $canonical_smiles);

// Accumulate data for batch insertion.
$data[] = [
Expand All @@ -103,17 +100,20 @@ public function handle()
/**
* Make an HTTP GET request with basic retry/backoff handling (e.g. 429 Too Many Requests).
*
* @return mixed (array|null) Returns the JSON-decoded response or null on failure.
* @return mixed (string|null) Returns the response body or null on failure.
*/
private function fetchFromApi(string $endpoint, string $smiles)
private function fetchFromApi(CmsClient $cmsClient, string $endpoint, string $smiles)
{
$maxRetries = 3;
$attempt = 0;
$backoffSeconds = 0.01;

while ($attempt < $maxRetries) {
try {
$response = Http::timeout(600)->get($endpoint);
$response = $cmsClient->get($endpoint, [
'smiles' => $smiles,
'toolkit' => 'rdkit',
], false);
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CmsClient::get() method is being called with 3 arguments, but the method signature only accepts 2 parameters ($endpoint and $params). The third argument false will be ignored. This appears to be a bug that needs to be fixed by updating the CmsClient::get() method signature to accept this third parameter.

Suggested change
], false);
]);

Copilot uses AI. Check for mistakes.

if ($response->successful()) {
return $response->body();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ private function fetchCitationFromAPIs($doi): ?array
$citationResponse = null;

// Try EuropePMC first
$europemcUrl = env('EUROPEPMC_WS_API');
$europemcUrl = config('services.citation.europepmc_url');
$europemcParams = [
'query' => 'DOI:'.$doi,
'format' => 'json',
Expand All @@ -161,14 +161,14 @@ private function fetchCitationFromAPIs($doi): ?array
$citationResponse = $this->formatCitationResponse($europemcResponse['resultList']['result'][0], 'europemc');
} else {
// Try CrossRef
$crossrefUrl = env('CROSSREF_WS_API').$doi;
$crossrefUrl = config('services.citation.crossref_url').$doi;
$response = $this->makeRequest($crossrefUrl);
$crossrefResponse = ($response && method_exists($response, 'json')) ? $response->json() : null;
if ($crossrefResponse && isset($crossrefResponse['message'])) {
$citationResponse = $this->formatCitationResponse($crossrefResponse['message'], 'crossref');
} else {
// Try DataCite as last resort
$dataciteUrl = env('DATACITE_WS_API').$doi;
$dataciteUrl = config('services.citation.datacite_url').$doi;
$response = $this->makeRequest($dataciteUrl);
$dataciteResponse = ($response && method_exists($response, 'json')) ? $response->json() : null;
if ($dataciteResponse && isset($dataciteResponse['data'])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,21 @@ public function infolist(Infolist $infolist): Infolist
])
->schema([
ImageEntry::make('parent_canonical_smiles')->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->parent_canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->parent_canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
->ring(5)
->defaultImageUrl(url('/images/placeholder.png')),
ImageEntry::make('canonical_smiles')->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
->ring(5)
->defaultImageUrl(url('/images/placeholder.png')),
ImageEntry::make('standardized_canonical_smiles')->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->standardized_canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->standardized_canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand All @@ -133,7 +133,7 @@ public function table(Table $table): Table
ImageColumn::make('structure')->square()
->label('Structure')
->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand Down
2 changes: 1 addition & 1 deletion app/Filament/Dashboard/Resources/MoleculeResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public static function table(Table $table): Table
ImageColumn::make('structure')->square()
->label('Structure')
->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function table(Table $table): Table
ImageColumn::make('structure')->square()
->label('Structure')
->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function table(Table $table): Table
ImageColumn::make('structure')->square()
->label('Structure')
->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function table(Table $table): Table
ImageColumn::make('structure')->square()
->label('Structure')
->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function table(Table $table): Table
])
->actions([
Tables\Actions\Action::make('edit')
->url(fn (SampleLocation $record) => env('APP_URL').'/dashboard/sample-locations/'.$record->id.'/edit')
->url(fn (SampleLocation $record) => config('app.url').'/dashboard/sample-locations/'.$record->id.'/edit')
// ->color('info')
->icon('heroicon-m-pencil-square'),
// Tables\Actions\EditAction::make(),
Expand Down
2 changes: 1 addition & 1 deletion app/Filament/Dashboard/Resources/ReportResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ public static function form(Form $form): Form
}),
Action::make('viewCompoundPage')
->color('info')
->url(fn (string $operation, $record): string => $operation === 'create' ? env('APP_URL').'/compounds/'.request()->compound_id : env('APP_URL').'/compounds/'.$record->mol_ids)
->url(fn (string $operation, $record): string => $operation === 'create' ? config('app.url').'/compounds/'.request()->compound_id : config('app.url').'/compounds/'.$record->mol_ids)
->openUrlInNewTab()
->hidden(function (Get $get, string $operation) {
return ! $get('type');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,21 @@ public function infolist(Infolist $infolist): Infolist
])
->schema([
ImageEntry::make('parent_canonical_smiles')->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->parent_canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->parent_canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
->ring(5)
->defaultImageUrl(url('/images/placeholder.png')),
ImageEntry::make('canonical_smiles')->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
->ring(5)
->defaultImageUrl(url('/images/placeholder.png')),
ImageEntry::make('standardized_canonical_smiles')->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->standardized_canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->standardized_canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand All @@ -128,7 +128,7 @@ public function table(Table $table): Table
ImageColumn::make('structure')->square()
->label('Structure')
->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function table(Table $table): Table
ImageColumn::make('structure')->square()
->label('Structure')
->state(function ($record) {
return env('CM_PUBLIC_API', 'https://api.cheminf.studio/latest/').'depict/2D?smiles='.urlencode($record->canonical_smiles).'&height=300&width=300&CIP=true&toolkit=cdk';
return getDepictUrl($record->canonical_smiles, 300, 300, 'cdk', true);
})
->width(200)
->height(200)
Expand Down
27 changes: 24 additions & 3 deletions app/Helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function doiRegxMatch($doi)
function fetchDOICitation($doi)
{
$citationResponse = null;
$europemcUrl = env('EUROPEPMC_WS_API');
$europemcUrl = config('services.citation.europepmc_url');
$europemcParams = [
'query' => 'DOI:'.$doi,
'format' => 'json',
Expand All @@ -89,13 +89,13 @@ function fetchDOICitation($doi)
$citationResponse = formatCitationResponse($europemcResponse['resultList']['result'][0], 'europemc');
} else {
// fetch citation from CrossRef
$crossrefUrl = env('CROSSREF_WS_API').$doi;
$crossrefUrl = config('services.citation.crossref_url').$doi;
$crossrefResponse = makeRequest($crossrefUrl);
if ($crossrefResponse && isset($crossrefResponse['message'])) {
$citationResponse = formatCitationResponse($crossrefResponse['message'], 'crossref');
} else {
// fetch citation from DataCite
$dataciteUrl = env('DATACITE_WS_API').$doi;
$dataciteUrl = config('services.citation.datacite_url').$doi;
$dataciteResponse = makeRequest($dataciteUrl);
if ($dataciteResponse && isset($dataciteResponse['data'])) {
$citationResponse = formatCitationResponse($dataciteResponse['data'], 'datacite');
Expand Down Expand Up @@ -638,3 +638,24 @@ function handleJobFailure(
$batchId
);
}

/**
* Generate a URL for 2D molecule depiction through the CMS proxy
*
* @param string $smiles The SMILES string
* @param int $height Image height (default: 300)
* @param int $width Image width (default: 300)
* @param string $toolkit Toolkit to use: cdk, rdkit, openbabel (default: cdk)
* @param bool $CIP Whether to include CIP labels (default: true)
* @return string The proxy URL
*/
function getDepictUrl(string $smiles, int $height = 300, int $width = 300, string $toolkit = 'cdk', bool $CIP = true): string
{
return route('cms.depict2d', [
'smiles' => $smiles,
'height' => $height,
'width' => $width,
'toolkit' => $toolkit,
'CIP' => $CIP ? 'true' : 'false',
]);
}
58 changes: 58 additions & 0 deletions app/Http/Controllers/CmsProxyController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\Http\Controllers;

use App\Services\CmsClient;
use Illuminate\Http\Request;

class CmsProxyController extends Controller
{
private CmsClient $cmsClient;

public function __construct(CmsClient $cmsClient)
{
$this->cmsClient = $cmsClient;
}

/**
* Generic proxy for CMS API requests
* CSRF protection is automatically applied by Laravel's web middleware
*
* @return \Illuminate\Http\Response
*/
public function proxy(Request $request)
{
$endpoint = $request->input('endpoint');
Comment on lines +24 to +25
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing input validation for the endpoint parameter. A malicious user could potentially pass arbitrary endpoints, which could lead to unintended API calls. Consider validating or sanitizing the endpoint input, or maintaining a whitelist of allowed endpoints.

Suggested change
{
$endpoint = $request->input('endpoint');
{
// Define a whitelist of allowed endpoints
$allowedEndpoints = [
'articles',
'users',
'depict/2D',
// Add other allowed endpoints here
];
$endpoint = $request->input('endpoint');
if (!in_array($endpoint, $allowedEndpoints, true)) {
return response('Invalid endpoint', 400);
}

Copilot uses AI. Check for mistakes.
$params = $request->except(['endpoint', '_token']);
$method = $request->method();

$response = match ($method) {
'GET' => $this->cmsClient->get($endpoint, $params),
'POST' => $this->cmsClient->post($endpoint, $params),
default => abort(405, 'Method not allowed'),
};

return response($response->body())
->header('Content-Type', $response->header('Content-Type'))
->header('Cache-Control', 'public, max-age=86400')
->setStatusCode($response->status());
Comment on lines +29 to +38
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for failed API requests. If the CMS API returns an error or times out, the proxy will forward that error response to the client without any logging or fallback behavior. Consider adding try-catch blocks and logging failures for monitoring and debugging purposes.

Copilot uses AI. Check for mistakes.
}

/**
* Proxy for 2D depiction - used by img tags
* No CSRF protection (called directly by browsers via img src)
* Rate limited via route middleware to prevent abuse
*
* @return \Illuminate\Http\Response
*/
public function depict2D(Request $request)
{
$params = $request->only(['smiles', 'height', 'width', 'toolkit', 'CIP']);

$response = $this->cmsClient->get('depict/2D', $params);

return response($response->body())
->header('Content-Type', $response->header('Content-Type') ?? 'image/svg+xml')
->header('Cache-Control', 'public, max-age=86400');
Comment on lines +52 to +56
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response status code is not preserved when the API request fails. The proxy always returns the CMS API's status code, but if the request throws an exception (timeout, network error), it will result in a 500 error without a proper error response. Consider wrapping the API call in a try-catch and returning an appropriate error status (e.g., 502 Bad Gateway) when the upstream service is unavailable.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +56
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for failed API requests in the depict2D method. If the CMS API is unavailable or returns an error, users will see broken images without any fallback. Consider adding try-catch blocks and returning a placeholder image or appropriate error response on failure.

Suggested change
$response = $this->cmsClient->get('depict/2D', $params);
return response($response->body())
->header('Content-Type', $response->header('Content-Type') ?? 'image/svg+xml')
->header('Cache-Control', 'public, max-age=86400');
try {
$response = $this->cmsClient->get('depict/2D', $params);
// If the response is not successful, return a placeholder image
if ($response->status() !== 200) {
return $this->placeholderImage();
}
return response($response->body())
->header('Content-Type', $response->header('Content-Type') ?? 'image/svg+xml')
->header('Cache-Control', 'public, max-age=86400');
} catch (\Exception $e) {
// On exception, return a placeholder image
return $this->placeholderImage();
}
}
/**
* Returns a simple SVG placeholder image indicating an error.
*
* @return \Illuminate\Http\Response
*/
private function placeholderImage()
{
$svg = <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40">
<rect width="120" height="40" fill="#eee"/>
<text x="60" y="25" font-size="14" text-anchor="middle" fill="#888">Image Error</text>
</svg>
SVG;
return response($svg)
->header('Content-Type', 'image/svg+xml')
->header('Cache-Control', 'public, max-age=300');

Copilot uses AI. Check for mistakes.
}
}
6 changes: 3 additions & 3 deletions app/Jobs/ImportEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ public function fetchDOICitation($doi, $molecule)
$citationResponse = null;
if ($citation->wasRecentlyCreated || $citation->title == '') {
// fetch citation from EuropePMC
$europemcUrl = env('EUROPEPMC_WS_API');
$europemcUrl = config('services.citation.europepmc_url');
$europemcParams = [
'query' => 'DOI:'.$doi,
'format' => 'json',
Expand All @@ -296,14 +296,14 @@ public function fetchDOICitation($doi, $molecule)
$citationResponse = $this->formatCitationResponse($europemcResponse['resultList']['result'][0], 'europemc');
} else {
// fetch citation from CrossRef
$crossrefUrl = env('CROSSREF_WS_API').$doi;
$crossrefUrl = config('services.citation.crossref_url').$doi;
$response = $this->makeRequest($crossrefUrl);
$crossrefResponse = $response ? $response->json() : null;
if ($crossrefResponse && isset($crossrefResponse['message'])) {
$citationResponse = $this->formatCitationResponse($crossrefResponse['message'], 'crossref');
} else {
// fetch citation from DataCite
$dataciteUrl = env('DATACITE_WS_API').$doi;
$dataciteUrl = config('services.citation.datacite_url').$doi;
$response = $this->makeRequest($dataciteUrl);
$dataciteResponse = $response ? $response->json() : null;
if ($dataciteResponse && isset($dataciteResponse['data'])) {
Expand Down
Loading