feat: Реализация базового CalDav без to-do через REST API обёртку
feat: Информативное логгирование и ответы сервера
This commit is contained in:
188
API_DOCS.md
Normal file
188
API_DOCS.md
Normal file
@@ -0,0 +1,188 @@
|
||||
## Calendar API Documentation
|
||||
|
||||
### Аутентификация
|
||||
Все эндпоинты требуют Basic авторизации. Используйте ваши учетные данные от CalDAV сервера.
|
||||
|
||||
```bash
|
||||
# Заголовки для всех запросов
|
||||
Authorization: Basic base64(username:password)
|
||||
CAL_ID: your_calendar_id
|
||||
```
|
||||
|
||||
### Обязательные заголовки
|
||||
- `Authorization`: Basic авторизация с креденшлами CalDAV
|
||||
- `CAL_ID`: Идентификатор календаря (например: "personal", "work", "family")
|
||||
|
||||
### Эндпоинты
|
||||
|
||||
#### 1. Получить все события
|
||||
```bash
|
||||
GET /calendar/events
|
||||
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
|
||||
CAL_ID: personal
|
||||
|
||||
# Ответ:
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uid": "12345-67890-abcdef",
|
||||
"summary": "Важная встреча",
|
||||
"description": "Обсуждение проекта",
|
||||
"startDateTime": "2025-07-20T10:00:00",
|
||||
"endDateTime": "2025-07-20T11:30:00",
|
||||
"location": "Конференц-зал"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Создать новое событие
|
||||
```bash
|
||||
POST /calendar/events
|
||||
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
|
||||
CAL_ID: personal
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"summary": "Новая встреча",
|
||||
"description": "Описание встречи",
|
||||
"startDateTime": "2025-07-20T14:00:00",
|
||||
"endDateTime": "2025-07-20T15:00:00",
|
||||
"location": "Офис"
|
||||
}
|
||||
|
||||
# Ответ (201 Created):
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uid": "generated-uid-12345",
|
||||
"summary": "Новая встреча",
|
||||
"description": "Описание встречи",
|
||||
"startDateTime": "2025-07-20T14:00:00",
|
||||
"endDateTime": "2025-07-20T15:00:00",
|
||||
"location": "Офис"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Получить событие по UID
|
||||
```bash
|
||||
GET /calendar/events/{uid}
|
||||
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
|
||||
CAL_ID: personal
|
||||
|
||||
# Ответ:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uid": "12345-67890-abcdef",
|
||||
"summary": "Важная встреча",
|
||||
"description": "Обсуждение проекта",
|
||||
"startDateTime": "2025-07-20T10:00:00",
|
||||
"endDateTime": "2025-07-20T11:30:00",
|
||||
"location": "Конференц-зал"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Обновить событие
|
||||
```bash
|
||||
PUT /calendar/events/{uid}
|
||||
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
|
||||
CAL_ID: personal
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"summary": "Обновленная встреча",
|
||||
"description": "Новое описание",
|
||||
"startDateTime": "2025-07-20T15:00:00",
|
||||
"endDateTime": "2025-07-20T16:00:00",
|
||||
"location": "Новый офис"
|
||||
}
|
||||
|
||||
# Ответ:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uid": "12345-67890-abcdef",
|
||||
"summary": "Обновленная встреча",
|
||||
"description": "Новое описание",
|
||||
"startDateTime": "2025-07-20T15:00:00",
|
||||
"endDateTime": "2025-07-20T16:00:00",
|
||||
"location": "Новый офис"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Удалить событие
|
||||
```bash
|
||||
DELETE /calendar/events/{uid}
|
||||
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
|
||||
CAL_ID: personal
|
||||
|
||||
# Ответ:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Событие успешно удалено"
|
||||
}
|
||||
```
|
||||
|
||||
### Примеры с curl
|
||||
|
||||
```bash
|
||||
# Получить все события из календаря "work"
|
||||
curl -X GET "http://localhost:8080/calendar/events" \
|
||||
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
|
||||
-H "CAL_ID: work"
|
||||
|
||||
# Создать событие в календаре "personal"
|
||||
curl -X POST "http://localhost:8080/calendar/events" \
|
||||
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
|
||||
-H "CAL_ID: personal" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"summary": "Тестовая встреча",
|
||||
"description": "Описание тестовой встречи",
|
||||
"startDateTime": "2025-07-20T10:00:00",
|
||||
"endDateTime": "2025-07-20T11:00:00",
|
||||
"location": "Онлайн"
|
||||
}'
|
||||
|
||||
# Обновить событие в календаре "family"
|
||||
curl -X PUT "http://localhost:8080/calendar/events/your-event-uid" \
|
||||
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
|
||||
-H "CAL_ID: family" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"summary": "Семейный обед",
|
||||
"startDateTime": "2025-07-20T14:00:00",
|
||||
"endDateTime": "2025-07-20T15:00:00"
|
||||
}'
|
||||
|
||||
# Удалить событие из календаря "personal"
|
||||
curl -X DELETE "http://localhost:8080/calendar/events/your-event-uid" \
|
||||
-H "Authorization: Basic $(echo -n 'username:password' | base64)" \
|
||||
-H "CAL_ID: personal"
|
||||
```
|
||||
|
||||
### Ошибки
|
||||
|
||||
При отсутствии заголовка `CAL_ID`:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Заголовок CAL_ID обязателен"
|
||||
}
|
||||
```
|
||||
|
||||
### Форматы дат
|
||||
Все даты должны быть в формате ISO LocalDateTime: `YYYY-MM-DDTHH:mm:ss`
|
||||
Например: `2025-07-20T14:30:00`
|
||||
|
||||
### Поддерживаемые календари
|
||||
API поддерживает работу с любыми календарями, доступными пользователю в CalDAV:
|
||||
- `personal` - личный календарь
|
||||
- `work` - рабочий календарь
|
||||
- `family` - семейный календарь
|
||||
- Любые другие календари, созданные пользователем
|
||||
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# webdav-service
|
||||
|
||||
This project was created using the [Ktor Project Generator](https://start.ktor.io).
|
||||
|
||||
Here are some useful links to get you started:
|
||||
|
||||
- [Ktor Documentation](https://ktor.io/docs/home.html)
|
||||
- [Ktor GitHub page](https://github.com/ktorio/ktor)
|
||||
- The [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). You'll need
|
||||
to [request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up) to join.
|
||||
|
||||
## Features
|
||||
|
||||
Here's a list of features included in this project:
|
||||
|
||||
| Name | Description |
|
||||
|------------------------------------------------------------------------|------------------------------------------------------------------------------------|
|
||||
| [Routing](https://start.ktor.io/p/routing) | Provides a structured routing DSL |
|
||||
| [Authentication](https://start.ktor.io/p/auth) | Provides extension point for handling the Authorization header |
|
||||
| [Authentication Basic](https://start.ktor.io/p/auth-basic) | Handles 'Basic' username / password authentication scheme |
|
||||
| [Call Logging](https://start.ktor.io/p/call-logging) | Logs client requests |
|
||||
| [Content Negotiation](https://start.ktor.io/p/content-negotiation) | Provides automatic content conversion according to Content-Type and Accept headers |
|
||||
| [kotlinx.serialization](https://start.ktor.io/p/kotlinx-serialization) | Handles JSON serialization using kotlinx.serialization library |
|
||||
|
||||
## Building & Running
|
||||
|
||||
To build or run the project, use one of the following tasks:
|
||||
|
||||
| Task | Description |
|
||||
|-------------------------------|----------------------------------------------------------------------|
|
||||
| `./gradlew test` | Run the tests |
|
||||
| `./gradlew build` | Build everything |
|
||||
| `buildFatJar` | Build an executable JAR of the server with all dependencies included |
|
||||
| `buildImage` | Build the docker image to use with the fat JAR |
|
||||
| `publishImageToLocalRegistry` | Publish the docker image locally |
|
||||
| `run` | Run the server |
|
||||
| `runDocker` | Run using the local docker image |
|
||||
|
||||
If the server starts successfully, you'll see the following output:
|
||||
|
||||
```
|
||||
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
|
||||
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
|
||||
```
|
||||
|
||||
40
build.gradle.kts
Normal file
40
build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
||||
val kotlin_version: String by project
|
||||
val logback_version: String by project
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") version "2.1.10"
|
||||
id("io.ktor.plugin") version "3.2.2"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
|
||||
}
|
||||
|
||||
group = "com.nano"
|
||||
version = "0.0.1"
|
||||
|
||||
application {
|
||||
mainClass = "com.nano.ApplicationKt"
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven("https://jitpack.io")
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-server-core")
|
||||
implementation("io.ktor:ktor-server-auth")
|
||||
implementation("io.ktor:ktor-server-call-logging")
|
||||
implementation("io.ktor:ktor-server-content-negotiation")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json")
|
||||
implementation("io.ktor:ktor-server-netty")
|
||||
implementation("ch.qos.logback:logback-classic:$logback_version")
|
||||
implementation("com.github.bitfireAT:dav4jvm:2.2.1")
|
||||
|
||||
testImplementation("io.ktor:ktor-server-test-host")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
|
||||
}
|
||||
17
src/main/kotlin/Application.kt
Normal file
17
src/main/kotlin/Application.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.nano
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.netty.*
|
||||
|
||||
fun main() {
|
||||
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
|
||||
.start(wait = true)
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
configureSecurity()
|
||||
configureMonitoring()
|
||||
configureSerialization()
|
||||
configureRouting()
|
||||
}
|
||||
18
src/main/kotlin/Monitoring.kt
Normal file
18
src/main/kotlin/Monitoring.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.nano
|
||||
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.plugins.calllogging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.slf4j.event.*
|
||||
|
||||
fun Application.configureMonitoring() {
|
||||
install(CallLogging) {
|
||||
level = Level.INFO
|
||||
filter { call -> call.request.path().startsWith("/") }
|
||||
}
|
||||
}
|
||||
347
src/main/kotlin/Routing.kt
Normal file
347
src/main/kotlin/Routing.kt
Normal file
@@ -0,0 +1,347 @@
|
||||
package com.nano
|
||||
|
||||
import com.nano.Service.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.plugins.calllogging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.slf4j.event.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Serializable
|
||||
data class CreateEventRequest(
|
||||
val summary: String,
|
||||
val description: String? = null,
|
||||
val startDateTime: String, // ISO format: "2025-07-20T10:00:00"
|
||||
val endDateTime: String,
|
||||
val location: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EventResponse(
|
||||
val uid: String,
|
||||
val summary: String,
|
||||
val description: String? = null,
|
||||
val startDateTime: String,
|
||||
val endDateTime: String,
|
||||
val location: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiResponse<T>(
|
||||
val success: Boolean,
|
||||
val data: T? = null,
|
||||
val message: String? = null
|
||||
)
|
||||
|
||||
fun Application.configureRouting() {
|
||||
routing {
|
||||
authenticate("calendar-auth") {
|
||||
route("/calendar") {
|
||||
route("/events") {
|
||||
|
||||
// Получение всех событий
|
||||
get {
|
||||
try {
|
||||
val principal = call.principal<UserIdPrincipal>()!!
|
||||
val username = principal.name
|
||||
|
||||
// Получаем пароль из заголовка Authorization для создания DAV подключения
|
||||
val authHeader = call.request.headers["Authorization"]
|
||||
val password = authHeader?.let { header ->
|
||||
val encoded = header.substringAfter("Basic ")
|
||||
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
|
||||
decoded.substringAfter(":")
|
||||
} ?: ""
|
||||
|
||||
// Получаем календарь ID из заголовка CAL_ID
|
||||
val calendarId = call.request.headers["CAL_ID"] ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
|
||||
)
|
||||
|
||||
val dav = Dav(username, password, calendarId)
|
||||
val eventManager = dav.getEventManager()
|
||||
|
||||
eventManager.getAllEvents().fold(
|
||||
onSuccess = { events ->
|
||||
val eventResponses = events.map { event ->
|
||||
EventResponse(
|
||||
uid = event.uid,
|
||||
summary = event.summary,
|
||||
description = event.description,
|
||||
startDateTime = event.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
endDateTime = event.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
location = event.location
|
||||
)
|
||||
}
|
||||
call.respond(ApiResponse(success = true, data = eventResponses))
|
||||
},
|
||||
onFailure = { error ->
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<String>(success = false, message = error.message)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<String>(success = false, message = "Неожиданная ошибка: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Создание нового события
|
||||
post {
|
||||
try {
|
||||
val principal = call.principal<UserIdPrincipal>()!!
|
||||
val username = principal.name
|
||||
|
||||
val authHeader = call.request.headers["Authorization"]
|
||||
val password = authHeader?.let { header ->
|
||||
val encoded = header.substringAfter("Basic ")
|
||||
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
|
||||
decoded.substringAfter(":")
|
||||
} ?: ""
|
||||
|
||||
// Получаем календарь ID из заголовка CAL_ID
|
||||
val calendarId = call.request.headers["CAL_ID"] ?: return@post call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
|
||||
)
|
||||
|
||||
val request = call.receive<CreateEventRequest>()
|
||||
|
||||
val event = CalendarEvent(
|
||||
summary = request.summary,
|
||||
description = request.description,
|
||||
startDateTime = LocalDateTime.parse(request.startDateTime),
|
||||
endDateTime = LocalDateTime.parse(request.endDateTime),
|
||||
location = request.location
|
||||
)
|
||||
|
||||
val dav = Dav(username, password, calendarId)
|
||||
val eventManager = dav.getEventManager()
|
||||
|
||||
eventManager.addEvent(event).fold(
|
||||
onSuccess = { createdEvent ->
|
||||
val response = EventResponse(
|
||||
uid = createdEvent.uid,
|
||||
summary = createdEvent.summary,
|
||||
description = createdEvent.description,
|
||||
startDateTime = createdEvent.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
endDateTime = createdEvent.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
location = createdEvent.location
|
||||
)
|
||||
call.respond(HttpStatusCode.Created, ApiResponse(success = true, data = response))
|
||||
},
|
||||
onFailure = { error ->
|
||||
val statusCode = when {
|
||||
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
|
||||
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
|
||||
error.message?.contains("не найден") == true -> HttpStatusCode.NotFound
|
||||
error.message?.contains("Конфликт") == true -> HttpStatusCode.Conflict
|
||||
else -> HttpStatusCode.BadRequest
|
||||
}
|
||||
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<String>(success = false, message = "Ошибка создания события: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Получение события по UID
|
||||
get("/{uid}") {
|
||||
try {
|
||||
val principal = call.principal<UserIdPrincipal>()!!
|
||||
val username = principal.name
|
||||
val uid = call.parameters["uid"] ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "UID события не указан")
|
||||
)
|
||||
|
||||
val authHeader = call.request.headers["Authorization"]
|
||||
val password = authHeader?.let { header ->
|
||||
val encoded = header.substringAfter("Basic ")
|
||||
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
|
||||
decoded.substringAfter(":")
|
||||
} ?: ""
|
||||
|
||||
// Получаем календарь ID из заголовка CAL_ID
|
||||
val calendarId = call.request.headers["CAL_ID"] ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
|
||||
)
|
||||
|
||||
val dav = Dav(username, password, calendarId)
|
||||
val eventManager = dav.getEventManager()
|
||||
|
||||
eventManager.getEvent(uid).fold(
|
||||
onSuccess = { event ->
|
||||
if (event != null) {
|
||||
val response = EventResponse(
|
||||
uid = event.uid,
|
||||
summary = event.summary,
|
||||
description = event.description,
|
||||
startDateTime = event.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
endDateTime = event.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
location = event.location
|
||||
)
|
||||
call.respond(ApiResponse(success = true, data = response))
|
||||
} else {
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
ApiResponse<String>(success = false, message = "Событие не найдено")
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
val statusCode = when {
|
||||
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
|
||||
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
|
||||
error.message?.contains("не найдено") == true -> HttpStatusCode.NotFound
|
||||
else -> HttpStatusCode.InternalServerError
|
||||
}
|
||||
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<String>(success = false, message = "Ошибка получения события: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление события
|
||||
put("/{uid}") {
|
||||
try {
|
||||
val principal = call.principal<UserIdPrincipal>()!!
|
||||
val username = principal.name
|
||||
val uid = call.parameters["uid"] ?: return@put call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "UID события не указан")
|
||||
)
|
||||
|
||||
val authHeader = call.request.headers["Authorization"]
|
||||
val password = authHeader?.let { header ->
|
||||
val encoded = header.substringAfter("Basic ")
|
||||
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
|
||||
decoded.substringAfter(":")
|
||||
} ?: ""
|
||||
|
||||
// Получаем календарь ID из заголовка CAL_ID
|
||||
val calendarId = call.request.headers["CAL_ID"] ?: return@put call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
|
||||
)
|
||||
|
||||
val request = call.receive<CreateEventRequest>()
|
||||
|
||||
val event = CalendarEvent(
|
||||
uid = uid,
|
||||
summary = request.summary,
|
||||
description = request.description,
|
||||
startDateTime = LocalDateTime.parse(request.startDateTime),
|
||||
endDateTime = LocalDateTime.parse(request.endDateTime),
|
||||
location = request.location
|
||||
)
|
||||
|
||||
val dav = Dav(username, password, calendarId)
|
||||
val eventManager = dav.getEventManager()
|
||||
|
||||
eventManager.updateEvent(event).fold(
|
||||
onSuccess = { updatedEvent ->
|
||||
val response = EventResponse(
|
||||
uid = updatedEvent.uid,
|
||||
summary = updatedEvent.summary,
|
||||
description = updatedEvent.description,
|
||||
startDateTime = updatedEvent.startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
endDateTime = updatedEvent.endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||
location = updatedEvent.location
|
||||
)
|
||||
call.respond(ApiResponse(success = true, data = response))
|
||||
},
|
||||
onFailure = { error ->
|
||||
val statusCode = when {
|
||||
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
|
||||
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
|
||||
error.message?.contains("не найдено") == true -> HttpStatusCode.NotFound
|
||||
error.message?.contains("Конфликт версий") == true -> HttpStatusCode.PreconditionFailed
|
||||
else -> HttpStatusCode.BadRequest
|
||||
}
|
||||
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<String>(success = false, message = "Ошибка обновления события: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление события
|
||||
delete("/{uid}") {
|
||||
try {
|
||||
val principal = call.principal<UserIdPrincipal>()!!
|
||||
val username = principal.name
|
||||
val uid = call.parameters["uid"] ?: return@delete call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "UID события не указан")
|
||||
)
|
||||
|
||||
val authHeader = call.request.headers["Authorization"]
|
||||
val password = authHeader?.let { header ->
|
||||
val encoded = header.substringAfter("Basic ")
|
||||
val decoded = java.util.Base64.getDecoder().decode(encoded).toString(Charsets.UTF_8)
|
||||
decoded.substringAfter(":")
|
||||
} ?: ""
|
||||
|
||||
// Получаем календарь ID из заголовка CAL_ID
|
||||
val calendarId = call.request.headers["CAL_ID"] ?: return@delete call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<String>(success = false, message = "Заголовок CAL_ID обязателен")
|
||||
)
|
||||
|
||||
val dav = Dav(username, password, calendarId)
|
||||
val eventManager = dav.getEventManager()
|
||||
|
||||
eventManager.deleteEvent(uid).fold(
|
||||
onSuccess = {
|
||||
call.respond(ApiResponse<String>(success = true, message = "Событие успешно удалено"))
|
||||
},
|
||||
onFailure = { error ->
|
||||
val statusCode = when {
|
||||
error.message?.contains("авторизации") == true -> HttpStatusCode.Unauthorized
|
||||
error.message?.contains("запрещен") == true -> HttpStatusCode.Forbidden
|
||||
error.message?.contains("не найдено") == true -> HttpStatusCode.NotFound
|
||||
else -> HttpStatusCode.BadRequest
|
||||
}
|
||||
call.respond(statusCode, ApiResponse<String>(success = false, message = error.message))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<String>(success = false, message = "Ошибка удаления события: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/main/kotlin/Security.kt
Normal file
31
src/main/kotlin/Security.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.nano
|
||||
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.plugins.calllogging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.slf4j.event.*
|
||||
|
||||
data class CalendarCredentials(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
fun Application.configureSecurity() {
|
||||
authentication {
|
||||
basic(name = "calendar-auth") {
|
||||
realm = "Calendar Access"
|
||||
validate { credentials ->
|
||||
if (credentials.name.isNotEmpty() && credentials.password.isNotEmpty()) {
|
||||
UserIdPrincipal(credentials.name)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/main/kotlin/Serialization.kt
Normal file
22
src/main/kotlin/Serialization.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.nano
|
||||
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.plugins.calllogging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.slf4j.event.*
|
||||
|
||||
fun Application.configureSerialization() {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
routing {
|
||||
get("/json/kotlinx-serialization") {
|
||||
call.respond(mapOf("hello" to "world"))
|
||||
}
|
||||
}
|
||||
}
|
||||
368
src/main/kotlin/Service/Calendar.kt
Normal file
368
src/main/kotlin/Service/Calendar.kt
Normal file
@@ -0,0 +1,368 @@
|
||||
package com.nano.Service
|
||||
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.CalendarData
|
||||
import io.ktor.http.Url
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.slf4j.LoggerFactory.getLogger
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
import kotlin.math.log
|
||||
|
||||
enum class DAVHOST(val url: String) {
|
||||
BAIKAL("https://baikal.illegalfiles.icu/dav.php"),
|
||||
}
|
||||
|
||||
data class CalendarEvent(
|
||||
val uid: String = UUID.randomUUID().toString(),
|
||||
val summary: String,
|
||||
val description: String? = null,
|
||||
val startDateTime: LocalDateTime,
|
||||
val endDateTime: LocalDateTime,
|
||||
val location: String? = null
|
||||
)
|
||||
|
||||
class Dav(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val calendarName: String,
|
||||
server: DAVHOST = DAVHOST.BAIKAL,
|
||||
) {
|
||||
val baseUrl = Url(server.url)
|
||||
|
||||
val authHandler = BasicDigestAuthHandler(
|
||||
domain = null, // Убираем ограничение по домену
|
||||
username = username,
|
||||
password = password,
|
||||
)
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
.authenticator(authHandler)
|
||||
.addNetworkInterceptor(authHandler)
|
||||
.build()
|
||||
|
||||
val calDav = DavCalendar(
|
||||
client,
|
||||
location = baseUrl.toString().toHttpUrlOrNull()?.newBuilder()
|
||||
?.addPathSegment("calendars")
|
||||
?.build() ?: throw IllegalArgumentException("Invalid URL: $baseUrl"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Получает менеджер для работы с событиями календаря
|
||||
*/
|
||||
fun getEventManager(): CalendarEventManager {
|
||||
return CalendarEventManager(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Менеджер для работы с событиями календаря.
|
||||
* Реализует основные CRUD операции с использованием библиотеки dav4jvm
|
||||
*/
|
||||
class CalendarEventManager(private val dav: Dav) {
|
||||
|
||||
private val logger = getLogger("Dav")
|
||||
|
||||
private fun createICalendarEvent(event: CalendarEvent): String {
|
||||
val dtFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")
|
||||
val now = LocalDateTime.now().format(dtFormatter)
|
||||
|
||||
return """BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//WebDAV Service//CalDAV Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:${event.uid}
|
||||
DTSTAMP:${now}Z
|
||||
DTSTART:${event.startDateTime.format(dtFormatter)}Z
|
||||
DTEND:${event.endDateTime.format(dtFormatter)}Z
|
||||
SUMMARY:${event.summary}
|
||||
${if (!event.description.isNullOrEmpty()) "DESCRIPTION:${event.description}" else ""}
|
||||
${if (!event.location.isNullOrEmpty()) "LOCATION:${event.location}" else ""}
|
||||
END:VEVENT
|
||||
END:VCALENDAR""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет новое событие в календарь
|
||||
*/
|
||||
fun addEvent(event: CalendarEvent): Result<CalendarEvent> {
|
||||
return try {
|
||||
val iCalData = createICalendarEvent(event)
|
||||
val eventUrl = dav.calDav.location.newBuilder()
|
||||
.addPathSegment(dav.username)
|
||||
.addPathSegment(dav.calendarName)
|
||||
.addPathSegment("${event.uid}.ics")
|
||||
.build()
|
||||
|
||||
val davResource = DavResource(dav.client, eventUrl)
|
||||
val requestBody = iCalData.toRequestBody("text/calendar; charset=utf-8".toMediaType())
|
||||
|
||||
var responseCode: Int? = null
|
||||
var responseMessage: String? = null
|
||||
|
||||
davResource.put(requestBody) { response ->
|
||||
responseCode = response.code
|
||||
responseMessage = response.message
|
||||
}
|
||||
|
||||
when {
|
||||
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
|
||||
responseCode in 200..299 -> Result.success(event)
|
||||
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
|
||||
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для записи в календарь '${dav.calendarName}'"))
|
||||
responseCode == 404 -> Result.failure(Exception("Календарь '${dav.calendarName}' не найден"))
|
||||
responseCode == 409 -> Result.failure(Exception("Конфликт: событие с UID '${event.uid}' уже существует"))
|
||||
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
|
||||
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("Ошибка при добавлении события: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает событие по UID
|
||||
*/
|
||||
fun getEvent(uid: String): Result<CalendarEvent?> {
|
||||
return try {
|
||||
val eventUrl = dav.calDav.location.newBuilder()
|
||||
.addPathSegment(dav.username)
|
||||
.addPathSegment(dav.calendarName)
|
||||
.addPathSegment("$uid.ics")
|
||||
.build()
|
||||
|
||||
val davResource = DavResource(dav.client, eventUrl)
|
||||
var eventData: String? = null
|
||||
var responseCode: Int? = null
|
||||
var responseMessage: String? = null
|
||||
|
||||
davResource.get("text/calendar", null) { response ->
|
||||
responseCode = response.code
|
||||
responseMessage = response.message
|
||||
if (response.isSuccessful) {
|
||||
eventData = response.body?.string()
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
|
||||
responseCode == 200 -> {
|
||||
val event = eventData?.let { parseICalendarEvent(it, uid) }
|
||||
Result.success(event)
|
||||
}
|
||||
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
|
||||
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для чтения календаря '${dav.calendarName}'"))
|
||||
responseCode == 404 -> Result.failure(Exception("Событие с UID '$uid' не найдено в календаре '${dav.calendarName}'"))
|
||||
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
|
||||
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("Ошибка при получении события: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет существующее событие
|
||||
*/
|
||||
fun updateEvent(event: CalendarEvent): Result<CalendarEvent> {
|
||||
return try {
|
||||
val iCalData = createICalendarEvent(event)
|
||||
val eventUrl = dav.calDav.location.newBuilder()
|
||||
.addPathSegment(dav.username)
|
||||
.addPathSegment(dav.calendarName)
|
||||
.addPathSegment("${event.uid}.ics")
|
||||
.build()
|
||||
|
||||
val davResource = DavResource(dav.client, eventUrl)
|
||||
val requestBody = iCalData.toRequestBody("text/calendar; charset=utf-8".toMediaType())
|
||||
|
||||
var responseCode: Int? = null
|
||||
var responseMessage: String? = null
|
||||
|
||||
davResource.put(requestBody) { response ->
|
||||
responseCode = response.code
|
||||
responseMessage = response.message
|
||||
}
|
||||
|
||||
when {
|
||||
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
|
||||
responseCode in 200..299 -> Result.success(event)
|
||||
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
|
||||
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для изменения календаря '${dav.calendarName}'"))
|
||||
responseCode == 404 -> Result.failure(Exception("Событие с UID '${event.uid}' не найдено в календаре '${dav.calendarName}'"))
|
||||
responseCode == 412 -> Result.failure(Exception("Конфликт версий: событие было изменено другим пользователем"))
|
||||
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
|
||||
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("Ошибка при обновлении события: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет событие по UID
|
||||
*/
|
||||
fun deleteEvent(uid: String): Result<Unit> {
|
||||
return try {
|
||||
val eventUrl = dav.calDav.location.newBuilder()
|
||||
.addPathSegment(dav.username)
|
||||
.addPathSegment(dav.calendarName)
|
||||
.addPathSegment("$uid.ics")
|
||||
.build()
|
||||
|
||||
val davResource = DavResource(dav.client, eventUrl)
|
||||
var responseCode: Int? = null
|
||||
var responseMessage: String? = null
|
||||
|
||||
davResource.delete { response ->
|
||||
responseCode = response.code
|
||||
responseMessage = response.message
|
||||
}
|
||||
|
||||
when {
|
||||
responseCode == null -> Result.failure(Exception("Не удалось получить ответ от сервера"))
|
||||
responseCode in 200..299 -> Result.success(Unit)
|
||||
responseCode == 401 -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
|
||||
responseCode == 403 -> Result.failure(Exception("Доступ запрещен: недостаточно прав для удаления из календаря '${dav.calendarName}'"))
|
||||
responseCode == 404 -> Result.failure(Exception("Событие с UID '$uid' не найдено в календаре '${dav.calendarName}'"))
|
||||
responseCode in 500..599 -> Result.failure(Exception("Ошибка сервера ($responseCode): $responseMessage"))
|
||||
else -> Result.failure(Exception("HTTP $responseCode: $responseMessage"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("Ошибка при удалении события: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает список всех событий из календаря
|
||||
*/
|
||||
fun getAllEvents(): Result<List<CalendarEvent>> {
|
||||
return try {
|
||||
val calendarUrl = dav.calDav.location.newBuilder()
|
||||
.addPathSegment(dav.username)
|
||||
.addPathSegment(dav.calendarName)
|
||||
.build()
|
||||
|
||||
val calendar = DavCalendar(dav.client, calendarUrl)
|
||||
val events = mutableListOf<CalendarEvent>()
|
||||
var hasError = false
|
||||
var errorMessage = ""
|
||||
|
||||
// Простое получение всех ресурсов календаря
|
||||
calendar.propfind(1, CalendarData.NAME) { response, relation ->
|
||||
try {
|
||||
if (relation == at.bitfire.dav4jvm.Response.HrefRelation.MEMBER) {
|
||||
response[CalendarData::class.java]?.let { calData ->
|
||||
val iCalContent = calData.iCalendar ?: return@propfind
|
||||
val uid = extractUidFromICal(iCalContent)
|
||||
uid?.let {
|
||||
parseICalendarEvent(iCalContent, it)?.let { event ->
|
||||
events.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
hasError = true
|
||||
errorMessage = "Ошибка при обработке события: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
hasError -> Result.failure(Exception(errorMessage))
|
||||
events.isEmpty() -> Result.success(emptyList())
|
||||
else -> Result.success(events)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
when {
|
||||
e.message?.contains("401") == true -> Result.failure(Exception("Ошибка авторизации: проверьте логин и пароль"))
|
||||
e.message?.contains("403") == true -> Result.failure(Exception("Доступ запрещен: недостаточно прав для чтения календаря '${dav.calendarName}'"))
|
||||
e.message?.contains("404") == true -> Result.failure(Exception("Календарь '${dav.calendarName}' не найден"))
|
||||
else -> Result.failure(Exception("Ошибка при получении списка событий: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает события за определенный период
|
||||
*/
|
||||
fun getEventsByDateRange(startDate: LocalDateTime, endDate: LocalDateTime): List<CalendarEvent> {
|
||||
return getAllEvents().getOrDefault(emptyList()).filter { event ->
|
||||
event.startDateTime.isAfter(startDate.minusDays(1)) &&
|
||||
event.endDateTime.isBefore(endDate.plusDays(1))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseICalendarEvent(iCalData: String, uid: String): CalendarEvent? {
|
||||
return try {
|
||||
val lines = iCalData.lines()
|
||||
var summary = ""
|
||||
var description: String? = null
|
||||
var location: String? = null
|
||||
var startDateTime: LocalDateTime? = null
|
||||
var endDateTime: LocalDateTime? = null
|
||||
|
||||
val dtFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||
val dtFormatterLocal = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")
|
||||
|
||||
for (line in lines) {
|
||||
when {
|
||||
line.startsWith("SUMMARY:") -> summary = line.substringAfter("SUMMARY:")
|
||||
line.startsWith("DESCRIPTION:") -> description = line.substringAfter("DESCRIPTION:")
|
||||
line.startsWith("LOCATION:") -> location = line.substringAfter("LOCATION:")
|
||||
line.startsWith("DTSTART:") -> {
|
||||
val dtStart = line.substringAfter("DTSTART:")
|
||||
startDateTime = try {
|
||||
if (dtStart.endsWith("Z")) {
|
||||
LocalDateTime.parse(dtStart, dtFormatter)
|
||||
} else {
|
||||
LocalDateTime.parse(dtStart, dtFormatterLocal)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
line.startsWith("DTEND:") -> {
|
||||
val dtEnd = line.substringAfter("DTEND:")
|
||||
endDateTime = try {
|
||||
if (dtEnd.endsWith("Z")) {
|
||||
LocalDateTime.parse(dtEnd, dtFormatter)
|
||||
} else {
|
||||
LocalDateTime.parse(dtEnd, dtFormatterLocal)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startDateTime != null && endDateTime != null) {
|
||||
CalendarEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
description = description,
|
||||
startDateTime = startDateTime,
|
||||
endDateTime = endDateTime,
|
||||
location = location
|
||||
)
|
||||
} else null
|
||||
} catch (_: Exception) {
|
||||
logger.info("Ошибка при парсинге события")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractUidFromICal(iCalData: String): String? {
|
||||
return iCalData.lines().find { it.startsWith("UID:") }?.substringAfter("UID:")
|
||||
}
|
||||
}
|
||||
21
src/test/kotlin/ApplicationTest.kt
Normal file
21
src/test/kotlin/ApplicationTest.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.nano
|
||||
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ApplicationTest {
|
||||
|
||||
@Test
|
||||
fun testRoot() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
client.get("/").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user