@openneuro/server 4.41.0 → 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.0",
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.0",
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": "5b3c9b202998ada4f890ffb09575dbd874ff2866"
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
  })
@@ -220,30 +220,59 @@ export const updateContributorsUtil = async (
220
220
  let dataciteData = await getDataciteYml(datasetId)
221
221
  if (!dataciteData) dataciteData = await emptyDataciteYml({ datasetId })
222
222
 
223
- const contributorsCopy: RawDataciteContributor[] = newContributors.map(
224
- (c) => ({
225
- name: c.name,
226
- givenName: c.givenName || "",
227
- familyName: c.familyName || "",
228
- order: c.order ?? null,
229
- nameType: "Personal" as const,
230
- nameIdentifiers: c.orcid
231
- ? [{
232
- nameIdentifier: `https://orcid.org/${c.orcid}`,
233
- nameIdentifierScheme: "ORCID",
234
- schemeUri: "https://orcid.org",
235
- }]
236
- : [],
237
- contributorType: c.contributorType || "Researcher",
238
- }),
239
- )
223
+ //
224
+ // 1. Build contributors (full form, includes `order`)
225
+ //
226
+ const contributorsCopy: RawDataciteContributor[] = newContributors.map((
227
+ c,
228
+ index,
229
+ ) => ({
230
+ name: c.name,
231
+ givenName: c.givenName || "",
232
+ familyName: c.familyName || "",
233
+ nameType: "Personal" as const,
234
+ nameIdentifiers: c.orcid
235
+ ? [{
236
+ nameIdentifier: `https://orcid.org/${
237
+ c.orcid.replace(/^https?:\/\/orcid\.org\//, "")
238
+ }`,
239
+ nameIdentifierScheme: "ORCID",
240
+ schemeUri: "https://orcid.org",
241
+ }]
242
+ : [],
243
+ contributorType: c.contributorType || "Researcher",
244
+ order: index + 1,
245
+ }))
240
246
 
241
247
  dataciteData.data.attributes.contributors = contributorsCopy
242
- dataciteData.data.attributes.creators = contributorsCopy.map((
243
- { contributorType: _, ...rest },
244
- ) => rest)
245
248
 
246
- await saveDataciteYmlToRepo(datasetId, userId, dataciteData)
249
+ //
250
+ // 2. Build creators (strictly filtered / no empty fields / no order)
251
+ //
252
+ type RawDataciteCreator = Omit<
253
+ RawDataciteContributor,
254
+ "contributorType" | "order"
255
+ >
256
+
257
+ const creators: RawDataciteCreator[] = contributorsCopy.map((c) => {
258
+ const creator: RawDataciteCreator = {
259
+ name: c.name,
260
+ nameType: "Personal",
261
+ }
262
+
263
+ if (c.givenName?.trim()) creator.givenName = c.givenName
264
+ if (c.familyName?.trim()) creator.familyName = c.familyName
265
+ if (c.nameIdentifiers?.length) creator.nameIdentifiers = c.nameIdentifiers
266
+
267
+ return creator
268
+ })
269
+
270
+ dataciteData.data.attributes.creators = creators
271
+
272
+ //
273
+ // 3. Save and commit once
274
+ //
275
+ const gitRef = await saveDataciteYmlToRepo(datasetId, userId, dataciteData)
247
276
 
248
277
  return {
249
278
  draft: {
@@ -252,5 +281,6 @@ export const updateContributorsUtil = async (
252
281
  files: [],
253
282
  modified: new Date().toISOString(),
254
283
  },
284
+ gitRef: gitRef.id,
255
285
  }
256
286
  }