@openneuro/server 4.41.1 → 4.43.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/server",
3
- "version": "4.41.1",
3
+ "version": "4.43.0",
4
4
  "description": "Core service for the OpenNeuro platform.",
5
5
  "license": "MIT",
6
6
  "main": "src/server.js",
@@ -21,7 +21,7 @@
21
21
  "@elastic/elasticsearch": "8.13.1",
22
22
  "@graphql-tools/schema": "^10.0.0",
23
23
  "@keyv/redis": "^4.5.0",
24
- "@openneuro/search": "^4.41.1",
24
+ "@openneuro/search": "^4.43.0",
25
25
  "@sentry/node": "^8.25.0",
26
26
  "@sentry/profiling-node": "^8.25.0",
27
27
  "base64url": "^3.0.0",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "11f72adfa4edde68dc840b22522a5742af88d5ea"
92
+ "gitHead": "4744c0f70be2518a5660efdd54fa874f79cbe57f"
93
93
  }
@@ -7,17 +7,30 @@ import { getDatasetWorker } from "../libs/datalad-service"
7
7
  import CacheItem, { CacheType } from "../cache/item"
8
8
  import { redis } from "../libs/redis"
9
9
 
10
- export const getDraftRevision = async (datasetId) => {
10
+ // Draft info resolver
11
+ type DraftInfo = {
12
+ ref: string
13
+ hexsha: string // Duplicate of ref for backwards compatibility
14
+ tree: string
15
+ message: string
16
+ modified: Date
17
+ }
18
+
19
+ export const getDraftRevision = async (datasetId): Promise<string> => {
20
+ const { hexsha } = await getDraftInfo(datasetId)
21
+ return hexsha
22
+ }
23
+
24
+ export const getDraftInfo = async (datasetId) => {
11
25
  const cache = new CacheItem(redis, CacheType.draftRevision, [datasetId], 10)
12
- return cache.get(async (_doNotCache): Promise<string> => {
26
+ return cache.get(async (_doNotCache): Promise<DraftInfo> => {
13
27
  const draftUrl = `http://${
14
28
  getDatasetWorker(
15
29
  datasetId,
16
30
  )
17
31
  }/datasets/${datasetId}/draft`
18
32
  const response = await fetch(draftUrl)
19
- const { hexsha } = await response.json()
20
- return hexsha
33
+ return await response.json()
21
34
  })
22
35
  }
23
36
 
@@ -89,7 +89,7 @@ export const getFiles = (datasetId, treeish): Promise<[DatasetFile?]> => {
89
89
  const cache = new CacheItem(redis, CacheType.commitFiles, [
90
90
  datasetId,
91
91
  treeish.substring(0, 7),
92
- ])
92
+ ], 432000)
93
93
  return cache.get(
94
94
  async (doNotCache): Promise<[DatasetFile?]> => {
95
95
  const response = await fetch(
@@ -222,7 +222,12 @@ export const getSnapshot = (
222
222
  datasetId,
223
223
  )
224
224
  }/datasets/${datasetId}/snapshots/${commitRef}`
225
- const cache = new CacheItem(redis, CacheType.snapshot, [datasetId, commitRef])
225
+ const cache = new CacheItem(
226
+ redis,
227
+ CacheType.snapshot,
228
+ [datasetId, commitRef],
229
+ 432000,
230
+ )
226
231
  return cache.get(() =>
227
232
  request
228
233
  .get(url)
@@ -281,7 +286,7 @@ export const downloadFiles = (datasetId, tag) => {
281
286
  const downloadCache = new CacheItem(redis, CacheType.snapshotDownload, [
282
287
  datasetId,
283
288
  tag,
284
- ])
289
+ ], 432000)
285
290
  // Return an existing cache object if we have one
286
291
  return downloadCache.get(async () => {
287
292
  // If not, fetch all trees sequentially and cache the result (hopefully some or all trees are cached)
@@ -1,5 +1,9 @@
1
+ import config from "../../config"
2
+ import { generateDataladCookie } from "../../libs/authentication/jwt"
3
+ import { getDatasetWorker } from "../../libs/datalad-service"
4
+ import { redlock } from "../../libs/redis"
1
5
  import FileCheck from "../../models/fileCheck"
2
- import { checkDatasetAdmin } from "../permissions"
6
+ import { checkDatasetAdmin, checkDatasetWrite } from "../permissions"
3
7
 
4
8
  export const updateFileCheck = async (
5
9
  obj,
@@ -15,3 +19,41 @@ export const updateFileCheck = async (
15
19
  .lean()
16
20
  .exec()
17
21
  }
22
+
23
+ export const fsckUrl = (datasetId) => {
24
+ return `http://${
25
+ getDatasetWorker(
26
+ datasetId,
27
+ )
28
+ }/datasets/${datasetId}/fsck`
29
+ }
30
+
31
+ export const fsckDataset = async (_, { datasetId }, { user, userInfo }) => {
32
+ // Anonymous users can't trigger fsck
33
+ try {
34
+ await checkDatasetWrite(datasetId, user, userInfo)
35
+ } catch {
36
+ return false
37
+ }
38
+ try {
39
+ // Lock for 30 minutes to avoid stacking fsck requests
40
+ await redlock.lock(`openneuro:fsck-local-lock:${datasetId}`, 1800000)
41
+ const response = await fetch(fsckUrl(datasetId), {
42
+ method: "POST",
43
+ body: JSON.stringify({}),
44
+ headers: {
45
+ cookie: generateDataladCookie(config)(userInfo),
46
+ },
47
+ })
48
+ if (response.status === 200) {
49
+ return true
50
+ } else {
51
+ return false
52
+ }
53
+ } catch (err) {
54
+ // Backend unavailable or lock failed
55
+ if (err) {
56
+ return false
57
+ }
58
+ }
59
+ }
@@ -1,4 +1,4 @@
1
- import { checkDatasetWrite } from "../permissions.js"
1
+ import { checkDatasetRead, checkDatasetWrite } from "../permissions.js"
2
2
  import { generateRepoToken } from "../../libs/authentication/jwt"
3
3
  import { getDatasetEndpoint } from "../../libs/datalad-service.js"
4
4
 
@@ -16,9 +16,17 @@ export async function prepareRepoAccess(
16
16
  { datasetId }: { datasetId: string },
17
17
  { user, userInfo }: { user: string; userInfo: Record<string, unknown> },
18
18
  ): Promise<{ token: string; endpoint: number }> {
19
- await checkDatasetWrite(datasetId, user, userInfo)
20
- return {
21
- token: generateRepoToken(userInfo, datasetId),
22
- endpoint: getDatasetEndpoint(datasetId),
19
+ try {
20
+ await checkDatasetWrite(datasetId, user, userInfo)
21
+ return {
22
+ token: generateRepoToken(userInfo, datasetId, false),
23
+ endpoint: getDatasetEndpoint(datasetId),
24
+ }
25
+ } catch {
26
+ await checkDatasetRead(datasetId, user, userInfo)
27
+ return {
28
+ token: generateRepoToken(userInfo, datasetId),
29
+ endpoint: getDatasetEndpoint(datasetId),
30
+ }
23
31
  }
24
32
  }
@@ -52,7 +52,7 @@ import {
52
52
  updateEventStatus,
53
53
  } from "./datasetEvents"
54
54
  import { createGitEvent } from "./gitEvents"
55
- import { updateFileCheck } from "./fileCheck"
55
+ import { fsckDataset, updateFileCheck } from "./fileCheck"
56
56
  import { updateContributors } from "../../datalad/contributors"
57
57
  import { updateWorkerTask } from "./worker"
58
58
 
@@ -91,6 +91,7 @@ const Mutation = {
91
91
  finishUpload,
92
92
  cacheClear,
93
93
  revalidate,
94
+ fsckDataset,
94
95
  prepareRepoAccess,
95
96
  reexportRemotes,
96
97
  resetDraft,
@@ -311,7 +311,7 @@ const Snapshot = {
311
311
  analytics: (snapshot) => analytics(snapshot),
312
312
  issues: (snapshot) => snapshotIssues(snapshot),
313
313
  issuesStatus: (snapshot) => issuesSnapshotStatus(snapshot),
314
- validation: (snapshot) => snapshotValidation(snapshot),
314
+ validation: snapshotValidation,
315
315
  contributors: (snapshot) => {
316
316
  const datasetId = snapshot.datasetId
317
317
  return contributors({
@@ -14,10 +14,9 @@ export const validation = async (dataset, _, { userInfo }) => {
14
14
  redis,
15
15
  CacheType.validation,
16
16
  [dataset.id, dataset.revision],
17
- // This cache is valid forever but may be large, drop inaccessed values weekly
18
- 604800,
19
17
  )
20
- return cache.get((doNotCache) => {
18
+ let validationObject
19
+ const cacheResult = await cache.get((doNotCache) => {
21
20
  return Validation.findOne({
22
21
  id: dataset.revision,
23
22
  datasetId: dataset.id,
@@ -33,9 +32,11 @@ export const validation = async (dataset, _, { userInfo }) => {
33
32
  )
34
33
  }
35
34
  if (data) {
36
- // Return with errors and warning counts appended
35
+ // Return with errors and warning counts
36
+ validationObject = data.toObject()
37
37
  return {
38
- ...data.toObject(),
38
+ id: validationObject.id,
39
+ datasetId: validationObject.datasetId,
39
40
  errors: data.issues.filter((issue) =>
40
41
  issue.severity === "error"
41
42
  ).length,
@@ -49,31 +50,41 @@ export const validation = async (dataset, _, { userInfo }) => {
49
50
  }
50
51
  })
51
52
  })
53
+ if (!cacheResult) return null
54
+ // Return the cached result along with lazy-loaded issues and codeMessages
55
+ const result = {
56
+ ...cacheResult,
57
+ issues: async () => {
58
+ if (!validationObject) {
59
+ validationObject = (await Validation.findOne({
60
+ id: dataset.revision,
61
+ datasetId: dataset.id,
62
+ }, { issues: 1 }).exec()).toObject()
63
+ }
64
+ return validationObject.issues
65
+ },
66
+ codeMessages: async () => {
67
+ if (!validationObject) {
68
+ validationObject = (await Validation.findOne({
69
+ id: dataset.revision,
70
+ datasetId: dataset.id,
71
+ }, { codeMessages: 1 }).exec()).toObject()
72
+ }
73
+ return validationObject.codeMessages
74
+ },
75
+ }
76
+ return result
52
77
  }
53
78
 
54
79
  /**
55
80
  * Snapshot issues resolver for schema validator
56
81
  */
57
- export const snapshotValidation = async (snapshot) => {
58
- const datasetId = snapshot.id.split(":")[0]
59
- const validation = await Validation.findOne({
60
- id: snapshot.hexsha,
61
- datasetId,
62
- }).exec()
63
- if (validation) {
64
- // Return with errors and warning counts appended
65
- return {
66
- ...validation.toObject(),
67
- errors: validation.issues.filter((issue) =>
68
- issue.severity === "error"
69
- ).length,
70
- warnings:
71
- validation.issues.filter((issue) => issue.severity === "warning")
72
- .length,
73
- }
74
- } else {
75
- return null
82
+ export const snapshotValidation = async (snapshot, _, context) => {
83
+ const dataset = {
84
+ id: snapshot.id.split(":")[0],
85
+ revision: snapshot.hexsha,
76
86
  }
87
+ return validation(dataset, _, context)
77
88
  }
78
89
 
79
90
  export function validationSeveritySort(a, b) {
@@ -178,6 +178,8 @@ export const typeDefs = `
178
178
  finishUpload(uploadId: ID!): Boolean
179
179
  # Drop cached data for a dataset - requires site admin access
180
180
  cacheClear(datasetId: ID!): Boolean
181
+ # Rerun fsck on a dataset if 30 minutes have passed since last request
182
+ fsckDataset(datasetId: ID!): Boolean
181
183
  # Rerun the latest validator on a given commit
182
184
  revalidate(datasetId: ID!, ref: String!): Boolean
183
185
  # Request a temporary token for git access
@@ -31,6 +31,11 @@ export const buildToken = (
31
31
  name: user.name,
32
32
  admin: user.admin,
33
33
  }
34
+ // Give anonymous reviewers a generic name/email for git operations
35
+ if (user.reviewer) {
36
+ fields.name = "Anonymous Reviewer"
37
+ fields.email = "reviewer@openneuro.org"
38
+ }
34
39
  // Allow extensions of the base token format
35
40
  if (options) {
36
41
  if (options && "scopes" in options) {
@@ -98,12 +103,16 @@ export function generateReviewerToken(
98
103
  export function generateRepoToken(
99
104
  user,
100
105
  datasetId,
106
+ readonly = true,
101
107
  expiresIn = 7 * 60 * 60 * 24,
102
108
  ) {
103
109
  const options = {
104
- scopes: ["dataset:git"],
110
+ scopes: ["dataset:git:read"],
105
111
  dataset: datasetId,
106
112
  }
113
+ if (!readonly) {
114
+ options.scopes.push("dataset:git:write")
115
+ }
107
116
  return buildToken(config, user, expiresIn, options)
108
117
  }
109
118
 
package/src/sentry.ts CHANGED
@@ -21,4 +21,8 @@ Sentry.init({
21
21
  }
22
22
  return event
23
23
  },
24
+ ignoreErrors: [
25
+ "You do not have access to read this dataset.",
26
+ "You do not have access to modify this dataset.",
27
+ ],
24
28
  })