diff --git a/.github/workflows/build-docker-base.yml b/.github/workflows/build-docker-base.yml new file mode 100644 index 000000000..a4d98977c --- /dev/null +++ b/.github/workflows/build-docker-base.yml @@ -0,0 +1,48 @@ +name: Build Docker Base Images + +on: + workflow_dispatch: # Allow manual trigger + +permissions: {} + +jobs: + build-docker-base: + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: { persist-credentials: false } + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1 + + - name: Login to Docker Hub + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME }} + password: ${{ secrets.GHCR_TOKEN }} + + - name: Use Node.js 20 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: 20 + + - name: Build and push base2-slim image + run: npm run build-docker-base-slim + + - name: Build and push base2 image + run: npm run build-docker-base diff --git a/.github/workflows/mark-as-draft-on-requesting-changes.yml b/.github/workflows/mark-as-draft-on-requesting-changes.yml index fea29ab86..61407184c 100644 --- a/.github/workflows/mark-as-draft-on-requesting-changes.yml +++ b/.github/workflows/mark-as-draft-on-requesting-changes.yml @@ -1,56 +1,65 @@ name: Mark PR as draft when changes are requested -on: - pull_request_review: - types: [submitted] - - pull_request: - types: [labeled] +# pull_request_target is safe here because: +# 1. Does not use any external actions; only uses the GitHub CLI via run commands +# 2. Has minimal permissions +# 3. Doesn't checkout or execute any untrusted code from PRs +# 4. Only adds/removes labels or changes the draft status +on: # zizmor: ignore[dangerous-triggers] + pull_request_target: + types: + - review_submitted + - labeled + - ready_for_review permissions: {} jobs: mark-draft: runs-on: ubuntu-latest + permissions: + pull-requests: write if: | ( - github.event_name == 'pull_request_review' && + github.event.action == 'review_submitted' && github.event.review.state == 'changes_requested' ) || ( - github.event_name == 'pull_request' && + github.event.action == 'labeled' && github.event.label.name == 'pr:please address review comments' ) steps: - name: Add label on requested changes - if: github.event_name == 'pull_request_review' + if: github.event.review.state == 'changes_requested' env: - GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} run: | - gh issue edit "$PR_NUMBER" \ - --repo "$REPO" \ + gh issue edit "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ --add-label "pr:please address review comments" - name: Mark PR as draft env: - GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }} - PR_URL: ${{ github.event.pull_request.html_url }} - run: gh pr ready "$PR_URL" --undo || true + GH_TOKEN: ${{ github.token }} + run: | + gh pr ready "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ + --undo || true + # || true to ignore the case where the pr is already a draft + ready-for-review: runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review' + permissions: + pull-requests: write + if: github.event.action == 'ready_for_review' steps: - name: Update labels for review env: - GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} run: | - gh issue edit "$PR_NUMBER" \ - --repo "$REPO" \ + gh issue edit "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ --remove-label "pr:please address review comments" || true - gh issue edit "$PR_NUMBER" \ - --repo "$REPO" \ + gh issue edit "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ --add-label "pr:needs review" diff --git a/server/analytics/plausible-analytics.js b/server/analytics/plausible-analytics.js index a043c2464..015f63331 100644 --- a/server/analytics/plausible-analytics.js +++ b/server/analytics/plausible-analytics.js @@ -5,7 +5,7 @@ const { escape } = require("html-escaper"); * Returns a string that represents the javascript that is required to insert the Plausible Analytics script * into a webpage. * @param {string} scriptUrl the Plausible Analytics script url. - * @param {string} domainsToMonitor Domains to track seperated by a ',' to add Plausible Analytics script. + * @param {string} domainsToMonitor Domains to track separated by a ',' to add Plausible Analytics script. * @returns {string} HTML script tags to inject into page */ function getPlausibleAnalyticsScript(scriptUrl, domainsToMonitor) { diff --git a/server/model/domain_expiry.js b/server/model/domain_expiry.js index b7992575f..3502a4b08 100644 --- a/server/model/domain_expiry.js +++ b/server/model/domain_expiry.js @@ -159,20 +159,19 @@ class DomainExpiry extends BeanModel { const tld = parseTld(target); // Avoid logging for incomplete/invalid input while editing monitors. - if (!tld.domain) { - throw new TranslatableError("domain_expiry_unsupported_invalid_domain", { hostname: tld.hostname }); - } - if (!tld.publicSuffix) { - throw new TranslatableError("domain_expiry_unsupported_public_suffix", { publicSuffix: tld.publicSuffix }); - } if (tld.isIp) { throw new TranslatableError("domain_expiry_unsupported_is_ip", { hostname: tld.hostname }); } - // No one-letter public suffix exists; treat this as an incomplete/invalid input while typing. if (tld.publicSuffix.length < 2) { throw new TranslatableError("domain_expiry_public_suffix_too_short", { publicSuffix: tld.publicSuffix }); } + if (!tld.isIcann) { + throw new TranslatableError("domain_expiry_unsupported_is_icann", { + domain: tld.domain, + publicSuffix: tld.publicSuffix, + }); + } const rdap = await getRdapServer(tld.publicSuffix); if (!rdap) { diff --git a/server/monitor-types/postgres.js b/server/monitor-types/postgres.js index fb6cc9b0d..c9daf65f0 100644 --- a/server/monitor-types/postgres.js +++ b/server/monitor-types/postgres.js @@ -3,26 +3,61 @@ const { log, UP } = require("../../src/util"); const dayjs = require("dayjs"); const postgresConParse = require("pg-connection-string").parse; const { Client } = require("pg"); +const { ConditionVariable } = require("../monitor-conditions/variables"); +const { defaultStringOperators } = require("../monitor-conditions/operators"); +const { ConditionExpressionGroup } = require("../monitor-conditions/expression"); +const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator"); class PostgresMonitorType extends MonitorType { name = "postgres"; + supportsConditions = true; + conditionVariables = [new ConditionVariable("result", defaultStringOperators)]; + /** * @inheritdoc */ async check(monitor, heartbeat, _server) { - let startTime = dayjs().valueOf(); - let query = monitor.databaseQuery; // No query provided by user, use SELECT 1 if (!query || (typeof query === "string" && query.trim() === "")) { query = "SELECT 1"; } - await this.postgresQuery(monitor.databaseConnectionString, query); - heartbeat.msg = ""; - heartbeat.status = UP; - heartbeat.ping = dayjs().valueOf() - startTime; + const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null; + const hasConditions = conditions && conditions.children && conditions.children.length > 0; + + const startTime = dayjs().valueOf(); + + try { + if (hasConditions) { + // When conditions are enabled, expect a single value result + const result = await this.postgresQuerySingleValue(monitor.databaseConnectionString, query); + heartbeat.ping = dayjs().valueOf() - startTime; + + const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) }); + + if (!conditionsResult) { + throw new Error(`Query result did not meet the specified conditions (${result})`); + } + + heartbeat.status = UP; + heartbeat.msg = "Query did meet specified conditions"; + } else { + // Backwards compatible: just check connection and return row count + const result = await this.postgresQuery(monitor.databaseConnectionString, query); + heartbeat.ping = dayjs().valueOf() - startTime; + heartbeat.status = UP; + heartbeat.msg = result; + } + } catch (error) { + heartbeat.ping = dayjs().valueOf() - startTime; + // Re-throw condition errors as-is, wrap database errors + if (error.message.includes("did not meet the specified conditions")) { + throw error; + } + throw new Error(`Database connection/query failed: ${error.message}`); + } } /** @@ -76,6 +111,75 @@ class PostgresMonitorType extends MonitorType { }); }); } + + /** + * Run a query on Postgres + * @param {string} connectionString The database connection string + * @param {string} query The query to validate the database with + * @returns {Promise<(string[] | object[] | object)>} Response from + * server + */ + async postgresQuerySingleValue(connectionString, query) { + return new Promise((resolve, reject) => { + const config = postgresConParse(connectionString); + + // Fix #3868, which true/false is not parsed to boolean + if (typeof config.ssl === "string") { + config.ssl = config.ssl === "true"; + } + + if (config.password === "") { + // See https://github.com/brianc/node-postgres/issues/1927 + reject(new Error("Password is undefined.")); + return; + } + const client = new Client(config); + + client.on("error", (error) => { + log.debug(this.name, "Error caught in the error event handler."); + reject(error); + }); + + client.connect((err) => { + if (err) { + reject(err); + client.end(); + } else { + // Connected here + try { + client.query(query, (err, res) => { + if (err) { + reject(err); + } else { + // Check if we have results + if (!res.rows || res.rows.length === 0) { + reject(new Error("Query returned no results")); + return; + } + // Check if we have multiple rows + if (res.rows.length > 1) { + reject(new Error("Multiple values were found, expected only one value")); + return; + } + const firstRow = res.rows[0]; + const columnNames = Object.keys(firstRow); + // Check if we have multiple columns + if (columnNames.length > 1) { + reject(new Error("Multiple columns were found, expected only one value")); + return; + } + resolve(firstRow[columnNames[0]]); + } + client.end(); + }); + } catch (e) { + reject(e); + client.end(); + } + } + }); + }); + } } module.exports = { diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index b541a8ad8..098c07286 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -1,5 +1,5 @@