diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..e7a346d --- /dev/null +++ b/API_DOCS.md @@ -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` - семейный календарь +- Любые другие календари, созданные пользователем diff --git a/README.md b/README.md new file mode 100644 index 0000000..80413c1 --- /dev/null +++ b/README.md @@ -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 +``` + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b070f24 --- /dev/null +++ b/build.gradle.kts @@ -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") +} diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt new file mode 100644 index 0000000..dbbdec1 --- /dev/null +++ b/src/main/kotlin/Application.kt @@ -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() +} diff --git a/src/main/kotlin/Monitoring.kt b/src/main/kotlin/Monitoring.kt new file mode 100644 index 0000000..f5171e5 --- /dev/null +++ b/src/main/kotlin/Monitoring.kt @@ -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("/") } + } +} diff --git a/src/main/kotlin/Routing.kt b/src/main/kotlin/Routing.kt new file mode 100644 index 0000000..1b9f549 --- /dev/null +++ b/src/main/kotlin/Routing.kt @@ -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( + 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()!! + 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(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(success = false, message = error.message) + ) + } + ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ApiResponse(success = false, message = "Неожиданная ошибка: ${e.message}") + ) + } + } + + // Создание нового события + post { + try { + val principal = call.principal()!! + 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(success = false, message = "Заголовок CAL_ID обязателен") + ) + + val request = call.receive() + + 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(success = false, message = error.message)) + } + ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ApiResponse(success = false, message = "Ошибка создания события: ${e.message}") + ) + } + } + + // Получение события по UID + get("/{uid}") { + try { + val principal = call.principal()!! + val username = principal.name + val uid = call.parameters["uid"] ?: return@get call.respond( + HttpStatusCode.BadRequest, + ApiResponse(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(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(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(success = false, message = error.message)) + } + ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ApiResponse(success = false, message = "Ошибка получения события: ${e.message}") + ) + } + } + + // Обновление события + put("/{uid}") { + try { + val principal = call.principal()!! + val username = principal.name + val uid = call.parameters["uid"] ?: return@put call.respond( + HttpStatusCode.BadRequest, + ApiResponse(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(success = false, message = "Заголовок CAL_ID обязателен") + ) + + val request = call.receive() + + 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(success = false, message = error.message)) + } + ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ApiResponse(success = false, message = "Ошибка обновления события: ${e.message}") + ) + } + } + + // Удаление события + delete("/{uid}") { + try { + val principal = call.principal()!! + val username = principal.name + val uid = call.parameters["uid"] ?: return@delete call.respond( + HttpStatusCode.BadRequest, + ApiResponse(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(success = false, message = "Заголовок CAL_ID обязателен") + ) + + val dav = Dav(username, password, calendarId) + val eventManager = dav.getEventManager() + + eventManager.deleteEvent(uid).fold( + onSuccess = { + call.respond(ApiResponse(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(success = false, message = error.message)) + } + ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + ApiResponse(success = false, message = "Ошибка удаления события: ${e.message}") + ) + } + } + } + } + } + } +} diff --git a/src/main/kotlin/Security.kt b/src/main/kotlin/Security.kt new file mode 100644 index 0000000..af3f286 --- /dev/null +++ b/src/main/kotlin/Security.kt @@ -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 + } + } + } + } +} diff --git a/src/main/kotlin/Serialization.kt b/src/main/kotlin/Serialization.kt new file mode 100644 index 0000000..942c72e --- /dev/null +++ b/src/main/kotlin/Serialization.kt @@ -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")) + } + } +} diff --git a/src/main/kotlin/Service/Calendar.kt b/src/main/kotlin/Service/Calendar.kt new file mode 100644 index 0000000..4121d6a --- /dev/null +++ b/src/main/kotlin/Service/Calendar.kt @@ -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 { + 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 { + 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 { + 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 { + 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> { + return try { + val calendarUrl = dav.calDav.location.newBuilder() + .addPathSegment(dav.username) + .addPathSegment(dav.calendarName) + .build() + + val calendar = DavCalendar(dav.client, calendarUrl) + val events = mutableListOf() + 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 { + 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:") + } +} \ No newline at end of file diff --git a/src/test/kotlin/ApplicationTest.kt b/src/test/kotlin/ApplicationTest.kt new file mode 100644 index 0000000..09a4244 --- /dev/null +++ b/src/test/kotlin/ApplicationTest.kt @@ -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) + } + } + +}