Skip to content

Commit da27bd1

Browse files
authored
Merge pull request #4700 from guardian/an/archive-on-undelete
on undeletion, mark image as archived so it cannot be immediately rereaped
2 parents be6766e + bcf6b9b commit da27bd1

10 files changed

Lines changed: 103 additions & 54 deletions

File tree

common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import com.gu.mediaservice.model.leases.LeasesByMedia
88
import com.gu.mediaservice.model.usage.Usage
99
import com.typesafe.scalalogging.LazyLogging
1010
import play.api.http.HeaderNames
11-
import play.api.libs.json.{JsArray, JsObject, JsValue, Json, Reads}
11+
import play.api.libs.json.{JsArray, JsObject, JsTrue, JsValue, Json, Reads}
1212

1313
import scala.concurrent.duration.{Duration, DurationInt}
1414
import scala.concurrent.{ExecutionContext, Future}
@@ -38,8 +38,8 @@ object ClientResponse {
3838
case class ClientErrorMessages(errorMessage: String, downstreamErrorMessage: String)
3939

4040
object GridClient extends LazyLogging {
41-
def apply(services: Services)(implicit wsClient: WSClient): GridClient =
42-
new GridClient(services)
41+
def apply(services: Services, originUri: String)(implicit wsClient: WSClient): GridClient =
42+
new GridClient(services, originUri)
4343

4444
sealed trait Response {
4545
def status: Int
@@ -96,7 +96,7 @@ object GridClient extends LazyLogging {
9696

9797
}
9898

99-
class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLogging {
99+
class GridClient(services: Services, originDomain: String)(implicit wsClient: WSClient) extends LazyLogging {
100100

101101
/*
102102
* `requestTimeout` will set the max duration of the request before timing out. You may also want to increase the
@@ -260,6 +260,11 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo
260260
authorisedRequest.post(Json.obj("data" -> data)).map { response => validateResponse(response, url)}
261261
}
262262

263+
def putArchived(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext) = {
264+
val url = new URL(s"${services.metadataBaseUri}/metadata/$mediaId/archived")
265+
val request = authFn(wsClient.url(url.toString))
266+
request.put(Json.obj("data" -> JsTrue)).map { response => validateResponse(response, url)}
267+
}
263268
}
264269

265270
class DownstreamApiInBadStateException(message: String, downstreamMessage: String) extends IllegalStateException(message) {

image-loader/app/ImageLoaderComponents.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class ImageLoaderComponents(context: Context) extends GridComponents(context, ne
2121
logger.info(s" $index -> ${processor.description}")
2222
}
2323

24-
private val gridClient = GridClient(config.services)(wsClient)
24+
private val gridClient = GridClient(config.services, config.services.loaderBaseUri)(wsClient)
2525

2626
val store = new ImageLoaderStore(config)
2727
val maybeIngestQueue = config.maybeIngestSqsQueueUrl.map(queueUrl => new SimpleSqsMessageConsumer(queueUrl, config))

kahuna/public/js/components/gr-archiver/gr-archiver.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,16 @@ module.controller('grArchiverCtrl', [
2727
'humanJoin',
2828
'mediaApi',
2929
'getDeletedState',
30+
'apiPoll',
3031
function ($scope,
3132
$window,
3233
archiveService,
3334
imageAccessor,
3435
imageLogic,
3536
humanJoin,
3637
mediaApi,
37-
getDeletedState) {
38+
getDeletedState,
39+
apiPoll) {
3840

3941
const ctrl = this;
4042

@@ -115,14 +117,31 @@ module.controller('grArchiverCtrl', [
115117
reduce((all, items) => all.union(items)).
116118
toArray();
117119
}
120+
121+
function pollUndeleted(imageId) {
122+
const findImage = () => mediaApi.find(imageId).then(
123+
(i) => i.data?.softDeletedMetadata === undefined && i.data.userMetadata?.data?.archived
124+
? Promise.resolve()
125+
: Promise.reject()
126+
);
127+
return apiPoll(findImage);
128+
}
118129
function undelete() {
119130
ctrl.undeleting = true;
120131
const imageId = ctrl.image.data.id;
121132
mediaApi.undelete(imageId)
122-
.then(
123-
ctrl.canUndelete = ctrl.isDeleted = false
124-
).catch(() => {
133+
.then(() => pollUndeleted(imageId))
134+
.then(() => {
135+
ctrl.canUndelete = false;
136+
ctrl.isDeleted = false;
137+
ctrl.image.softDeletedMetadata = undefined;
138+
if (ctrl.image.userMetadata?.data !== undefined) {
139+
ctrl.image.userMetadata.data.archived = true;
140+
}
141+
$scope.$emit('images-updated', [ctrl.image]);
142+
}).catch((e) => {
125143
$window.alert('Failed to undelete image!, please try again.');
144+
console.error('Image undeletion failed', e);
126145
}).finally(() => {
127146
ctrl.undeleting = false;
128147
});

kahuna/public/js/components/gr-undelete-image/gr-un-delete-image.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,35 @@ export const undeleteImage = angular.module('gr.undeleteImage', [
77
]);
88

99
undeleteImage.controller('grUnDeleteImageCtrl', [
10-
'$rootScope', '$q', '$timeout', 'mediaApi', 'apiPoll',
11-
function ($rootScope, $q, $timeout, mediaApi, apiPoll) {
10+
'$rootScope', '$q', 'mediaApi', 'apiPoll',
11+
function ($rootScope, $q, mediaApi, apiPoll) {
1212
var ctrl = this;
1313

1414
ctrl.$onInit = () => {
1515

16-
function pollDeleted (image) {
16+
function pollUndeleted (image) {
1717
const findImage = () => mediaApi.find(image.data.id).then(
18-
() => $q.reject(),
19-
// resolve when image cannot be found, i.e. image has been deleted.
20-
() => $q.resolve()
18+
(i) => i.data.softDeletedMetadata === undefined && i.data.userMetadata?.data?.archived
19+
? $q.resolve()
20+
: $q.reject()
2121
);
2222

23-
apiPoll(findImage);
23+
return apiPoll(findImage);
2424
}
2525

2626
ctrl.unDeleteImage = function (image) {
2727
return mediaApi.undelete(image.data.id)
28-
.then(() => pollDeleted(image))
28+
.then(() => pollUndeleted(image))
2929
.catch((err) => {
30-
$rootScope.$emit('image-delete-failure', err, image);
30+
$rootScope.$emit('image-undelete-failure', err, image);
3131
});
3232
};
3333

3434
ctrl.unDeleteSelected = function () {
3535
// HACK to wait for thrall to process the message so that when we
3636
// poll the api, it will be up to date.
3737
return $q.all(Array.from(ctrl.images.values()).map(image => ctrl.unDeleteImage(image)))
38-
.then(() => $rootScope.$emit('images-deleted', ctrl.images));
38+
.then(() => $rootScope.$emit('images-undeleted', ctrl.images));
3939
};
4040
};
4141
}

kahuna/public/js/image/controller.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ image.controller('ImageCtrl', [
156156

157157
ctrl.image = image;
158158
if (ctrl.image && ctrl.image.data.softDeletedMetadata !== undefined) { ctrl.isDeleted = true; }
159+
160+
$scope.$watch('ctrl.image.data.softDeletedMetadata', () => {
161+
if (ctrl.image) {
162+
ctrl.isDeleted = ctrl.image?.data.softDeletedMetadata !== undefined;
163+
}
164+
});
165+
159166
ctrl.optimisedImageUri = optimisedImageUri;
160167
ctrl.lowResImageUri = lowResImageUri;
161168

kahuna/public/js/search/results.js

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -827,24 +827,27 @@ results.controller('SearchResultsCtrl', [
827827
});
828828
};
829829

830-
const freeImageDeleteListener = $rootScope.$on('images-deleted', (e, images) => {
831-
images.forEach(image => {
832-
// TODO: should not be needed here, the selection and
833-
// results should listen to these events and update
834-
// itself outside of any controller
835-
ctrl.deselect(image);
830+
const imageDeleteHandler = (_, images) => {
831+
images.forEach(image => {
832+
// TODO: should not be needed here, the selection and
833+
// results should listen to these events and update
834+
// itself outside of any controller
835+
ctrl.deselect(image);
836836

837-
const indexAll = ctrl.imagesAll.findIndex(i => image.data.id === i.data.id);
838-
results.removeAt(indexAll);
837+
const indexAll = ctrl.imagesAll.findIndex(i => image.data.id === i.data.id);
838+
results.removeAt(indexAll);
839839

840-
updateImageArray(ctrl.images, image);
841-
updateImageArray(ctrl.imagesAll, image);
840+
updateImageArray(ctrl.images, image);
841+
updateImageArray(ctrl.imagesAll, image);
842842

843-
updatePositions(image);
843+
updatePositions(image);
844844

845-
ctrl.totalResults--;
846-
});
847-
});
845+
ctrl.totalResults--;
846+
});
847+
};
848+
849+
const freeImageDeleteListener = $rootScope.$on('images-deleted', imageDeleteHandler);
850+
const freeImageUndeleteListener = $rootScope.$on('images-undeleted', imageDeleteHandler);
848851

849852
// Safer than clearing the timeout in case of race conditions
850853
// FIXME: nicer (reactive?) way to do this?
@@ -913,6 +916,7 @@ results.controller('SearchResultsCtrl', [
913916
}
914917
freeUpdatesListener();
915918
freeImageDeleteListener();
919+
freeImageUndeleteListener();
916920
scopeGone = true;
917921
});
918922
}

media-api/app/controllers/MediaApi.scala

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class MediaApi(
5050
)(implicit val ec: ExecutionContext) extends BaseController with MessageSubjects with ArgoHelpers with ContentDisposition {
5151

5252
val services: Services = new Services(config.domainRoot, config.serviceHosts, Set.empty)
53-
val gridClient: GridClient = GridClient(services)(ws)
53+
val gridClient: GridClient = GridClient(services, services.apiBaseUri)(ws)
5454

5555
// Process-local cache keyed on normalised query text. Stores the Bedrock Future so that
5656
// concurrent requests for the same query share a single in-flight Bedrock call, and
@@ -404,15 +404,19 @@ class MediaApi(
404404
&& ImageExtras.userMayUndeleteImage(request.user, image, authorisation) =>
405405
logger.info(logMarker, s"undeleting image $id")
406406

407-
softDeletedMetadataTable.updateStatus(id, isDeleted = false)
408-
.map { _ =>
409-
messageSender.publish(
410-
UpdateMessage(
411-
subject = UnSoftDeleteImage,
412-
id = Some(id)
413-
)
407+
for {
408+
// Take an "undeletion" as a marker that the file should be kept and not reaped.
409+
// Mark as archived before undeleting, to make sure the reaper doesn't immediately reap it.
410+
_ <- gridClient.putArchived(id, auth.getOnBehalfOfPrincipal(request.user))
411+
_ <- softDeletedMetadataTable.updateStatus(id, isDeleted = false)
412+
_ = messageSender.publish(
413+
UpdateMessage(
414+
subject = UnSoftDeleteImage,
415+
id = Some(id)
414416
)
415-
}.map { _ => Accepted }
417+
)
418+
} yield Accepted
419+
416420
case Some(image) if isVisibleToAccessor(request.user, image) =>
417421
logger.info(logMarker, s"user ${request.user.accessor.identity} was not permitted to undelete image $id")
418422
Future.successful(ImageDeleteForbidden)

metadata-editor/app/controllers/EditsController.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class EditsController(
5959
import com.gu.mediaservice.lib.metadata.UsageRightsMetadataMapper.usageRightsToMetadata
6060

6161
val services: Services = new Services(config.domainRoot, config.serviceHosts, Set.empty)
62-
val gridClient: GridClient = GridClient(services)(ws)
62+
val gridClient: GridClient = GridClient(services, services.metadataBaseUri)(ws)
6363

6464
val metadataBaseUri = config.services.metadataBaseUri
6565
private val AuthenticatedAndAuthorised = auth andThen authorisation.CommonActionFilters.authorisedForArchive

rest-lib/src/main/scala/com/gu/mediaservice/lib/guardian/auth/PandaAuthenticationProvider.scala

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,26 +112,36 @@ class PandaAuthenticationProvider(
112112
override def flushToken: Option[(RequestHeader, Result) => Result] = Some((rh, _) => processLogout(rh))
113113

114114
val PandaCookieKey: TypedKey[Cookie] = TypedKey[Cookie]("PandaCookie")
115+
val OriginKey: TypedKey[String] = TypedKey[String]("Origin")
116+
117+
private def withPersistedOrigin(request: Principal): WSRequest => WSRequest = { wsRequest =>
118+
request.attributes.get(OriginKey) match {
119+
case Some(origin) => wsRequest.withHttpHeaders(("Origin", origin))
120+
case None => wsRequest
121+
}
122+
}
123+
124+
private def withPersistedCookie(request: Principal): Either[String, WSRequest => WSRequest] = {
125+
val cookieName = panDomainSettings.settings.cookieSettings.cookieName
126+
request.attributes.get(PandaCookieKey) match {
127+
case Some(cookie) => Right(wsRequest => wsRequest.addCookies(DefaultWSCookie(cookieName, cookie.value)))
128+
case None => Left(s"Pan domain cookie $cookieName is missing in principal.")
129+
}
130+
}
115131

116132
/**
117133
* A function that allows downstream API calls to be made using the credentials of the inflight request
118134
*
119135
* @param request The request header of the inflight call
120136
* @return A function that adds appropriate data to a WSRequest
121137
*/
122-
override def onBehalfOf(request: Principal): Either[String, WSRequest => WSRequest] = {
123-
val cookieName = panDomainSettings.settings.cookieSettings.cookieName
124-
request.attributes.get(PandaCookieKey) match {
125-
case Some(cookie) => Right { wsRequest: WSRequest =>
126-
wsRequest.addCookies(DefaultWSCookie(cookieName, cookie.value))
127-
}
128-
case None => Left(s"Pan domain cookie $cookieName is missing in principal.")
129-
}
130-
}
138+
override def onBehalfOf(request: Principal): Either[String, WSRequest => WSRequest] =
139+
withPersistedCookie(request).map(_.andThen(withPersistedOrigin(request)))
131140

132141
private def gridUserFrom(pandaUser: User, request: RequestHeader): UserPrincipal = {
133142
val maybePandaCookie: Option[TypedEntry[Cookie]] = request.cookies.get(panDomainSettings.settings.cookieSettings.cookieName).map(TypedEntry[Cookie](PandaCookieKey, _))
134-
val attributes = TypedMap.empty + (maybePandaCookie.toSeq:_*)
143+
val maybeOrigin = request.headers.get("Origin").map(TypedEntry[String](OriginKey, _))
144+
val attributes = TypedMap(Seq(maybePandaCookie, maybeOrigin).flatten:_*)
135145
UserPrincipal(
136146
firstName = pandaUser.firstName,
137147
lastName = pandaUser.lastName,

thrall/app/ThrallComponents.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ThrallComponents(context: Context) extends GridComponents(context, new Thr
3131
es.ensureIndexExistsAndAliasAssigned()
3232

3333
val services: Services = new Services(config.domainRoot, config.serviceHosts, Set.empty)
34-
val gridClient: GridClient = GridClient(services)(wsClient)
34+
val gridClient: GridClient = GridClient(services, services.thrallBaseUri)(wsClient)
3535

3636
// before firing up anything to consume streams or say we are OK let's do the critical good to go check
3737
private val goodToGoCheckResult = Await.ready(GoodToGoCheck.run(es), 30 seconds)

0 commit comments

Comments
 (0)