threadListAvatars: dynamic unauthorized forums

This is an implementation of point 3 in the "Idea" section of the
following doc: go/eu7T9m (public link available in the linked bug).

Paraphrasing the previous document:
This change adds code to maintain a list of forums from which the
extension couldn't load any thread, which probably correspond to private
forums. When a forum is in this list, avatars are no longer retrieved
from threads in that forum until the entry expires.

Bug: 2
Change-Id: I05e7b02818181f410855948e1af6ec75fc7c792b
diff --git a/src/common/api.js b/src/common/api.js
index 065d84c..b2075c3 100644
--- a/src/common/api.js
+++ b/src/common/api.js
@@ -2,7 +2,9 @@
 
 // Function to wrap calls to the Community Console API with intelligent error
 // handling.
-export function CCApi(method, data, authenticated, authuser = 0) {
+export function CCApi(
+    method, data, authenticated, authuser = 0,
+    returnUnauthorizedStatus = false) {
   var authuserPart =
       authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser);
 
@@ -29,12 +31,24 @@
       })
       .then(res => {
         if (res.status == 400) {
+          // If the canonicalCode is PERMISSION_DENIED:
+          if (returnUnauthorizedStatus && res.body?.[2] == 7)
+            return {
+              unauthorized: true,
+            };
+
           throw new Error(
               res.body[4] ||
               ('Response status 400 for method ' + method + '. ' +
                'Error code: ' + (res.body[2] ?? 'unknown')));
         }
 
+        if (returnUnauthorizedStatus)
+          return {
+            unauthorized: false,
+            body: res.body,
+          };
+
         return res.body;
       });
 }
diff --git a/src/contentScripts/communityConsole/avatars.js b/src/contentScripts/communityConsole/avatars.js
index 3b27333..5e5eeee 100644
--- a/src/contentScripts/communityConsole/avatars.js
+++ b/src/contentScripts/communityConsole/avatars.js
@@ -58,7 +58,9 @@
   // the thread belongs to a known private forum.
   shouldRetrieveAvatars(thread) {
     return this.getPrivateForums().then(privateForums => {
-      return !privateForums.includes(thread.forum);
+      if (privateForums.includes(thread.forum)) return false;
+
+      return this.db.isForumUnauthorized(thread.forum).then(res => !res);
     });
   }
 
@@ -90,8 +92,16 @@
                // Due to the fact that we have to call this endpoint
                // anonymously, this means we can't retrieve information about
                // threads in private forums.
-               /* authentication = */ false)
-        .then(data => {
+               /* authentication = */ false, /* authuser = */ 0,
+               /* returnUnauthorizedStatus = */ true)
+        .then(response => {
+          if (response.unauthorized)
+            return this.db.putUnauthorizedForum(thread.forum).then(() => {
+              throw new Error('Permission denied to load thread.');
+            });
+
+          var data = response.body;
+
           var numMessages = data?.['1']?.['8'];
           if (numMessages === undefined)
             throw new Error(
diff --git a/src/contentScripts/communityConsole/utils/AvatarsDB.js b/src/contentScripts/communityConsole/utils/AvatarsDB.js
index b59460d..a8c155c 100644
--- a/src/contentScripts/communityConsole/utils/AvatarsDB.js
+++ b/src/contentScripts/communityConsole/utils/AvatarsDB.js
@@ -3,11 +3,14 @@
 const dbName = 'TWPTAvatarsDB';
 const threadListLoadEvent = 'TWPT_ViewForumResponse';
 // Time after the last use when a cache entry should be deleted (in s):
-const expirationTime = 4 * 24 * 60 * 60;  // 4 days
+const cacheExpirationTime = 4 * 24 * 60 * 60;  // 4 days
 // Probability of running the piece of code to remove unused cache entries after
 // loading the thread list.
 const probRemoveUnusedCacheEntries = 0.10;  // 10%
 
+// Time after which an unauthorized forum entry expires (in s).
+const unauthorizedForumExpirationTime = 1 * 24 * 60 * 60;  // 1 day
+
 export default class AvatarsDB {
   constructor() {
     this.dbPromise = undefined;
@@ -38,6 +41,7 @@
       });
   }
 
+  // avatarsCache methods:
   getCacheEntry(threadId) {
     return this.dbPromise.then(db => db.get('avatarsCache', threadId));
   }
@@ -55,7 +59,7 @@
     return this.dbPromise
         .then(db => {
           var upperBoundTimestamp =
-              Math.floor(Date.now() / 1000) - expirationTime;
+              Math.floor(Date.now() / 1000) - cacheExpirationTime;
           var range = IDBKeyRange.upperBound(upperBoundTimestamp);
 
           var tx = db.transaction('avatarsCache', 'readwrite');
@@ -127,4 +131,30 @@
         });
     });
   }
+
+  // unauthorizedForums methods:
+  isForumUnauthorized(forumId) {
+    return this.dbPromise.then(db => db.get('unauthorizedForums', forumId))
+        .then(entry => {
+          if (entry === undefined) return false;
+
+          var now = Math.floor(Date.now() / 1000);
+          if (entry.expirationTimestamp > now) return true;
+
+          this.invalidateUnauthorizedForum(forumId);
+          return false;
+        });
+  }
+
+  putUnauthorizedForum(forumId) {
+    return this.dbPromise.then(db => db.put('unauthorizedForums', {
+      forumId,
+      expirationTimestamp:
+          Math.floor(Date.now() / 1000) + unauthorizedForumExpirationTime,
+    }));
+  }
+
+  invalidateUnauthorizedForum(forumId) {
+    return this.dbPromise.then(db => db.delete('unauthorizedForums', forumId));
+  }
 };