@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 +3 -3
- package/src/datalad/draft.ts +17 -4
- package/src/datalad/files.ts +1 -1
- package/src/datalad/snapshots.ts +7 -2
- package/src/graphql/resolvers/fileCheck.ts +43 -1
- package/src/graphql/resolvers/git.ts +13 -5
- package/src/graphql/resolvers/mutation.ts +2 -1
- package/src/graphql/resolvers/snapshots.ts +1 -1
- package/src/graphql/resolvers/validation.ts +35 -24
- package/src/graphql/schema.ts +2 -0
- package/src/libs/authentication/jwt.ts +10 -1
- package/src/sentry.ts +4 -0
- package/src/utils/datacite-utils.ts +51 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openneuro/server",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
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": "
|
|
92
|
+
"gitHead": "4744c0f70be2518a5660efdd54fa874f79cbe57f"
|
|
93
93
|
}
|
package/src/datalad/draft.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
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
|
-
|
|
20
|
-
return hexsha
|
|
33
|
+
return await response.json()
|
|
21
34
|
})
|
|
22
35
|
}
|
|
23
36
|
|
package/src/datalad/files.ts
CHANGED
|
@@ -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(
|
package/src/datalad/snapshots.ts
CHANGED
|
@@ -222,7 +222,12 @@ export const getSnapshot = (
|
|
|
222
222
|
datasetId,
|
|
223
223
|
)
|
|
224
224
|
}/datasets/${datasetId}/snapshots/${commitRef}`
|
|
225
|
-
const cache = new CacheItem(
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
35
|
+
// Return with errors and warning counts
|
|
36
|
+
validationObject = data.toObject()
|
|
37
37
|
return {
|
|
38
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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) {
|
package/src/graphql/schema.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
:
|
|
237
|
-
|
|
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
|
-
|
|
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
|
}
|