forked from IQSS/dataverse
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAccess.java
More file actions
2384 lines (2059 loc) · 117 KB
/
Copy pathAccess.java
File metadata and controls
2384 lines (2059 loc) · 117 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/
package edu.harvard.iq.dataverse.api;
import edu.harvard.iq.dataverse.*;
import edu.harvard.iq.dataverse.api.auth.AuthRequired;
import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.DataverseRole;
import edu.harvard.iq.dataverse.authorization.Permission;
import edu.harvard.iq.dataverse.authorization.RoleAssignee;
import edu.harvard.iq.dataverse.authorization.users.ApiToken;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.GuestUser;
import edu.harvard.iq.dataverse.authorization.users.User;
import edu.harvard.iq.dataverse.dataaccess.*;
import edu.harvard.iq.dataverse.datavariable.DataVariable;
import edu.harvard.iq.dataverse.datavariable.VariableServiceBean;
import edu.harvard.iq.dataverse.dataverse.featured.DataverseFeaturedItem;
import edu.harvard.iq.dataverse.dataverse.featured.DataverseFeaturedItemServiceBean;
import edu.harvard.iq.dataverse.engine.command.Command;
import edu.harvard.iq.dataverse.engine.command.DataverseRequest;
import edu.harvard.iq.dataverse.engine.command.exception.CommandException;
import edu.harvard.iq.dataverse.engine.command.impl.*;
import edu.harvard.iq.dataverse.export.DDIExportServiceBean;
import edu.harvard.iq.dataverse.makedatacount.MakeDataCountLoggingServiceBean;
import edu.harvard.iq.dataverse.makedatacount.MakeDataCountLoggingServiceBean.MakeDataCountEntry;
import edu.harvard.iq.dataverse.mydata.Pager;
import edu.harvard.iq.dataverse.settings.JvmSettings;
import edu.harvard.iq.dataverse.settings.SettingsServiceBean;
import edu.harvard.iq.dataverse.util.*;
import edu.harvard.iq.dataverse.util.json.JsonParseException;
import edu.harvard.iq.dataverse.util.json.JsonUtil;
import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder;
import jakarta.ejb.EJB;
import jakarta.inject.Inject;
import jakarta.json.*;
import jakarta.persistence.TypedQuery;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.*;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataParam;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import static edu.harvard.iq.dataverse.api.Datasets.handleVersion;
import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json;
import static jakarta.ws.rs.core.Response.Status.*;
/*
Custom API exceptions [NOT YET IMPLEMENTED]
import edu.harvard.iq.dataverse.api.exceptions.NotFoundException;
import edu.harvard.iq.dataverse.api.exceptions.ServiceUnavailableException;
import edu.harvard.iq.dataverse.api.exceptions.PermissionDeniedException;
import edu.harvard.iq.dataverse.api.exceptions.AuthorizationRequiredException;
*/
/**
*
* @author Leonid Andreev
*
* The data (file) access API is based on the DVN access API v.1.0 (that came
* with the v.3.* of the DVN app) and extended for DVN 4.0 to include some
* extra fancy functionality, such as subsetting individual columns in tabular
* data files and more.
*/
@Path("access")
@Tag(name = "Access", description = "Download files, bundles, citations, metadata, and access-related file assets.")
public class Access extends AbstractApiBean {
private static final Logger logger = Logger.getLogger(Access.class.getCanonicalName());
@EJB
DataFileServiceBean dataFileService;
@EJB
DatasetServiceBean datasetService;
@EJB
DatasetVersionServiceBean versionService;
@EJB
DataverseServiceBean dataverseService;
@EJB
VariableServiceBean variableService;
@EJB
SettingsServiceBean settingsService;
@EJB
SystemConfig systemConfig;
@EJB
DDIExportServiceBean ddiExportService;
@EJB
PermissionServiceBean permissionService;
@Inject
DataverseSession session;
@Inject
DataverseRequestServiceBean dvRequestService;
@EJB
GuestbookResponseServiceBean guestbookResponseService;
@EJB
DataverseRoleServiceBean roleService;
@EJB
UserNotificationServiceBean userNotificationService;
@EJB
FileDownloadServiceBean fileDownloadService;
@EJB
AuxiliaryFileServiceBean auxiliaryFileService;
@Inject
PermissionsWrapper permissionsWrapper;
@Inject
MakeDataCountLoggingServiceBean mdcLogService;
@Inject
DataverseFeaturedItemServiceBean dataverseFeaturedItemServiceBean;
private static final String DEFAULT_BUNDLE_NAME = "dataverse_files.zip";
private static final int GUESTBOOK_RESPONSE_SIGNEDURL_TIMEOUT_MINUTES = 1;
//@EJB
@GET
@AuthRequired
@Path("datafile/{fileId}/citation/{format}")
public Response datafileCitation(@Context ContainerRequestContext crc,
@PathParam("fileId") String fileId,
@PathParam("format") String formatString) {
DataCitation.Format format = DataCitation.getFormat(formatString);
if (format == null) {
return badRequest(BundleUtil.getStringFromBundle("datasets.api.citation.invalidFormat"));
}
DataFile df = findDataFileOrDieWrapper(fileId);
// This will throw a ForbiddenException if access isn't authorized:
checkAuthorization(crc, df);
String dataCitationFormatted = (new DataCitation(df.getFileMetadata())).toString(format, true, false);
return Response.ok().type(DataCitation.getCitationFormatMediaType(format, true)).entity(dataCitationFormatted).build();
}
// TODO:
// versions? -- L.A. 4.0 beta 10
@GET
@AuthRequired
@Path("datafile/bundle/{fileId}")
@Produces({"application/zip"})
@Operation(summary = "Build a file bundle",
description = "Streams a ZIP bundle for a data file, including citation exports and optional tabular metadata when available.")
@SecurityRequirement(name = "DataverseApiKey")
public BundleDownloadInstance datafileBundle(@Context ContainerRequestContext crc,
@Parameter(description = "Data file id or persistent identifier for the bundle.", required = true)
@PathParam("fileId") String fileId,
@Parameter(description = "File metadata id used to select a specific file metadata record.")
@QueryParam("fileMetadataId") Long fileMetadataId,
@Parameter(description = "Whether guestbook records have already been written for this download.")
@QueryParam("gbrecs") boolean gbrecs,
@Parameter(description = "Guestbook response id list supplied by the user interface.")
@QueryParam("gbrids") String gbrids,
@Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ {
DataFile df = findDataFileOrDieWrapper(fileId);
// This will throw a ForbiddenException if access isn't authorized:
checkAuthorization(crc, df);
User requestor = getRequestor(crc);
if (checkGuestbookRequiredResponse(crc, uriInfo, df, gbrids)) {
throw new BadRequestException(BundleUtil.getStringFromBundle("access.api.download.failure.guestbookResponseMissing", getGuestbookIdFromDatafile(df)));
}
if (gbrecs != true && df.isReleased()) {
// Write Guestbook record if not done previously and file is released
GuestbookResponse gbr = guestbookResponseService.initAPIGuestbookResponse(df.getOwner(), df, session, requestor);
guestbookResponseService.save(gbr);
MakeDataCountEntry entry = new MakeDataCountEntry(uriInfo, headers, dvRequestService, df);
mdcLogService.logEntry(entry);
}
DownloadInfo dInfo = new DownloadInfo(df);
BundleDownloadInstance downloadInstance = new BundleDownloadInstance(dInfo);
FileMetadata fileMetadata = null;
if (fileMetadataId == null) {
fileMetadata = df.getFileMetadata();
} else {
fileMetadata = dataFileService.findFileMetadata(fileMetadataId);
}
downloadInstance.setFileCitationEndNote(new DataCitation(fileMetadata).toEndNoteString());
downloadInstance.setFileCitationRIS(new DataCitation(fileMetadata).toRISString());
downloadInstance.setFileCitationBibtex(new DataCitation(fileMetadata).toBibtexString());
ByteArrayOutputStream outStream = null;
outStream = new ByteArrayOutputStream();
Long dfId = df.getId();
try {
ddiExportService.exportDataFile(
dfId,
outStream,
null,
null,
fileMetadataId);
downloadInstance.setFileDDIXML(outStream.toString());
} catch (Exception ex) {
// if we can't generate the DDI, it's ok;
// we'll just generate the bundle without it.
}
return downloadInstance;
}
@POST
@AuthRequired
@Path("datafile/bundle/{fileId}")
@Produces({"application/zip"})
@Operation(summary = "Submit guestbook response for a file bundle",
description = "Records the supplied guestbook response and then streams the ZIP bundle for a data file.")
@SecurityRequirement(name = "DataverseApiKey")
public BundleDownloadInstance datafileBundleWithGuestbookResponse(@Context ContainerRequestContext crc,
@Parameter(description = "Data file id or persistent identifier for the bundle.", required = true)
@PathParam("fileId") String fileId,
@Parameter(description = "File metadata id used to select a specific file metadata record.")
@QueryParam("fileMetadataId") Long fileMetadataId,
@Parameter(description = "Whether guestbook records have already been written for this download.")
@QueryParam("gbrecs") boolean gbrecs,
@Parameter(description = "Guestbook response id list supplied by the user interface.")
@QueryParam("gbrids") String gbrids,
@Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response,
@RequestBody(description = "Guestbook response JSON for the requested data file.")
String jsonBody) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ {
processDatafileWithGuestbookResponse(crc, headers, fileId, uriInfo, gbrecs, jsonBody);
// JSF UI passes the guestbook response id(s) in thus this qp can be removed when JSF is removed
if (gbrids == null || gbrids.isEmpty()) {
gbrids = (String) crc.getProperty("gbrids");
}
// There is no get for this so we shouldn't return a signed url
// return the download instance
return datafileBundle(crc, fileId, fileMetadataId, gbrecs, gbrids, uriInfo, headers, response);
}
//Added a wrapper method since the original method throws a wrapped response
//the access methods return files instead of responses so we convert to a WebApplicationException
private DataFile findDataFileOrDieWrapper(String fileId){
DataFile df = null;
try {
df = findDataFileOrDie(fileId);
} catch (WrappedResponse ex) {
logger.warning("Access: datafile service could not locate a DataFile object for id "+fileId+"!");
logger.warning(ex.getWrappedMessageWhenJson());
throw new NotFoundException();
}
return df;
}
@GET
@AuthRequired
@Path("datafile/{fileId:.+}")
@Produces({"application/xml","*/*"})
public Response datafile(@Context ContainerRequestContext crc, @PathParam("fileId") String fileId, @QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids,
@Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) /*throws NotFoundException, ServiceUnavailableException, PermissionDeniedException, AuthorizationRequiredException*/ {
fileId = normalizeFileId(fileId);
DataFile df = findDataFileOrDieWrapper(fileId);
GuestbookResponse gbr = null;
if (df.isHarvested()) {
String errorMessage = "Datafile " + fileId + " is a harvested file that cannot be accessed in this Dataverse";
throw new NotFoundException(errorMessage);
// (nobody should ever be using this API on a harvested DataFile)!
}
// This will throw a ForbiddenException if access isn't authorized:
checkAuthorization(crc, df);
User requestor = getRequestor(crc);
if (checkGuestbookRequiredResponse(crc, uriInfo, df, gbrids)) {
return error(BAD_REQUEST, BundleUtil.getStringFromBundle("access.api.download.failure.guestbookResponseMissing", getGuestbookIdFromDatafile(df)));
}
if (gbrecs != true && df.isReleased()){
// Write Guestbook record if not done previously and file is released
gbr = guestbookResponseService.initAPIGuestbookResponse(df.getOwner(), df, session, requestor);
}
DownloadInfo dInfo = new DownloadInfo(df);
logger.fine("checking if thumbnails are supported on this file.");
if (FileUtil.isThumbnailSupported(df)) {
dInfo.addServiceAvailable(new OptionalAccessService("thumbnail", "image/png", "imageThumb=true", "Image Thumbnail (64x64)"));
}
if (df.isTabularData()) {
String originalMimeType = df.getDataTable().getOriginalFileFormat();
dInfo.addServiceAvailable(new OptionalAccessService("original", originalMimeType, "format=original","Saved original (" + originalMimeType + ")"));
dInfo.addServiceAvailable(new OptionalAccessService("tabular", "text/tab-separated-values", "format=tab", "Tabular file in native format"));
dInfo.addServiceAvailable(new OptionalAccessService("R", "application/x-rlang-transport", "format=RData", "Data in R format"));
dInfo.addServiceAvailable(new OptionalAccessService("preprocessed", "application/json", "format=prep", "Preprocessed data in JSON"));
dInfo.addServiceAvailable(new OptionalAccessService("subset", "text/tab-separated-values", "variables=<LIST>", "Column-wise Subsetting"));
}
String driverId = DataAccess.getStorageDriverFromIdentifier(df.getStorageIdentifier());
if(systemConfig.isGlobusFileDownload() && (GlobusAccessibleStore.acceptsGlobusTransfers(driverId) || GlobusAccessibleStore.allowsGlobusReferences(driverId))) {
dInfo.addServiceAvailable(new OptionalAccessService("GlobusTransfer", df.getContentType(), "format=GlobusTransfer", "Download via Globus"));
}
DownloadInstance downloadInstance = new DownloadInstance(dInfo);
downloadInstance.setRequestUriInfo(uriInfo);
downloadInstance.setRequestHttpHeaders(headers);
if (gbr != null){
downloadInstance.setGbr(gbr);
downloadInstance.setDataverseRequestService(dvRequestService);
downloadInstance.setCommand(engineSvc);
}
boolean serviceRequested = false;
boolean serviceFound = false;
for (String key : uriInfo.getQueryParameters().keySet()) {
String value = uriInfo.getQueryParameters().getFirst(key);
logger.fine("is download service supported? key=" + key + ", value=" + value);
// The loop goes through all query params (e.g. including key, gbrecs, persistentId, etc. )
// So we need to identify when a service is being called and then let checkIfServiceSupportedAndSetConverter see if the required one exists
if (key.equals("imageThumb") || key.equals("format") || key.equals("variables") || key.equals("noVarHeader")) {
serviceRequested = true;
//In the dataset file table context a user is allowed to select original as the format
//for download
// if the dataset has tabular files - it should not be applied to instances
// where the file selected is not tabular see #6972
if("format".equals(key) && "original".equals(value) && !df.isTabularData()) {
serviceRequested = false;
break;
}
//Only need to check if this key is associated with a service
if (downloadInstance.checkIfServiceSupportedAndSetConverter(key, value)) {
// this automatically sets the conversion parameters in
// the download instance to key and value;
// TODO: I should probably set these explicitly instead.
logger.fine("yes!");
if (downloadInstance.getConversionParam().equals("subset")) {
String subsetParam = downloadInstance.getConversionParamValue();
String variableIdParams[] = subsetParam.split(",");
if (variableIdParams != null && variableIdParams.length > 0) {
logger.fine(variableIdParams.length + " tokens;");
for (int i = 0; i < variableIdParams.length; i++) {
logger.fine("token: " + variableIdParams[i]);
String token = variableIdParams[i].replaceFirst("^v", "");
Long variableId = null;
try {
variableId = new Long(token);
} catch (NumberFormatException nfe) {
variableId = null;
}
if (variableId != null) {
logger.fine("attempting to look up variable id " + variableId);
if (variableService != null) {
DataVariable variable = variableService.find(variableId);
if (variable != null) {
if (downloadInstance.getExtraArguments() == null) {
downloadInstance.setExtraArguments(new ArrayList<Object>());
}
logger.fine("putting variable id " + variable.getId() + " on the parameters list of the download instance.");
downloadInstance.getExtraArguments().add(variable);
// if (!variable.getDataTable().getDataFile().getId().equals(sf.getId())) {
// variableList.add(variable);
// }
}
} else {
logger.fine("variable service is null.");
}
}
}
}
}
logger.fine("downloadInstance: " + downloadInstance.getConversionParam() + "," + downloadInstance.getConversionParamValue());
serviceFound = true;
break;
}
} else {
}
}
if (serviceRequested && !serviceFound) {
// Service not supported/bad arguments, etc.:
// One could return
// a ServiceNotAvailableException. However, since the returns are all files of
// some sort, it seems reasonable, and more standard, to just return
// a NotFoundException.
throw new NotFoundException("datafile access error: requested optional service (image scaling, format conversion, etc.) is not supported on this datafile.");
} // Else - the file itself was requested or we have the info needed to invoke the service and get the derived info
logger.fine("Returning download instance");
/*
* Provide some browser-friendly headers: (?)
*/
if (headers.getRequestHeaders().containsKey("Range")) {
return Response.status(PARTIAL_CONTENT).entity(downloadInstance).build();
}
return Response.ok(downloadInstance).build();
}
@POST
@AuthRequired
@Path("datafile/{fileId:.+}")
@Produces({"application/json"})
@Operation(summary = "Submit guestbook response for a data file",
description = "Records the supplied guestbook response and returns access details for a data file download.")
@SecurityRequirement(name = "DataverseApiKey")
public Response datafileWithGuestbookResponse(@Context ContainerRequestContext crc,
@Parameter(description = "Data file id, persistent identifier, or path-style file reference.", required = true)
@PathParam("fileId") String fileId,
@Parameter(description = "Whether guestbook records have already been written for this download.")
@QueryParam("gbrecs") boolean gbrecs,
@Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response,
@RequestBody(description = "Guestbook response JSON for the requested data file.")
String jsonBody) {
fileId = normalizeFileId(fileId);
return processDatafileWithGuestbookResponse(crc, headers, fileId, uriInfo, gbrecs, jsonBody);
}
private String normalizeFileId(String fileId) {
String fId = fileId;
// check first if there's a trailing slash, and chop it:
while (fId.lastIndexOf('/') == fId.length() - 1) {
fId = fId.substring(0, fId.length() - 1);
}
// Handle persistentId by converting it back to ID
if (fileId.equals(PERSISTENT_ID_KEY)) {
DataFile file = findDataFileOrDieWrapper(fileId);
fId = String.valueOf(file.getId());
}
if (fId.indexOf('/') > -1) {
// This is for embedding folder names into the Access API URLs;
// something like /api/access/datafile/folder/subfolder/1234
// instead of the normal /api/access/datafile/1234 notation.
// this is supported only for recreating folders during recursive downloads -
// i.e. they are embedded into the URL for the remote client like wget,
// but can be safely ignored here.
fId = fId.substring(fId.lastIndexOf('/') + 1);
}
return fId;
}
// for bundle arg list
private List<String> getGuestbookIdFromDatafile(DataFile df) {
return df != null && df.getOwner() != null && df.getOwner().getGuestbook() != null ? List.of(df.getOwner().getGuestbook().getId().toString()) : List.of();
}
// Process the guestbook response from JSON and return a signedUrl to the matching GET call
private Response processDatafileWithGuestbookResponse(ContainerRequestContext crc, HttpHeaders headers, String fileIds, UriInfo uriInfo, boolean gbrecs, String jsonBody) {
User user = getRequestUser(crc);
// Get and validate all the DataFiles first
Map<Long, DataFile> datafilesMap = getDatafilesMap(crc, fileIds);
// Handle Guestbook Responses
String displayName = "";
String gbrids = null;
List<String> fileIdList = new ArrayList<>();
String id = null;
try {
// since all files must be in the same Dataset we can generate a Guestbook Response once and just replace the DataFile for each file in the list
DataFile firstDatafile = datafilesMap.values().size() > 0 ? (DataFile) Arrays.stream(datafilesMap.values().toArray()).findFirst().get() : null;
id = firstDatafile.getOwner().getId().toString();
GuestbookResponse gbr = getGuestbookResponseFromBody(firstDatafile, GuestbookResponse.DOWNLOAD, jsonBody, user);
boolean guestbookResponseRequired = checkGuestbookRequiredResponse(crc, uriInfo, firstDatafile, null);
for (DataFile df : datafilesMap.values()) {
displayName = df.getDisplayName();
fileIdList.add(String.valueOf(df.getId()));
if (guestbookResponseRequired) {
if (gbr != null) {
gbr.setDataFile(df);
guestbookResponseService.save(gbr);
gbrids = gbr.getId().toString();
MakeDataCountEntry entry = new MakeDataCountEntry(uriInfo, headers, dvRequestService, df);
mdcLogService.logEntry(entry);
} else {
return error(BAD_REQUEST, BundleUtil.getStringFromBundle("access.api.download.failure.guestbookResponseMissing", getGuestbookIdFromDatafile(df)));
}
} else if (gbrecs != true && df.isReleased()) {
// Write Guestbook record if not done previously and file is released
GuestbookResponse defaultResponse = guestbookResponseService.initAPIGuestbookResponse(df.getOwner(), df, session, user);
guestbookResponseService.save(defaultResponse);
MakeDataCountEntry entry = new MakeDataCountEntry(uriInfo, headers, dvRequestService, df);
mdcLogService.logEntry(entry);
}
}
} catch (JsonParseException ex) {
List<String> args = Arrays.asList(displayName, ex.getLocalizedMessage());
return error(BAD_REQUEST, BundleUtil.getStringFromBundle("access.api.download.failure.guestbook.commandError", args));
}
// Check if requesting datafile(s) or all files within dataset
if (!uriInfo.getPath().toLowerCase().contains("/dataset/")) {
id = String.join(",", fileIdList);
}
return returnSignedUrl(crc, uriInfo, user, id, gbrids);
}
private Map<Long, DataFile> getDatafilesMap(ContainerRequestContext crc, String fileIds) {
String fileIdParams[] = getFileIdsCSV(fileIds);
Map<Long, DataFile> datafilesMap = new HashMap<>();
Long datasetId = null;
// Get and validate all the DataFiles first
if (fileIdParams != null && fileIdParams.length > 0) {
for (int i = 0; i < fileIdParams.length; i++) {
DataFile df = findDataFileOrDieWrapper(fileIdParams[i]);
if (df.isHarvested()) {
String errorMessage = "Datafile " + df.getId() + " is a harvested file that cannot be accessed in this Dataverse";
throw new NotFoundException(errorMessage);
// (nobody should ever be using this API on a harvested DataFile)!
}
// Make sure all files are from the same dataset
if (datasetId == null) {
datasetId = df.getOwner().getId();
} else {
if (!datasetId.equals(df.getOwner().getId())) {
// All files must be from the same Dataset
throw new BadRequestException(BundleUtil.getStringFromBundle("access.api.download.failure.multipleDatasets"));
}
}
// This will throw a ForbiddenException if access isn't authorized:
checkAuthorization(crc, df);
datafilesMap.put(df.getId(), df);
}
}
return datafilesMap;
}
private Response returnSignedUrl(ContainerRequestContext crc, UriInfo uriInfo, User user, String id, String gbrids) {
// Create the signed URL
String userIdentifier = null;
String key = null;
if (user != null && user instanceof AuthenticatedUser) {
AuthenticatedUser requestor = (AuthenticatedUser) user;
userIdentifier = requestor.getUserIdentifier();
// Find the latest token: Use for signing
// Could be null if no token was generated: Generate one to be used for signing (expire in 1 minute to match timeout in signedUrl)
// Could be expired: The user was already authenticated (possible by bearer token). Only used for signing so we don't care
ApiToken apiToken = authSvc.findApiTokenByUser(requestor);
if (apiToken == null) {
logger.fine("Generating temporary API token for user " + userIdentifier);
apiToken = authSvc.generateApiTokenForUser(requestor, AuthenticationServiceBean.INTERVAL.MINUTES, GUESTBOOK_RESPONSE_SIGNEDURL_TIMEOUT_MINUTES);
}
if (apiToken != null) {
key = apiToken.getTokenString();
}
} else {
// Guest
userIdentifier = "guest";
// Note: In order for the key to match we need to replace ":persistentId" with the actual file id since that is what will be sent in via the signed url.
key = URLDecoder.decode(uriInfo.getAbsolutePath().toASCIIString())
.replace(":persistentId", id); //TODO find a better one for here and in SignedUrlAuthMechanism.java
}
UriBuilder builder = UriBuilder.fromUri(uriInfo.getRequestUri());
builder.replaceQueryParam("gbrecs", true);
if (gbrids != null && !gbrids.isEmpty()) {
builder.replaceQueryParam("gbrids", gbrids);
}
builder.replaceQueryParam("persistentId", null); // remove this as a parm and add the id to the path
crc.setProperty("gbrids", gbrids);
String baseUrlEncoded = builder.build().toString();
String baseUrl = URLDecoder.decode(baseUrlEncoded, StandardCharsets.UTF_8);
baseUrl = baseUrl.replace(":persistentId", id);
key = JvmSettings.API_SIGNING_SECRET.lookupOptional().orElse("") + key;
String signedUrl = UrlSignerUtil.signUrl(baseUrl, GUESTBOOK_RESPONSE_SIGNEDURL_TIMEOUT_MINUTES, userIdentifier, "GET", key);
return ok(Json.createObjectBuilder().add(URLTokenUtil.SIGNED_URL, signedUrl));
}
/*
* Variants of the Access API calls for retrieving datafile-level
* Metadata.
*/
// Metadata format defaults to DDI:
@GET
@AuthRequired
@Path("datafile/{fileId}/metadata")
@Produces({"text/xml"})
@Operation(summary = "Export tabular file metadata",
description = "Streams tabular data file metadata in the default DDI XML format.")
@SecurityRequirement(name = "DataverseApiKey")
public String tabularDatafileMetadata(@Context ContainerRequestContext crc,
@Parameter(description = "Data file id or persistent identifier for the tabular file.", required = true)
@PathParam("fileId") String fileId,
@Parameter(description = "File metadata id used to select a specific file metadata record.")
@QueryParam("fileMetadataId") Long fileMetadataId,
@Parameter(description = "Comma-separated metadata sections to exclude from the export.")
@QueryParam("exclude") String exclude,
@Parameter(description = "Comma-separated metadata sections to include in the export.")
@QueryParam("include") String include,
@Context HttpHeaders header, @Context HttpServletResponse response) throws NotFoundException, ServiceUnavailableException /*, PermissionDeniedException, AuthorizationRequiredException*/ {
return tabularDatafileMetadataDDI(crc, fileId, fileMetadataId, exclude, include, header, response);
}
/*
* This has been moved here, under /api/access, from the /api/meta hierarchy
* which we are going to retire.
*/
@Path("datafile/{fileId}/metadata/ddi")
@AuthRequired
@GET
@Produces({"text/xml"})
@Operation(summary = "Export tabular file metadata as DDI",
description = "Streams DDI XML metadata for a tabular data file.")
@SecurityRequirement(name = "DataverseApiKey")
public String tabularDatafileMetadataDDI(@Context ContainerRequestContext crc,
@Parameter(description = "Data file id or persistent identifier for the tabular file.", required = true)
@PathParam("fileId") String fileId,
@Parameter(description = "File metadata id used to select a specific file metadata record.")
@QueryParam("fileMetadataId") Long fileMetadataId,
@Parameter(description = "Comma-separated metadata sections to exclude from the export.")
@QueryParam("exclude") String exclude,
@Parameter(description = "Comma-separated metadata sections to include in the export.")
@QueryParam("include") String include,
@Context HttpHeaders header, @Context HttpServletResponse response) throws NotFoundException, ServiceUnavailableException /*, PermissionDeniedException, AuthorizationRequiredException*/ {
String retValue = "";
DataFile dataFile = null;
dataFile = findDataFileOrDieWrapper(fileId);
if (!dataFile.isTabularData()) {
throw new BadRequestException("tabular data required");
}
if (FileUtil.isRetentionExpired(dataFile)) {
throw new BadRequestException("unable to download file with expired retention");
}
if (dataFile.isRestricted() || FileUtil.isActivelyEmbargoed(dataFile)) {
boolean hasPermissionToDownloadFile = false;
DataverseRequest dataverseRequest;
dataverseRequest = createDataverseRequest(getRequestUser(crc));
if (dataverseRequest != null && dataverseRequest.getUser() instanceof GuestUser) {
// We must be in the UI. Try to get a non-GuestUser from the session.
dataverseRequest = dvRequestService.getDataverseRequest();
}
hasPermissionToDownloadFile = permissionService.requestOn(dataverseRequest, dataFile).has(Permission.DownloadFile);
if (!hasPermissionToDownloadFile) {
throw new BadRequestException("no permission to download file");
}
}
response.setHeader("Content-disposition", "attachment; filename=\"dataverse_files.zip\"");
FileMetadata fm = null;
if (fileMetadataId == null) {
fm = dataFile.getFileMetadata();
} else {
fm = dataFileService.findFileMetadata(fileMetadataId);
}
String fileName = fm.getLabel().replaceAll("\\.tab$", "-ddi.xml");
response.setHeader("Content-disposition", "attachment; filename=\""+fileName+"\"");
response.setHeader("Content-Type", "application/xml; name=\""+fileName+"\"");
ByteArrayOutputStream outStream = null;
outStream = new ByteArrayOutputStream();
Long dataFileId = dataFile.getId();
try {
ddiExportService.exportDataFile(
dataFileId,
outStream,
exclude,
include,
fileMetadataId);
retValue = outStream.toString();
} catch (Exception e) {
// For whatever reason we've failed to generate a partial
// metadata record requested.
// We return Service Unavailable.
throw new ServiceUnavailableException();
}
return retValue;
}
/*
* GET method for retrieving a list of auxiliary files associated with
* a tabular datafile.
*/
@GET
@AuthRequired
@Path("datafile/{fileId}/auxiliary")
public Response listDatafileMetadataAux(@Context ContainerRequestContext crc,
@PathParam("fileId") String fileId,
@Context UriInfo uriInfo,
@Context HttpHeaders headers,
@Context HttpServletResponse response) throws ServiceUnavailableException {
return listAuxiliaryFiles(getRequestUser(crc), fileId, null, uriInfo, headers, response);
}
/*
* GET method for retrieving a list auxiliary files associated with
* a tabular datafile and having the specified origin.
*/
@GET
@AuthRequired
@Path("datafile/{fileId}/auxiliary/{origin}")
public Response listDatafileMetadataAuxByOrigin(@Context ContainerRequestContext crc,
@PathParam("fileId") String fileId,
@PathParam("origin") String origin,
@Context UriInfo uriInfo,
@Context HttpHeaders headers,
@Context HttpServletResponse response) throws ServiceUnavailableException {
return listAuxiliaryFiles(getRequestUser(crc), fileId, origin, uriInfo, headers, response);
}
private Response listAuxiliaryFiles(User user, String fileId, String origin, UriInfo uriInfo, HttpHeaders headers, HttpServletResponse response) {
DataFile df = findDataFileOrDieWrapper(fileId);
List<AuxiliaryFile> auxFileList = auxiliaryFileService.findAuxiliaryFiles(df, origin);
if (auxFileList == null || auxFileList.isEmpty()) {
throw new NotFoundException("No Auxiliary files exist for datafile " + fileId + (origin==null ? "": " and the specified origin"));
}
boolean isAccessAllowed = isAccessAuthorized(user, df);
JsonArrayBuilder jab = Json.createArrayBuilder();
auxFileList.forEach(auxFile -> {
if (isAccessAllowed || auxFile.getIsPublic()) {
NullSafeJsonBuilder job = NullSafeJsonBuilder.jsonObjectBuilder();
job.add("formatTag", auxFile.getFormatTag());
job.add("formatVersion", auxFile.getFormatVersion());
job.add("fileSize", auxFile.getFileSize());
job.add("contentType", auxFile.getContentType());
job.add("isPublic", auxFile.getIsPublic());
job.add("type", auxFile.getType());
jab.add(job);
}
});
return ok(jab);
}
/*
* GET method for retrieving various auxiliary files associated with
* a tabular datafile.
*
*/
@GET
@AuthRequired
@Path("datafile/{fileId}/auxiliary/{formatTag}/{formatVersion}")
public DownloadInstance downloadAuxiliaryFile(@Context ContainerRequestContext crc,
@PathParam("fileId") String fileId,
@PathParam("formatTag") String formatTag,
@PathParam("formatVersion") String formatVersion,
@Context UriInfo uriInfo,
@Context HttpHeaders headers,
@Context HttpServletResponse response) throws ServiceUnavailableException {
DataFile df = findDataFileOrDieWrapper(fileId);
DownloadInfo dInfo = new DownloadInfo(df);
boolean publiclyAvailable = false;
DownloadInstance downloadInstance;
AuxiliaryFile auxFile = null;
/*
The special case for "preprocessed" metadata should not be here at all.
Access to the format should be handled by the /api/access/datafile/{id}?format=prep
form exclusively (this is what Data Explorer has been using all along).
We may have advertised /api/access/datafile/{id}/metadata/preprocessed
in the past - but it has been broken since 5.3 anyway, since the /{formatVersion}
element was added to the @Path.
Now that the api method has been renamed /api/access/datafile/{id}/auxiliary/...,
nobody should be using it to access the "preprocessed" format.
Leaving the special case below commented-out, for now. - L.A.
// formatTag=preprocessed is handled as a special case.
// This is (as of now) the only aux. tabular metadata format that Dataverse
// can generate (and cache) itself. (All the other formats served have
// to be deposited first, by the @POST version of this API).
if ("preprocessed".equals(formatTag)) {
dInfo.addServiceAvailable(new OptionalAccessService("preprocessed", "application/json", "format=prep", "Preprocessed data in JSON"));
downloadInstance = new DownloadInstance(dInfo);
if (downloadInstance.checkIfServiceSupportedAndSetConverter("format", "prep")) {
logger.fine("Preprocessed data for tabular file "+fileId);
}
} else { */
// All other (deposited) formats:
auxFile = auxiliaryFileService.lookupAuxiliaryFile(df, formatTag, formatVersion);
if (auxFile == null) {
throw new NotFoundException("Auxiliary metadata format " + formatTag + " is not available for datafile " + fileId);
}
// Don't consider aux file public unless data file is published.
if (auxFile.getIsPublic() && df.getPublicationDate() != null) {
publiclyAvailable = true;
}
downloadInstance = new DownloadInstance(dInfo);
downloadInstance.setAuxiliaryFile(auxFile);
/*}*/
// Unless this format is explicitly authorized to be publicly available,
// the following will check access authorization (based on the access rules
// as defined for the DataFile itself), and will throw a ForbiddenException
// if access is denied:
if (!publiclyAvailable) {
checkAuthorization(crc, df);
}
return downloadInstance;
}
/*
* API method for downloading zipped bundles of multiple files. Uses POST to avoid long lists of file IDs that can make the URL longer than what's supported by browsers/servers
*/
// TODO: Rather than only supporting looking up files by their database IDs,
// consider supporting persistent identifiers.
@POST
@AuthRequired
@Path("datafiles")
@Consumes("text/plain")
@Produces({ "application/zip" })
@Operation(summary = "Stream a ZIP for selected files",
description = "Accepts a text list of data file ids and streams the selected files as a ZIP archive.")
@SecurityRequirement(name = "DataverseApiKey")
public Response postDownloadDatafiles(@Context ContainerRequestContext crc,
@RequestBody(description = "Text list of data file ids to include in the ZIP archive.")
String body,
@Parameter(description = "Whether guestbook records have already been written for this download.")
@QueryParam("gbrecs") boolean gbrecs,
@Parameter(description = "Guestbook response id list supplied by the user interface.")
@QueryParam("gbrids") String gbrids,
@Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException {
processDatafileWithGuestbookResponse(crc, headers, body, uriInfo, gbrecs, body);
// JSF UI passes the guestbook response id(s) in thus this qp can be removed when JSF is removed
if (gbrids == null || gbrids.isEmpty()) {
gbrids = (String) crc.getProperty("gbrids");
}
// There is no get for this so we shouldn't return a signed url
// initiate the download now
return downloadDatafiles(crc, body, gbrecs, gbrids, uriInfo, headers, response, null);
}
@GET
@AuthRequired
@Path("dataset/{id}")
@Produces({"application/zip"})
public Response downloadAllFromLatest(@Context ContainerRequestContext crc, @PathParam("id") String datasetIdOrPersistentId,
@QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids,
@Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException {
try {
User user = getRequestUser(crc);
DataverseRequest req = createDataverseRequest(user);
final Dataset retrieved = findDatasetOrDie(datasetIdOrPersistentId);
if (!(user instanceof GuestUser)) {
// The reason we are only looking up a draft version for a NON-guest user
// is that we know that guest never has the Permission.ViewUnpublishedDataset.
final DatasetVersion draft = versionService.getDatasetVersionById(retrieved.getId(), DatasetVersion.VersionState.DRAFT.toString());
if (draft != null && permissionService.requestOn(req, retrieved).has(Permission.ViewUnpublishedDataset)) {
String fileIds = getFileIdsAsCommaSeparated(draft.getFileMetadatas());
// We don't want downloads from Draft versions to be counted,
// so we are setting the gbrecs (aka "do not write guestbook response")
// variable accordingly:
return downloadDatafiles(crc, fileIds, true, gbrids, uriInfo, headers, response, "draft");
}
}
// OK, it was not the draft. Let's see if we can serve a published version.
final DatasetVersion latest = versionService.getLatestReleasedVersionFast(retrieved.getId());
// Make sure to throw a clean error code if we have failed to locate an
// accessible version:
// (A "Not Found" would be more appropriate here, I believe, than a "Bad Request".
// But we've been using the latter for a while, and it's a popular API...
// and this return code is expected by our tests - so I'm choosing it to keep
// -- L.A.)
if (latest == null) {
return error(BAD_REQUEST, BundleUtil.getStringFromBundle("access.api.exception.dataset.not.found"));
//throw new NotFoundException();
}
String fileIds = getFileIdsAsCommaSeparated(latest.getFileMetadatas());
return downloadDatafiles(crc, fileIds, gbrecs, gbrids, uriInfo, headers, response, latest.getFriendlyVersionNumber());
} catch (WrappedResponse wr) {
return wr.getResponse();
}
}
@POST
@AuthRequired
@Path("dataset/{id}")
@Produces({"application/zip"})
@Operation(summary = "Submit guestbook response for latest dataset files",
description = "Records a guestbook response and prepares a ZIP download for files in the latest accessible dataset version.")
@SecurityRequirement(name = "DataverseApiKey")
public Response downloadAllFromLatestWithGuestbookResponse(@Context ContainerRequestContext crc,
@Parameter(description = "Dataset id or persistent identifier.", required = true)
@PathParam("id") String datasetIdOrPersistentId,
@Parameter(description = "Whether guestbook records have already been written for this download.")
@QueryParam("gbrecs") boolean gbrecs,
@Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response,
@RequestBody(description = "Guestbook response JSON for the dataset file download.")
String jsonBody) throws WebApplicationException {
try {
User user = getRequestUser(crc);
DataverseRequest req = createDataverseRequest(user);
final Dataset retrieved = findDatasetOrDie(datasetIdOrPersistentId);
String fileIds = "";
String version = null;
// If user can view the draft version download those files and don't count them
if (!(user instanceof GuestUser)) {
final DatasetVersion draft = versionService.getDatasetVersionById(retrieved.getId(), DatasetVersion.VersionState.DRAFT.toString());
if (draft != null && permissionService.requestOn(req, retrieved).has(Permission.ViewUnpublishedDataset)) {
fileIds = getFileIdsAsCommaSeparated(draft.getFileMetadatas());
gbrecs = true;
version = "draft";
}
}
if (version == null) {
final DatasetVersion latest = versionService.getLatestReleasedVersionFast(retrieved.getId());
fileIds = getFileIdsAsCommaSeparated(latest.getFileMetadatas());
version = latest.getFriendlyVersionNumber();
}
return processDatafileWithGuestbookResponse(crc, headers, fileIds, uriInfo, gbrecs, jsonBody);
} catch (WrappedResponse wr) {
return wr.getResponse();
}
}
@GET
@AuthRequired
@Path("dataset/{id}/versions/{versionId}")
@Produces({"application/zip"})
public Response downloadAllFromVersion(@Context ContainerRequestContext crc, @PathParam("id") String datasetIdOrPersistentId, @PathParam("versionId") String versionId,
@QueryParam("gbrecs") boolean gbrecs, @QueryParam("gbrids") String gbrids, @QueryParam("key") String apiTokenParam, @QueryParam("signed") boolean signed, @Context UriInfo uriInfo, @Context HttpHeaders headers, @Context HttpServletResponse response) throws WebApplicationException {
try {
DatasetVersion dsv = getDatasetVersionFromVersion(crc, datasetIdOrPersistentId, versionId);
if (dsv == null) {
// (A "Not Found" would be more appropriate here, I believe, than a "Bad Request".
// But we've been using the latter for a while, and it's a popular API...
// and this return code is expected by our tests - so I'm choosing it to keep
// -- L.A.)
return error(BAD_REQUEST, BundleUtil.getStringFromBundle("access.api.exception.version.not.found"));
}
String fileIds = getFileIdsAsCommaSeparated(dsv.getFileMetadatas());
// We don't want downloads from Draft versions to be counted,
// so we are setting the gbrecs (aka "do not write guestbook response")
// variable accordingly:
if (dsv.isDraft()) {
gbrecs = true;
}
return downloadDatafiles(crc, fileIds, gbrecs, gbrids, uriInfo, headers, response, dsv.getFriendlyVersionNumber().toLowerCase());
} catch (WrappedResponse wr) {
return wr.getResponse();
}
}
@POST
@AuthRequired