Sat, 06 Jan 2024 20:32:56 +0100
Added tag v1.2.2 for changeset 238de141d189
1 /*
2 * Copyright 2021 Mike Becker. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions are met:
6 *
7 * 1. Redistributions of source code must retain the above copyright
8 * notice, this list of conditions and the following disclaimer.
9 *
10 * 2. Redistributions in binary form must reproduce the above copyright
11 * notice, this list of conditions and the following disclaimer in the
12 * documentation and/or other materials provided with the distribution.
13 *
14 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 */
26 package de.uapcore.lightpit.servlet
28 import de.uapcore.lightpit.*
29 import de.uapcore.lightpit.dao.DataAccessObject
30 import de.uapcore.lightpit.entities.*
31 import de.uapcore.lightpit.types.*
32 import de.uapcore.lightpit.viewmodel.*
33 import jakarta.servlet.annotation.WebServlet
34 import java.sql.Date
36 @WebServlet(urlPatterns = ["/projects/*"])
37 class ProjectServlet : AbstractServlet() {
39 init {
40 get("/", this::projects)
41 get("/%project", this::project)
42 get("/%project/issues/%version/%component/", this::project)
43 get("/%project/edit", this::projectForm)
44 get("/-/create", this::projectForm)
45 post("/-/commit", this::projectCommit)
46 post("/%project/vcs/analyze", this::vcsAnalyze)
48 get("/%project/versions/", this::versions)
49 get("/%project/versions/%version/edit", this::versionForm)
50 get("/%project/versions/-/create", this::versionForm)
51 post("/%project/versions/-/commit", this::versionCommit)
53 get("/%project/components/", this::components)
54 get("/%project/components/%component/edit", this::componentForm)
55 get("/%project/components/-/create", this::componentForm)
56 post("/%project/components/-/commit", this::componentCommit)
58 get("/%project/issues/%version/%component/%issue", this::issue)
59 get("/%project/issues/%version/%component/%issue/edit", this::issueForm)
60 post("/%project/issues/%version/%component/%issue/comment", this::issueComment)
61 post("/%project/issues/%version/%component/%issue/relation", this::issueRelation)
62 get("/%project/issues/%version/%component/%issue/removeRelation", this::issueRemoveRelation)
63 get("/%project/issues/%version/%component/-/create", this::issueForm)
64 post("/%project/issues/%version/%component/-/commit", this::issueCommit)
65 }
67 private fun projects(http: HttpRequest, dao: DataAccessObject) {
68 val projects = dao.listProjects()
69 val projectInfos = projects.map {
70 ProjectInfo(
71 project = it,
72 versions = dao.listVersions(it),
73 components = emptyList(), // not required in this view
74 issueSummary = dao.collectIssueSummary(it)
75 )
76 }
78 with(http) {
79 view = ProjectsView(projectInfos)
80 navigationMenu = projectNavMenu(projects)
81 styleSheets = listOf("projects")
82 render("projects")
83 }
84 }
86 private fun sanitizeNode(name: String): String {
87 val san = name.replace(Regex("[/\\\\]"), "-")
88 return if (san.startsWith(".")) {
89 "v$san"
90 } else {
91 san
92 }
93 }
95 private fun feedPath(project: Project) = "feed/${project.node}/issues.rss"
97 private fun project(http: HttpRequest, dao: DataAccessObject) {
98 withPathInfo(http, dao)?.let {path ->
99 val project = path.projectInfo.project
101 val filter = IssueFilter(http)
103 val needRelationsMap = filter.onlyBlocker
105 val relationsMap = if (needRelationsMap) dao.getIssueRelationMap(project, filter.includeDone) else emptyMap()
107 val specificVersion = path.versionInfo !is OptionalPathInfo.All
108 val version = if (path.versionInfo is OptionalPathInfo.Specific) path.versionInfo.elem else null
109 val specificComponent = path.componentInfo !is OptionalPathInfo.All
110 val component = if (path.componentInfo is OptionalPathInfo.Specific) path.componentInfo.elem else null
112 val issues = dao.listIssues(project, filter.includeDone, specificVersion, version, specificComponent, component)
113 .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary))
114 .filter {
115 (!filter.onlyMine || (it.assignee?.username ?: "") == (http.remoteUser ?: "<Anonymous>")) &&
116 (!filter.onlyBlocker || (relationsMap[it.id]?.any { (_,type) -> type.blocking }?:false)) &&
117 (filter.status.isEmpty() || filter.status.contains(it.status)) &&
118 (filter.category.isEmpty() || filter.category.contains(it.category))
119 }
121 with(http) {
122 pageTitle = project.name
123 view = ProjectDetails(path, issues, filter)
124 feedPath = feedPath(project)
125 navigationMenu = projectNavMenu(dao.listProjects(), path)
126 styleSheets = listOf("projects")
127 javascript = "project-details"
128 render("project-details")
129 }
130 }
131 }
133 private fun projectForm(http: HttpRequest, dao: DataAccessObject) {
134 http.styleSheets = listOf("projects")
135 if (!http.pathParams.containsKey("project")) {
136 http.view = ProjectEditView(Project(-1), dao.listUsers())
137 http.navigationMenu = projectNavMenu(dao.listProjects())
138 http.render("project-form")
139 } else {
140 withPathInfo(http, dao)?.let { path ->
141 http.view = ProjectEditView(path.projectInfo.project, dao.listUsers())
142 http.navigationMenu = projectNavMenu(dao.listProjects(), path)
143 http.render("project-form")
144 }
145 }
146 }
148 private fun projectCommit(http: HttpRequest, dao: DataAccessObject) {
149 val project = Project(http.param("id")?.toIntOrNull() ?: -1).apply {
150 name = http.param("name") ?: ""
151 node = http.param("node") ?: ""
152 description = http.param("description") ?: ""
153 ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
154 repoUrl = http.param("repoUrl") ?: ""
155 vcs = VcsType.valueOf(http.param("vcs") ?: "None")
156 owner = (http.param("owner")?.toIntOrNull() ?: -1).let {
157 if (it < 0) null else dao.findUser(it)
158 }
159 // intentional defaults
160 if (node.isBlank()) node = name
161 // sanitizing
162 node = sanitizeNode(node)
163 }
165 if (project.id < 0) {
166 dao.insertProject(project)
167 } else {
168 dao.updateProject(project)
169 }
171 http.renderCommit("projects/${project.node}")
172 }
174 private fun vcsAnalyze(http: HttpRequest, dao: DataAccessObject) {
175 withPathInfo(http, dao)?.let { path ->
176 // if analysis is not configured, reject the request
177 if (path.projectInfo.project.vcs == VcsType.None) {
178 http.response.sendError(404)
179 return
180 }
182 // obtain the list of issues for this project to filter cross-project references
183 val knownIds = dao.listIssues(path.projectInfo.project, true).map { it.id }
185 // read the provided commit log and merge only the refs that relate issues from the current project
186 dao.mergeCommitRefs(parseCommitRefs(http.body).filter { knownIds.contains(it.issueId) })
187 }
188 }
190 private fun versions(http: HttpRequest, dao: DataAccessObject) {
191 withPathInfo(http, dao)?.let { path ->
192 with(http) {
193 pageTitle = "${path.projectInfo.project.name} - ${i18n("navmenu.versions")}"
194 view = VersionsView(
195 path.projectInfo,
196 dao.listVersionSummaries(path.projectInfo.project)
197 )
198 feedPath = feedPath(path.projectInfo.project)
199 navigationMenu = projectNavMenu(dao.listProjects(), path)
200 styleSheets = listOf("projects")
201 javascript = "project-details"
202 render("versions")
203 }
204 }
205 }
207 private fun versionForm(http: HttpRequest, dao: DataAccessObject) {
208 withPathInfo(http, dao)?.let { path ->
209 val version = if (path.versionInfo is OptionalPathInfo.Specific)
210 path.versionInfo.elem else Version(-1, path.projectInfo.project.id)
212 with(http) {
213 view = VersionEditView(path.projectInfo, version)
214 feedPath = feedPath(path.projectInfo.project)
215 navigationMenu = projectNavMenu(dao.listProjects(), path)
216 styleSheets = listOf("projects")
217 render("version-form")
218 }
219 }
220 }
222 private fun obtainIdAndProject(http: HttpRequest, dao: DataAccessObject): Pair<Int, Project>? {
223 val id = http.param("id")?.toIntOrNull()
224 val projectid = http.param("projectid")?.toIntOrNull() ?: -1
225 val project = dao.findProject(projectid)
226 return if (id == null || project == null) {
227 http.response.sendError(400)
228 null
229 } else {
230 Pair(id, project)
231 }
232 }
234 private fun versionCommit(http: HttpRequest, dao: DataAccessObject) {
235 val idParams = obtainIdAndProject(http, dao) ?: return
236 val (id, project) = idParams
238 val version = Version(id, project.id).apply {
239 name = http.param("name") ?: ""
240 node = http.param("node") ?: ""
241 ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
242 status = http.param("status")?.let(VersionStatus::valueOf) ?: VersionStatus.Future
243 // TODO: process error messages
244 eol = http.param("eol", ::dateOptValidator, null, mutableListOf())
245 release = http.param("release", ::dateOptValidator, null, mutableListOf())
246 // intentional defaults
247 if (node.isBlank()) node = name
248 // sanitizing
249 node = sanitizeNode(node)
250 }
252 // sanitize eol and release date
253 if (version.status.isEndOfLife) {
254 if (version.eol == null) version.eol = Date(System.currentTimeMillis())
255 } else if (version.status.isReleased) {
256 if (version.release == null) version.release = Date(System.currentTimeMillis())
257 }
259 if (id < 0) {
260 dao.insertVersion(version)
261 } else {
262 dao.updateVersion(version)
263 }
265 http.renderCommit("projects/${project.node}/versions/")
266 }
268 private fun components(http: HttpRequest, dao: DataAccessObject) {
269 withPathInfo(http, dao)?.let { path ->
270 with(http) {
271 pageTitle = "${path.projectInfo.project.name} - ${i18n("navmenu.components")}"
272 view = ComponentsView(
273 path.projectInfo,
274 dao.listComponentSummaries(path.projectInfo.project)
275 )
276 feedPath = feedPath(path.projectInfo.project)
277 navigationMenu = projectNavMenu(dao.listProjects(), path)
278 styleSheets = listOf("projects")
279 javascript = "project-details"
280 render("components")
281 }
282 }
283 }
285 private fun componentForm(http: HttpRequest, dao: DataAccessObject) {
286 withPathInfo(http, dao)?.let { path ->
287 val component = if (path.componentInfo is OptionalPathInfo.Specific)
288 path.componentInfo.elem else Component(-1, path.projectInfo.project.id)
290 with(http) {
291 view = ComponentEditView(path.projectInfo, component, dao.listUsers())
292 feedPath = feedPath(path.projectInfo.project)
293 navigationMenu = projectNavMenu(dao.listProjects(), path)
294 styleSheets = listOf("projects")
295 render("component-form")
296 }
297 }
298 }
300 private fun componentCommit(http: HttpRequest, dao: DataAccessObject) {
301 val idParams = obtainIdAndProject(http, dao) ?: return
302 val (id, project) = idParams
304 val component = Component(id, project.id).apply {
305 name = http.param("name") ?: ""
306 node = http.param("node") ?: ""
307 ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
308 color = WebColor(http.param("color") ?: "#000000")
309 description = http.param("description")
310 // TODO: process error message
311 active = http.param("active", ::boolValidator, true, mutableListOf())
312 lead = (http.param("lead")?.toIntOrNull() ?: -1).let {
313 if (it < 0) null else dao.findUser(it)
314 }
315 // intentional defaults
316 if (node.isBlank()) node = name
317 // sanitizing
318 node = sanitizeNode(node)
319 }
321 if (id < 0) {
322 dao.insertComponent(component)
323 } else {
324 dao.updateComponent(component)
325 }
327 http.renderCommit("projects/${project.node}/components/")
328 }
330 private fun issue(http: HttpRequest, dao: DataAccessObject) {
331 val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
332 if (issue == null) {
333 http.response.sendError(404)
334 return
335 }
336 renderIssueView(http, dao, issue)
337 }
339 private fun renderIssueView(
340 http: HttpRequest,
341 dao: DataAccessObject,
342 issue: Issue,
343 relationError: String? = null
344 ) {
345 withPathInfo(http, dao)?.let {path ->
346 val comments = dao.listComments(issue)
348 with(http) {
349 pageTitle = "${path.projectInfo.project.name}: #${issue.id} ${issue.subject}"
350 view = IssueDetailView(
351 path,
352 issue,
353 comments,
354 path.projectInfo.project,
355 dao.listIssues(path.projectInfo.project, true),
356 dao.listIssueRelations(issue),
357 relationError,
358 dao.listCommitRefs(issue)
359 )
360 feedPath = feedPath(path.projectInfo.project)
361 navigationMenu = projectNavMenu(dao.listProjects(), path)
362 styleSheets = listOf("projects")
363 javascript = "issue-editor"
364 render("issue-view")
365 }
366 }
367 }
369 private fun issueForm(http: HttpRequest, dao: DataAccessObject) {
370 withPathInfo(http, dao)?.let { path ->
371 val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) ?: Issue(
372 -1,
373 path.projectInfo.project,
374 )
376 // for new issues set some defaults
377 if (issue.id < 0) {
378 // pre-select component, if available in the path info
379 if (path.componentInfo is OptionalPathInfo.Specific) {
380 issue.component = path.componentInfo.elem
381 }
383 // pre-select version, if available in the path info
384 if (path.versionInfo is OptionalPathInfo.Specific) {
385 if (path.versionInfo.elem.status.isReleased) {
386 issue.affected = path.versionInfo.elem
387 } else {
388 issue.resolved = path.versionInfo.elem
389 }
390 }
391 }
393 with(http) {
394 view = IssueEditView(
395 issue,
396 path.projectInfo.versions,
397 path.projectInfo.components,
398 dao.listUsers(),
399 path.projectInfo.project,
400 path
401 )
402 feedPath = feedPath(path.projectInfo.project)
403 navigationMenu = projectNavMenu(dao.listProjects(), path)
404 styleSheets = listOf("projects")
405 javascript = "issue-editor"
406 render("issue-form")
407 }
408 }
409 }
411 private fun issueComment(http: HttpRequest, dao: DataAccessObject) {
412 withPathInfo(http, dao)?.run {
413 val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
414 if (issue == null) {
415 http.response.sendError(404)
416 return
417 }
419 val commentId = http.param("commentid")?.toIntOrNull() ?: -1
420 if (commentId > 0) {
421 val comment = dao.findComment(commentId)
422 if (comment == null) {
423 http.response.sendError(404)
424 return
425 }
426 val originalAuthor = comment.author?.username
427 if (originalAuthor != null && originalAuthor == http.remoteUser) {
428 val newComment = http.param("comment")
429 if (!newComment.isNullOrBlank()) {
430 comment.comment = newComment
431 dao.updateComment(comment)
432 dao.insertHistoryEvent(issue, comment)
433 } else {
434 logger.debug("Not updating comment ${comment.id} because nothing changed.")
435 }
436 } else {
437 http.response.sendError(403)
438 return
439 }
440 } else {
441 val comment = IssueComment(-1, issue.id).apply {
442 author = http.remoteUser?.let { dao.findUserByName(it) }
443 comment = http.param("comment") ?: ""
444 }
445 val newId = dao.insertComment(comment)
446 dao.insertHistoryEvent(issue, comment, newId)
447 }
449 http.renderCommit("${issuesHref}${issue.id}")
450 }
451 }
453 private fun issueCommit(http: HttpRequest, dao: DataAccessObject) {
454 withPathInfo(http, dao)?.run {
455 val issue = Issue(
456 http.param("id")?.toIntOrNull() ?: -1,
457 projectInfo.project
458 ).apply {
459 component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1)
460 category = IssueCategory.valueOf(http.param("category") ?: "")
461 status = IssueStatus.valueOf(http.param("status") ?: "")
462 subject = http.param("subject") ?: ""
463 description = http.param("description") ?: ""
464 assignee = http.param("assignee")?.toIntOrNull()?.let {
465 when (it) {
466 -1 -> null
467 -2 -> component?.lead
468 else -> dao.findUser(it)
469 }
470 }
471 // TODO: process error messages
472 eta = http.param("eta", ::dateOptValidator, null, mutableListOf())
474 affected = http.param("affected")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
475 resolved = http.param("resolved")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
476 }
478 val openId = if (issue.id < 0) {
479 val id = dao.insertIssue(issue)
480 dao.insertHistoryEvent(issue, id)
481 id
482 } else {
483 val reference = dao.findIssue(issue.id)
484 if (reference == null) {
485 http.response.sendError(404)
486 return
487 }
489 if (issue.hasChanged(reference)) {
490 dao.updateIssue(issue)
491 dao.insertHistoryEvent(issue)
492 } else {
493 logger.debug("Not updating issue ${issue.id} because nothing changed.")
494 }
496 val newComment = http.param("comment")
497 if (!newComment.isNullOrBlank()) {
498 val comment = IssueComment(-1, issue.id).apply {
499 author = http.remoteUser?.let { dao.findUserByName(it) }
500 comment = newComment
501 }
502 val commentid = dao.insertComment(comment)
503 dao.insertHistoryEvent(issue, comment, commentid)
504 }
505 issue.id
506 }
508 if (http.param("more") != null) {
509 http.renderCommit("${issuesHref}-/create")
510 } else {
511 http.renderCommit("${issuesHref}${openId}")
512 }
513 }
514 }
516 private fun issueRelation(http: HttpRequest, dao: DataAccessObject) {
517 withPathInfo(http, dao)?.run {
518 val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
519 if (issue == null) {
520 http.response.sendError(404)
521 return
522 }
524 // determine the relation type
525 val type: Pair<RelationType, Boolean>? = http.param("type")?.let {
526 try {
527 if (it.startsWith("!")) {
528 Pair(RelationType.valueOf(it.substring(1)), true)
529 } else {
530 Pair(RelationType.valueOf(it), false)
531 }
532 } catch (_: IllegalArgumentException) {
533 null
534 }
535 }
537 // if the relation type was invalid, send HTTP 500
538 if (type == null) {
539 http.response.sendError(500)
540 return
541 }
543 // determine the target issue
544 val targetIssue: Issue? = http.param("issue")?.let {
545 if (it.startsWith("#") && it.length > 1) {
546 it.substring(1).split(" ", limit = 2)[0].toIntOrNull()
547 ?.let(dao::findIssue)
548 ?.takeIf { target -> target.project.id == issue.project.id }
549 } else {
550 null
551 }
552 }
554 // check if the target issue is valid
555 if (targetIssue == null) {
556 renderIssueView(http, dao, issue, "issue.relations.target.invalid")
557 return
558 }
560 // commit the result
561 dao.insertIssueRelation(IssueRelation(issue, targetIssue, type.first, type.second))
562 http.renderCommit("${issuesHref}${issue.id}")
563 }
564 }
566 private fun issueRemoveRelation(http: HttpRequest, dao: DataAccessObject) {
567 withPathInfo(http, dao)?.run {
568 val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
569 if (issue == null) {
570 http.response.sendError(404)
571 return
572 }
574 // determine relation
575 val type = http.param("type")?.let {
576 try {RelationType.valueOf(it)}
577 catch (_:IllegalArgumentException) {null}
578 }
579 if (type == null) {
580 http.response.sendError(500)
581 return
582 }
583 val rel = http.param("to")?.toIntOrNull()?.let(dao::findIssue)?.let {
584 IssueRelation(
585 issue,
586 it,
587 type,
588 http.param("reverse")?.toBoolean() ?: false
589 )
590 }
592 // execute removal, if there is something to remove
593 rel?.run(dao::deleteIssueRelation)
595 // always pretend that the operation was successful - if there was nothing to remove, it's okay
596 http.renderCommit("${issuesHref}${issue.id}")
597 }
598 }
599 }