Now I see why the clever and elegant solution to use "Vary: Cookie", as
suggested by Joerg, does not fix /uv page expiration after login and
logout, and I can also explain the strange differences between the local
Fossil built-in web server, and my remote web server.

The local Fossil built-in web server uses the HTTP/1.0 protocol. On my
remote web server, Apache automatically upgrades the CGI responses
generated by Fossil to HTTP/1.1.

HTTP/1.0 does not yet support ETags, but only Last-Modified stamps, and
thus web browsers do not include If-None-Match with their requests, but
just stick to If-Modified-Since [0]. Interestingly, "Vary: Cookie" works
(i.e. /uv pages are expired after login and logout) with Chrome, and looks
like it's working with IE and Edge (but in fact they are not caching pages
with "Vary: Cookie" at all, mimicking a correct refresh triggered by a
cookie update; it may be my browser settings).


With HTTP/1.1, browsers include If-None-Match with their requests, but
"Vary: Cookie" no longer has any effects (i.e. /uv pages are not expired
after login and logout).

I think that "Vary: Cookie" is intended to work with unconditional HTTP
requests: the browser is directed to stick to the expiry date and use the
cached page, unless the cookies have changed.

But caching works differently with conditional HTTP requests
(If-None-Match, If-Modified-Since): pages are always revalidated with the
server, whether or not the cookies have changed, and "Vary: Cookie" has no
additional effects, here.

I've attached a simple PHP script to test this:

The script generates two web pages, one to expire after 10 seconds (through
a "Cache-Control: max-age=10" HTTP response header), the other to handle a
conditional request (through a "Cache-Control: must-revalidate, private"
header) that always returns "304 Not Modified" (to simulate the current
Fossil "Last-Modified always wins" caching behavior).

Repeatedly clicking the "Reload" links causes the first page to refresh
every 10 seconds (watch the "Date" and "Cookie" entries). The second page
remains unchanged (unless reloaded with Ctrl+F5).

Clicking "Update cookie" modifies the test cookie (by JavaScript). Now the
first page is refreshed immediately - the effect of "Vary: Cookie". The
second page still remains unchanged.

That's why /uv pages are not expired after login and logout, even if the
login cookie has changed. The browser always revalidates the page, and
whether or not Fossil detects an ETag mismatch after login and logout
(currently, it doesn't, as the ETag is not "login-time-sensitive"), it is
immediately undermined by a Last-Modified match. "Vary: Cookie" can't fix

I have been using Fossil with the patch to expire /uv web pages whenever a
new user is logged-in for a few days, now.

With the repository index page set to a /uv page [1], I have a very smooth
user experience for login and logout actions:

After login, I can immediately see the Admin menu entry and the user name
display in the top right corner, without the need to do a "hard" reload
(Ctrl+F5) -- exactly the way it was before Fossil supported HTTP caching.

My index page has a direct logout link, and when clicked, the Admin menu
entry and user name display are again updated immediately:

   [/login?out | Logout]

If the user login state does not change, Fossil sends a "304 Not Modified"
response, and the web browser shows the cached page, with the Admin menu
entry and the user name display in sync.

If there's future plans to use HTTP caching not only for /uv, but also for
/doc and /wiki pages, more people may run into the issue that they need to
do "hard" reloads after login and logout.

I have refactored the patch (attached) to have one single function handle
either conditional request, and hide the logic to ensure that ETag
mismatches won't be undermined by Last-Modified matches, so it's easier to
reuse it for other page generators than /uv, at a later stage. I'm not sure
if it's safe to change HTTP/1.0 to HTTP/1.1 for the local Fossil built-in
web server (I think it is, as it's likely that it is silently upgraded by
most web servers).

"Vary: Cookie" was left in place, just in case, as a possible fallback for
local HTTP/1.0 servers. Like this, it's easy to test the impact of "Vary:
Cookie", as removing the ETAG_CEXP flag from the call to etag_check()
changes ETag generation to be "login-time-agnostic", again.

I would really like to encourage you to try the patch, and see how this
changes the user experience for login and logout actions related to /uv web
pages, on local and remote Fossil web servers.

Thank you very much

[1] A /uv repository index page can be updated by scripting and replaced
for local clones, and unlike with /doc or /wiki, changes never show up in
the Fossil timeline and/or file hierarchy. I'm keeping the (identical)
index pages for my repositories in a separate meta-repository, so no need
to archive their history with each repository individually.

===================== vary-cookie.php ==================================


   if (isset($_GET['rv']))
     if (isset($_SERVER['HTTP_IF_NONE_MATCH']) ||
       header('Not Modified',true,304); // always hit
       header('ETag: "caffee"');
       header('Last-Modified: Wed, 28 Feb 2018 00:00:00 GMT');
       header('Cache-Control: must-revalidate, private');
     header('Expires: '.gmdate('D, d M Y H:i:s',time()+10).' GMT');
     header('Cache-Control: public, max-age=10');

   setcookie('test-cookie',@$_COOKIE['test-cookie'] ?: '␢');
   header('Vary: Cookie');

   echo <<<html
<!DOCTYPE html>
<meta charset="utf-8">
   window.onload = function() {
     document.getElementById('u').onclick = function() {
       var ck = (new Date().getTime()).toString(16).slice(-8);
       document.cookie = 'test-cookie='+ck;
       document.getElementById('c').innerHTML = '→ <code>'+ck+'</code>';

<h2>Test HTTP "Vary: Cookie" Request Header</h2>

<li><a href="{$_SERVER['SCRIPT_NAME']}">Reload:</a>
   <code>Cache-Control: public, max-age=10</code></li>
<li><a href="{$_SERVER['SCRIPT_NAME']}?rv">Reload:</a>
   <code>Cache-Control: must-revalidate, private</code>
   → <code>304 Not Modified</code></li>

<li><a id="u" href="#">Update cookie</a> <span id="c"></span></li>

<h3>Script State:</h3>



   echo '<li>Mode: <code>'.
         (isset($_GET['rv']) ? 'must-revalidate' : 'max-age=10').
   echo '<li>Date: <code>'.gmdate('D, d M Y H:i:s')." GMT</code></li>\n";
   echo '<li>Cookie: <code>'.@$_COOKIE['test-cookie']."</code></li>\n";

   echo "</ul>\n\n<h3>Request Headers:</h3>\n\n<ul>\n";
   foreach ($_SERVER as $k=>$v)
     if (substr($k,0,5)=='HTTP_')
       echo '<li><code>'.htmlspecialchars($k).': '.

   echo "</ul>\n\n<h3>Response Headers:</h3>\n\n<ul>\n";
   foreach (headers_list() as $k=>$v)
     echo '<li><code>'.htmlspecialchars($v)."</code></li>\n";

   echo <<<html



===================== vary-cookie.php ==================================

===================== Patch for Fossil [a7056e64] ======================

Index: src/cgi.c
--- src/cgi.c
+++ src/cgi.c
@@ -249,11 +249,11 @@
      iReplyStatus = 200;
      zReplyStatus = "OK";

    if( g.fullHttpReply ){
-    fprintf(g.httpOut, "HTTP/1.0 %d %s\r\n", iReplyStatus, zReplyStatus);
+    fprintf(g.httpOut, "HTTP/1.1 %d %s\r\n", iReplyStatus, zReplyStatus);
      fprintf(g.httpOut, "Date: %s\r\n", cgi_rfc822_datestamp(time(0)));
      fprintf(g.httpOut, "Connection: close\r\n");
      fprintf(g.httpOut, "X-UA-Compatible: IE=edge\r\n");
      fprintf(g.httpOut, "Status: %d %s\r\n", iReplyStatus, zReplyStatus);
@@ -269,10 +269,11 @@
      fprintf(g.httpOut, "Cache-control: no-cache\r\n");
    if( etag_mtime()>0 ){
      fprintf(g.httpOut, "Last-Modified: %s\r\n",
+    fprintf(g.httpOut, "Vary: Cookie\r\n"); /* HTTP/1.0 (no ETags) */

    if( blob_size(&extraHeader)>0 ){
      fprintf(g.httpOut, "%s", blob_buffer(&extraHeader));

Index: src/doc.c
--- src/doc.c
+++ src/doc.c
@@ -641,17 +641,21 @@
      if( isUV ){
        if( db_table_exists("repository","unversioned") ){
          Stmt q;
+        char *zHash=0;
+        sqlite3_int64 mtime=0;
          db_prepare(&q, "SELECT hash, mtime FROM unversioned"
                         " WHERE name=%Q", zName);
          if( db_step(&q)==SQLITE_ROW ){
-          etag_check(ETAG_HASH, db_column_text(&q,0));
-          etag_last_modified(db_column_int64(&q,1));
+          zHash = fossil_strdup(db_column_text(&q,0));
+          mtime = db_column_int64(&q,1);
+        etag_check(ETAG_HASH|ETAG_CEXP, zHash, mtime);
+        if( zHash ) free(zHash);
          if( unversioned_content(zName, &filebody)==0 ){
            rid = 1;
            zDfltTitle = zName;
@@ -847,11 +851,11 @@
  void logo_page(void){
    Blob logo;
    char *zMime;

-  etag_check(ETAG_CONFIG, 0);
+  etag_check(ETAG_CONFIG, 0, 0);
    zMime = db_get("logo-mimetype", "image/gif");
    db_blob(&logo, "SELECT value FROM config WHERE name='logo-image'");
    if( blob_size(&logo)==0 ){
      blob_init(&logo, (char*)aLogo, sizeof(aLogo));
@@ -881,11 +885,11 @@
  void background_page(void){
    Blob bgimg;
    char *zMime;

-  etag_check(ETAG_CONFIG, 0);
+  etag_check(ETAG_CONFIG, 0, 0);
    zMime = db_get("background-mimetype", "image/gif");
    db_blob(&bgimg, "SELECT value FROM config WHERE
    if( blob_size(&bgimg)==0 ){
      blob_init(&bgimg, (char*)aBackground, sizeof(aBackground));

Index: src/etag.c
--- src/etag.c
+++ src/etag.c
@@ -24,10 +24,11 @@
  **   (1)  The mtime on the Fossil executable
  **   (2)  The last change to the CONFIG table
  **   (3)  The last change to the EVENT table
  **   (4)  The value of the display cookie
  **   (5)  A hash value supplied by the page generator
+**   (6)  The "user.cexpire" field for logged-in users
  ** Item (1) is always included in the ETag.  The other elements are
  ** optional.  Because (1) is always included as part of the ETag, all
  ** outstanding ETags can be invalidated by touching the fossil executable.
@@ -60,20 +61,21 @@
  #define ETAG_CONFIG   0x01 /* Output depends on the CONFIG table */
  #define ETAG_DATA     0x02 /* Output depends on the EVENT table */
  #define ETAG_COOKIE   0x04 /* Output depends on a display cookie value */
  #define ETAG_HASH     0x08 /* Output depends on a hash */
+#define ETAG_CEXP     0x10 /* Output depends on "user.cexpire" */

  static char zETag[33];      /* The generated ETag */
  static int iMaxAge = 0;     /* The max-age parameter in the reply */
  static sqlite3_int64 iEtagMtime = 0;  /* Last-Modified time */

  ** Generate an ETag
-void etag_check(unsigned eFlags, const char *zHash){
+void etag_check(unsigned eFlags, const char *zHash, sqlite3_int64 lmt){
    sqlite3_int64 mtime;
    const char *zIfNoneMatch;
    char zBuf[50];
    assert( zETag[0]==0 );  /* Only call this routine once! */

@@ -82,10 +84,22 @@

    /* Always include the mtime of the executable as part of the hash */
    mtime = file_mtime(g.nameOfExe, ExtFILE);
    sqlite3_snprintf(sizeof(zBuf),zBuf,"mtime: %lld\n", mtime);
    md5sum_step_text(zBuf, -1);
+  /* Include "user.cexpire" for logged-in users in the hash */
+  if ( (eFlags & ETAG_CEXP)!=0 && g.zLogin ){
+    char *zCExp = db_text(0, "SELECT cexpire FROM user WHERE uid=%d",
+                              g.userUid);
+    if ( zCExp ){
+      md5sum_step_text("cexp: ", -1);
+      md5sum_step_text(zCExp, -1);
+      md5sum_step_text("\n", 1);
+      fossil_free(zCExp);
+    }
+  }

    if( (eFlags & ETAG_HASH)!=0 && zHash ){
      md5sum_step_text("hash: ", -1);
      md5sum_step_text(zHash, -1);
      md5sum_step_text("\n", 1);
@@ -118,11 +132,17 @@
    memcpy(zETag, md5sum_finish(0), 33);

    /* Check to see if the generated ETag matches If-None-Match and
    ** generate a 304 reply if it does. */
    zIfNoneMatch = P("HTTP_IF_NONE_MATCH");
-  if( zIfNoneMatch==0 ) return;
+  if( zIfNoneMatch==0 ){
+    /* Prevent the If-Modified-Since cache handler to undermine cache
+    ** misses already cleared by the If-None-Match cache handler, and
+    ** call it only if there's no If-None-Match request header. */
+    if ( lmt ) etag_last_modified(lmt);
+    return;
+  }
    if( strcmp(zIfNoneMatch,zETag)!=0 ) return;

    /* If we get this far, it means that the content has
    ** not changed and we can do a 304 reply */
@@ -203,8 +223,8 @@
    int iKey = 0;
    db_find_and_open_repository(0, 0);
    zKey = find_option("key",0,1);
    zHash = find_option("hash",0,1);
    if( zKey ) iKey = atoi(zKey);
-  etag_check(iKey, zHash);
+  etag_check(iKey, zHash, 0);
    fossil_print("%s\n", etag_tag());

Index: src/style.c
--- src/style.c
+++ src/style.c
@@ -858,11 +858,11 @@
    if( zId && (nId = (int)strlen(zId))>=8 &&
strncmp(zId,MANIFEST_UUID,nId)==0 ){
      g.isConst = 1;
-    etag_check(0,0);
+    etag_check(0,0,0);
    blob_init(&out, zTxt, -1);

Index: src/tar.c
--- src/tar.c
+++ src/tar.c
@@ -772,11 +772,11 @@
    blob_appendf(&cacheKey, "/tarball/%z", rid_to_uuid(rid));
    blob_appendf(&cacheKey, "/%q", zName);
    if( zInclude ) blob_appendf(&cacheKey, ",in=%Q", zInclude);
    if( zExclude ) blob_appendf(&cacheKey, ",ex=%Q", zExclude);
    zKey = blob_str(&cacheKey);
-  etag_check(ETAG_HASH, zKey);
+  etag_check(ETAG_HASH, zKey, 0);

    if( P("debug")!=0 ){
      style_header("Tarball Generator Debug Screen");
      @ zName = "%h(zName)"<br />
      @ rid = %d(rid)<br />

Index: src/zip.c
--- src/zip.c
+++ src/zip.c
@@ -942,11 +942,11 @@
    blob_appendf(&cacheKey, "/%s/%z", g.zPath, rid_to_uuid(rid));
    blob_appendf(&cacheKey, "/%q", zName);
    if( zInclude ) blob_appendf(&cacheKey, ",in=%Q", zInclude);
    if( zExclude ) blob_appendf(&cacheKey, ",ex=%Q", zExclude);
    zKey = blob_str(&cacheKey);
-  etag_check(ETAG_HASH, zKey);
+  etag_check(ETAG_HASH, zKey, 0);

    if( P("debug")!=0 ){
      style_header("%s Archive Generator Debug Screen", zType);
      @ zName = "%h(zName)"<br />
      @ rid = %d(rid)<br />

===================== Patch for Fossil [a7056e64] ======================
fossil-users mailing list

Reply via email to