Allow users to download media files associated with an item directly via a link in the metadata

Hi all,
I wanted to allow users to download media files associated with an item directly via a link in the metadata. I have a custom module that does what I want but only works when logged in as the admin, while everyone else gets a permission error…
“Omeka\Mvc\Exception\PermissionDeniedException: Permission denied for the current user to access the download action of the CustomMediaDownload\Controller\DownloadController controller.”
I want all to be able to access and download media files via this module when the link is provided. Could someone help me adjust the code or permissions to resolve this issue?
Here is the relevant code from my custom module:
Module.php

<?php declare(strict_types=1); namespace CustomMediaDownload; use Omeka\Module\AbstractModule; class Module extends AbstractModule {public function getConfig() {return include __DIR__ . '/config/module.config.php';}} **module.config.php** <?php namespace CustomMediaDownload; use CustomMediaDownload\Controller\DownloadController; return [ 'router' => [ 'routes' => [ 'custom-media-download' => [ 'type' => 'Segment', 'options' => [ 'route' => '/media-download[/:item_id]', 'defaults' => [ 'controller' => DownloadController::class, 'action' => 'download', ], 'constraints' => [ 'item_id' => '[0-9]+', ], ], ], ], ], 'controllers' => [ 'factories' => [ Controller\DownloadController::class => function ($container) { return new Controller\DownloadController(); }, ], ], 'view_manager' => [ 'template_path_stack' => [ __DIR__ . '/../view', ], ], ]; **DownloadController.php** <?php declare(strict_types=1); namespace CustomMediaDownload\Controller; use Laminas\Mvc\Controller\AbstractActionController; use Omeka\Api\Exception\NotFoundException; class DownloadController extends AbstractActionController { protected function streamFile($filePath): bool { if (!file_exists($filePath)) { return false; } $chunkSize = 1024 * 1024; // 1MB $fp = fopen($filePath, 'rb'); if (!$fp) { return false; } while (!feof($fp)) { echo fread($fp, $chunkSize); flush(); } fclose($fp); return true; } public function downloadAction() { $itemId = $this->params()->fromRoute('item_id'); if (!$itemId) { return $this->getResponse()->setContent('No item ID provided.'); } $api = $this->api(); try { $item = $api->read('items', $itemId)->getContent(); } catch (NotFoundException $e) { return $this->getResponse()->setContent('Item not found.'); } $media = $item->media(); if (empty($media)) { return $this->getResponse()->setContent('No media attached to this item.'); } $itemTitle = $item->displayTitle(); $safeTitle = preg_replace('/[^A-Za-z0-9_\-]/', '_', $itemTitle); // Handle single file if (count($media) === 1) { $file = $media[0]; $extension = pathinfo($file->filename(), PATHINFO_EXTENSION); $downloadFilename = $safeTitle . '.' . $extension; $filePath = OMEKA_PATH . '/files/original/' . $file->filename(); if (!file_exists($filePath)) { return $this->getResponse()->setContent('File not found on server.'); } $mimeType = $file->mediaType(); // If it's a PDF, display inline if ($mimeType === 'application/pdf') { header('Content-Type: ' . $mimeType); header('Content-Disposition: inline; filename="' . $downloadFilename . '"'); } else { // For other file types, still force download header('Content-Type: ' . $mimeType); header('Content-Disposition: attachment; filename="' . $downloadFilename . '"'); } header('Content-Transfer-Encoding: binary'); header('Content-Length: ' . filesize($filePath)); $this->streamFile($filePath); exit; } // Handle multiple files: Create ZIP $zip = new \ZipArchive(); $zipFile = sys_get_temp_dir() . "/item_{$itemId}.zip"; if ($zip->open($zipFile, \ZipArchive::CREATE) === true) { $count = 1; foreach ($media as $file) { $filePath = OMEKA_PATH . '/files/original/' . $file->filename(); if (!file_exists($filePath)) { continue; } $mediaTitle = $file->displayTitle(); if (!$mediaTitle || $mediaTitle === $itemTitle) { $baseName = $safeTitle . '_' . $count; } else { $safeMediaTitle = preg_replace('/[^A-Za-z0-9_\-]/', '_', $mediaTitle); $baseName = $safeMediaTitle; } $extension = pathinfo($file->filename(), PATHINFO_EXTENSION); $zipEntryName = $baseName . '.' . $extension; $zip->addFile($filePath, $zipEntryName); $count++; } $zip->close(); $zipFilename = $safeTitle . '.zip'; header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . $zipFilename . '"'); header('Content-Length: ' . filesize($zipFile)); header('Content-Transfer-Encoding: binary'); $this->streamFile($zipFile); unlink($zipFile); exit; } return $this->getResponse()->setContent('Failed to create ZIP file.'); }}

Hi abdi, I think this may be more comfortable if you add the </> (Preformatted text style) to your code :wink:

<?php
  declare(strict_types=1);
  namespace CustomMediaDownload;
  use Omeka\Module\AbstractModule;
  ...

Hi @abdi ,

The short answer is that by default, module routes will require authentication. However, you can configure the permissions in your Module.php

Here is a simple example from the RSS Feed Module:

public function onBootstrap(MvcEvent $event): void
    {
        parent::onBootstrap($event);

        $this->getServiceLocator()->get('Omeka\Acl')
            ->allow(
                null,
                ['Feed\Controller\Feed']
            )
        ;
    }

Duly noted. I’ll make sure to format it better in the future! :slightly_smiling_face:

Thank you so much!! This worked perfectly. I really appreciate your help!

1 Like