Merge "WebLinks: Clarify that file path must be provided without leading slash"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 9ace97d3..139d6f8 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -535,6 +535,13 @@
 +
 By default, `false`.
 
+[[auth.cookieHttpOnly]]auth.cookieHttpOnly::
++
+Sets "httpOnly" flag of the authentication cookie. If `true`, cookie
+values can't be accessed by client side scripts.
++
+By default, `false`.
+
 [[auth.emailFormat]]auth.emailFormat::
 +
 Optional format string to construct user email addresses out of
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index c6e5623..4e60a30 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6985,11 +6985,11 @@
 |=================================
 |Field Name          ||Description
 |`patch`             |required|
-|`allow_conflicts`    |optional|
-If true, tolerate conflicts and add conflict markers where required.
 The patch to be applied. Must be compatible with `git diff` output.
 For example, link:#get-patch[Get Patch] output.
 The patch must be provided as UTF-8 text, either directly or base64-encoded.
+|`allow_conflicts`    |optional|
+If true, tolerate conflicts and add conflict markers where required.
 |=================================
 
 [[applypatchpatchset-input]]
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 9625039..a92d4f0 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -265,10 +265,10 @@
       outCookie.setDomain(domain);
     }
 
-    outCookie.setSecure(isSecure(request));
     outCookie.setPath(path);
     outCookie.setMaxAge(ageSeconds);
-    outCookie.setSecure(authConfig.getCookieSecure());
+    outCookie.setSecure(authConfig.getCookieSecure() && isSecure(request));
+    outCookie.setHttpOnly(authConfig.getCookieHttpOnly());
     response.addCookie(outCookie);
   }
 
diff --git a/java/com/google/gerrit/httpd/XsrfCookieFilter.java b/java/com/google/gerrit/httpd/XsrfCookieFilter.java
index 079efa4..1ec2649 100644
--- a/java/com/google/gerrit/httpd/XsrfCookieFilter.java
+++ b/java/com/google/gerrit/httpd/XsrfCookieFilter.java
@@ -65,6 +65,7 @@
     Cookie c = new Cookie(XsrfConstants.XSRF_COOKIE_NAME, nullToEmpty(v));
     c.setPath("/");
     c.setSecure(authConfig.getCookieSecure() && isSecure(req));
+    c.setHttpOnly(authConfig.getCookieHttpOnly());
     c.setMaxAge(
         v != null
             ? -1 // Set the cookie for this browser session.
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index b6ffcee..e180428 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -62,6 +62,7 @@
   private final String cookiePath;
   private final String cookieDomain;
   private final boolean cookieSecure;
+  private final boolean cookieHttpOnly;
   private final SignedToken emailReg;
   private final boolean allowRegisterNewEmail;
   private final boolean userNameCaseInsensitive;
@@ -91,6 +92,7 @@
     cookiePath = cfg.getString("auth", null, "cookiepath");
     cookieDomain = cfg.getString("auth", null, "cookiedomain");
     cookieSecure = cfg.getBoolean("auth", "cookiesecure", false);
+    cookieHttpOnly = cfg.getBoolean("auth", "cookiehttponly", false);
     trustContainerAuth = cfg.getBoolean("auth", "trustContainerAuth", false);
     enableRunAs = cfg.getBoolean("auth", null, "enableRunAs", true);
     gitBasicAuthPolicy = getBasicAuthPolicy(cfg);
@@ -218,6 +220,10 @@
     return cookieSecure;
   }
 
+  public boolean getCookieHttpOnly() {
+    return cookieHttpOnly;
+  }
+
   public SignedToken getEmailRegistrationToken() {
     return emailReg;
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 51f1629..abd8a23 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {customElement, property, state} from 'lit/decorators.js';
 import {Action, NOT_USEFUL, USEFUL} from '../../api/checks';
 import {assertIsDefined} from '../../utils/common-util';
 import {resolve} from '../../models/dependency';
@@ -24,6 +24,9 @@
   @property({type: String, reflect: true})
   icon?: string;
 
+  @state()
+  clicked = false;
+
   private getChecksModel = resolve(this, checksModelToken);
 
   override connectedCallback() {
@@ -81,7 +84,9 @@
 
   private renderName() {
     if (!this.icon) return html`${this.action.name}`;
-    return html` <gr-icon icon=${this.icon}></gr-icon> `;
+    return html`
+      <gr-icon ?filled=${this.clicked} icon=${this.icon}></gr-icon>
+    `;
   }
 
   private renderTooltip() {
@@ -94,7 +99,13 @@
   }
 
   handleClick(e: Event) {
-    e.stopPropagation();
+    if (this.action.name === USEFUL || this.action.name === NOT_USEFUL) {
+      this.clicked = true;
+    } else {
+      // For useful clicks the parent wants to receive the click for changing
+      // the "Was this helpful?" label.
+      e.stopPropagation();
+    }
     this.getChecksModel().triggerAction(this.action, undefined, this.context);
   }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 8958deb..1752da0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -645,6 +645,9 @@
   @property({type: Boolean})
   hideCodePointers = false;
 
+  @state()
+  notUsefulLabel = 'Was this helpful?';
+
   private getChangeModel = resolve(this, changeModelToken);
 
   private readonly getViewModel = resolve(this, changeViewModelToken);
@@ -785,9 +788,15 @@
     if (!useful || !notUseful) return;
     return html`
       <div class="useful">
-        <div class="title">Was this helpful?</div>
-        <gr-checks-action .action=${useful}></gr-checks-action>
-        <gr-checks-action .action=${notUseful}></gr-checks-action>
+        <div class="title">${this.notUsefulLabel}</div>
+        <gr-checks-action
+          @click=${() => (this.notUsefulLabel = 'Thanks!')}
+          .action=${useful}
+        ></gr-checks-action>
+        <gr-checks-action
+          @click=${() => (this.notUsefulLabel = 'Sorry about that')}
+          .action=${notUseful}
+        ></gr-checks-action>
       </div>
     `;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index b57e4ff..d183211 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -126,7 +126,7 @@
         this.repoCommentLinks = repoCommentLinks;
         // Always linkify URLs starting with https?://
         this.repoCommentLinks['ALWAYS_LINK_HTTP'] = {
-          match: '(https?://((?!&(gt|lt|amp|quot|apos);)\\S)+[\\w/~-])',
+          match: '(https?://((?!&(gt|lt|quot|apos);)\\S)+[\\w/~-])',
           link: '$1',
           enabled: true,
         };
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 23f1594..c1b38d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -235,6 +235,11 @@
       await checkLinking('https://www.google.com/');
       await checkLinking('https://www.google.com/asdf~');
       await checkLinking('https://www.google.com/asdf-');
+      await checkLinking('https://www.google.com/asdf-');
+      // matches & part as well, even we first linkify and then htmlEscape
+      await checkLinking(
+        'https://google.com/traces/list?project=gerrit&tid=123'
+      );
     });
   });
 
@@ -710,6 +715,10 @@
       await checkLinking('http://www.google.com');
       await checkLinking('https://www.google.com');
       await checkLinking('https://www.google.com/');
+      // matches & part as well, even we first linkify and then htmlEscape
+      await checkLinking(
+        'https://google.com/traces/list?project=gerrit&tid=123'
+      );
     });
 
     suite('user suggest fix', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index e9c132a..d27bd2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -9,22 +9,16 @@
 import {PluginApi} from '../../../api/plugin';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {readJSONResponsePayload} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {
+  readJSONResponsePayload,
+  throwingErrorCallback,
+} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 async function getErrorMessage(response: Response): Promise<string> {
   const text = await response.text();
   return text || `${response.status}`;
 }
 
-// This is an internal error, that must never be visible outside of this
-// file. It is used only inside GrPluginRestApi.send method. See detailed
-// explanation in the GrPluginRestApi.send method.
-class ResponseError extends Error {
-  public constructor(readonly response: Response) {
-    super();
-  }
-}
-
 export class GrPluginRestApi implements RestPluginApi {
   constructor(
     private readonly restApi: RestApiService,
@@ -120,53 +114,31 @@
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'send');
-    // Plugins typically don't want Gerrit to show error dialogs for failed
-    // requests. So we are defining a default errFn here, even if it is not
-    // explicitly set by the caller.
-    // TODO: We are soon getting rid of the `errFn` altogether. There are only
-    // 2 known usages of errFn in plugins: delete-project and verify-status.
-    errFn =
-      errFn ??
-      ((response: Response | null | undefined, error?: Error) => {
-        if (error) throw error;
-        // Some plugins show an error message if send is failed, smth like:
-        // pluginApi.send(...).catch(err => showError(err));
-        // The response can contain an error text, but getting this text is
-        // an asynchronous operation. At the same time, the errFn must be a
-        // synchronous function.
-        // As a workaround, we throw an ResponseError here and then catch
-        // it inside a catch block below and read the message.
-        if (response) throw new ResponseError(response);
-        throw new Error('Generic REST API error.');
-      });
-    return this.fetch(method, url, payload, errFn, contentType)
-      .then(response => {
-        // Will typically not happen. The response can only be unset, if the
-        // errFn handles the error and then returns void or undefined or null.
-        // But the errFn above always throws.
-        if (!response) {
-          throw new Error('plugin rest-api call failed');
-        }
-        // Will typically not happen. errFn will have dealt with that and the
-        // caller will get a rejected promise already.
-        if (response.status < 200 || response.status >= 300) {
-          return getErrorMessage(response).then(msg =>
-            Promise.reject(new Error(msg))
-          );
-        } else {
-          return readJSONResponsePayload(response).then(
-            obj => obj.parsed
-          ) as Promise<T>;
-        }
-      })
-      .catch(err => {
-        if (err instanceof ResponseError) {
-          return getErrorMessage(err.response).then(msg => {
-            throw new Error(msg);
-          });
-        }
-        throw err;
-      });
+    return this.fetch(
+      method,
+      url,
+      payload,
+      errFn ?? throwingErrorCallback,
+      contentType
+    ).then(response => {
+      // Will typically not happen. The response can only be unset, if the
+      // errFn handles the error and then returns void or undefined or null.
+      // But the errFn above always throws.
+      if (!response) {
+        throw new Error('plugin rest-api call failed');
+      }
+      // Will typically not happen. errFn will have dealt with that and the
+      // caller will get a rejected promise already.
+      if (response.status < 200 || response.status >= 300) {
+        return getErrorMessage(response).then(msg =>
+          Promise.reject(new Error(msg))
+        );
+      } else {
+        return readJSONResponsePayload(response).then(
+          obj => obj.parsed
+        ) as Promise<T>;
+      }
+    });
   }
 
   get<T>(url: string) {
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
index 05bef49..27948fd 100644
--- a/polygerrit-ui/app/embed/gr-textarea.ts
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -173,6 +173,8 @@
 
   private focused = false;
 
+  private currentCursorPosition = -1;
+
   private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
 
   static override get styles() {
@@ -488,6 +490,19 @@
       event.preventDefault();
       this.fire('saveShortcut');
     }
+    // Prevent looping of cursor position when CTRL+ARROW_LEFT/ARROW_RIGHT is
+    // pressed.
+    if (event.ctrlKey || event.metaKey || event.altKey) {
+      if (event.key === 'ArrowLeft' && this.currentCursorPosition === 0) {
+        event.preventDefault();
+      }
+      if (
+        event.key === 'ArrowRight' &&
+        this.currentCursorPosition === (this.value?.length ?? 0)
+      ) {
+        event.preventDefault();
+      }
+    }
     await this.toggleHintVisibilityIfAny();
   }
 
@@ -597,7 +612,9 @@
   }
 
   private onCursorPositionChange() {
-    this.fire('cursorPositionChange', {position: this.getCursorPosition()});
+    const cursorPosition = this.getCursorPosition();
+    this.fire('cursorPositionChange', {position: cursorPosition});
+    this.currentCursorPosition = cursorPosition;
   }
 
   private async updateValueInDom() {
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
index b701dcb..d125d2f 100644
--- a/polygerrit-ui/app/embed/gr-textarea_test.ts
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -232,4 +232,56 @@
 
     assert.equal(element.value, oldValue + hint);
   });
+
+  test('when cursor is at end, Mod + ArrowRight does not change cursor position', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+    const value = 'Hola amigos';
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+    await element.updateComplete;
+    element.value = value;
+    await element.putCursorAtEnd();
+    await element.updateComplete;
+
+    editableDiv.dispatchEvent(
+      new KeyboardEvent('keydown', {key: 'ArrowRight', metaKey: true})
+    );
+    await element.updateComplete;
+    await rafPromise();
+
+    assert.equal(cursorPosition, value.length);
+  });
+
+  test('when cursor is at 0, Mod + ArrowLeft does not change cursor position', async () => {
+    const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+    let cursorPosition = -1;
+    const value = 'Hola amigos';
+    const editableDiv = element.shadowRoot!.querySelector(
+      '.editableDiv'
+    ) as HTMLDivElement;
+    element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+      const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+        .detail;
+      cursorPosition = detail.position;
+    });
+    await element.updateComplete;
+    element.value = value;
+    element.setCursorPosition(0);
+    await element.updateComplete;
+
+    editableDiv.dispatchEvent(
+      new KeyboardEvent('keydown', {key: 'ArrowLeft', metaKey: true})
+    );
+    await element.updateComplete;
+    await rafPromise();
+
+    assert.equal(cursorPosition, 0);
+  });
 });
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts
index be11278..f6fe242 100644
--- a/polygerrit-ui/app/models/checks/checks-fakes.ts
+++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -5,6 +5,7 @@
  */
 import {
   Action,
+  ActionResult,
   Category,
   Link,
   LinkIcon,
@@ -185,13 +186,25 @@
       actions: [
         {
           name: 'useful',
+          tooltip: 'This check result was helpful',
           callback: () =>
-            Promise.resolve({message: 'fake "useful report" triggered'}),
+            new Promise(resolve => {
+              setTimeout(
+                () => resolve({message: 'Feedback recorded.'} as ActionResult),
+                1000
+              );
+            }),
         },
         {
           name: 'not-useful',
+          tooltip: 'This check result was not helpful',
           callback: () =>
-            Promise.resolve({message: 'fake "not useful report" triggered'}),
+            new Promise(resolve => {
+              setTimeout(
+                () => resolve({message: 'Feedback recorded.'} as ActionResult),
+                1000
+              );
+            }),
         },
       ],
       fixes: [