@openneuro/server 4.47.7 → 5.0.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 +10 -7
- package/src/app.ts +1 -1
- package/src/cache/__tests__/tree.spec.ts +212 -0
- package/src/cache/tree.ts +148 -0
- package/src/datalad/__tests__/dataRetentionNotifications.spec.ts +11 -0
- package/src/datalad/__tests__/files.spec.ts +249 -0
- package/src/datalad/dataRetentionNotifications.ts +5 -0
- package/src/datalad/dataset.ts +29 -1
- package/src/datalad/files.ts +362 -39
- package/src/datalad/snapshots.ts +29 -54
- package/src/graphql/resolvers/__tests__/response-status.spec.ts +42 -0
- package/src/graphql/resolvers/build-search-query.ts +391 -0
- package/src/graphql/resolvers/cache.ts +5 -1
- package/src/graphql/resolvers/dataset-search.ts +40 -23
- package/src/graphql/resolvers/datasetEvents.ts +48 -78
- package/src/graphql/resolvers/draft.ts +5 -2
- package/src/graphql/resolvers/holdDeletion.ts +21 -0
- package/src/graphql/resolvers/index.ts +6 -0
- package/src/graphql/resolvers/mutation.ts +2 -0
- package/src/graphql/resolvers/response-status.ts +43 -0
- package/src/graphql/resolvers/snapshots.ts +9 -18
- package/src/graphql/resolvers/summary.ts +17 -0
- package/src/graphql/schema.ts +54 -14
- package/src/handlers/datalad.ts +4 -0
- package/src/handlers/doi.ts +32 -36
- package/src/libs/doi/__tests__/doi.spec.ts +50 -12
- package/src/libs/doi/__tests__/validate.spec.ts +110 -0
- package/src/libs/doi/index.ts +108 -71
- package/src/libs/doi/metadata.ts +101 -0
- package/src/libs/doi/validate.ts +59 -0
- package/src/libs/presign.ts +137 -0
- package/src/models/dataset.ts +2 -0
- package/src/models/doi.ts +7 -0
- package/src/queues/producer-methods.ts +9 -5
- package/src/queues/queue-schedule.ts +1 -1
- package/src/queues/queues.ts +2 -2
- package/src/routes.ts +10 -2
- package/src/types/datacite/LICENSE +37 -0
- package/src/types/datacite/README.md +3 -0
- package/src/types/datacite/datacite-v4.5.json +643 -0
- package/src/types/datacite/datacite-v4.5.ts +281 -0
- package/src/types/datacite.ts +53 -63
- package/src/utils/datacite-mapper.ts +7 -3
- package/src/utils/datacite-utils.ts +12 -15
- package/src/libs/doi/__tests__/__snapshots__/doi.spec.ts.snap +0 -17
|
@@ -3,11 +3,25 @@ import {
|
|
|
3
3
|
computeTotalSize,
|
|
4
4
|
decodeFilePath,
|
|
5
5
|
encodeFilePath,
|
|
6
|
+
entryToDatasetFile,
|
|
6
7
|
fileUrl,
|
|
8
|
+
getFileName,
|
|
9
|
+
parseS3Url,
|
|
10
|
+
workerFileToEntry,
|
|
7
11
|
} from "../files"
|
|
12
|
+
import type { TreeEntry } from "../../cache/tree"
|
|
8
13
|
|
|
9
14
|
vi.mock("ioredis")
|
|
10
15
|
vi.mock("../../config.ts")
|
|
16
|
+
vi.mock("../../libs/redis", () => ({ redis: {} }))
|
|
17
|
+
vi.mock("../../libs/presign", () => ({
|
|
18
|
+
getPresignedUrl: vi.fn().mockResolvedValue(
|
|
19
|
+
"https://s3.amazonaws.com/bucket/key?presigned=true",
|
|
20
|
+
),
|
|
21
|
+
publicS3Url: vi.fn().mockReturnValue(
|
|
22
|
+
"https://s3.amazonaws.com/bucket/key?versionId=abc123",
|
|
23
|
+
),
|
|
24
|
+
}))
|
|
11
25
|
|
|
12
26
|
const filename = "sub-01/anat/sub-01_T1w.nii.gz"
|
|
13
27
|
|
|
@@ -49,4 +63,239 @@ describe("datalad files", () => {
|
|
|
49
63
|
expect(computeTotalSize(mockFileSizes)).toBe(1957206)
|
|
50
64
|
})
|
|
51
65
|
})
|
|
66
|
+
describe("entryToDatasetFile()", () => {
|
|
67
|
+
it("returns a directory entry", async () => {
|
|
68
|
+
const entry: TreeEntry = {
|
|
69
|
+
n: "sub-01",
|
|
70
|
+
h: "abc123",
|
|
71
|
+
s: 0,
|
|
72
|
+
k: "",
|
|
73
|
+
v: "",
|
|
74
|
+
b: "",
|
|
75
|
+
p: false,
|
|
76
|
+
d: true,
|
|
77
|
+
}
|
|
78
|
+
const result = await entryToDatasetFile(entry, "ds000001")
|
|
79
|
+
expect(result).toEqual({
|
|
80
|
+
id: "abc123",
|
|
81
|
+
filename: "sub-01",
|
|
82
|
+
directory: true,
|
|
83
|
+
size: 0,
|
|
84
|
+
urls: [],
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
it("returns a presigned URL for private files", async () => {
|
|
88
|
+
const entry: TreeEntry = {
|
|
89
|
+
n: "sub-01_T1w.nii.gz",
|
|
90
|
+
h: "def456",
|
|
91
|
+
s: 12345,
|
|
92
|
+
k: "datasets/ds000001/sub-01_T1w.nii.gz",
|
|
93
|
+
v: "ver1",
|
|
94
|
+
b: "private-bucket",
|
|
95
|
+
p: true,
|
|
96
|
+
d: false,
|
|
97
|
+
}
|
|
98
|
+
const result = await entryToDatasetFile(entry, "ds000001")
|
|
99
|
+
expect(result).toEqual({
|
|
100
|
+
id: "def456",
|
|
101
|
+
filename: "sub-01_T1w.nii.gz",
|
|
102
|
+
directory: false,
|
|
103
|
+
size: 12345,
|
|
104
|
+
urls: ["https://s3.amazonaws.com/bucket/key?presigned=true"],
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
it("returns a public S3 URL for public files with S3 keys", async () => {
|
|
108
|
+
const entry: TreeEntry = {
|
|
109
|
+
n: "README",
|
|
110
|
+
h: "ghi789",
|
|
111
|
+
s: 500,
|
|
112
|
+
k: "datasets/ds000001/README",
|
|
113
|
+
v: "ver2",
|
|
114
|
+
b: "",
|
|
115
|
+
p: false,
|
|
116
|
+
d: false,
|
|
117
|
+
}
|
|
118
|
+
const result = await entryToDatasetFile(entry, "ds000001")
|
|
119
|
+
expect(result).toEqual({
|
|
120
|
+
id: "ghi789",
|
|
121
|
+
filename: "README",
|
|
122
|
+
directory: false,
|
|
123
|
+
size: 500,
|
|
124
|
+
urls: ["https://s3.amazonaws.com/bucket/key?versionId=abc123"],
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
it("falls back to server object URL when no S3 key/version", async () => {
|
|
128
|
+
process.env.CRN_SERVER_URL = "https://openneuro.org"
|
|
129
|
+
const entry: TreeEntry = {
|
|
130
|
+
n: "sub-01_T1w.nii.gz",
|
|
131
|
+
h: "jkl012",
|
|
132
|
+
s: 9999,
|
|
133
|
+
k: "",
|
|
134
|
+
v: "",
|
|
135
|
+
b: "",
|
|
136
|
+
p: false,
|
|
137
|
+
d: false,
|
|
138
|
+
}
|
|
139
|
+
const result = await entryToDatasetFile(entry, "ds000001")
|
|
140
|
+
expect(result).toEqual({
|
|
141
|
+
id: "jkl012",
|
|
142
|
+
filename: "sub-01_T1w.nii.gz",
|
|
143
|
+
directory: false,
|
|
144
|
+
size: 9999,
|
|
145
|
+
urls: [
|
|
146
|
+
"https://openneuro.org/crn/datasets/ds000001/objects/jkl012?filename=sub-01_T1w.nii.gz",
|
|
147
|
+
],
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
describe("parseS3Url()", () => {
|
|
152
|
+
it("parses a standard S3 URL with versionId", () => {
|
|
153
|
+
expect(
|
|
154
|
+
parseS3Url(
|
|
155
|
+
"https://s3.amazonaws.com/openneuro.org/datasets/ds000001/sub-01_T1w.nii.gz?versionId=abc123",
|
|
156
|
+
),
|
|
157
|
+
).toEqual({
|
|
158
|
+
bucket: "openneuro.org",
|
|
159
|
+
s3Key: "datasets/ds000001/sub-01_T1w.nii.gz",
|
|
160
|
+
versionId: "abc123",
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
it("returns empty versionId when missing", () => {
|
|
164
|
+
const result = parseS3Url(
|
|
165
|
+
"https://s3.amazonaws.com/openneuro.org/datasets/ds000001/README",
|
|
166
|
+
)
|
|
167
|
+
expect(result).toEqual({
|
|
168
|
+
bucket: "openneuro.org",
|
|
169
|
+
s3Key: "datasets/ds000001/README",
|
|
170
|
+
versionId: "",
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
it("decodes percent-encoded path components", () => {
|
|
174
|
+
const result = parseS3Url(
|
|
175
|
+
"https://s3.amazonaws.com/bucket/path%20with%20spaces/file%2B1.txt?versionId=v1",
|
|
176
|
+
)
|
|
177
|
+
expect(result).toEqual({
|
|
178
|
+
bucket: "bucket",
|
|
179
|
+
s3Key: "path with spaces/file+1.txt",
|
|
180
|
+
versionId: "v1",
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
it("returns null for invalid URLs", () => {
|
|
184
|
+
expect(parseS3Url("not-a-url")).toBeNull()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
describe("workerFileToEntry()", () => {
|
|
188
|
+
it("converts a directory file", () => {
|
|
189
|
+
const file = {
|
|
190
|
+
id: "tree-hash",
|
|
191
|
+
filename: "sub-01",
|
|
192
|
+
directory: true,
|
|
193
|
+
size: 0,
|
|
194
|
+
urls: [],
|
|
195
|
+
}
|
|
196
|
+
expect(workerFileToEntry(file, false)).toEqual({
|
|
197
|
+
n: "sub-01",
|
|
198
|
+
h: "tree-hash",
|
|
199
|
+
s: 0,
|
|
200
|
+
k: "",
|
|
201
|
+
v: "",
|
|
202
|
+
b: "",
|
|
203
|
+
p: false,
|
|
204
|
+
d: true,
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
it("converts a public file with S3 URL", () => {
|
|
208
|
+
const file = {
|
|
209
|
+
id: "blob-hash",
|
|
210
|
+
filename: "README",
|
|
211
|
+
directory: false,
|
|
212
|
+
size: 500,
|
|
213
|
+
urls: [
|
|
214
|
+
"https://s3.amazonaws.com/openneuro.org/datasets/ds000001/README?versionId=ver2",
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
const result = workerFileToEntry(file, false)
|
|
218
|
+
expect(result).toEqual({
|
|
219
|
+
n: "README",
|
|
220
|
+
h: "blob-hash",
|
|
221
|
+
s: 500,
|
|
222
|
+
k: "datasets/ds000001/README",
|
|
223
|
+
v: "ver2",
|
|
224
|
+
b: "openneuro.org",
|
|
225
|
+
p: false,
|
|
226
|
+
d: false,
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
it("stores empty bucket when it matches the default bucket", () => {
|
|
230
|
+
const originalBucket = process.env.AWS_S3_PUBLIC_BUCKET
|
|
231
|
+
process.env.AWS_S3_PUBLIC_BUCKET = "openneuro.org"
|
|
232
|
+
const file = {
|
|
233
|
+
id: "blob-hash",
|
|
234
|
+
filename: "README",
|
|
235
|
+
directory: false,
|
|
236
|
+
size: 500,
|
|
237
|
+
urls: [
|
|
238
|
+
"https://s3.amazonaws.com/openneuro.org/datasets/ds000001/README?versionId=ver2",
|
|
239
|
+
],
|
|
240
|
+
}
|
|
241
|
+
const result = workerFileToEntry(file, false)
|
|
242
|
+
expect(result.b).toBe("")
|
|
243
|
+
process.env.AWS_S3_PUBLIC_BUCKET = originalBucket
|
|
244
|
+
})
|
|
245
|
+
it("sets presign flag from needsPresign parameter", () => {
|
|
246
|
+
const file = {
|
|
247
|
+
id: "blob-hash",
|
|
248
|
+
filename: "data.nii.gz",
|
|
249
|
+
directory: false,
|
|
250
|
+
size: 1000,
|
|
251
|
+
urls: [
|
|
252
|
+
"https://s3.amazonaws.com/private-bucket/data.nii.gz?versionId=v1",
|
|
253
|
+
],
|
|
254
|
+
}
|
|
255
|
+
expect(workerFileToEntry(file, true).p).toBe(true)
|
|
256
|
+
expect(workerFileToEntry(file, false).p).toBe(false)
|
|
257
|
+
})
|
|
258
|
+
it("handles a file with no URLs", () => {
|
|
259
|
+
const file = {
|
|
260
|
+
id: "blob-hash",
|
|
261
|
+
filename: "data.nii.gz",
|
|
262
|
+
directory: false,
|
|
263
|
+
size: 1000,
|
|
264
|
+
urls: [],
|
|
265
|
+
}
|
|
266
|
+
const result = workerFileToEntry(file, false)
|
|
267
|
+
expect(result.k).toBe("")
|
|
268
|
+
expect(result.v).toBe("")
|
|
269
|
+
expect(result.b).toBe("")
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
describe("fileUrl() with revision", () => {
|
|
273
|
+
it("includes the revision in the URL path", () => {
|
|
274
|
+
expect(
|
|
275
|
+
fileUrl("ds000001", "", "README", "abc123def"),
|
|
276
|
+
).toBe(
|
|
277
|
+
"http://datalad-0/datasets/ds000001/snapshots/abc123def/files/README",
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
it("encodes nested paths with a revision", () => {
|
|
281
|
+
expect(
|
|
282
|
+
fileUrl("ds000001", "sub-01/anat", "sub-01_T1w.nii.gz", "abc123def"),
|
|
283
|
+
).toBe(
|
|
284
|
+
"http://datalad-0/datasets/ds000001/snapshots/abc123def/files/sub-01:anat:sub-01_T1w.nii.gz",
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
describe("getFileName()", () => {
|
|
289
|
+
it("joins path and filename", () => {
|
|
290
|
+
expect(getFileName("sub-01/anat", "sub-01_T1w.nii.gz")).toBe(
|
|
291
|
+
"sub-01:anat:sub-01_T1w.nii.gz",
|
|
292
|
+
)
|
|
293
|
+
})
|
|
294
|
+
it("returns encoded filename when path is empty", () => {
|
|
295
|
+
expect(getFileName("", "README")).toBe("README")
|
|
296
|
+
})
|
|
297
|
+
it("returns encoded path when filename is empty", () => {
|
|
298
|
+
expect(getFileName("sub-01/anat", "")).toBe("sub-01:anat")
|
|
299
|
+
})
|
|
300
|
+
})
|
|
52
301
|
})
|
|
@@ -2,6 +2,7 @@ import config from "../config"
|
|
|
2
2
|
import notifications from "../libs/notifications"
|
|
3
3
|
import User from "../models/user"
|
|
4
4
|
import Permission from "../models/permission"
|
|
5
|
+
import Dataset from "../models/dataset"
|
|
5
6
|
import DataRetention from "../models/dataRetention"
|
|
6
7
|
import Deletion from "../models/deletion"
|
|
7
8
|
import { getDraftInfo } from "./draft"
|
|
@@ -45,6 +46,10 @@ export async function checkDataRetentionNotifications(
|
|
|
45
46
|
const deleted = await Deletion.findOne({ datasetId }).exec()
|
|
46
47
|
if (deleted) return
|
|
47
48
|
|
|
49
|
+
// Skip datasets with deletion hold set by an admin
|
|
50
|
+
const dataset = await Dataset.findOne({ id: datasetId }).exec()
|
|
51
|
+
if (dataset?.holdDeletion) return
|
|
52
|
+
|
|
48
53
|
const draft = await getDraftInfo(datasetId)
|
|
49
54
|
const snapshots = await getSnapshots(datasetId)
|
|
50
55
|
const lastSnapshot = snapshots?.length
|
package/src/datalad/dataset.ts
CHANGED
|
@@ -26,6 +26,8 @@ import BadAnnexObject from "../models/badAnnexObject"
|
|
|
26
26
|
import { datasetsConnection } from "./pagination"
|
|
27
27
|
import { getDatasetWorker } from "../libs/datalad-service"
|
|
28
28
|
import { createEvent, updateEvent } from "../libs/events"
|
|
29
|
+
import Doi from "../models/doi"
|
|
30
|
+
import { hideDoi, publishDoi } from "../libs/doi/index"
|
|
29
31
|
|
|
30
32
|
export const giveUploaderPermission = (datasetId, userId) => {
|
|
31
33
|
const permission = new Permission({ datasetId, userId, level: "admin" })
|
|
@@ -490,7 +492,7 @@ export const flagAnnexObject = (
|
|
|
490
492
|
}
|
|
491
493
|
|
|
492
494
|
/**
|
|
493
|
-
* Update public state
|
|
495
|
+
* Update public state and transition DOI states accordingly.
|
|
494
496
|
*/
|
|
495
497
|
export async function updatePublic(datasetId, publicFlag, user) {
|
|
496
498
|
const event = await createEvent(
|
|
@@ -503,6 +505,32 @@ export async function updatePublic(datasetId, publicFlag, user) {
|
|
|
503
505
|
{ public: publicFlag, publishDate: new Date() },
|
|
504
506
|
).exec()
|
|
505
507
|
await updateEvent(event)
|
|
508
|
+
|
|
509
|
+
// Transition DOI states
|
|
510
|
+
try {
|
|
511
|
+
if (publicFlag) {
|
|
512
|
+
// Draft transition to Findable for all DOIs on this dataset
|
|
513
|
+
const draftDois = await Doi.find({ datasetId, state: "draft" })
|
|
514
|
+
for (const record of draftDois) {
|
|
515
|
+
await publishDoi(record.doi)
|
|
516
|
+
record.state = "findable"
|
|
517
|
+
await record.save()
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
// Findable transition to Registered when unpublishing
|
|
521
|
+
const findableDois = await Doi.find({
|
|
522
|
+
datasetId,
|
|
523
|
+
state: { $in: ["findable", null] },
|
|
524
|
+
})
|
|
525
|
+
for (const record of findableDois) {
|
|
526
|
+
await hideDoi(record.doi)
|
|
527
|
+
record.state = "registered"
|
|
528
|
+
await record.save()
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
} catch (err) {
|
|
532
|
+
Sentry.captureException(err)
|
|
533
|
+
}
|
|
506
534
|
}
|
|
507
535
|
|
|
508
536
|
export const getDatasetAnalytics = (datasetId, _tag) => {
|