-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathStoryController.scala
More file actions
322 lines (297 loc) · 16.3 KB
/
Copy pathStoryController.scala
File metadata and controls
322 lines (297 loc) · 16.3 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
package controllers
import controllers.base._
import controllers.helper.ControllerUtils.isAdmin
import controllers.helper.SignedMediaUtils
import formats.json.StoryFormats
import models.auth.{DefaultEnv, WithAdmin}
import models.story.{Story, StoryMedia, StoryPhotoUpload, StoryRejection}
import play.api.libs.json.{JsBoolean, Json}
import play.api.{Configuration, Logger}
import play.silhouette.api.Silhouette
import service.{ConfigService, ImageSigningService, LostMediaLog, RateLimiter, StoryService}
import java.io.File
import java.time.{Duration, OffsetDateTime}
import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
/**
* HTTP surface for lived-experience stories (#4054): public reads for the label-detail card, authenticated
* submission (multipart, optional photo), the author's hard-delete retraction, signed media serving, and the
* admin moderation endpoints. Stories are deliberately absent from /v3 (see StoryFormats).
*/
@Singleton
class StoryController @Inject() (
cc: CustomControllerComponents,
val silhouette: Silhouette[DefaultEnv],
implicit val config: Configuration,
implicit val assets: AssetsFinder,
configService: ConfigService,
storyService: StoryService,
signingService: ImageSigningService,
rateLimiter: RateLimiter,
lostMediaLog: LostMediaLog,
implicit val ec: ExecutionContext
) extends CustomBaseController(cc) {
private val logger = Logger(this.getClass)
private val photoMaxBytes: Long = config.get[Long]("stories.photo-max-bytes")
/**
* Renders the public /stories page: the city's community stories, newest first (#4688).
*
* A bare SecuredAction (anonymous auto-accounts included) like /leaderboard, so anyone browsing the site can read
* the stories that are already public on every label card.
*/
def storiesPage = cc.securityService.SecuredAction { implicit request =>
for {
commonData <- configService.getCommonPageData(request2Messages.lang)
stories <- storyService.getStoriesForCity(StoryController.ListingMax)
} yield {
cc.loggingService.insert(request.identity.userId, request.ipAddress, "Visit_Stories")
Ok(views.html.apps.storyList(commonData, request.identity, stories))
}
}
/**
* All stories for a label, shaped for the viewer. Public read: the share landing (/label/:id) opens the card with
* no session at all, so this mirrors LabelController.getLabelData's UserAwareAction (#456).
*/
def getStories(labelId: Int) = silhouette.UserAwareAction.async { implicit request =>
val viewerUserId = request.identity.map(_.userId)
val storiesFuture = storyService.getStoriesForLabel(labelId, viewerUserId, isAdmin(request.identity))
val isProblemFuture = storyService.isLabelAccessProblem(labelId)
for {
stories <- storiesFuture
isProblem <- isProblemFuture
} yield Ok(
Json.obj(
"label_id" -> labelId,
"max_text_length" -> storyService.maxTextLength, // Composer counter limit; sourced here, never a JS literal.
// Problem-vs-feature story prompts flip on this; sourced from LabelTypeEnum, never re-derived in JS. Null
// when the label doesn't exist (the card then keeps its default copy).
"is_access_problem" -> isProblem,
"stories" -> stories.map(StoryFormats.storyForViewToJson)
)
)
}
/**
* Submits a story (multipart form). Data parts: `label_id`, `text`, optional `display_name_mode`
* (anonymous|username, default anonymous), optional `alt_text`; optional file part `photo`.
* Any authenticated user may post, anonymous sessions included — the same bar as label-map comments.
*
* The parser is bounded at the photo cap plus 1 MiB of text/framing headroom, so an oversized upload is cut off
* at that point (Play answers 413 with a non-JSON body; the composer maps that status) instead of being buffered
* to disk in full only for StoryService to reject it.
*/
def submitStory = cc.securityService.SecuredAction(parse.multipartFormData(photoMaxBytes + (1L << 20))) {
implicit request =>
val userId = request.identity.userId
def dataPart(name: String): Option[String] = request.body.dataParts.get(name).flatMap(_.headOption)
// Inert-until-enabled IP burst layer on top of the always-on per-user DB limit in StoryService.
val ipKey = s"story-submit:ip:${request.ipAddress}"
val ipLimit = rateLimiter.limit("story-submit")
if (!rateLimiter.allow(ipKey, ipLimit)) {
// Time left in this IP's window, not the whole window length — the caller is already partway through it.
val retryAfter = rateLimiter.retryAfterSeconds(ipKey).orElse(Some(ipLimit.window.toSeconds))
Future.successful(rejectionResult(StoryRejection.RateLimitedIp(retryAfter)))
} else {
dataPart("label_id").flatMap(s => Try(s.toInt).toOption) match {
case None => Future.successful(BadRequest(Json.obj("error" -> "story.error.label-id-missing")))
case Some(labelId) =>
val text = dataPart("text").getOrElse("")
val displayNameMode = dataPart("display_name_mode").getOrElse(Story.DisplayNameAnonymous)
val altText = dataPart("alt_text").map(_.trim).filter(_.nonEmpty)
val photo =
request.body.file("photo").map { filePart => StoryPhotoUpload(filePart.ref.path.toFile, altText) }
cc.loggingService.insert(
userId,
request.ipAddress,
s"Click_module=StorySubmit_labelId=${labelId}_hasPhoto=${photo.isDefined}"
)
storyService.submitStory(labelId, userId, text, displayNameMode, photo).map {
case Right(story) => Ok(StoryFormats.storyForViewToJson(story))
case Left(rejection) => rejectionResult(rejection)
}
}
}
}
/**
* Edits the author's own story in place (multipart, same field names as submitStory minus `label_id`). Photo
* semantics: a `photo` file part replaces the existing photo, `remove_photo=true` drops it, and neither keeps it
* (with `alt_text` re-applied). No daily-rate-limit charge — an edit isn't a new story — but the IP burst layer
* still applies. A non-owner gets the same 404 as a missing story.
*/
def updateOwnStory(storyId: Int) =
cc.securityService.SecuredAction(parse.multipartFormData(photoMaxBytes + (1L << 20))) { implicit request =>
val userId = request.identity.userId
def dataPart(name: String): Option[String] = request.body.dataParts.get(name).flatMap(_.headOption)
val ipKey = s"story-submit:ip:${request.ipAddress}"
val ipLimit = rateLimiter.limit("story-submit")
if (!rateLimiter.allow(ipKey, ipLimit)) {
// Time left in this IP's window, not the whole window length — the caller is already partway through it.
val retryAfter = rateLimiter.retryAfterSeconds(ipKey).orElse(Some(ipLimit.window.toSeconds))
Future.successful(rejectionResult(StoryRejection.RateLimitedIp(retryAfter)))
} else {
val text = dataPart("text").getOrElse("")
val displayNameMode = dataPart("display_name_mode").getOrElse(Story.DisplayNameAnonymous)
val altText = dataPart("alt_text").map(_.trim).filter(_.nonEmpty)
val removePhoto = dataPart("remove_photo").contains("true")
val photo =
request.body.file("photo").map { filePart => StoryPhotoUpload(filePart.ref.path.toFile, altText) }
cc.loggingService.insert(
userId,
request.ipAddress,
s"Click_module=StoryUpdate_storyId=${storyId}_hasPhoto=${photo.isDefined}"
)
storyService.updateOwnStory(storyId, userId, text, displayNameMode, photo, removePhoto, altText).map {
case Right(_) => Ok(Json.obj("success" -> true))
case Left(rejection) => rejectionResult(rejection)
}
}
}
/**
* The author's retraction: a real hard delete of the row and any media bytes (#4054). Ownership is enforced in the
* DAO's delete predicate; a non-owner gets the same 404 as a missing story.
*/
def deleteOwnStory(storyId: Int) = cc.securityService.SecuredAction { implicit request =>
cc.loggingService.insert(request.identity.userId, request.ipAddress, s"Click_module=StoryDelete_storyId=$storyId")
storyService.deleteOwnStory(storyId, request.identity.userId).map { deleted =>
if (deleted) Ok(Json.obj("success" -> true)) else NotFound(Json.obj("success" -> false))
}
}
/** The signed-in user's own stories (hidden ones included), for the dashboard management list. */
def getMyStories = cc.securityService.SecuredAction { implicit request =>
storyService.getStoriesForUser(request.identity.userId).map { stories =>
Ok(
Json.obj(
// The dashboard's edit composer needs the same cap the card's does; sourced here, never a JS literal.
"max_text_length" -> storyService.maxTextLength,
"stories" -> stories.map(StoryFormats.storyForOwnerToJson)
)
)
}
}
/**
* Serves a story photo from disk. Requires a valid HMAC signature (?exp=...&sig=...) and an allowed Referer/Origin;
* media on a hidden story 404s unless the viewer is the author or an admin. UserAware (not Secured) because story
* photos render for signed-out visitors on the public /label/:id page — the signed, expiring URL is the gate.
*/
def serveStoryMedia(storyMediaId: Int) = silhouette.UserAwareAction.async { implicit request =>
val earlyReject =
if (!SignedMediaUtils.refererAllowed(request, config)) Some(Forbidden("Request origin not allowed."))
else
// Reverse-routed so the verified path can't drift from the one StoryService signs.
SignedMediaUtils.verifySignature(
request,
routes.StoryController.serveStoryMedia(storyMediaId).url,
signingService
)
earlyReject match {
case Some(result) => Future.successful(result)
case None =>
// The three misses below — no such row, hidden from this viewer, bytes gone — must stay externally
// indistinguishable, so a prober can't tell which ids exist or are hidden; the one shared value keeps a
// future reword from splitting them apart.
val notFound = NotFound("Story media not found.")
storyService.getMediaForServing(storyMediaId).map {
case None => notFound
case Some((media, story)) =>
val file = storyService.storyMediaFile(storyMediaId)
if (!file.exists()) {
// Missing bytes are data loss, not an ordinary miss, and the response has to stay a plain 404, so the
// log is the only place it can be said. Checked before viewability so hidden stories count too —
// otherwise a loss inventory built from this log would silently exclude every moderated story.
logLostMedia(media, file)
notFound
} else if (!story.viewableBy(request.identity.map(_.userId), isAdmin(request.identity))) {
notFound
} else
// `private`: whether a hidden story's media serves depends on who's asking, so shared caches must
// never hold it; the viewer's own browser may, for as long as the signed URL stays valid.
Ok.sendFile(file, inline = true)
.as(media.mimeType)
.withHeaders("Cache-Control" -> s"private, max-age=${signingService.expirySeconds}")
}
}
}
/**
* Reports a media row whose bytes are missing from disk (#4925). Kept high-signal by skipping the upload grace
* window, since a false alarm on this tripwire has real cost; `LostMediaLog` handles the rest.
*
* @param media The media row whose file is missing.
* @param file Where the bytes should have been.
*/
private def logLostMedia(media: StoryMedia, file: File): Unit = {
if (!StoryController.withinUploadWindow(media.createdAt, OffsetDateTime.now)) {
lostMediaLog.reportMissing("story_media", media.storyMediaId.toString, file.getAbsolutePath, irreplaceable = true)
}
}
/** Most recent stories across all users, hidden included — the admin moderation queue feed. */
def getRecentStories(n: Int) = cc.securityService.SecuredAction(WithAdmin()) { implicit request =>
logger.debug(request.toString) // The request is unused, but SecuredAction needs it and the compiler wants it read.
// Clamp both ends: a negative n would reach Slick's .take and emit an invalid negative SQL LIMIT (500 otherwise).
storyService.getRecentStories(math.min(math.max(n, 0), 500)).map { stories =>
Ok(Json.obj("stories" -> stories.map(StoryFormats.storyForAdminToJson)))
}
}
/** Hides (quarantines) or unhides a story. Reversible, keeps row and bytes — the retraction path is the DELETEs. */
def setStoryVisibility(storyId: Int) = cc.securityService.SecuredAction(WithAdmin(), parse.json) { implicit request =>
(request.body \ "hidden").asOpt[Boolean] match {
case None => Future.successful(BadRequest(Json.obj("error" -> "Expected JSON body: {\"hidden\": Boolean}")))
case Some(hidden) =>
cc.loggingService.insert(
request.identity.userId,
request.ipAddress,
s"Click_module=AdminStoryVisibility_storyId=${storyId}_hidden=$hidden"
)
storyService.setStoryVisibility(storyId, request.identity.userId, hidden).map { updated =>
if (updated) Ok(Json.obj("success" -> true, "hidden" -> JsBoolean(hidden)))
else NotFound(Json.obj("success" -> false))
}
}
}
/** Admin hard delete (row + bytes) — for content that must not survive even as quarantined evidence. */
def adminDeleteStory(storyId: Int) = cc.securityService.SecuredAction(WithAdmin()) { implicit request =>
cc.loggingService.insert(
request.identity.userId,
request.ipAddress,
s"Click_module=AdminStoryDelete_storyId=$storyId"
)
storyService.adminDeleteStory(storyId).map { deleted =>
if (deleted) Ok(Json.obj("success" -> true)) else NotFound(Json.obj("success" -> false))
}
}
/** Maps a submission rejection to its HTTP status; the body carries the i18n key + English fallback. */
private def rejectionResult(rejection: StoryRejection) = {
val body = StoryFormats.rejectionToJson(rejection)
// Retry-After is the standard companion to a 429; the body carries the same number for the composer's copy.
def tooMany(retryAfter: Option[Long]) =
retryAfter.foldLeft(TooManyRequests(body))((res, secs) => res.withHeaders("Retry-After" -> secs.toString))
rejection match {
case StoryRejection.LabelNotFound => NotFound(body)
case StoryRejection.StoryNotFound => NotFound(body)
case StoryRejection.AlreadyExists => Conflict(body)
case StoryRejection.RateLimited(retryAfter) => tooMany(retryAfter)
case StoryRejection.RateLimitedIp(retryAfter) => tooMany(retryAfter)
case StoryRejection.PhotoTooLarge => EntityTooLarge(body)
case _ => BadRequest(body)
}
}
}
object StoryController {
/** Cap on the stories the /stories page renders, so the page stays bounded as a city's story count grows. */
val ListingMax: Int = 500
// The upload flow commits the media row before the file move lands (StoryService's place-before-commit windows),
// so a row this young with no bytes is almost certainly mid-upload. The window is sub-second; a minute is generous
// slack.
private val lostMediaGrace = Duration.ofMinutes(1)
/**
* Whether a media row is young enough that missing bytes read as an upload in progress rather than as loss.
*
* The data-loss log deduplicates per item, so a false alarm doesn't merely add noise — it spends the one line that
* media id will ever get, and a real loss discovered later says nothing at all.
*
* @param createdAt When the media row was committed.
* @param now The instant to judge it against.
* @return True while the row is inside the upload window and its file may still be landing.
*/
private[controllers] def withinUploadWindow(createdAt: OffsetDateTime, now: OffsetDateTime): Boolean =
createdAt.isAfter(now.minus(lostMediaGrace))
}