20

Given https://www.example.com/image-list:

...
<a href="/image/1337">
  <img src="//static.example.com/thumbnails/86fb269d190d2c85f6e0468ceca42a20.png"/>
</a>
<a href="//static.example.com/full/86fb269d190d2c85f6e0468ceca42a20.png"
   download="1337 - Hello world!.png">
  Download
</a>
...

This is a user script environment, so I have no access to server configuration. As such:

  1. I can't make server accept user-friendly file names like https://static.example.com/full/86fb269d190d2c85f6e0468ceca42a20 - 1337 - Hello World!.png.
  2. I can't configure Cross-Origin Resource Sharing. www.example.com and static.example.com are separated by CORS wall by design.

How to make Firefox and Chrome display Save File As dialog with the suggested file name "1337 - Hello world!.png" when a user clicks on the "Download" link?

After some failing and googling, I learned these problems:

  1. Firefox completely ignores existence of the download attribute on some image MIME types.
  2. Firefox completely ignores existence of the download attribute on cross-site links.
  3. Chrome completely ignores value of the download attribute on cross-site links.

All these points don't make any sense to me, all look like "let's put random non-sensical limitations on the feature", but I have to accept them as it's my environment.

Do any ways to solve the problem exist?


Background: I'm writing a user script for an image board which uses MD5 hashes as file names. I want to make saving with user-friendly names easier. Anything which gets me closer to this would be helpful.

I guess I can get around the limitations by using object URLs to blobs and a local proxy with hacked CORS headers, but this setup is obviously beyond reasonable. Saving through canvas could work (are images "protected" by CORS in this case too?), but it will either force double lossy compression or lossy-to-lossless conversion, given JPEG files, neither of which are good.

Athari
  • 33,702
  • 16
  • 105
  • 146
  • 1
    @RokoC.Buljan *More* user-friendly than the original string. That string "md5 - id - title.ext" was mentioned because it would have been easily possible if I had access to server configuration. All you need is to make the server ignore all symbols between MD5 hash and file extension. What I'm actually considering is "artist - id.ext" (the image board in question doesn't provide titles or original file names), but it's irrelevant really. – Athari Jan 07 '18 at 05:16
  • 1
    @RokoC.Buljan Post page. Contains the image, comments to it and stuff like that. It's for context, only the second link actually matters. – Athari Jan 07 '18 at 05:24
  • 1
    _“(are images "protected" by CORS in this case too?)”_ - yes, the canvas will become “tainted” if you paint images from cross-domain origins onto it, and that will prevent you from “exporting” the canvas content as a new image. (Of course the remote server could lift those restrictions, but that does not seem to be an option here.) https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image – CBroe Jan 09 '18 at 14:55
  • I think they are pretty sensible browser limitations if the source is CORS enabled - it also stops CSRF attacks of downloading something-bad.exe to pretty-image.jpg . You're only solution seems to be to proxy or to have a feature on the static domain to rename the file before downloading. – sidhuko Jan 12 '18 at 20:14
  • Cannot not reproduce 2. and 3. at OP. – guest271314 Jan 13 '18 at 17:04
  • @Athari the example you provide has the domains as `www.example.com` and `static.example.com`. Is your environment similar to that in that the two domains are sub-domains of the same parent super-domain (e.g. `example.com`)? – pseudosavant Jan 17 '18 at 19:42
  • @pseudosavant Yes. However, I don't see why it matters. The important part is that they are separate according to CORS. – Athari Jan 17 '18 at 20:11
  • @Athari True `CORS` treats them separate, but there might be a way around using [`document.domain`](https://developer.mozilla.org/en-US/docs/Web/API/Document/domain) if they do share the same super-domain. I've been thinking about it but I don't have a solution yet. – pseudosavant Jan 17 '18 at 23:22
  • Note to self: use GreaseMonkey's [GM.xmlHttpRequest](https://wiki.greasespot.net/GM.xmlHttpRequest) to bypass stupid CORS. Then use one of the hacks suggested below to download a file (the blob thing probably). – Athari Apr 01 '21 at 11:41

6 Answers6

9

All modern browsers will ignore the download attribute in the anchor tag for cross-origin URL'S.

Reference : https://html.spec.whatwg.org/multipage/links.html#downloading-resources

According to the spec makers, this represents a security loophole as a user could be tricked into downloading malicious files while browsing a secure site, believing that the file is also originating from the same secure site.

Any interesting conversation for implementing this feature in the firefox browser can be found here : https://bugzilla.mozilla.org/show_bug.cgi?id=676619


[ Edit by Athari ]

Quote from specification:

This could be dangerous, because, for instance, a hostile server could be trying to get a user to unknowingly download private information and then re-upload it to the hostile server, by tricking the user into thinking the data is from the hostile server.

Thus, it is in the user's interests that the user be somehow notified that the resource in question comes from quite a different source, and to prevent confusion, any suggested filename from the potentially hostile interface origin should be ignored.

Clarification on the mysterious scenario:

the more serious issue with CORS downloads is if a malicious site forces a download of a file form a legitimate site and some how gets access to its content. so lets say I download the user gmail inbox page and explore its messages.

in this case an evil site will have to fool the user into downloading the file and uploading it back to the server, so lets say we have a gmail.com/inbox.html actually contains all the user mail messages and the attacker sites offers a download link for a coupon file, that should be uploaded to another evil site. the coupon will supposedly offer a 30% discount on a new Ipad. the download link will actually point to gmail.com/inbox.html and will download it as "30off.coupon", the if the user will download this file and upload it without checking it's content the evil site will get the user "coupon" and so its inbox content.

Important notes:

  1. Google originally didn't limit download attribute by CORS and was explicitly against this. It was later forced to adjust Chrome implementation.

    Google was opposed to using CORS for this.

  2. Alternative solutions were proposed with giving a user a warning about cross-origin downloads. They were ignored.

    Well there can be notification or deny/allow mechanism when downloading from another origin (e.g. like in case of geolocation API). Or not to send cookies in case of cross origin request with download attribute.

  3. Some developers do share the opinion that the restriction is too strong, severely limits the usage of the feature and that the scenario is so complicated that the user who would do this would easily download and run an executable file. Their opinion was disregarded.

    The case against allowing cross-origin downloads is centered around the premise that visitors of an [evil] site (eg, discountipads.com) could unknowingly download a file from a site containing their own personal information (eg, gmail.com) and save it to their disk using a misleading name (eg, "discount.coupon") AND THEN proceed to another malicious page where they manually upload that same file they just downloaded. This is quite far-fetched in my opinion, and anyone who would succumb to such trivial trickery perhaps does not belong online in the first place. I mean c'mon...Click here to download our special discount offer and then re-upload it through our special form! Seriously? Download our special offer and then email it to this Yahoo address for a big discount! Do the people who fall for these things even know how to do email attachments?

    I'm all for browser security, but if the good people of Chromium have no problem with this I don't see why Firefox has to completely banish it. At the very least I'd like to see a preference in about:config to enable cross-origin @download for "advanced" users (default it to false). Even better would be a confirmation box similar to: "Although this page is encrypted, the information you submit through this form won't be" or: "This page is requesting to install addons" or: "Files downloaded from the web may harm your computer" or even: "The security certificate of this page is invalid" ...y'know what I mean? There are myriad ways to heighten the user's awareness and inform them this might not be safe. One extra click and a short (or long?) delay is enough to let them assess the risk.

    As the web grows, and the use of CDNs grows, and the presence of advanced web-apps grows, and the need to manage files hosted across servers grows, features like @download will become more important. And when a browser like Chrome supports it fully whereas Firefox does not, this is not a win for Firefox.

    In short, I think that mitigating the potential evil uses of @download by simply ignoring the attribute in cross-origin scenarios is a woefully ill-thought move. I'm not saying the risk is entirely non-existent, quite the contrary: I am saying there are plenty of risky things one does online in the course of his day...downloading ANY file is high among them. Why not work around that issue with a well-thought user experience?

Overall, considering widespread use of CDNs and intentionally putting user-generated content on a different domain, the primary use for the download attribute is specifying a file name for blob downloads (URL.createObjectURL) and the like. It can't be used in a lot of configurations and certainly not very useful in user scripts.

Community
  • 1
  • 1
23nigam
  • 601
  • 5
  • 13
  • If you use a CDN to host your files/images (which is highly recommended practice), then the "download" attribute is made utterly useless by this heavy-handed over-security on the CORS side. Another case of security obliterating user experience. – 3Dom Aug 30 '20 at 04:25
5

Try something like:

  1. Get the external image to your server first
  2. Return the fetched image from your server.
  3. Dynamically create an anchor with download name and .click() it!

while the above was just a pretty short tips list... give this a try: on www.example.com place a fetch-image.php with this content:

<?php
$url = $_GET["url"];                     // the image URL
$info = getimagesize($url);              // get image data
header("Content-type: ". $info['mime']); // act as image with right MIME type
readfile($url);                          // read binary image data
die();

or with any other server-side language that achieves the same.
The above should return any external image as it's sitting on your domain.

On your images-list page, what you can try now is:

<a 
  href="//static.example.com/thumbnails/86fb269d190d2c85f6e0468ceca42a20.png" 
  download="1337 - Hello world!.png">DOWNLOAD</a>

and this JS:

function fetchImageAndDownload (e) {
    e.preventDefault(); // Prevent default browser action 

    const url = this.getAttribute("href");       // Anchor href 
    const downloadName = this.download;          // Anchor download name
    
    const img = document.createElement("img");   // Create in-memory image
    img.addEventListener("load", () => {
        const a = document.createElement("a");   // Create in-memory anchor
        a.href = img.src;                        // href toward your server-image
        a.download = downloadName;               // :)
        a.click();                               // Trigger click (download)
    });
    img.src = 'fetch-image.php?url='+ url;       // Request image from your server
}

document.querySelectorAll("[download]").forEach((el) => {
    el.addEventListener("click", fetchImageAndDownload);
});

You should see finally the image downloaded as 1337 - Hello world!.png instead of 86fb269d190d2c85f6e0468ceca42a20.png like it was the case.

Notice: I'm not sure about the implications of simultaneous requests toward fetch-image.php - make sure to test, test.

Roko C. Buljan
  • 196,159
  • 39
  • 305
  • 313
4

If you have access to both, backend and frontend code, here are steps which could help you

I'm not sure which type of backend language you are using, so I will just explain what need to be done without code sample.

In backend, for preview your code should work as is, if you get in query string something like ?download=true then your backend should pack file as dispositioned content, in other words you would use content-disposition response header. This will open you possibility to put additional attributes to content, like filename, so it could be simething like this

Content-Disposition: attachment; filename="filename.jpg"

Now, in frontent, any link which should behave as download button need to contain ?download=true in href query parameter AND target="_blank" which will temporary open another tab in browser for download purpose.

<a href="/image/1337">
  <img src="//static.example.com/thumbnails/86fb269d190d2c85f6e0468ceca42a20.png"/>
</a>
<a href="//static.example.com/full/86fb269d190d2c85f6e0468ceca42a20.png?download=true" target="_blank" title="1337 - Hello world!.png">
  Download Full size
</a>

I know that this works without CORS setup and if user clicks on download link, but I never tested Save As dialog in browser... and it will take some time to build this again, so please give it a try.

Milan Jaric
  • 5,556
  • 2
  • 26
  • 34
1

You can try to do this

var downloadHandler = function(){
    var url = this.dataset.url;
    var name = this.dataset.name;
    // by this you can automaticaly convert any supportable image type to other, it is destination image format
    var mime = this.dataset.type || 'image/jpg';
    var image = new Image();
    //We need image and canvas for converting url to blob.
    //Image is better then recieve blob through XHR request, because of crossOrigin mode
image.crossOrigin = "Anonymous";
   
    
    image.onload = function(oEvent) {
      //draw image on canvas
      var canvas = document.createElement('canvas');
      canvas.width = this.naturalWidth;
      canvas.height = this.naturalHeight;
      canvas.getContext('2d').drawImage(this, 0, 0, canvas.width, canvas.height);
      // get image from canvas as blob
      var binStr = atob( canvas.toDataURL(mime).split(',')[1] ),
            len = binStr.length,
            arr = new Uint8Array(len);

        for (var i = 0; i < len; i++ ) {
          arr[i] = binStr.charCodeAt(i);
        }

      var blob = new Blob( [arr], {type: mime} );
      //IE not works with a.click() for downloading
      if (window.navigator && window.navigator.msSaveOrOpenBlob)     {
      window.navigator.msSaveOrOpenBlob(blob, name);
      } else {
          var a = document.createElement("a");  
          a.href = URL.createObjectURL(blob);                     
          a.download = name;              
          a.click();  
      }
    };

    image.src = url;
}

document.querySelector("[download]").addEventListener("click", downloadHandler)
<button 
data-name="file.png" 
data-url="https://tpc.googlesyndication.com/simgad/14257743829768205599"
data-type="image/png"
download>
download
</button>
Another modern way for modern browsers (except Internet Explorer)
var downloadHandler = function(){
  var url = this.dataset.url;
  var name = this.dataset.name;
  fetch(url).then(function(response) {
    return response.blob();
  }).then(function(blob) {
    //IE and edge not works with a.click() for downloading
      if (window.navigator && window.navigator.msSaveOrOpenBlob)     {
      window.navigator.msSaveOrOpenBlob(blob, name);
      } else {
          var a = document.createElement("a");  
          a.href = URL.createObjectURL(blob);                     
          a.download = name;              
          a.click();  
      }
  });
};

document.querySelector("[download]").addEventListener("click", downloadHandler)
<button 
data-name="file.png" 
data-url="https://tpc.googlesyndication.com/simgad/14257743829768205599"
download>
download
</button>
Alex Nikulin
  • 8,194
  • 4
  • 35
  • 37
  • 1
    Canvas + base64 will **alter the image** (in your case the downloaded file is larger than the original) and wipe out image data and you could potentially run out of physical canvas pixels. Yeah, if that all even matters to OP, otherwise - seems ok – Roko C. Buljan Jan 13 '18 at 16:17
  • @roko-c-buljan yes you are right:) Another way is to receive an image by XHR request as arraybuffer and pack it as a blob. – Alex Nikulin Jan 15 '18 at 04:55
  • 5
    This solution implies that the file is served as a cross-origin compliant resource, making the whole process useless, since if it were, one could directly use `download`. – Kaiido Jan 16 '18 at 05:40
  • @Kaiido, I agree with you, my post is not fully satisfied.But leave this post here for someone else:). Maybe someone find my post helpful. – Alex Nikulin Jan 18 '18 at 05:04
1

Relevant Chromium API...

https://developer.chrome.com/extensions/downloads#event-onDeterminingFilename

Example...

chrome.downloads.onDeterminingFilename.addListener(function(item,suggest){

 suggest({filename:"downloads/"+item.filename}); // suggest only the folder

 suggest({filename:"downloads/image23.png"}); // suggest folder and filename

});

Oops... you're on server side but I assumed client side! I'll leave this here though in case someone needs it.

-3

I'm able to rename base64 images with a save as input field.

I think your best bet is to create your own "save as" box. When a user clicks "download", display a "File Name: {input field}" and a save button. Have the save button change filename and then call the download function.

function save2() {
    var gh = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADNQTFRFAAAAPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09EvTTBAAAABB0Uk5TAA8fLz9PX29/j5+vv8/f7/rfIY4AAARsSURBVHja7VvZkusqDAyrAQvQ/3/teZjExrHDZoGrbl0/z0RtLY1oya/X/8+Nh/NHzYuAGPRz9llERET1GACJiIgYngaAkmvNnwTgERHtgwDefngawDofgDkAwPlp4I4AzHQA/gggstk0jF/P7ELQ3wBQPBoBRPRzTyI8P/bBGvh79FMstFXCvDSAt0kHzyBYNv7jj/iAx48DEiwzEfj0AFi/EEw4F+2B/5mfXQsbB4ZDQOKGwM2ioE+4hUdEm3jCjzybRbw4gIXkrxfbySnqCfYhS48rG23fs/wRGYdcGIQv1PsOcIgTkp//xTcs4WbyTEjs67pmFfh8+3+X1s0Jy3z7rxezaZ9EdTjI2MC1MpA37LqN65kjdoJuPmtUUpC40NmvLy2WntM3OcH09RupE8KdMLjefufgBE1gvz2blnj/2pDY7wikSPold9M+dCVSWpDuln1HUMLuCfsHEndP2H+9uO+kJEfVaicNq+zin9udxY6gQcrRlFeNHUG1oCfpjpIjAtmaukQXHRabpJwdMNlFSzZFdL3Dv4WkrlH4lyH6Y6jOgj0BSPUGWV0InrQAztISr2UgahFe1r3XJgHRC9C+qhK3CqC/4H6Sm1XV64ApCKt5NegOgFTGGGPMIlnhx22NA64zhUsppTxVMtcuvY5hcCqX31DhgAu+EgZ+WLjSjoPJvF6mBH5lIFvC7wHBJ7kAAAByjFdkAvdDg0o7/PPByiOCSSIvbfhBo6HExvES/ftwjOs7v7iyoZCl0qhMhHWpDQoX9QvH/xJd+osriAbr9ZktEQONCm3yAD5EEU833YWIlgsA1PD5UwGAGz4DLAAIw0eAeQBs/CTaZi2o8VNYyAIwP2qAHsCSZYGR6xD5xtgPTwGeBzB+I0Xlj+Oajo2kCEK+GRqfg2sWwEAaKhCNLDdsRCkgnwLg8kEeDyDmLQwHoAp3w+EA1kJPPBoAL6lEYnAZmuLtfCwRbToZLwEYNP7X5Vs33NEFuI15BS6U7+auuydmGkoKXI1Kt9RlIZPHIIllLbfzWwboCm2AF480b7WUQkipDWySkhPlg7ggU9apWPFqkWzV2TZC1Am1a1UMltMWW8F6Xve4qpRCX86U3ZQkcEtFF79UKtW8RSJnsvr+IDK7N23HRScH+mrtWQ/RCF3D+DYOaM337bOKftvQ78iKps3fjbDIrkeX22cVLqAKAovVFfD1DzRi/V4AgbWmDMW8ivmO7Qto9FlV/FvGr5xsZilj3/hXI00UTPcKi6PYgkrXR5qnb/72ZuRho03fSF5E1xOGg7qvb5VPz2akTmcbnT48LExDCysycxitdGfRcWUbar2gvj59cDfqyH3NoMpNyt+k5r77t1B+tb/eZNzJtTt1y+4umXM49b9g1AmFUPvloDdzqsppDweA/RuSOoDLv6D7GvRAKPUP5ceo3DWbX4nFXm5iy8ubEfqCWiut22HDDqZcyBuP6zL6s0euLVzbBqunfWbFpTZmhfdjjVFy9seO/6nnH0Mpp/3TjvofAAAAAElFTkSuQmCC"

    var a  = document.createElement('a');
    a.href = gh;
    a.download = document.getElementById("input").value;
   

    a.click()
    
}
Filename: <input id="input" name="input" placeholder="enter image name"></insput><button onclick="save2()">Save</button>

The above code will let you name the file for the user If you want the user to be able to name the file himself then you need to create an input field that updates a.download = 'imagetest.png'; through a listening function or onkeychange. I got it to work through "a.download = document.getElementById("input").value;". It's amazing what getElementByID can do.

Browsers are very limited in the ability to name files for people to download. I imagine it's a lack of functionality that has been overlooked.

If you want to take it a step further you can hide the "file name:" input field with display:hidden;. And you can have a "download" button with an onclick function that sets the "file name: input / save" div to no longer set to display:hidden;. This can also be done through getelementbyID.



I did however notice that there seems to be an issue with renaming the image with a URL instead of base64. So I tried integrating my code with yours though none of the code below seemed to work properly when clicking on your download link. I still think it's close enough that you may want to fiddle around with it if this is the route you want to go:

<script>
    function save2() {
        var gh = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png"

        var a  = document.createElement('a');
        a.href = gh;
        a.download = document.getElementById("input").value;



        a.click()

    }

    function downloadit(){



               var x = document.getElementById("input").value;
                  document.getElementById("dl").getAttribute("download") = x;

    }





         document.getElementById("dl").onclick = function() {
    document.getElementById("dl").download= a.download = document.getElementById("input").value;
    return false;

    }
</script>

<a href="/image/1337">
  <img src="//static.example.com/thumbnails/86fb269d190d2c85f6e0468ceca42a20.png"/>
</a>
<a onclick='downloadit()' id="dl" href="//static.example.com/full/86fb269d190d2c85f6e0468ceca42a20.png"
   download="1337 - Hello world!.png">
  Download
</a>

    Filename: <input id="input" name="input" placeholder="enter image name"></insput><button onclick="save2()">Save</button>



Lastly, I was able to rename your example.com image with the following script. Though when I try it with a working googleimage it doesn't seem to rename. So you might want to dabble with this as well.

        document.getElementById("dl").onclick = function() {
    document.getElementById("dl").download=document.getElementById("input").value;
   
        
    }
  Filename: <input id="input" name="input" placeholder="enter image name"></input>
<a id="dl" href="//static.example.com/full/86fb269d190d2c85f6e0468ceca42a20.png"
   download="1337 - Hello world!.png">
  Download
</a>

  
Michael d
  • 305
  • 2
  • 16