Erased all my entries AND the fields

So I’m trying to understand what happened here. I wanted a ‘clicks’ field to increment when a link is clicked on and I set up this code to execute onclick on the front end for each of my 4 entries. When I clicked, not only did all 4 of my entries get wiped clean but the field setup as well. What’s wrong with my code?

const updateClick = document.getElementsByClassName('update-click')

for (var i=0, len=updateClick.length|0; i<len; i=i+1|0) {
    updateClick[i].onclick = function(e){
      var dataId = this.getAttribute('data-id') // this attr contains the _id value

      fetch('/adserver/api/collections/updateCollection/banner_ads?token=xxxxxxxxxxxxxxxx', {
        method: 'post',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            data: {
              fields: [
                {
                  '_id' : dataId,
                  'clicks' : 10  // before incrementing I wanted to see if this value would save
                }
              ]

          }
        })
      })
      .then(collection => collection.json())
      .then(collection => console.log(collection));

    }
}
  1. wrong endpoint

/updateCollection/
updates the collection itself, its labels and fields config - not an entry

You need to call /save/ instead:
/api/collections/save/COLLECTION_NAME

  1. wrong params

The params expected are

{
  data: {_id: ENTRY_ID, ...},  // or array of entries [{_id: ...}, {_id: ...}]
  revision: true,              // optional; default: false  
}

// update
corrected misspelled param revision (was revisions).

1 Like

Thanks - that helped! It’s a little disappointing though that the docs were not clear enough.

This is my code now:

const updateClick = document.getElementsByClassName('update-click')

for (var i=0, len=updateClick.length|0; i<len; i=i+1|0) {
    updateClick[i].onclick = function(e){
      var dataId = this.getAttribute('data-id') // this attr contains the _id value
      var clicks = parseInt(this.getAttribute('data-clicks')) + 1
      var link = this.getAttribute('data-link')

      fetch('/adserver/api/collections/save/banner_ads?token=xxxxxxxxxxxxxxxxxx', {
        method: 'post',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          data: {
            _id : dataId,
            clicks : clicks,  // before incrementing I wanted to see if this value would saveCollection

          },
          revisions: true,
        })
      })

      window.open(link, '_blank'); // open banner ad link in new tab

    }
}

I’m thinking to modify this code some more so that I do not have the clicks value inside the html and instead pull it from the js array

Oh I think you should do much more than just removing the clicks-value from the DOM because you should NOT have a write-access-token made public.
Anybody could use it to write anything to your collection.

Instead you might want to create a custom endpoint like

/api/public/click

that only expects the banner._id as a parameter and increments the counter on call instead of setting the clicks field value directly.

In addition you might plan on using a NONCE/csrf-token in order to make sure nobody is flooding your API with false “click” counts.

Just a thought.

Ok, why is the insecure option the default one in the docs?

Because they want to show you how to interact with Cockpit and its API - not how to architect your application.

As long as you’d run those fetch commands in an SSR node application they would not expose any of it to the client.
And nobody should need to tell you, a web developer, that running it in the browser is exposing it to the public - and therefore granting the public access to everything this API token has access to.

But this is not about “not exposing an API token” but about knowing when and how to expose access to your data in a way that keeps your data save and still gives you all the functionalities that you need.

Every program and every privileged user of the system should operate using the least amount of privilege necessary to complete the job.

Jerome Saltzer, Communications of the ACM
#PrincipleOfLeastPrivilege

On another point: given your example I’m still not sure how you are making certain in the end, that the user really clicked the banner and visited the affiliated link.
Because even if the click-counts are run through the suggested custom endpoint somebody could just call your API more often than clicks are actually made and so the counter would not reflect the actual amount of browser-impressions the link has had.
I just hope that this counter is not used to determine later if and how much somebody gets paid based on it but that it is only a simple tool of measuring the clicks.

Here is an idea:

  • create the custom endpoint
  • to register the click
  • AND to actually forward to the related affiliate link

This way you’d not use JS to register a click but you’d simply put the endpoint URL as the link the user is clicking and at least you’d be sure the url was requested.

<a href='https://yourCockpitInstance/api/public/link?_id=BANNER_ID'>...</a>

This means now the “visitors via affiliate link” counter and the “clicks on affiliate link” counter would be in sync.

Now you’d still have to make sure nobody is calling those URLs “just for fun” because this would still create more impressions/clicks than actually are happening.

Simplest way would be to check on your custom endpoint for the Referrer header field. Even though it can be faked it is still considered as a reliable source given your traffic goes over HTTPS.

// /config/api/public/link.php
<?php


// confirm referrer
$referrer = $this->app->request->server['HTTP_REFERER'];
$referrerHost = parse_url($referrer,  PHP_URL_HOST);

if($referrerHost !== 'yourwebsite.com'){
  $this->stop(null, 403); // forbidden
}

$bannerId = $this->param('_id')
$bannerState = $this->module('collections')->findOne('banner_ads', ['filter' => ['_id' => $bannerId]])

if(!$bannerId){
  $this->stop(['error' => 'Missing parameter "_id"'], 400); // bad request
}

if(!$bannerState){
  $this->stop(null, 404); // not found
}


// register click
$bannerState['counter']++;
$this->module('collections')->save('banner_ads', $bannerState);

// forward to affiliate link
header('Location: ' . $bannerState['affiliateUrl']);
die();

(not tested)

But maybe I’m overthinking this all together. :man_shrugging:

1 Like

Thanks for the info and sample code. Will look at it in a couple days as I had to jump on another project.

It took a lot of searching for working code on github and these forums, but finally got everything to work, even updating the views value for each banner when it became visible in the front end carousel. I still have tons of questions though.