Account created on 20 December 2008, over 16 years ago
#

Merge Requests

More

Recent comments

🇺🇸United States glynster

Ah ha good to know semiaddict. As you are the creator of this wonderful gem will you be adding the testing or waiting on the maintainers?

🇺🇸United States glynster

What’s the holdup on merging the PR? We use this in production across multiple websites. What can we do to get this moved into a new release?

🇺🇸United States glynster

Correct on the screenshot I grabbed that was an admin page. Here is a varnish hit.

🇺🇸United States glynster

To see if varnish is working you need to review the network tab, select the page and view the headers. Search for caching like attached.

🇺🇸United States glynster

@summer here is the complete varnish vcl we are using successfully.

vcl 4.0;

import std;

backend default {
  .host                   = "127.0.0.1";
  .port                   = "8080";
  .connect_timeout        = 600s;
  .first_byte_timeout     = 600s;
  .between_bytes_timeout  = 600s;
}

# Access control list for specific requests like PURGE.
# Here you need to put the IP address of your web server.
acl internal {
  "127.0.0.1";
  "191.101.233.147";
}

# Respond to incoming requests.
sub vcl_recv {

  # Cache invalidation support:
  #
  #  1. ENTIRE SITE (200):
  #     - method: BAN
  #     - path: /site
  #     - header X-Varnish-Purge: site secret (MUST match X-Varnish-Secret value).
  #  2. CACHE TAGS (200):
  #     - method: BAN
  #     - path: /tags
  #     - header X-Varnish-Purge: site secret (MUST match X-Varnish-Secret value).
  #     - header X-Tag: 32f5 de19 143b - hashed version of the tag to invalidate
  #  3. DEFLATE CALL (200):
  #     - method: BAN
  #     - path: /deflate
  #     - header X-Varnish-Purge: site secret (MUST match X-Varnish-Secret value).
  #  4. SINGLE URL + VARIANTS (200):
  #     - method: BAN
  #     - path: (the path to invalidate, e.g.: "path/a?p=1" or "path/*")
  #     - header Host: the hostname to clear the path for.
  #     - header X-Varnish-Purge: site secret (no verification)
  if (req.method == "BAN") {
    if (!req.http.X-Varnish-Purge) {
      return (synth(405, "Permission denied."));
    }
    if (!client.ip ~ internal) {
      return (synth(405, "Permission denied."));
    }
    set req.http.X-Varnish-Purge = std.tolower(req.http.X-Varnish-Purge);
    if (req.url == "/site") {
      ban("obj.http.X-Varnish-Secret == " + req.http.X-Varnish-Purge);
      return (synth(200, "Site banned."));
    }
    else if ((req.url == "/tags") && req.http.X-Tag) {
      set req.http.X-Tag = "(^|\s)" + regsuball(std.tolower(req.http.X-Tag), "\ ", "(\\s|$)|(^|\\s)") + "(\s|$)";
      ban("obj.http.X-Varnish-Secret == " + req.http.X-Varnish-Purge + " && obj.http.X-Tag ~ " + req.http.X-Tag);
      return (synth(200, "Tags banned."));
    }
    else if (req.url == "/deflate") {
      ban("obj.http.ETag ~ .{8}" + req.http.X-Deflate-Tag + " && obj.http.X-Deflate-Key != " + req.http.X-Deflate-Key);
      return (synth(200, "Deflate operation performed."));
    }
    else {
      set req.url = std.tolower(req.url);
      if (req.url ~ "\*") {
        set req.url = regsuball(req.url, "\*", "\.*");
        ban("obj.http.X-Varnish-Secret == " + req.http.X-Varnish-Purge + " && obj.http.X-Url ~ ^" + req.url + "$");
        return (synth(200, "WILDCARD URL banned."));
      }
      else {
        ban("obj.http.X-Varnish-Secret == " + req.http.X-Varnish-Purge + " && obj.http.X-Url ~ " + req.url);
        return (synth(200, "URL banned."));
      }
    }
  }

  unset req.http.X-Real-Forwarded-For;
  set   req.http.X-Real-Forwarded-For = client.ip;
  unset req.http.X-Varnish-Client-IP;
  set   req.http.X-Varnish-Client-IP = client.ip;

  set req.url = std.querysort(req.url);

  if (!req.method ~ "BAN|PURGE|GET|HEAD|PUT|POST|TRACE|OPTIONS|DELETE") {
    return(synth(400, "Bad request"));
  }

  if (req.url ~ "^/(cron|install|update)\.php") {
    return(synth(403, "Forbidden"));
  }

  if (req.method != "GET" && req.method != "HEAD") {
    return (pass);
  }

  if (req.http.Upgrade ~ "(?i)websocket") {
    return (pipe);
  }

  if (req.url ~ "^/(cron|install|update)\.php") {
    if (!client.ip ~ internal) {
      return(synth(403, "Forbidden"));
    }
    return(pass);
  }

  if (req.url ~ "(?i)\.(twig|yml|module|info|inc|profile|engine|test|po|txt|theme|svn|git|tpl(\.php)?)(\?.*|)$" && !req.url ~ "(?i)robots\.txt") {
    if (!client.ip ~ internal) {
      return(synth(403, "Forbidden"));
    }
  }

  # Pass Caching if it was requested from backend.
  if (req.http.Cookie ~ "(^|;\s*)(S?SESS[a-zA-Z0-9]+|DRUPAL_UID)=\w+") {
    set req.http.X-Pass-Varnish = "YES";
    return (pass);
  }

  if (req.url ~ "\.(jpeg|jpg|png|gif|webp|svg|ico|swf|js|css|txt|eot|woff|woff2|ttf|htc)(\?.*|)$") {
    unset req.http.Cookie;
    return (hash);
  }

  if (req.url ~ "\.(webm|mp3|m4a|mp4|m4v|mov|mpeg|mpg|avi|divx|ogg|ogv|wma|pdf|tar|gz|gzip|bz2)(\?.*|)$") {
    unset req.http.Cookie;
    return(pipe);
  }

  if ((req.url ~ "/system/ajax/") && (! req.url ~ "/cached")) {
    return(pass);
  }

  if (req.url ~ "/user" || req.url ~ "/admin") {
    return(pass);
  }

  if (
    req.url ~ "^/sites/.*/files/" || req.url ~ "^/sites/all/themes/" || req.url ~ "^/modules/.*\.(js|css)\?") {
    unset req.http.Cookie;
  }

  set req.http.Surrogate-Capability = "abc=ESI/1.0";

  return (hash);
}

sub vcl_hash {

  /** Default hash */
  hash_data(req.url);
  hash_data(req.http.host);

  /** Place ajax into separate bin. */
  hash_data(req.http.X-Requested-With);

  /** Add protocol if available. */
  hash_data(req.http.X-Forwarded-Proto);

  /** Process authenticated users */
  if (req.http.Cookie ~ "^(|.*; ?)S?SESS([a-z0-9]{32}=[^;]+)(;.*|)$") {

    /** Extract full session value */
    set req.http.X-SESS = regsub(req.http.Cookie, "^(|.*; ?)S?SESS([a-z0-9]{32}=[^;]+)(;.*|)$", "\2");

    # Get Cookie Bin. And Set new header for Vary caching.
    if (req.http.Cookie ~ "^(|.*; ?)ADVBIN=([^;]+)(;.*|)$") {
      set req.http.X-Bin  = "role:" + regsub(req.http.Cookie, "^(|.*; ?)ADVBIN=([^;]+)(;.*|)$", "\2");
    }

    /** ESI_CACHEMODE_1 - SHARED */
    if (req.url ~ "/adv_varnish/esi/" && req.url ~ "[\?&]cachemode=1(&|$)") {
      set req.http.X-Bin = "role:anonymous";
    }

    /** ESI_CACHEMODE_2 - ROLE */
    /** X-Bin role:...         */
    /** This is default behavior User role will be set as cookie bin at the beginning of this condition. */

    /** ESI_CACHEMODE_3 - USER */
    if (req.url ~ "/adv_varnish/esi/" && req.url ~ "[\?&]cachemode=3(&|$)") {
      /** Set user session as bin */
      set req.http.X-Bin  = "user:" + req.http.X-SESS;
    }
    set req.http.X-URL = req.url;
  }
  else {
    set req.http.X-Bin = "role:anonymous";
  }

  /** If Bin is set - add it to hash data for this page */
  hash_data(req.http.X-Bin);

  return (lookup);
}

# Instruct Varnish what to do in the case of certain backend responses (beresp).
sub vcl_backend_response {

  # Set appropriate caching headers
  if (beresp.http.X-Authenticated-User == "true") {
    set beresp.ttl = 0s;
    set beresp.uncacheable = true;
    set beresp.http.Cache-Control = "no-cache, no-store, must-revalidate";
    set beresp.http.Pragma = "no-cache";
    set beresp.http.Expires = "0";
  } else {
    set beresp.http.Cache-Control = "public, max-age=120s";
    set beresp.ttl = 2m;
  }

  /** Enable ESI if requested on this page */
  if (beresp.http.X-DOESI) {
    set beresp.do_esi = true;
    /** Avoid cache onn Browser side */
    unset beresp.http.ETag;
    unset beresp.http.Last-Modified;
  }

  /** compression, vcl_miss/vcl_pass unset compression from the backend */
  if (!beresp.http.Content-Encoding && (
    beresp.http.content-type ~ "text" ||
    beresp.http.content-type ~ "application/xml" ||
    beresp.http.content-type ~ "application/xml\+rss" ||
    beresp.http.content-type ~ "application/rss\+xml" ||
    beresp.http.content-type ~ "application/xhtml+xml" ||
    beresp.http.content-type ~ "application/x-javascript" ||
    beresp.http.content-type ~ "application/javascript" ||
    beresp.http.content-type ~ "application/json" ||
    beresp.http.content-type ~ "font/truetype" ||
    beresp.http.content-type ~ "application/x-font-ttf" ||
    beresp.http.content-type ~ "application/x-font-opentype" ||
    beresp.http.content-type ~ "font/opentype" ||
    beresp.http.content-type ~ "application/vnd\.ms-fontobject" ||
    beresp.http.content-type ~ "image/svg\+xml" ||
    beresp.http.content-type ~ "image/x-icon"
  ))  {
   set beresp.do_gzip = true;
  }

  # Set ban-lurker friendly custom headers.
  set beresp.http.X-Url = bereq.url;
  set beresp.http.X-Host = bereq.http.host;

  # Cache 404s, 301s, at 500s with a short lifetime to protect the backend.
  if (beresp.status == 404 || beresp.status == 301 || beresp.status == 500) {
    set beresp.ttl = 10m;
  }

  # Don't allow static files to set cookies.
  # (?i) denotes case insensitive in PCRE (perl compatible regular expressions).
  # This list of extensions appears twice, once here and again in vcl_recv so
  # make sure you edit both and keep them equal.
  if (bereq.url ~ "(?i)\.(jpeg|jpg|png|gif|webp|svg|ico|swf|js|css|txt|eot|woff|woff2|ttf|htc|mp3|m4a|mp4|m4v|mov|mpeg|mpg|avi|divx|ogg|ogv|wma|pdf|tar|gz|gzip|bz2|asc|dat|doc|xls|ppt|tgz|csv)(\?.*|)$") {
    unset beresp.http.set-cookie;
    return(deliver);
  }

  # Allow items to remain in cache up to X hours past their cache expiration.
  set beresp.grace = std.duration(beresp.http.X-Grace + "s", 0s);

  # Use ttl from X-TTL header. If X-Adv-Varnish header exists (page created by Drupal) and
  # missing X-Drupal-[Dynamic-]Cache headers, then the page should not be cached for some
  # reason (Page-Cache-Kill-Switch, Vary per User or Session etc)
  set beresp.ttl = std.duration(beresp.http.X-TTL + "s", 0s);
  if (bereq.url !~ "/adv_varnish/esi/" &&
    beresp.http.X-Adv-Varnish == "Cache-Enabled" &&
    beresp.http.X-Drupal-Dynamic-Cache != "MISS" &&
    beresp.http.X-Drupal-Dynamic-Cache != "HIT" &&
    beresp.http.X-Drupal-Cache != "HIT" &&
    beresp.http.X-Drupal-Cache != "MISS") {
    set beresp.ttl = 0s;
  }

  if (beresp.http.Set-Cookie) {
    set beresp.http.X-Cacheable = "NO:Cookie in the response";
    set beresp.ttl = 0s;
  }
  elsif (beresp.ttl <= 0s) {
    set beresp.http.X-Cacheable = "NO:Not Cacheable";
  }
  elsif (beresp.http.Cache-Control ~ "private" && !beresp.http.X-DOESI) {
    set beresp.http.X-Cacheable = "NO:Cache-Control=private";
    set beresp.uncacheable = true;
  }
  else {
    set beresp.http.X-Cacheable = "YES";
  }

  if (beresp.ttl > 0s) {
    unset beresp.http.Set-Cookie;
  }
  set beresp.http.X-Varnish-Secret = std.tolower(beresp.http.X-Varnish-Secret);

  set beresp.http.X-TTL2 = beresp.ttl;

  # Disable buffering only for BigPipe responses
  if (beresp.http.Surrogate-Control ~ "BigPipe/1.0") {
    set beresp.do_stream = true;
    set beresp.ttl = 0s;
  }
}

# Set a header to track a cache HITs and MISSes.
sub vcl_deliver {

  # If the header doesn't already exist, set it.
  #if (!req.http.X-Bin) {
  #set resp.http.X-Bin = "role:anonymous";
  #}
  set resp.http.X-Bin = req.http.X-Bin;

  if (obj.hits > 0) {
    set resp.http.X-Varnish-Cache = "HIT";
    set resp.http.X-Cache-TTL-Remaining = req.http.X-Cache-TTL-Remaining;

    if (resp.http.Age) {
      set resp.http.X-Cache-Age = resp.http.Age;
    }
  }
  else {
    set resp.http.X-Varnish-Cache = "MISS";
  }

  set resp.http.X-Cache-Hits = obj.hits;

  # If it's a Drupal-Page with X-Bin vary, tell browsers to vary by Cookie.
  if (resp.http.Vary ~ "X-Bin") {
    set resp.http.Vary = resp.http.Vary + ", Cookie";
  }

  # Remove ban-lurker friendly custom headers when delivering to client.
  if (!resp.http.X-Cache-Debug) {
    unset resp.http.X-Url;
    unset resp.http.X-Host;
    unset resp.http.Purge-Cache-Tags;
    unset resp.http.X-Drupal-Cache-Contexts;
    unset resp.http.X-Drupal-Cache-Tags;
    unset resp.http.X-Drupal-Dynamic-Cache;
    unset resp.http.X-Bin;
    unset resp.http.X-Tag;
    unset resp.http.X-TTL2;
    unset resp.http.X-Cache-TTL;
    unset resp.http.X-Powered-By;
    unset resp.http.Via;
    unset resp.http.X-Generator;
    unset resp.http.Connection;
    unset resp.http.Server;
    unset resp.http.X-DOESI;
    unset resp.http.X-Varnish-Secret;
    unset resp.http.X-Deflate-Key;
  }

  return (deliver);
}

# Right after an object has been found (hit) in the cache.
sub vcl_hit {
  set req.http.X-Cache-TTL-Remaining = obj.ttl;

  if (obj.ttl >= 0s) {
    return (deliver);
  }

  if (std.healthy(req.backend_hint)) {
    if (obj.ttl + 10s > 0s) {
      return (deliver);
    } else {
      return(deliver);
    }
  } else {
    if (obj.ttl + obj.grace > 0s) {
      return (deliver);
    } else {
      return (deliver);
    }
  }

  return (deliver);
}

# Right after an object was looked up and not found in cache.
sub vcl_miss {
  return (fetch);
}

# Run after a pass in vcl_recv OR after a lookup that returned a hitpass.
sub vcl_pass {
  # stub
}

sub vcl_pipe {
  if (req.http.upgrade) {
    set bereq.http.upgrade = req.http.upgrade;
  }
  set bereq.http.connection = "close";
}

sub vcl_synth {

  if (resp.status == 400) {
    set resp.status = 400;
    set resp.http.Content-Type = "text/html; charset=utf-8";

    synthetic ({"
    <?xml version="1.0" encoding="utf-8"?>
    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
    <html>
    <head>
    <title>400 Bad request</title>
    </head>
    <body>
    <h1>Error 400 Bad request</h1>
    <p>Bad request</p>
    </body>
    </html>
    "});

    return(deliver);
  }

  if (resp.status == 401) {
    set resp.status = 401;
    set resp.http.Content-Type = "text/html; charset=utf-8";
    set resp.http.WWW-Authenticate = "Basic realm=Authentication required. Please login";

    synthetic ({"
    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
    "http://www.w3.org/TR/1999/REC-html401-19991224/loose.dtd">
    <HTML>
    <HEAD>
    <TITLE>Error</TITLE>
    <META HTTP-EQUIV='Content-Type' CONTENT='text/html;'>
    </HEAD>
    <BODY><H1>401 Unauthorized.</H1></BODY>
    </HTML>
    "});

    return(deliver);
  }

  if (resp.status == 403) {
    set resp.status = 403;
    set resp.http.Content-Type = "text/html; charset=utf-8";

    synthetic ({"
    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
    "http://www.w3.org/TR/1999/REC-html401-19991224/loose.dtd">
    <HTML>
    <HEAD>
    <TITLE>403 Forbidden</TITLE>
    <META HTTP-EQUIV='Content-Type' CONTENT='text/html;'>
    </HEAD>
    <BODY><H1>Forbidden</H1></BODY>
    <p>You don't have permissions to access "} + req.url + {" on this server</p>
    </HTML>
    "});
    return(deliver);
  }

}

sub vcl_fini {
  return (ok);
}
🇺🇸United States glynster

Rockstar thank you so much for pushing this fix.

🇺🇸United States glynster

Yes this suggestions resolves the issue as well

/**
 * Implements hook_library_info_build().
 */
function turnstile_library_info_build() {
  $config = \Drupal::config('turnstile.settings');
  $turnstile_src = $config->get('turnstile_src');

  $libraries = [];

  $libraries['turnstile.remote'] = [
    'js' => [
      $turnstile_src => [
        'type' => 'external',
        'attributes' => [
          'defer' => TRUE,
          'async' => TRUE,
        ],
      ],
    ],
  ];

  return $libraries;
}
🇺🇸United States glynster

@greatmatter you are referring to the services file right?

parameters:
  session.storage.options:
    cookie_samesite: Lax

We already have this set on our sites, so we can rule that one out.

I can confirm that updating the widget does resolve the issue

/**
 * Build the Turnstile captcha form.
 *
 * @return mixed
 *   The return value.
 */
public function getWidget($validation_function) {
  // Add Turnstile script with async and defer attributes.
  $widget['form']['#attached']['html_head'][] = [
    [
      '#tag' => 'script',
      '#attributes' => [
        'src' => 'https://challenges.cloudflare.com/turnstile/v0/api.js',
        'async' => 'async',
        'defer' => 'defer',
      ],
    ],
    'turnstile_api',
  ];

  // Captcha requires TRUE to be returned in solution.
  $widget['solution'] = TRUE;
  $widget['captcha_validate'] = $validation_function;
  $widget['form']['captcha_response'] = [
    '#type' => 'hidden',
    '#value' => self::EMPTY_CAPTCHA_RESPONSE,
  ];

  // Allows the CAPTCHA to be displayed on cached pages.
  $widget['cacheable'] = TRUE;

  // Ensure the widget is properly rendered.
  $widget['form']['turnstile_widget'] = [
    '#markup' => '<div' . $this->getAttributesString() . '></div>',
  ];
  
  return $widget;
}
🇺🇸United States glynster

Okay well all our sites are behind Cloudflare and as soon as we disable RocketLoader it works. But we have always used this service. Since the change this is now conflicting it seems.

🇺🇸United States glynster

Only 1, nothing fancy going on here. These are just basic webforms and GIn Login pages.

🇺🇸United States glynster

I wish I could have good news and it be that simple but it is not.

This seems to work:

/**
 * @file
 * JavaScript behaviors for Turnstile.
 */
(function ($, Drupal, once) {
  'use strict';

  /**
   * Ensures Turnstile renders correctly on page load & AJAX events.
   */
  Drupal.behaviors.turnstileAutoRender = {
    attach: function (context) {
      once('turnstile-init', '.cf-turnstile', context).forEach((el) => {
        turnstile.render(el);
      });
    }
  };

  /**
   * Re-renders Turnstile when an AJAX request updates the form.
   */
  Drupal.AjaxCommands.prototype.turnstileRender = function () {
    $('.cf-turnstile').each(function(){
      turnstile.render(this);
    });
  };

})(jQuery, Drupal, once);
🇺🇸United States glynster

No luck on my end We have cleared caches Drupal, Varnish, Cloudflare, etc. The only fix is this:

turnstile.render('.cf-turnstile', { sitekey: "#################" });

With the current version or revert to 1.1.13.

🇺🇸United States glynster

@greatmatter thanks so much for being so responsive. I just deployed with your new hotfix and sadly I get the same results, fails to load/initialize.

🇺🇸United States glynster

So it seems this actually fixes the issue:

1️⃣ Run This in Browser Console to Force a Reset

document.querySelectorAll('.cf-turnstile').forEach(el => {
turnstile.remove(el);
});
turnstile.render('.cf-turnstile', { sitekey: "######" });

2. Modify the Form Rendering Code to Prevent Duplicate Initialization

To avoid this problem, modify your Drupal form rendering:

$form['turnstile_widget'] = [
  '#markup' => '<div id="turnstile-container" class="cf-turnstile" data-sitekey="' . turnstile_site_key() . '"></div>',
];

$form['#attached']['drupalSettings']['turnstile'] = [
  'callback' => 'turnstileSuccess',
  'errorCallback' => 'turnstileError',
];

$form['#attached']['library'][] = 'turnstile/turnstile';

Then add this script in turnstile.ajax.js to remove any existing Turnstile instances before rendering a new one:

document.addEventListener("DOMContentLoaded", function () {
  const turnstileContainer = document.querySelector('.cf-turnstile');
  if (turnstileContainer) {
    turnstile.remove(turnstileContainer);
    turnstile.render(turnstileContainer, {
      sitekey: drupalSettings.turnstile.sitekey
    });
  }
});
🇺🇸United States glynster

I am debugging at the moment to try and be useful. There are no console errors. However here are some observations:

Locally it does load fine.
On production it does not.
Markup wise I can see

<div class="cf-turnstile" data-sitekey="################" data-theme="light" data-size="normal" data-language="auto" data-retry="auto" interval="8000" data-appearance="always"></div>

but on version 1.1.13 the markup is:

<div class="cf-turnstile" data-sitekey="################" data-theme="light" data-size="normal" data-language="auto" data-retry="auto" interval="8000" data-appearance="always"><div><input type="hidden" name="cf-turnstile-response" id="cf-chl-widget-b5dkd_response" value="TOKEN"></div></div>

🇺🇸United States glynster

@useernamee great news. This will be very helpful. My only suggesting is the API route /api/lupus/site-info changes to /api/site-info seems more appropriate to me. Great job!

🇺🇸United States glynster

@fago I have fixed the phpcs issues and added some tests. Let me know.

🇺🇸United States glynster

@useernamee this is what we are currently doing lupus_ce_renderer_response_data for various things thanks for the tip!

Let me know if there is anything else I can do to help here.

🇺🇸United States glynster

Confirmed that, once the merge is applied and the submodule is installed, layouts with paragraphs clone perfectly? RTBC to have this committed.

🇺🇸United States glynster

@fago, this makes total sense and the entire reason behind Nuxt layers.

How do you propose I can help here?

🇺🇸United States glynster

@fago sorry for the confusion. Added a new subscriber, tested and works! MR is ready.

🇺🇸United States glynster

@fago I have gone ahead and created a merge request:
https://www.drupal.org/project/lupus_ce_renderer/issues/3506828 Add basic user session info to ce-api Active

🇺🇸United States glynster

Yup no worries! I will review the cache and do a pull request as suggested in the correct place.

🇺🇸United States glynster

@fargo just adding a tested patch here to make sure I have done this correctly?

🇺🇸United States glynster

Great I do remember seeing something from you about this. Cool. That will be nice as I just merged the account links to the admin menu so you have the complete package. Once that is rolled out it will be solid.

🇺🇸United States glynster

I agree with the layer side of things. ATM I am working on a webform comp and field render. I prefer to keep all the API as json and allow the frontend to render as needed. Perhaps the first step is the Nuxt Layer for admin we were chatting about? Get feedback and testing and that would be one way I can help?

🇺🇸United States glynster

Of course I agree with you 100%!!

🇺🇸United States glynster

Sounds like a perfect idea to me, and as you say it can be added to depending on your needs. Totally agree on the lookup once and cached on Nuxt end. We can achieve this via useState

🇺🇸United States glynster

@fago, it is a Nuxt layer and intended as a solid base. The issue is the customizations (Paragraphs, Layouts). How about I setup a branch that is more streamlined and more basic like what you have with your other 2 options? I have also utilized the app.config for theming overrides. Then you can see how to integrate this? The idea is to add the git via the package and then extend from within the Nuxt config. Very little friction. Let me know your thoughts.

🇺🇸United States glynster

@anybody I can confirm that #51 🐛 Update module can get 'stuck' with 'no releases available' Needs work works well and resolves the issue across all our sites running Drupal 10.3+. This bug has plagued us for the past six months. After installing and deploying the patch, the status page now functions correctly again. For us, this issue only occurred in the production environment.

🇺🇸United States glynster

@fago, here is what I have generated for a Nuxt layer:

https://github.com/StirStudios/stir_nuxt_base

At the moment, it is suited to our project needs, though it may include more than necessary. Here’s what it does currently:

  • Wraps Lupus CE and other checks into a composable.
  • Checks for user roles and session, returning an admin editing menu. I didn’t see the need to include the full Drupal admin menu—just the editing tabs.
  • Adds a Vite hook so Nuxt dev tools work locally with DDEV.
  • Nuxt 4 compatibility.
  • Smooth scroll.
  • Page transition.
  • Turnstile for forms (currently webform, with CSRF token fetching).
  • Robots.
  • Sitemap (we generated an API on the Drupal end).
  • Nuxt UI (as the base for ease of theming).
  • Heavily integrated with Paragraphs, as you’ll see.
  • Paragraph entity edit links

This is my first time working with layers, and it made sense to start with Nuxt 4 compatibility. The webform is quite flexible, allowing for many options, and basic validation is handled via Nuxt UI and Yup. I’ve also implemented basic theming for user login forms, though there’s less flexibility since we don’t have the same control over theming as we do with webforms.

🇺🇸United States glynster

@fago, here is what I have generated for a Nuxt layer:

https://github.com/StirStudios/stir_nuxt_base

At the moment, it is suited to our project needs, though it may include more than necessary. Here’s what it does currently:

  1. Wraps Lupus CE and other checks into a composable.
  2. Checks for user roles and session, returning an admin editing menu. I didn’t see the need to include the full Drupal admin menu—just the editing tabs.
  3. Adds a Vite hook so Nuxt dev tools work locally with DDEV.
  4. Nuxt 4 compatibility.
  5. Smooth scroll.
  6. Page transition.
  7. Turnstile for forms (currently webform, with CSRF token fetching).
  8. Robots.
  9. Sitemap (we generated an API on the Drupal end).
  10. Nuxt UI (as the base for ease of theming).
  11. Heavily integrated with Paragraphs, as you’ll see.

This is my first time working with layers, and it made sense to start with Nuxt 4 compatibility. The webform is quite flexible, allowing for many options, and basic validation is handled via Nuxt UI and Yup. I’ve also implemented basic theming for user login forms, though there’s less flexibility since we don’t have the same control over theming as we do with webforms.

🇺🇸United States glynster

I can get this response if I try user/logout (if I am logged in). What we currently have setup is a absolute url such as https://test-drupal.ddev.site/user/logout. This redirects the user to Drupal backend for confirmation. That works without issue using the Drupal process.

🇺🇸United States glynster

@fago, is there a bare minimum you’d like to see here? For example, here are a few suggestions:

  1. Base theme with Nuxt UI (Tailwind).
  2. Admin menu.
  3. Sticky main navigation.
  4. Footer.
  5. Light/dark mode toggle.

The components might be tricky since they depend on the Drupal setup. For instance, we use Paragraph Layouts.

I’d love to help—just need a solid plan to get started.

🇺🇸United States glynster

I can't confirm this, but I suspect it might be a configuration issue.

Have you set up your services correctly?

parameters:
  session.storage.options:
    cookie_domain: '.ddev.site'
    cookie_domain_bc_mode: true

This ensures the cookie is shared across all subdomains. Make sure both the front-end and back-end are using the same domain, differentiated only by a subdomain.

🇺🇸United States glynster

Uninstall and reinstall:

drush pm-uninstall -y update && drush pm-enable -y update

Then clear the fetch task:

drush php:eval "\Drupal::keyValue('update_fetch_task')->deleteAll();"
🇺🇸United States glynster

Now that you explain it, it does make this pointless.

🇺🇸United States glynster

I really like this idea! Starting with a basic Nuxt version sounds great, and allowing features to be configurable within nuxt.config adds flexibility for those who want to enable them. Nuxt UI/Tailwind is a solid choice—it stays within the Nuxt ecosystem and provides a strong foundation for development. But as you mentioned, it’s important for devs to pick the tools that best suit their project.

What are your thoughts on collaboration? I assume we could handle most of this on GitHub?

🇺🇸United States glynster

The idea is to add an extra layer of security to the Lupus API by requiring an authentication key (API key) to be included in the headers of all requests made to the Drupal backend. This key could be configured either in the environment file (env) or directly through the Lupus config form. This would help restrict access to the API, ensuring that only authorized clients can make requests.

Here’s a more detailed explanation:

Authentication Key: The proposal is to have a configurable API key that must be included in the request headers. This key could be set in the CMS environment file or through the Lupus config form, providing flexibility depending on the setup. This method is similar to approaches used in other systems, like the Rest API module, which allows protection of REST API endpoints.

Security Benefits: Implementing this would add a significant security layer to the API, helping to prevent unauthorized access. This feature would be particularly useful in cases where the API might otherwise be publicly accessible.

Implementation Flexibility: The use of a header-based approach is both simple and effective, allowing for easy integration into existing workflows. It’s a familiar pattern for many developers, which should make adoption straightforward.

While the rest_api_access_token module is just an example of a contrib module that offers protection for REST API endpoints, the concept here is to integrate a similar mechanism directly into Lupus, providing a built-in option for those who want this additional security feature.

I hope this provides a clearer picture of the proposal. Let me know if you have any questions or if further discussion is needed.

🇺🇸United States glynster

We use this in a custom theme without disabling bigpipe

use Drupal\Core\Render\Element\StatusMessages;

function yourtheme_preprocess_block__system_messages_block(&$variables) {
  $variables['content'] = StatusMessages::renderMessages();
  $variables['#cache']['max-age'] = 0;
}

Resolved issues for us.

🇺🇸United States glynster

I like the idea of some granular permissions are exposed options you can add to the API.

The simple solution of adding user info to the renderer made many things possible. Just on that level it helps with so much flexibility.

These guys have done some interesting stuff:
https://www.drupal.org/project/nuxtify

Mainly in regards to the API points for user info, sessions. Otherwise their approach is completely different.

Again always happy to help where I can!

🇺🇸United States glynster

In our example, we used the Drupal Tabs already integrated within Nuxt. For most cases with our development, this setup meets the client’s needs, with the exception of providing a way to return to the backend and log out.

Here is the code we generated:

<script setup lang="ts">
import type { TabsProps } from '~/types'

const props = defineProps<TabsProps>()
const config = useRuntimeConfig()
const siteApi = config.public.api

// Function to format local task links
const getLocalTaskLinks = () => {
  return props.tabs.primary.map((tab) => ({
    label: tab.label,
    to: tab.label === 'View' ? tab.url : `${siteApi}${tab.url}`,
    icon: filterIconByLabel(tab.label),
  }))
}

// Custom function to filter icons based on tab labels
const filterIconByLabel = (label: string) => {
  switch (label) {
    case 'View':
      return 'i-heroicons-eye'
    case 'Edit':
      return 'i-heroicons-pencil'
    case 'Delete':
      return 'i-heroicons-trash'
    case 'Revisions':
      return 'i-heroicons-document-duplicate'
    case 'Export':
      return 'i-heroicons-arrow-up-tray'
    case 'API':
      return 'i-heroicons-code-bracket'
    default:
      return null
  }
}

let links = []

if (props.tabs.primary && props.tabs.primary.length > 0) {
  links = [
    [
      {
        label: 'Drupal CMS',
        icon: 'i-heroicons-home',
        to: `${siteApi}/admin/content`,
      },
    ],
    [...getLocalTaskLinks()],
    [
      {
        label: 'Log out',
        icon: 'i-heroicons-arrow-left-start-on-rectangle',
        to: `${siteApi}/user/logout`,
      },
    ],
  ]
} else {
  links = [
    [
      {
        label: 'Drupal CMS',
        icon: 'i-heroicons-home',
        to: `${siteApi}/admin/content`,
      },
    ],
    [
      {
        label: 'Log out',
        icon: 'i-heroicons-arrow-left-start-on-rectangle',
        to: `${siteApi}/user/logout`,
      },
    ],
  ]
}
</script>

<template>
  <div
    class="admin-links md:px-auto sticky top-0 z-20 w-full bg-zinc-200 bg-opacity-70 px-4 px-8 text-black shadow shadow-gray-300 backdrop-blur-md dark:bg-gray-800 dark:text-white dark:shadow-gray-700"
  >
    <UHorizontalNavigation
      :links="links"
      :ui="{
        base: 'text-xs',
      }"
    />
  </div>
</template>

<style lang="css">
.admin-links {
  .truncate {
    @apply hidden md:block;
  }
}
</style>

There are a few key points to note:

  1. The Drupal Tabs were relative, so we had to force the API environment to recognize the root.
  2. We added some extra links, as having access to the API is incredibly helpful for debugging and checking things. This has been invaluable.
  3. We included a link back to Drupal—specifically, for us, this is at admin/content.
  4. We are also using Nuxt UI, as we've migrated away from Bootstrap in favor of Tailwind.
  5. On the Drupal side, we added additional routing for the API call:
custom_builder.routing.yml

custom.ce_api:
  path: '/ce-api/node/{node}'
  defaults:
    _controller: '\Drupal\custom\Controller\LocalApiController::nodeExtraSettings'
    _title: 'API'
  requirements:
    _permission: 'access content'
  options:
    parameters:
      node:
        type: 'entity:node'

I believe incorporating this link into the default Lupus setup will be a game-changer for developers.

🇺🇸United States glynster

Yes, your thoughts align perfectly with what we had in mind. That’s why we decided to revert to a simpler approach. This highlights the beauty of Lupus, where we consistently rely on Drupal to excel at what it does best. Now that you’ve created separate tasks, I’ll respond to those accordingly.

🇺🇸United States glynster

This gives you and idea on larger device and smaller.

🇺🇸United States glynster

@fargo we have played a lot with the setup and ended up reverting to the default you have setup. I think it is the right approach as it is exactly what Drupal does.

So in nuxt for login we just redirect them to Drupal backend and log them in. That sets the session and then when they start editing or viewing content they have access to the admin menu in the front end.

We just extended the Lupus renderer to include some simple user info and this gave us the ability to add admin capability like this:

function custom_lupus_ce_renderer_response_alter(array &$data, BubbleableMetadata $bubbleable_metadata, Request $request) {
  // Get the currently logged-in user.
  $user = \Drupal::currentUser();

  // Create an array containing the current user's name, uid, and roles.
  $current_user = [
    'uid' => $user->id(),
    'name' => $user->getDisplayName(),
    'roles' => $user->getRoles(),
  ];

  // Add the current user array to the data.
  $data['current_user'] = $current_user;

  // Fetch the site configuration.
  $site_config = \Drupal::config('system.site');
  $site_info = [
    'name' => $site_config->get('name'),
    'slogan' => $site_config->get('slogan'),
    'mail' => $site_config->get('mail'),
  ];

  // Add the site info to the data.
  $data['site_info'] = $site_info;

  // Remove 'meta' attribute from 'content' if it exists.
  if (isset($data['content']['meta'])) {
    unset($data['content']['meta']);
  }
}

I would love to share what we did on the Nuxt end for admin features as I think it has been super helpful. Bottom line this is all possible due to the session.

I love what you guys are doing and opening up the flexibility of Nuxt as the frontend has been awesome.

🇺🇸United States glynster

Yup great job on the caching tweak. I feel like this could be added to the sub theme as a tiny default tweak?

🇺🇸United States glynster

Thanks for the patch @bradhawkins. As this was a breaking change it was better to add a schema instead. Please update to the latest version and enjoy.

🇺🇸United States glynster

Thanks for all the work here @avpaderno.

🇺🇸United States glynster

glynster made their first commit to this issue’s fork.

🇺🇸United States glynster

Thanks @R0djer adding this to the next release.

Production build 0.71.5 2024