Force File Downloads with .htaccess

Discussion in 'Customization & add-ons' started by karyng01, Jun 2, 2012.

  1. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    Goal: Trying to force files to download when clicking on a URL link, rather than open up in a PDF or MP3 browser plugin.

    Background: On a protected aMember generated page I have PDF & MP3 content that I would like to force customers to download when they click on the content URL link. The help text (next to the download link) currently says "Right-click and Save Link As..." but still people naturally just click on the link and then the file is handled in whatever way the web browser used chooses, with whatever PDF or MP3 plugins they have locally installed.

    Research: There is a neat technique using .htaccess that forces files to download in binary (octet-stream) format but I can't get it to work inside of aMember 4.1.15.

    Technique: This forces the file download to happen as an octet-stream (binary) which most web browsers then treat by automatically offering you a "Save File As..." prompt, (rather than trying to display the file using their built in PDF view or MP3 player plugin).

    Source of information: http://css-tricks.com/snippets/htaccess/force-files-to-download-not-open-in-browser/

    Code:
    <FilesMatch "\.(mp3|pdf)$"
      ForceType application/octet-stream
      Header set Content-Disposition attachment
    </FilesMatch>
    The Problem: When I add this code to the main .htaccess file (see screen shot below) for aMember, the code doesn't seem to apply to the protected membership pages. Does anyone know how to modify the .htaccess file to work with these protected pages and so force the PDF & MP3 downloads?

    Protected Pages Example Links: http://www.youramembersite.com/members/content/p/id/13/

    htaccess-location.PNG

    Thanks!
    Aly
  2. alexander

    alexander Administrator Staff Member

    Joined:
    Jan 8, 2003
    Messages:
    6,274
    You can change this in aMember only.
    For example have a look to /amember/application/default/controllers/FileController.php
    Need to change this line:

    header('Content-type: ' . $file->getType());
  3. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    Thanks Alex that is marvelous, you made my day!

    Aly
  4. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    Playing with this I found that setting the header content-type this way, affected the whole page as a unit and I couldn't target individual downloads on that page unlike with the .htaccess method that used a file match method.

    http://snipplr.com/view/8752/universal-force-file-download/

    So I looked around for a file download script and here is one I'm considering using (see above link). The biggest concern was security, by openly referencing a file download program in your download URLs, how to prevent hackers from downloading any file using the script? However I think the script addresses this well enough by restricting the root directory from which downloads are allowed, but if anyone who is an experienced PHP coder thinks differently (PHP isn't my main skill set) I would appreciate your opinion.

    The download script I've tested and it works.
  5. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    Here's the embedded code in case the link ever goes away.

    PHP:
    <?php
    if ($_REQUEST["filename"] != "") {
            
    $filename str_replace("../"""$_REQUEST["filename"]);
            if (
    $filename != "") {
                   
                    
    /*
                            this is where you set the base path and the file for security reasons.
                            If you wish to manually hard code a base path, then comment out the following line,
                            and use the line after it
                    */
                    
    $file dirname(__FILE__) . "/files/" $filename;
                   
                    
    //$file = "/var/www/htdocs/audio/" . $filename;
                   
                   
                    
    if (file_exists($file)) {
                            
    header ("Content-type: octet/stream");
                            
    header ("Content-disposition: attachment; filename=" str_replace(" ""_"basename($file)) . ";");
                            
    header ("Content-Length: " filesize($file));
                            
    readfile($file);
                            die();
                    }
                    else {
                            echo 
    '
                                    <script type="text/javascript">
                                    <!--
                                    alert("' 
    $file '\n\nI do not currently have this image ready for download");
                                    -->
                                    </script>
                            '
    ;
                    }
            } else {}
    } else {}
    ?>
  6. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    Update on using this script, don't do it! The script works great restricting file downloads only to those in the designated folder, except on testing this morning I found if the direct download URL of a content file is known then anyone can download your content without being logged in to aMember, as the script bypasses the built in .htaccess protection aMember provides.

    Let me think about this some more for a solution. Its likely Alex's solution is the better way to go if I can figure it out as that will function within the bounds of aMember's .htaccess protection. The problem I was having is when specifying the file type it applied it for the whole page, so the entire page became a downloadable PDF or MP3 on load rather than when just clicking the file download links. Likely my error.

    header('Content-type: ' . $file->getType());
  7. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    I am a very beginner PHP programmer and do not write elegant code, so I recommend you thoroughly test the script for function & security with your own setup if you choose to use it.

    Central Download Folder
    The following code works if you store your downloads in a "downloadable content" root directory with known sub-folder names.

    Folder to ProductID Linking
    You will need to manually link (inside the script) the sub-folder name to a product ID (find the product ID's up from within aMember's admin panel), this is the part that really isn't elegant. Someone clever can add a database field that stores the product folder names & links them to product ID's and then do a query, but this was beyond me.

    Script Checks
    The script checks for the following:
    1. If the user is logged in (and if not redirects them to the login screen, this prevents anyone sharing & using a download URL who is not a member)

    2. The user has an active subscription for the file they are requesting download for and if not download is prevented

    3. If the sub-folder or file names are not within the "downloadable content" root directory, the download is prevented (this stops the script from being used to access any file on the server)

    4. If the sub-folder or file names are not recognized the download is prevented

    Hope this helps someone with this problem. Its a long way round the issue, there has to be a simpler solution. If anyone has one, I welcome it.

    Aly

    PHP:
    <?php
    require_once '/home/user/public_html/amember/library/Am/Lite.php';  // Modify this path to suit your setup
    if (Am_Lite::getInstance()->isLoggedIn()) {  // Check to see if user logged in, if so allow download, and if not redirects user to login page
        
    if ($_REQUEST["filename"] != "") {  // Check filename not equal to blank
                
    $filename str_replace("../"""$_REQUEST["filename"]);  // Returns filename without ../ prefixes
                
    $filenameFolder strstr($filename,'/',true); // Returns top level folder name
           
                
    if ($filenameFolder == "folder1")    {  // Asign productID based on recognized folder name
                        
    $productID 1;                  // Folders & productID's are unique to each aMember setup
                
    }
                elseif (
    $filenameFolder == "folder2") {  // Asign productID based on recognized folder name
                        
    $productID 2;                  // Folders & productID's are unique to each aMember setup
                
    }
                elseif (
    $filenameFolder == "folder3") {  // Asign productID based on recognized folder name
                        
    $productID 3;                  // Folders & productID's are unique to each aMember setup
                
    }
                else {    
    // If filename folder not recognized then end
                        
    echo '
                                <script type="text/javascript">
                                <!--
                                alert("\n\nThis file cannot be downloaded.");
                                -->
                                </script>
                        '
    ;
                        die();
                }
                if (
    Am_Lite::getInstance()->haveSubscriptions($productID)) {  // Check the $productID is a current user subscription, if yes then allow check for valid filename
                        
    if ($filename != "") {
                           
                                
    /*
                                        this is where you set the base path and the file for security reasons.
                                        If you wish to manually hard code a base path, then comment out the following line,
                                        and use the line after it
                                */
                                //$file = dirname(__FILE__) . "/classes/" . $filename;
                           
                                
    $file "/home/user/public_html/classes/" $filename;
                           
                           
                                if (
    file_exists($file)) {
                                        
    header ("Content-type: octet/stream");
                                        
    header ("Content-disposition: attachment; filename=" str_replace(" ""_"basename($file)) . ";");
                                        
    header ("Content-Length: " filesize($file));
                                        
    readfile($file);
                                        die();
                                }
                                else {
                                        echo 
    '
                                                <script type="text/javascript">
                                                <!--
                                                alert("\n\nThis file cannot be downloaded.");
                                                -->
                                                </script>
                                        '
    ;
                                }
                        } else {}
                } else {
                          echo 
    '
                                  <script type="text/javascript">
                                  <!--
                                  alert("\n\nThis file cannot be downloaded.");
                                  -->
                                  </script>
                          '
    ;
                  }
        } else {}
    }
    else {
        
    header'Location: http://www.yourwebsite.com/members/member' );  // Redirects user to login page (if user not logged in)
    }
    ?>
  8. skippybosco

    skippybosco CGI-Central Partner Staff Member

    Joined:
    Aug 22, 2006
    Messages:
    2,526
    Thanks for continuing to follow up on this thread and for sharing your findings!
  9. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    One more update to this script, as time & use revealed a file size issue where the script would fail with files sized around 200MB. Added the ob_end_clean() command to erase the output buffer and turn off output buffering to the script. All credit for this solution to http://www.lacisoft.com/blog/2012/01/21/php-download-script-for-big-files/

    Also added header ('Content-Transfer-Encoding: binary'); to help define the output.

    Had initially thought the download failures part way through the file transfers were a PHP script timeout issues but increasing the max_execution_time = 7200 [120 minutes] in the php5.ini file (to allow more time for a slow download connection) or setting the script_time_limit(0) [no time limit] inside the script made no difference.

    ob_end_clean() fixed the issue.

    PHP:
      if (file_exists($file)) {
              
    header ("Content-type: octet/stream");
              
    header ("Content-disposition: attachment; filename=" str_replace(" ""_"basename($file)) . ";");
              
    header ('Content-Transfer-Encoding: binary');
              
    header ("Content-Length: " filesize($file));
              
    ob_end_clean();
              
    readfile($file);
              die();
      }
  10. thehpmc

    thehpmc Member

    Joined:
    Aug 24, 2006
    Messages:
    901
    I use a similar method for downloading PDF files from my site:
    Code:
      // Start sending the headers
      // The name of the download file is stored in $pdf_file
        header("Cache-Control: public");
        header("Pragma: public");
        header("Content-Description: File Transfer");
        header("Content-type: application/pdf");
        header("Content-Disposition: attachment; filename=$row[pdf_file].pdf");
        header("Content-Transfer-Encoding: binary");
        readfile ($link) ;
    My original intention was partly to force the 'open file/Save file as' dialogue box to open however I have come to the conclusion that whilst it works as such most of the time it will not work this way if the downloading computer has been set, via its preferences, to handle a certain type downloads to be handled in a certain way. These preferences always over ride what the download site is trying to do.
  11. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    That's great! Thanks for the heads-up on your technique.
  12. karyng01

    karyng01 aMember Pro Customer

    Joined:
    Jul 30, 2008
    Messages:
    56
    Two more updates to the script.

    Downloading Large Files Fix
    After further research and discussion with aMember Team, aMember & PHP isn't intended to be used for such large file downloads 200MB and its much better to use a CDN (Content Delivery Network) such as Amazon S3 service integrated with aMember.

    However I did find a way to make it work from a regular hosted website without paying Amazon. Just ZIP the file and don't use a download script for 200MB plus sized files. Then reference the download using a straight URL path to the file (no PHP dowload script reference). All web browsers will automatically prompt to save a zipped file anyway without needing a download script to force a download.

    When the web browser starts the download (rather than a PHP script) just a regular FTP connection is established within the browser session and a large file will successfully be transferred without any timeout issues happening, at least on my hosting. The timeout issues appear only to happen when the FTP session is started from within the PHP script.

    Android Tablet & Phone File Association Fix
    Found with use and testing that files downloaded using the PHP script were not playing on Android tablets built in media applications, the file associations were broken. MP4 videos & MP3 audios & PDF files weren't recognized by their file types.

    The fix is simple, the problem was a semi-colon added to the end of the filename by the PHP script. All OS & media players other than Android tablet & phone based had no problem with the semi-colon. However the semi-colon broke the default media player file association with the built in applications to play audios, videos & PDF files.

    The fix: Remove the semi-colon from being added to the end of the file name and then Android correctly associates the downloaded files with the default media player applications. Plus testing confirms everything still works everywhere else on other OS & media players.

    PHP:
    //original code
    header ("Content-disposition: attachment; filename=" str_replace(" ""_"basename($file)) . ";");
     
    //remove semi-colon from end of contructed file name
    header ("Content-disposition: attachment; filename=" str_replace(" ""_"basename($file)));

Share This Page