From 8ae8e65e12656d30326392da9222a62a0cb1ddaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lnwza007=20=E2=9A=A1=EF=B8=8F?= Date: Wed, 6 Nov 2024 16:09:09 +0700 Subject: [PATCH] =?UTF-8?q?=E0=B8=9B=E0=B8=A3=E0=B8=B1=E0=B8=9A=E0=B9=81?= =?UTF-8?q?=E0=B8=95=E0=B9=88=E0=B8=87=E0=B9=81=E0=B8=AA=E0=B8=94=E0=B8=87?= =?UTF-8?q?=20LOG=20=E0=B9=81=E0=B8=A5=E0=B8=B0=E0=B8=A3=E0=B8=B0=E0=B8=9A?= =?UTF-8?q?=E0=B8=9A=20Search,=20=E0=B9=81=E0=B8=81=E0=B9=89=E0=B9=84?= =?UTF-8?q?=E0=B8=82=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Dockerfile.jvm | 5 ++- Dockerfile.native | 5 ++- README.md | 2 +- build.gradle.kts | 2 +- doc/README-EN.md | 2 +- doc/README-JP.md | 2 +- script/http/client_req.http | 25 +++++++++---- src/main/kotlin/org/fenrirs/relay/Gateway.kt | 21 ++++++----- .../relay/core/nip01/BasicProtocolFlow.kt | 30 +++++----------- .../core/nip01/command/CommandFactory.kt | 2 +- .../core/nip01/response/RelayResponse.kt | 4 +-- .../fenrirs/relay/core/nip50/SearchEngine.kt | 35 +++++++++++++++++-- .../org/fenrirs/storage/DatabaseFactory.kt | 4 +-- .../org/fenrirs/storage/Subscription.kt | 10 +++--- .../storage/statement/StoredServiceImpl.kt | 2 +- src/main/kotlin/org/fenrirs/utils/ShiftTo.kt | 25 ++++++++++--- src/main/resources/logback.xml | 20 +++++++++-- 18 files changed, 134 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index 17e7ad2..92092f5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ out/ .classpath .factorypath /test-results-native/test/TEST-junit-jupiter.xml +/logs/ diff --git a/Dockerfile.jvm b/Dockerfile.jvm index 2f16779..9ebd122 100644 --- a/Dockerfile.jvm +++ b/Dockerfile.jvm @@ -29,4 +29,7 @@ COPY --chown=runner:appgroup .env /app/.env EXPOSE 6724 -ENTRYPOINT ["java", "-Xms256m", "-Xmx3g", "-XX:+UseG1GC", "-jar", "fenrir-s-1.0-all-optimized.jar"] +ENTRYPOINT sh -c "java -Xms256m -Xmx4g -XX:+UseG1GC -jar fenrir-s-1.0-all-optimized.jar; \ + while [ ! -f logs/activity.log ]; do sleep 3; done; \ + cd logs; tail -n 100 -f activity.log" + diff --git a/Dockerfile.native b/Dockerfile.native index a32f235..83cea20 100644 --- a/Dockerfile.native +++ b/Dockerfile.native @@ -28,4 +28,7 @@ COPY --chown=runner:appgroup .env /app/.env EXPOSE 6724 -ENTRYPOINT ["/app/fenrir-s-v1.0"] +ENTRYPOINT sh -c "/app/fenrir-s-v1.0; \ + while [ ! -f logs/activity.log ]; do sleep 3; done; \ + cd logs; tail -n 100 -f activity.log" + diff --git a/README.md b/README.md index 3c06830..72eed3f 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ git clone https://github.com/rushmi0/Fenrir-s.git cd Fenrir-s ``` -2. ปรับแต่งไฟล์ `application.toml` ตามต้องการ +2. ปรับแต่งไฟล์ `.env` ตามต้องการ 3. รัน Docker Compose: diff --git a/build.gradle.kts b/build.gradle.kts index 4dde8a4..622567d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -158,7 +158,7 @@ micronaut { optimizeClassLoading = true deduceEnvironment = true optimizeNetty = true - replaceLogbackXml = true + replaceLogbackXml = false } } diff --git a/doc/README-EN.md b/doc/README-EN.md index 3e3162b..5d07c6f 100644 --- a/doc/README-EN.md +++ b/doc/README-EN.md @@ -129,7 +129,7 @@ git clone https://github.com/rushmi0/Fenrir-s.git cd Fenrir-s ``` -2. Customize the application.toml file as needed. +2. Customize the `.env` file as needed. 3. Run Docker Compose: diff --git a/doc/README-JP.md b/doc/README-JP.md index 16a49e5..4822397 100644 --- a/doc/README-JP.md +++ b/doc/README-JP.md @@ -124,7 +124,7 @@ git clone https://github.com/rushmi0/Fenrir-s.git cd Fenrir-s ``` -2. 適当に`application.toml`ファイルをカスタマイズする。 +2. 適当に`.env`ファイルをカスタマイズする。 3. Docker Composeを実行する: diff --git a/script/http/client_req.http b/script/http/client_req.http index b923c07..c018517 100644 --- a/script/http/client_req.http +++ b/script/http/client_req.http @@ -3,12 +3,12 @@ accept: application/nostr+json ### -GET https://relay.nostr.band +GET https://relay.rushmi0.win/ accept: application/nostr+json ### -GET http://localhost:6725 +GET http://localhost:6724 accept: application/nostr+json @@ -29,7 +29,7 @@ Content-Type: application/json ### -WEBSOCKET ws://localhost:6724 +WEBSOCKET ws://localhost:6725 Content-Type: application/json ["COUNT","hsZEOtaDsENYkP5H-JIWp",{"kinds": [1],"limit": 2}, {"kinds": [4],"limit": 150}] @@ -43,7 +43,7 @@ Content-Type: application/json ### -WEBSOCKET ws://localhost:6724 +WEBSOCKET ws://localhost:6725 Content-Type: application/json ["COUNT","hsZEOtaDsENYkP5H-JIWp",{"kinds": [0],"authors":["e4b2c64f0e4e54abb34d5624cd040e05ecc77f0c467cc46e2cc4d5be98abe3e3"]}] @@ -230,13 +230,26 @@ Content-Type: application/json ["REQ","6Y92Rj5mPPvkyh9_pYBXD",{"#p":["e4b2c64f0e4e54abb34d5624cd040e05ecc77f0c467cc46e2cc4d5be98abe3e3"],"kinds":[1,6,16,7,9735,2004,30023],"limit":150}] ### -WEBSOCKET ws://localhost:6724 +WEBSOCKET ws://localhost:6725 Content-Type: application/json [ "REQ", "Rj5mPPvkyh9", { - "search" : "after" + "search" : "d8914525efb7030f8d1c86283b8a6313e4e6dfe573f2c1b5b130eaa5767d530e" + } +] + +### + +WEBSOCKET ws://localhost:6724 +Content-Type: application/json + +[ + "COUNT", + "Rj5mPPvkyh9", + { + "search" : "dd653b86c26bdc8991cc9401b7ff8c7914c04b24348272a85871bebfd503491a" } ] \ No newline at end of file diff --git a/src/main/kotlin/org/fenrirs/relay/Gateway.kt b/src/main/kotlin/org/fenrirs/relay/Gateway.kt index 9c278e8..c3c23aa 100644 --- a/src/main/kotlin/org/fenrirs/relay/Gateway.kt +++ b/src/main/kotlin/org/fenrirs/relay/Gateway.kt @@ -12,13 +12,13 @@ import io.micronaut.websocket.annotation.ServerWebSocket import jakarta.inject.Inject -import org.fenrirs.relay.core.nip01.command.CLOSE import org.fenrirs.relay.core.nip01.command.EVENT +import org.fenrirs.relay.core.nip01.command.CLOSE +import org.fenrirs.relay.core.nip01.command.COUNT import org.fenrirs.relay.core.nip01.command.REQ import org.fenrirs.relay.core.nip01.command.CommandFactory.parse import org.fenrirs.relay.core.nip01.response.RelayResponse import org.fenrirs.relay.core.nip01.ProtocolFlowFactory -import org.fenrirs.relay.core.nip01.command.COUNT import org.fenrirs.relay.core.nip11.RelayInformation import org.fenrirs.storage.Subscription.clearSession @@ -26,6 +26,7 @@ import org.fenrirs.utils.Color.GREEN import org.fenrirs.utils.Color.PURPLE import org.fenrirs.utils.Color.RED import org.fenrirs.utils.Color.RESET +import org.fenrirs.utils.Color.YELLOW import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -48,22 +49,20 @@ class Gateway @Inject constructor( // ดึงข้อมูลของไคลเอนต์ val clientIp = request.headers["X-Forwarded-For"] ?: request.remoteAddress.address.hostAddress - val userAgent = request.headers["User-Agent"] + val userAgent = request.headers["User-Agent"] ?: "N/A" + val sessionId = session?.id ?: "N/A" - // Log ข้อมูลไคลเอนต์ที่เชื่อมต่อ - LOG.info("Client IP: $clientIp, Agent: $userAgent") + LOG.info("${YELLOW}Client IP: $clientIp, Session ID: $sessionId$RESET") + LOG.info("User Agent: $userAgent") session?.let { - LOG.info("${GREEN}open$RESET ${session.id}") + LOG.info("${GREEN}* open$RESET ${session.id}") return@let HttpResponse.ok("Session opened") .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*") } //LOG.info("${YELLOW}accept: $RESET$accept ${BLUE}session: $RESET${session?.id}") - val contentType = when { - accept == "application/nostr+json" -> MediaType.APPLICATION_JSON - else -> MediaType.TEXT_HTML - } + val contentType = if (accept == "application/nostr+json") MediaType.APPLICATION_JSON else MediaType.TEXT_HTML return HttpResponse.ok(nip11.loadRelayInfo(contentType)) .contentType(contentType) @@ -98,7 +97,7 @@ class Gateway @Inject constructor( @OnClose fun onClose(session: WebSocketSession) { - LOG.info("${PURPLE}close: ${RESET}$session") + LOG.info("${PURPLE}# close: ${RESET}$session") clearSession(session) } diff --git a/src/main/kotlin/org/fenrirs/relay/core/nip01/BasicProtocolFlow.kt b/src/main/kotlin/org/fenrirs/relay/core/nip01/BasicProtocolFlow.kt index ab54198..d3092e1 100644 --- a/src/main/kotlin/org/fenrirs/relay/core/nip01/BasicProtocolFlow.kt +++ b/src/main/kotlin/org/fenrirs/relay/core/nip01/BasicProtocolFlow.kt @@ -26,7 +26,6 @@ import org.fenrirs.storage.Subscription.isSubscriptionActive import org.fenrirs.storage.Subscription.saveSubscription import org.fenrirs.storage.statement.StoredServiceImpl -import org.fenrirs.utils.Color.YELLOW import org.fenrirs.utils.Color.GREEN import org.fenrirs.utils.Color.PURPLE import org.fenrirs.utils.Color.RED @@ -56,7 +55,7 @@ class BasicProtocolFlow @Inject constructor( RelayResponse.OK(event.id!!, false, warning).toClient(session) } - // ดึงข้อมูล public key ของ relay owner และรายการ passList จาก src/main/resources/application.toml + // ดึงข้อมูล public key ของ relay owner และรายการ passList จาก .env val relayOwner = env.RELAY_OWNER val passList: List = getPassList(relayOwner) val followsPass: Boolean = env.FOLLOWS_PASS @@ -167,7 +166,7 @@ class BasicProtocolFlow @Inject constructor( val (success, message) = action.invoke() if (success) { - LOG.info("Event kind: ${PURPLE}[${event.kind}] ${RESET}handled ${GREEN}saved") + LOG.info("Session ${session.id} handled ${GREEN}saved, ${RESET}Event ID: ${PURPLE}[${event.id}]${RESET}") RelayResponse.OK(event.id!!, true, message).toClient(session) } else { LOG.warn("${RED}Failed ${RESET}to handle event: ${event.id}") @@ -282,6 +281,9 @@ class BasicProtocolFlow @Inject constructor( filtersX: List, session: WebSocketSession ) { + //LOG.info("${GREEN}filters ${YELLOW}[${filtersX.size}] ${RESET}req subscription ID: ${CYAN}$subscriptionId ${RESET}") + //LOG.info("FiltersX: $filtersX") + filtersX.forEach { filter -> sqlExec.filterList(filter)?.forEach { event -> RelayResponse.EVENT(subscriptionId, event).toClient(session) @@ -351,20 +353,6 @@ class BasicProtocolFlow @Inject constructor( } - /** - * ฟังก์ชัน handleResponse ใช้ในการส่งการตอบกลับไปยังไคลเอนต์ตามประเภทที่กำหนด - * - * @param subscriptionId ไอดีที่ใช้ในการติดตามหรืออ้างอิงการร้องขอนั้นๆ จากไคลเอนต์ - * @param data ข้อมูลที่ต้องการส่งกลับ - * @param session เซสชัน WebSocket ที่ใช้ในการตอบกลับ - */ - private inline fun handleResponse(subscriptionId: String, data: Any, session: WebSocketSession) { - when (T::class) { - COUNT::class -> RelayResponse.COUNT(subscriptionId, data).toClient(session) - REQ::class -> RelayResponse.EVENT(subscriptionId, data as Event).toClient(session) - } - } - /** * ฟังก์ชัน startRealTimeUpdates ใช้ในการเริ่มต้นการอัปเดตข้อมูลแบบเรียลไทม์ @@ -394,11 +382,11 @@ class BasicProtocolFlow @Inject constructor( when (T::class) { COUNT::class -> { val count = events.size - val response = if (count >= 93_412_452) ApproximateCountREQ(93_412_452, true) else CountREQ(count) - handleResponse(subscriptionId, response, session) + val data = if (count >= 93_412_452) ApproximateCountREQ(93_412_452, true) else CountREQ(count) + RelayResponse.COUNT(subscriptionId, data).toClient(session) } REQ::class -> events.forEach { event -> - handleResponse(subscriptionId, event, session) + RelayResponse.EVENT(subscriptionId, event).toClient(session) } } } @@ -420,7 +408,7 @@ class BasicProtocolFlow @Inject constructor( * @param session เซสชัน WebSocket ที่ใช้ในการตอบกลับ */ fun onClose(subscriptionId: String, session: WebSocketSession) { - LOG.info("${YELLOW}cancel ${RESET}subscription ID: $subscriptionId") + //LOG.info("${YELLOW}cancel ${RESET}subscription ID: $subscriptionId") RelayResponse.CANCEL(subscriptionId).toClient(session) } diff --git a/src/main/kotlin/org/fenrirs/relay/core/nip01/command/CommandFactory.kt b/src/main/kotlin/org/fenrirs/relay/core/nip01/command/CommandFactory.kt index e1f035a..b10ff29 100644 --- a/src/main/kotlin/org/fenrirs/relay/core/nip01/command/CommandFactory.kt +++ b/src/main/kotlin/org/fenrirs/relay/core/nip01/command/CommandFactory.kt @@ -88,7 +88,7 @@ object CommandFactory { // ตรวจสอบจำนวน filters ว่าไม่เกินค่าที่กำหนด if (filtersJson.size > env.MAX_FILTERS) { - throw IllegalArgumentException("rate-limited: max filters ${env.MAX_FILTERS} values in each sub ID allowed") + throw IllegalArgumentException("rate-limited: max filter ${env.MAX_FILTERS} values each sub ID allowed") } val data: Map = filtersJson.flatMap { it.entries }.associate { it.key to it.value } diff --git a/src/main/kotlin/org/fenrirs/relay/core/nip01/response/RelayResponse.kt b/src/main/kotlin/org/fenrirs/relay/core/nip01/response/RelayResponse.kt index cee813c..60a9ed1 100644 --- a/src/main/kotlin/org/fenrirs/relay/core/nip01/response/RelayResponse.kt +++ b/src/main/kotlin/org/fenrirs/relay/core/nip01/response/RelayResponse.kt @@ -108,11 +108,11 @@ sealed class RelayResponse { clearSubscription(session, subscriptionId) } } catch (e: WebSocketSessionException) { - LOG.warn("$session is closed, cannot send message") + LOG.info("$session is closed, cannot send message") } } - else -> LOG.warn("$session is closed") + else -> LOG.info("$session is closed") } } diff --git a/src/main/kotlin/org/fenrirs/relay/core/nip50/SearchEngine.kt b/src/main/kotlin/org/fenrirs/relay/core/nip50/SearchEngine.kt index 209f21c..b144d74 100644 --- a/src/main/kotlin/org/fenrirs/relay/core/nip50/SearchEngine.kt +++ b/src/main/kotlin/org/fenrirs/relay/core/nip50/SearchEngine.kt @@ -1,10 +1,16 @@ package org.fenrirs.relay.core.nip50 -import jakarta.inject.Singleton +import io.micronaut.context.annotation.Bean +import org.fenrirs.storage.table.EVENT.CONTENT +import org.fenrirs.storage.table.EVENT.EVENT_ID +import org.fenrirs.storage.table.EVENT.PUBKEY +import org.fenrirs.utils.Bech32 +import org.fenrirs.utils.ShiftTo.toHex import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -@Singleton +@Bean class SearchEngine { @@ -46,6 +52,7 @@ class SearchEngine { */ class MatchOp(expr1: Expression<*>, expr2: Expression<*>) : ComparisonOp(expr1, expr2, "@@") + /** * ฟังก์ชันสำหรับสร้างการเปรียบเทียบ `tsvector` กับ `tsquery` * - ใช้ตัวดำเนินการ `@@` สำหรับการค้นหาข้อความ @@ -72,4 +79,28 @@ class SearchEngine { } + /** + * ฟังก์ชันสำหรับตรวจสอบประเภทของ search query และเลือกคอลัมน์ที่เหมาะสมสำหรับการค้นหา + * + * - หาก search เริ่มต้นด้วย "npub" จะทำการถอดรหัสเป็นค่า hex เพื่อค้นหาในคอลัมน์ PUBKEY + * - หาก search เป็น hex string ความยาว 64 ตัวอักษร จะค้นหาในคอลัมน์ EVENT_ID หรือ PUBKEY + * - กรณีอื่นๆ จะค้นหาในคอลัมน์ CONTENT โดยใช้ tsQuery + * + * @param search ข้อความที่ต้องการค้นหา + * @return Op เงื่อนไขการค้นหาสำหรับ query + */ + fun searchQuery(search: String): Op { + return when { + search.startsWith("npub") -> { + val decodedHex = Bech32.decode(search).data.toHex() + PUBKEY eq decodedHex + } + search.length == 64 && search.all { it in '0'..'9' || it in 'a'..'f' } -> { + (EVENT_ID eq search) or (PUBKEY eq search) + } + else -> tsQuery(CONTENT, search) + } + } + + } \ No newline at end of file diff --git a/src/main/kotlin/org/fenrirs/storage/DatabaseFactory.kt b/src/main/kotlin/org/fenrirs/storage/DatabaseFactory.kt index 90a1e47..e622f16 100644 --- a/src/main/kotlin/org/fenrirs/storage/DatabaseFactory.kt +++ b/src/main/kotlin/org/fenrirs/storage/DatabaseFactory.kt @@ -45,8 +45,8 @@ object DatabaseFactory { username = ENV.DATABASE_USERNAME password = ENV.DATABASE_PASSWORD - minimumIdle = 2 - maximumPoolSize = 10 + minimumIdle = 10 + maximumPoolSize = 64 isAutoCommit = false diff --git a/src/main/kotlin/org/fenrirs/storage/Subscription.kt b/src/main/kotlin/org/fenrirs/storage/Subscription.kt index 45ae485..2331f4f 100644 --- a/src/main/kotlin/org/fenrirs/storage/Subscription.kt +++ b/src/main/kotlin/org/fenrirs/storage/Subscription.kt @@ -2,14 +2,14 @@ package org.fenrirs.storage import io.github.reactivecircus.cache4k.Cache import io.micronaut.context.annotation.Bean -import io.micronaut.context.annotation.Factory import io.micronaut.websocket.WebSocketSession + import org.fenrirs.relay.core.nip01.SubscriptionData import org.fenrirs.relay.policy.FiltersX -import org.slf4j.LoggerFactory + +//import org.slf4j.LoggerFactory @Bean -@Factory object Subscription { private val config: Cache = Cache.Builder() @@ -84,7 +84,7 @@ object Subscription { if (!session.isOpen) { // ถ้า session ถูกปิดไปแล้ว ให้ล้างข้อมูล session นั้นออก clearSession(session) - LOG.info("Clear Session: $session") + //LOG.info("${PURPLE}clear: $RESET$session") return false } //LOG.info("session: $session") @@ -114,6 +114,6 @@ object Subscription { fun clearSession(session: WebSocketSession) = remove(session.id) - private val LOG = LoggerFactory.getLogger(Subscription::class.java) + //private val LOG = LoggerFactory.getLogger(Subscription::class.java) } diff --git a/src/main/kotlin/org/fenrirs/storage/statement/StoredServiceImpl.kt b/src/main/kotlin/org/fenrirs/storage/statement/StoredServiceImpl.kt index b682338..14597a4 100644 --- a/src/main/kotlin/org/fenrirs/storage/statement/StoredServiceImpl.kt +++ b/src/main/kotlin/org/fenrirs/storage/statement/StoredServiceImpl.kt @@ -134,7 +134,7 @@ class StoredServiceImpl @Inject constructor( // ถ้ามีการระบุ search ใน filters ให้เพิ่มเงื่อนไขการค้นหา CONTENT ที่ตรงกับ search ที่กำหนดโดยใช้ full-text search filters.search?.let { query.andWhere { - ts.tsQuery(CONTENT, filters.search) + ts.searchQuery(filters.search) } } diff --git a/src/main/kotlin/org/fenrirs/utils/ShiftTo.kt b/src/main/kotlin/org/fenrirs/utils/ShiftTo.kt index 2c9ecc1..261d96e 100644 --- a/src/main/kotlin/org/fenrirs/utils/ShiftTo.kt +++ b/src/main/kotlin/org/fenrirs/utils/ShiftTo.kt @@ -1,14 +1,10 @@ package org.fenrirs.utils import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import kotlinx.serialization.KSerializer -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* -import kotlinx.serialization.serializer import org.fenrirs.relay.policy.Event import org.slf4j.LoggerFactory import java.lang.management.ManagementFactory -import java.lang.management.MemoryMXBean import java.math.BigInteger import java.security.MessageDigest import java.util.concurrent.TimeUnit @@ -151,6 +147,27 @@ object ShiftTo { } } + + /* + fun renderTable(data: List>): String { + val colWidth1 = data.maxOf { it.first.length } + 2 + val colWidth2 = data.maxOf { it.second.length } + 2 + + val separator = "${Color.GREEN}+${"─".repeat(colWidth1)}+${"─".repeat(colWidth2)}+${Color.RESET}" + val table = StringBuilder().apply { + appendLine(separator) + data.forEachIndexed { index, (label, value) -> + appendLine( + "${Color.GREEN}│${Color.RESET} ${label.padEnd(colWidth1 - 2)} ${Color.GREEN}│${Color.RESET} ${value.padEnd(colWidth2 - 2)} ${Color.GREEN}│${Color.RESET}" + ) + if (index < data.size - 1) appendLine(separator) else append(separator) + } + } + return table.toString() + } + */ + + val LOG = LoggerFactory.getLogger(ShiftTo::class.java) } \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index d8e26ee..dba1d4e 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -14,12 +14,28 @@ - - + + + logs/activity.log + true + + logs/activity.%d{yyyy-MM-dd}.log + 90 + + + + [%boldMagenta(%d{yyyy-MM-dd HH:mm:ss.SSS,UTC})] %green([%thread]) %highlight(%-5level) %boldYellow(%logger{36}) - %msg%n + + + + + + + \ No newline at end of file