@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/bin/thub-admin.js +3 -5
  2. package/config.json +1 -6
  3. package/package.json +2 -2
  4. package/public/css/thub.css +129 -0
  5. package/public/js/config-import.js +37 -33
  6. package/public/js/copy-to-clipboard.js +21 -18
  7. package/public/js/help.js +93 -0
  8. package/public/js/list-search.js +51 -0
  9. package/public/js/live.js +287 -0
  10. package/public/js/log-viewer.js +289 -45
  11. package/public/js/remove-resource.js +5 -3
  12. package/public/js/resource-card.js +14 -13
  13. package/public/js/tooltips.js +3 -1
  14. package/scripts/install-default-config.js +1 -1
  15. package/scripts/install-target.js +2 -2
  16. package/src/api/admin.js +1 -1
  17. package/src/api/agent.js +4 -10
  18. package/src/api/resource.js +6 -11
  19. package/src/api/sse.js +13 -10
  20. package/src/config.js +16 -9
  21. package/src/db/migrations/022_add_sessions.sql +10 -0
  22. package/src/db/migrations/023_drop_artifacts.sql +4 -0
  23. package/src/dev/virtual.js +1 -1
  24. package/src/server.js +55 -21
  25. package/src/services/cleanup.js +2 -2
  26. package/src/services/jobs.js +33 -19
  27. package/src/services/list-prefs.js +4 -0
  28. package/src/services/live.js +124 -0
  29. package/src/services/logs.js +12 -14
  30. package/src/services/retention.js +5 -14
  31. package/src/services/search.js +59 -0
  32. package/src/services/session-store.js +127 -0
  33. package/src/web/routes.js +205 -34
  34. package/test/cleanup.test.js +1 -5
  35. package/test/list-prefs.test.js +6 -0
  36. package/test/live.test.js +153 -0
  37. package/test/logs.test.js +150 -0
  38. package/test/scheduler.test.js +10 -12
  39. package/test/search.test.js +97 -0
  40. package/test/session.test.js +132 -0
  41. package/test/views.test.js +30 -0
  42. package/views/admin/agents.pug +28 -10
  43. package/views/groups/list.pug +14 -1
  44. package/views/help/_agent-cli.pug +103 -0
  45. package/views/help/_agent-setup.pug +72 -0
  46. package/views/help/_ci.pug +44 -0
  47. package/views/help/_client-machines.pug +87 -0
  48. package/views/help/_client-setup.pug +122 -0
  49. package/views/help/_coordinator.pug +137 -0
  50. package/views/help/_docker.pug +124 -0
  51. package/views/help/_env.pug +127 -0
  52. package/views/help/_git.pug +130 -0
  53. package/views/help/_mixins.pug +24 -0
  54. package/views/help/_overview.pug +118 -0
  55. package/views/help/_quick-start.pug +37 -0
  56. package/views/help/_troubleshooting.pug +44 -0
  57. package/views/help/index.pug +67 -0
  58. package/views/index.pug +3 -2
  59. package/views/jobs/list.pug +8 -5
  60. package/views/jobs/show.pug +0 -10
  61. package/views/layout.pug +24 -1
  62. package/views/mixins/list-controls.pug +36 -0
  63. package/views/mixins/log-viewer.pug +39 -3
  64. package/views/mixins/resource.pug +1 -1
  65. package/views/resources/list.pug +7 -4
  66. package/src/services/artifacts.js +0 -149
@@ -0,0 +1,67 @@
1
+ extends ../layout
2
+ include _mixins
3
+
4
+ block content
5
+ -
6
+ const toc = [
7
+ { id: 'overview', label: 'Product overview' },
8
+ { id: 'use-cases', label: 'Use cases' },
9
+ { id: 'quick-start', label: 'Quick start' },
10
+ { id: 'coordinator', label: 'Coordinator setup' },
11
+ { id: 'agent-setup', label: 'Agent setup' },
12
+ { id: 'client-setup', label: 'Client setup' },
13
+ { id: 'client-machines', label: 'Client machines (HW / SW)' },
14
+ { id: 'docker', label: 'Docker registry' },
15
+ { id: 'git', label: 'Git repositories' },
16
+ { id: 'agent-cli', label: 'Agent CLI reference' },
17
+ { id: 'env', label: 'Environment variables' },
18
+ { id: 'ci', label: 'CI/CD (GitHub Actions)' },
19
+ { id: 'troubleshooting', label: 'Troubleshooting' }
20
+ ]
21
+
22
+ .row.g-4
23
+ .col-lg-3.d-none.d-lg-block
24
+ .thub-help-toc.sticky-top
25
+ input.form-control.form-control-sm.mb-3#help-filter(
26
+ type="search"
27
+ placeholder="Filter help…"
28
+ aria-label="Filter help sections"
29
+ )
30
+ nav.nav.nav-pills.flex-column#help-toc
31
+ each item in toc
32
+ a.nav-link.py-1(href=`#${item.id}` data-help-toc=item.id)= item.label
33
+ p.small.text-body-secondary.mt-3.mb-0
34
+ | Coordinator v#{coordinatorVersion} · examples use
35
+ br
36
+ code.small= coordinatorUrl
37
+
38
+ .col-lg-9
39
+ .d-flex.flex-wrap.align-items-center.justify-content-between.gap-2.mb-3
40
+ h1.h3.mb-0 TestHub help
41
+ //- Small screens have no sidebar: jump menu + filter instead.
42
+ .d-lg-none.d-flex.gap-2.w-100
43
+ select.form-select.form-select-sm#help-jump(aria-label="Jump to section")
44
+ each item in toc
45
+ option(value=item.id)= item.label
46
+ input.form-control.form-control-sm#help-filter-sm(type="search" placeholder="Filter…" aria-label="Filter help sections")
47
+ p.lead.
48
+ Everything needed to run TestHub: what it is, how to install and configure the
49
+ Coordinator, Agent and Client, how to prepare lab machines, and how jobs use Docker,
50
+ git and environment variables.
51
+ p.text-body-secondary.small#help-no-match(hidden) No section matches the filter.
52
+
53
+ include _overview
54
+ include _quick-start
55
+ include _coordinator
56
+ include _agent-setup
57
+ include _client-setup
58
+ include _client-machines
59
+ include _docker
60
+ include _git
61
+ include _agent-cli
62
+ include _env
63
+ include _ci
64
+ include _troubleshooting
65
+
66
+ block scripts
67
+ script(src="/js/help.js")
package/views/index.pug CHANGED
@@ -6,7 +6,8 @@ include mixins/updates
6
6
  block content
7
7
  h1.h4.mb-2 Overview
8
8
 
9
- .row.g-3.mb-4
9
+ //- data-live: swapped in place when it changes (public/js/live.js).
10
+ .row.g-3.mb-4(data-live="overview-stats")
10
11
  .col-md-4
11
12
  .card
12
13
  .card-body
@@ -23,7 +24,7 @@ block content
23
24
  h6.card-subtitle.mb-2.text-body-secondary Resources online
24
25
  p.display-6= `${onlineCount} / ${resources.length}`
25
26
 
26
- .row.g-3
27
+ .row.g-3(data-live="overview-cards")
27
28
  each r in resources
28
29
  .col-sm-6.col-md-4.col-lg-3
29
30
  .card.h-100(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}` class=`border-${{IDLE:'success',BUSY:'primary',OUT_OF_SERVICE:'danger',MAINTENANCE:'secondary',REGISTERED:'secondary'}[r.status] || 'secondary'}`)
@@ -11,7 +11,9 @@ block content
11
11
  button.btn.btn-sm.btn-outline-warning(type="button" data-bs-toggle="modal" data-bs-target="#reset-queue-modal") Reset queue
12
12
  button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean up database
13
13
 
14
- form.row.g-2.mb-3(method="get")
14
+ form.row.g-2.mb-3(method="get" role="search")
15
+ .col-12.col-md-5.col-lg-4
16
+ +searchBox(filters.q, 'Search jobs: id, user, resource, command, repo…', list.urlFor({ q: '' }))
15
17
  .col-auto
16
18
  select.form-select.form-select-sm(name="state" onchange="this.form.submit()")
17
19
  option(value="") any state
@@ -25,7 +27,8 @@ block content
25
27
  .col-auto.ms-auto
26
28
  +pageSizePicker(list)
27
29
 
28
- +listToolbar(list, 'jobs')
30
+ div(data-live="list-toolbar")
31
+ +listToolbar(list, 'jobs')
29
32
 
30
33
  table.table.table-hover.align-middle
31
34
  thead
@@ -39,7 +42,7 @@ block content
39
42
  +sortHeader(list, 'duration', 'Duration', 'desc')
40
43
  if canManage
41
44
  th
42
- tbody
45
+ tbody(data-live="rows")
43
46
  each j in jobs
44
47
  tr
45
48
  td: a(href=`/jobs/${j.id}`)= j.id
@@ -84,7 +87,7 @@ block content
84
87
  |
85
88
  code CANCELED
86
89
  | .
87
- p.small.text-body-secondary.mb-0 Finished jobs, logs and artifacts are not touched.
90
+ p.small.text-body-secondary.mb-0 Finished jobs and their logs are not touched.
88
91
  else
89
92
  p.mb-0 There are no active jobs — nothing to cancel.
90
93
 
@@ -129,7 +132,7 @@ block content
129
132
  onclick="document.getElementById('cleanup-before').value = this.dataset.value"
130
133
  )= preset.label
131
134
  p
132
- | Deletes finished jobs that ended before then, with their logs and artifacts (files too), and the resource, agent and group history logged before then — then compacts the database file.
135
+ | Deletes finished jobs that ended before then, with their logs, and the resource, agent and group history logged before then — then compacts the database file.
133
136
  |
134
137
  strong This cannot be undone.
135
138
  | Active jobs are never touched.
@@ -49,15 +49,5 @@ block content
49
49
  dt.col-sm-2 Message
50
50
  dd.col-sm-10= job.message
51
51
 
52
- h2.h6.mt-4#artifacts Artifacts
53
- if artifacts.length
54
- ul.list-group
55
- each a in artifacts
56
- li.list-group-item.d-flex.justify-content-between
57
- span= a.name
58
- a(href=a.url) download
59
- else
60
- p.text-body-secondary No artifacts yet.
61
-
62
52
  h2.h6.mt-4 Log
63
53
  +logViewer(job.id, jobActive)
package/views/layout.pug CHANGED
@@ -7,7 +7,9 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
7
7
  link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css")
8
8
  link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css")
9
9
  link(rel="stylesheet" href="/css/thub.css")
10
- body
10
+ //- liveTopics (routes.js): the page updates itself when these change
11
+ //- (public/js/live.js, services/live.js).
12
+ body(data-live-topics=user && liveTopics ? liveTopics : false)
11
13
  nav.navbar.navbar-expand-lg.bg-body-tertiary.border-bottom
12
14
  .container-fluid
13
15
  a.navbar-brand.d-flex.flex-column.lh-1.py-1(href="/")
@@ -42,6 +44,24 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
42
44
  li.nav-item
43
45
  a.nav-link(href="/admin/agents" class=active === 'agents' ? 'active' : '') Agents
44
46
  ul.navbar-nav
47
+ if user && liveTopics
48
+ //- Live-update status, driven by public/js/live.js.
49
+ li.nav-item.d-flex.align-items-center.me-3
50
+ span#thub-live.thub-live.small.text-body-secondary(data-state="connecting" title="Connecting to live updates…" role="status")
51
+ span.thub-live-dot(aria-hidden="true")
52
+ span.thub-live-label Live
53
+ if user
54
+ li.nav-item.d-flex.align-items-center.me-2
55
+ a.nav-link.d-flex.align-items-center(
56
+ href="/help"
57
+ class=active === 'help' ? 'active' : ''
58
+ aria-current=active === 'help' ? 'page' : false
59
+ data-bs-toggle="tooltip"
60
+ data-bs-placement="bottom"
61
+ data-bs-title="Product guide, setup and CLI reference"
62
+ )
63
+ i.bi.bi-question-circle.me-1
64
+ | Help
45
65
  if user && user.role === 'admin'
46
66
  li.nav-item.d-flex.align-items-center.me-2
47
67
  form(method="post" action="/updates/check" onsubmit="this.querySelector('button').disabled = true; this.querySelector('i').className = 'spinner-border spinner-border-sm';")
@@ -171,6 +191,9 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
171
191
  script(src="/js/toasts.js")
172
192
  script(src="/js/copy-to-clipboard.js")
173
193
  script(src="/js/confirm.js")
194
+ script(src="/js/list-search.js")
195
+ if user && liveTopics
196
+ script(src="/js/live.js")
174
197
  if user && user.role === 'admin' && updates.coordinatorPending
175
198
  script(src="/js/update-watch.js")
176
199
  block scripts
@@ -16,6 +16,38 @@ mixin pageSizePicker(list)
16
16
  each s in list.pageSizes
17
17
  option(value=list.urlFor({ size: s }) selected=list.prefs.size === s)= s === 'all' ? 'All' : s
18
18
 
19
+ //- Search box (?q=) for a list page: every word must match (routes.js,
20
+ //- services/search.js). Goes inside a GET form — the Jobs filter form, or
21
+ //- +searchForm on its own. `/` focuses it, Esc clears (public/js/list-search.js).
22
+ mixin searchBox(q, placeholder, clearUrl)
23
+ .input-group.input-group-sm.thub-search
24
+ span.input-group-text
25
+ i.bi.bi-search
26
+ input.form-control(
27
+ type="search"
28
+ name="q"
29
+ value=q
30
+ placeholder=placeholder
31
+ aria-label=placeholder
32
+ maxlength="200"
33
+ autocomplete="off"
34
+ data-list-search
35
+ data-bs-toggle="tooltip"
36
+ data-bs-title="Press Enter to search · / to focus · Esc to clear"
37
+ )
38
+ if q
39
+ a.btn.btn-outline-secondary(href=clearUrl aria-label="Clear search" data-list-search-clear)
40
+ i.bi.bi-x-lg
41
+
42
+ mixin searchForm(q, placeholder, clearUrl)
43
+ form.m-0(method="get" role="search")
44
+ +searchBox(q, placeholder, clearUrl)
45
+
46
+ //- "matching “foo”" after a list's count, when a search is active.
47
+ mixin searchNote(q)
48
+ if q
49
+ | matching “#{q}”
50
+
19
51
  //- Shown above the table: "Showing 26–50 of 312 jobs" and the page links.
20
52
  mixin listToolbar(list, noun)
21
53
  - const p = list.pagination
@@ -25,6 +57,10 @@ mixin listToolbar(list, noun)
25
57
  | Showing #{p.from}–#{p.to} of #{p.total} #{noun}
26
58
  else
27
59
  | No #{noun}
60
+ +searchNote(list.q)
61
+ if list.q
62
+ | ·
63
+ a(href=list.urlFor({ q: '' })) show all
28
64
  if p.pages > 1
29
65
  +pager(list)
30
66
 
@@ -1,12 +1,48 @@
1
1
  mixin logViewer(jobId, jobActive)
2
- .mb-2
3
- select#stream-filter.form-select.form-select-sm.w-auto.d-inline-block
2
+ //- Stream filter + search (public/js/log-viewer.js): matches are
3
+ //- highlighted; Enter / Shift+Enter or the arrows step through them.
4
+ //- The log loads from its end; earlier lines load as you scroll up.
5
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-2
6
+ select#stream-filter.form-select.form-select-sm.w-auto(aria-label="Log stream")
4
7
  option(value="") all streams
5
8
  option(value="uart") uart
6
9
  option(value="runner") runner
7
10
  option(value="flash") flash
8
11
  option(value="emulator") emulator
9
- pre#log-viewer.bg-body-tertiary.p-3.rounded(
12
+ .input-group.input-group-sm.thub-search(role="search")
13
+ span.input-group-text
14
+ i.bi.bi-search
15
+ input#log-search.form-control(
16
+ type="search"
17
+ placeholder="Search log…"
18
+ aria-label="Search log"
19
+ autocomplete="off"
20
+ maxlength="200"
21
+ data-bs-toggle="tooltip"
22
+ data-bs-title="Enter: next match · Shift+Enter: previous · Esc: clear"
23
+ )
24
+ span#log-search-count.input-group-text.small(aria-live="polite" hidden)
25
+ button#log-search-prev.btn.btn-outline-secondary(type="button" aria-label="Previous match" disabled)
26
+ i.bi.bi-chevron-up
27
+ button#log-search-next.btn.btn-outline-secondary(type="button" aria-label="Next match" disabled)
28
+ i.bi.bi-chevron-down
29
+ .form-check.form-check-inline.small.mb-0
30
+ input#log-search-only.form-check-input(type="checkbox")
31
+ label.form-check-label(for="log-search-only") Display only matching lines
32
+ a.btn.btn-sm.btn-outline-secondary.ms-auto(
33
+ href=`/jobs/${jobId}/log.txt`
34
+ target="_blank"
35
+ rel="noopener"
36
+ data-bs-toggle="tooltip"
37
+ data-bs-title="Open the full log as plain text in a new tab"
38
+ )
39
+ i.bi.bi-file-earmark-text.me-1
40
+ | Raw
41
+ //- "Showing the last 1,000 of 5,321 lines · Load all" (log-viewer.js).
42
+ .d-flex.flex-wrap.align-items-center.gap-2.small.text-body-secondary.mb-1(aria-live="polite")
43
+ span#log-info Loading log…
44
+ button#log-load-all.btn.btn-link.btn-sm.p-0.align-baseline(type="button" hidden) Load all
45
+ pre#log-viewer.bg-body-tertiary.p-3.rounded.position-relative(
10
46
  style="height: 420px; overflow-y: auto;"
11
47
  data-job-id=jobId
12
48
  data-job-active=jobActive ? 'true' : 'false'
@@ -322,7 +322,7 @@ mixin resourceCard(r, groupsById, returnTo)
322
322
  ) Connected USB devices
323
323
 
324
324
  .tab-content
325
- .tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0")
325
+ .tab-pane.fade.show.active(id=`rc-${r.id}-details` role="tabpanel" aria-labelledby=`rc-${r.id}-details-tab` tabindex="0" data-live=`rc-details-${r.id}`)
326
326
  +resourceDetails(r, groupsById, returnTo, local, external)
327
327
  +configPanes(r, returnTo)
328
328
  .tab-pane.fade(id=`rc-${r.id}-usb` role="tabpanel" aria-labelledby=`rc-${r.id}-usb-tab` tabindex="0")
@@ -6,15 +6,18 @@ include ../mixins/list-controls
6
6
 
7
7
  block content
8
8
  h1.h4.mb-3 Resources
9
- p.text-body-secondary.small.
9
+ .d-flex.flex-wrap.align-items-center.gap-2.mb-3
10
+ +updatesToolbar('client', 'clients', '/resources')
11
+ p.text-body-secondary.small: i.
10
12
  Resources register themselves — start a Client with the shared join key and it appears here. Click a resource for its details, including IP addresses.
11
13
 
12
14
  .d-flex.flex-wrap.align-items-center.gap-2.mb-3
13
- +updatesToolbar('client', 'clients', '/resources')
15
+ +searchForm(list.q, 'Search resources: name, label, group, status, IP…', list.urlFor({ q: '' }))
14
16
  .ms-auto
15
17
  +pageSizePicker(list)
16
18
 
17
- +listToolbar(list, 'resources')
19
+ div(data-live="list-toolbar")
20
+ +listToolbar(list, 'resources')
18
21
 
19
22
  table.table.table-hover.align-middle
20
23
  thead
@@ -29,7 +32,7 @@ block content
29
32
  +sortHeader(list, 'heartbeat', 'Last heartbeat', 'desc')
30
33
  if user.role === 'admin'
31
34
  th Actions
32
- tbody
35
+ tbody(data-live="rows")
33
36
  each r in resources
34
37
  tr(role="button" tabindex="0" data-resource-card=`resource-card-${r.id}`)
35
38
  td= r.name
@@ -1,149 +0,0 @@
1
- /**
2
- * @file packages/coordinator/src/services/artifacts.js
3
- * @description Job artifact storage: saves uploaded files and generates download links
4
- *
5
- * @author Andrian Yablonskyy
6
- * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
- *
8
- * This file is part of TestHub and is proprietary and confidential.
9
- * Unauthorized copying, modification, distribution, or use of this file,
10
- * via any medium, is strictly prohibited without prior written permission
11
- * from AdSystem.PRO.
12
- */
13
-
14
- 'use strict';
15
-
16
- const fs = require('node:fs'),
17
- path = require('node:path'),
18
- crypto = require('node:crypto');
19
-
20
- // §3.1 / §12: stores uploaded result files under artifacts/<jobId>/ and
21
- // generates HMAC-signed, time-limited download links (no server-side
22
- // session needed for the Agent/CI to fetch them).
23
- function createArtifactsService(db, { config }){
24
- const secret = config.sessionSecret;
25
-
26
- function jobDir(jobId){
27
- const dir = path.join(config.artifactsDir, jobId);
28
- fs.mkdirSync(dir, { recursive: true });
29
- return dir;
30
- }
31
-
32
- function sha256File(filePath){
33
- const hash = crypto.createHash('sha256');
34
- hash.update(fs.readFileSync(filePath));
35
- return hash.digest('hex');
36
- }
37
-
38
- // `files` are multer file objects already written to a temp path.
39
- function storeUploaded(jobId, files){
40
- const dir = jobDir(jobId),
41
- stored = [];
42
- for (const file of files){
43
- const dest = path.join(dir, path.basename(file.originalname));
44
- fs.renameSync(file.path, dest);
45
- const sha256 = sha256File(dest),
46
- size = fs.statSync(dest).size,
47
- info = db
48
- .prepare(
49
- `INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
50
- VALUES (?, ?, ?, ?, ?, ?, ?)`
51
- )
52
- .run(jobId, file.originalname, dest, size, sha256, file.mimetype, new Date().toISOString());
53
- stored.push(get(info.lastInsertRowid));
54
- }
55
- return stored;
56
- }
57
-
58
- function storeGenerated(jobId, name, buffer, contentType){
59
- const dir = jobDir(jobId),
60
- dest = path.join(dir, name);
61
- fs.writeFileSync(dest, buffer);
62
- const info = db
63
- .prepare(
64
- `INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
65
- VALUES (?, ?, ?, ?, ?, ?, ?)`
66
- )
67
- .run(jobId, name, dest, buffer.length, sha256File(dest), contentType, new Date().toISOString());
68
- return get(info.lastInsertRowid);
69
- }
70
-
71
- function get(id){
72
- return db.prepare('SELECT * FROM artifacts WHERE id = ?').get(id);
73
- }
74
-
75
- function listForJob(jobId){
76
- return db.prepare('SELECT * FROM artifacts WHERE job_id = ? ORDER BY id ASC').all(jobId);
77
- }
78
-
79
- function sign(artifactId, ttlHours = config.artifacts.linkTtlHours){
80
- const expiresAt = Date.now() + ttlHours * 3600 * 1000,
81
- payload = `${artifactId}.${expiresAt}`,
82
- mac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
83
- return `${payload}.${mac}`;
84
- }
85
-
86
- function verify(token){
87
- const [artifactId, expiresAt, mac] = String(token).split('.');
88
- if (!artifactId || !expiresAt || !mac){
89
- throw invalidLink();
90
- }
91
- const payload = `${artifactId}.${expiresAt}`,
92
- expectedMac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
93
- if (!crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expectedMac))){
94
- throw invalidLink();
95
- }
96
- if (Date.now() > Number(expiresAt)){
97
- throw invalidLink('Link expired');
98
- }
99
- const artifact = get(Number(artifactId));
100
- if (!artifact){
101
- throw invalidLink();
102
- }
103
- return artifact;
104
- }
105
-
106
- function invalidLink(message = 'Invalid or expired link'){
107
- return Object.assign(new Error(message), { status: 403 });
108
- }
109
-
110
- function signedUrl(artifact){
111
- return `${config.publicUrl}/artifacts/download/${sign(artifact.id)}`;
112
- }
113
-
114
- // Removes a job's artifact files from disk (not the DB rows — the
115
- // caller, jobs.js's cleanHistory, deletes those as part of the same
116
- // transaction that also deletes the job itself).
117
- function deleteJobArtifacts(jobId){
118
- fs.rmSync(path.join(config.artifactsDir, jobId), { recursive: true, force: true });
119
- }
120
-
121
- function purgeOlderThan(days){
122
- const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString(),
123
- stale = db
124
- .prepare(
125
- `SELECT a.* FROM artifacts a JOIN jobs j ON j.id = a.job_id
126
- WHERE j.finished_at IS NOT NULL AND j.finished_at <= ?`
127
- )
128
- .all(cutoff);
129
- for (const artifact of stale){
130
- fs.rmSync(artifact.path, { force: true });
131
- db.prepare('DELETE FROM artifacts WHERE id = ?').run(artifact.id);
132
- }
133
- }
134
-
135
- return {
136
- storeUploaded,
137
- storeGenerated,
138
- get,
139
- listForJob,
140
- sign,
141
- verify,
142
- signedUrl,
143
- purgeOlderThan,
144
- deleteJobArtifacts,
145
- jobDir
146
- };
147
- }
148
-
149
- module.exports = { createArtifactsService };