-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathServer.kt
More file actions
227 lines (215 loc) · 10.5 KB
/
Copy pathServer.kt
File metadata and controls
227 lines (215 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
package eu.darken.octi.server
import eu.darken.octi.server.account.AccountRoute
import eu.darken.octi.server.account.AccountStorageRoute
import eu.darken.octi.server.account.share.ShareRoute
import eu.darken.octi.server.common.debug.logging.Logging.Priority.ERROR
import eu.darken.octi.server.common.debug.logging.Logging.Priority.INFO
import eu.darken.octi.server.common.debug.logging.Logging.Priority.WARN
import eu.darken.octi.server.common.debug.logging.asLog
import eu.darken.octi.server.common.debug.logging.log
import eu.darken.octi.server.common.debug.logging.logTag
import eu.darken.octi.server.common.AccountRateLimiter
import eu.darken.octi.server.common.AccountRateLimiterKey
import eu.darken.octi.server.common.DeviceClientIdentityTrackerKey
import eu.darken.octi.server.common.IpDeviceTracker
import eu.darken.octi.server.common.IpDeviceTrackerKey
import eu.darken.octi.server.common.TrustedProxyIpsKey
import eu.darken.octi.server.common.installCallLogging
import eu.darken.octi.server.common.installRateLimit
import io.ktor.server.plugins.bodylimit.*
import eu.darken.octi.server.device.DeviceClientIdentityTracker
import eu.darken.octi.server.device.DeviceRoute
import eu.darken.octi.server.module.BlobRoute
import eu.darken.octi.server.module.ModuleRoute
import eu.darken.octi.server.myip.MyIpRoute
import eu.darken.octi.server.status.StatusRoute
import eu.darken.octi.server.ws.WsRoute
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.autohead.*
// ConditionalHeaders not used globally — see comment in server setup
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class Server @Inject constructor(
private val config: App.Config,
private val statusRoute: StatusRoute,
private val accountRoute: AccountRoute,
private val shareRoute: ShareRoute,
private val deviceRoute: DeviceRoute,
private val moduleRoute: ModuleRoute,
private val blobRoute: BlobRoute,
private val accountStorageRoute: AccountStorageRoute,
private val myIpRoute: MyIpRoute,
private val wsRoute: WsRoute,
private val serializers: SerializersModule,
private val ipDeviceTracker: IpDeviceTracker,
private val accountRateLimiter: AccountRateLimiter,
private val deviceClientIdentityTracker: DeviceClientIdentityTracker,
) {
@Suppress("ExtractKtorModule")
private val server by lazy {
embeddedServer(Netty, config.port) {
installCallLogging(config.trustedProxyIps)
// CORS is installed before rate-limit/auth so preflight OPTIONS short-circuit
// here and never reach those paths. Browser clients are the only callers
// subject to CORS; Android/desktop/CLI are unaffected regardless of config.
if (config.corsAllowedOrigins.isNotEmpty()) {
log(TAG, INFO) { "CORS allowed origins: ${config.corsAllowedOrigins.joinToString(", ")}" }
install(CORS) {
config.corsAllowedOrigins.forEach { origin ->
// hosts() expects host[:port] split from scheme; allowHost handles parsing.
// Format already validated by App.parseCorsAllowedOrigins.
val scheme = origin.substringBefore("://")
val hostAndPort = origin.substringAfter("://")
allowHost(hostAndPort, schemes = listOf(scheme))
}
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Head)
allowMethod(HttpMethod.Options)
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.IfMatch)
allowHeader(HttpHeaders.IfNoneMatch)
allowHeader(HttpHeaders.Range)
allowHeader(HttpHeaders.IfRange)
allowHeader("X-Device-ID")
allowHeader("Octi-Device-Version")
allowHeader("Octi-Device-Platform")
allowHeader("Octi-Device-Label")
allowHeader("Octi-Device-Capabilities")
allowHeader("Upload-Offset")
exposeHeader(HttpHeaders.ETag)
exposeHeader(HttpHeaders.LastModified)
// ModuleRoute sets X-Modified-At with the payload's server-side
// modification timestamp. Browsers can only read non-safelisted
// response headers cross-origin when they're listed here.
// octi-web's multi-connector merge uses it to order data when the
// same peer device is reachable via two connectors — newest
// X-Modified-At per (deviceId, moduleId) wins.
exposeHeader("X-Modified-At")
exposeHeader(HttpHeaders.ContentRange)
exposeHeader(HttpHeaders.AcceptRanges)
exposeHeader(HttpHeaders.RetryAfter)
exposeHeader("Upload-Offset")
exposeHeader("Upload-Length")
exposeHeader("Upload-Expires")
exposeHeader("Upload-State")
exposeHeader("X-Blob-ID")
// We use Authorization headers, not cookies — keep credentials off so
// browsers don't refuse the response when the same origin is reused
// across users on the SPA.
allowCredentials = false
}
} else {
log(TAG, INFO) { "CORS disabled (--cors-allowed-origins= was set to empty); browser clients won't be able to reach this server" }
}
install(AutoHeadResponse)
// PartialContent is NOT installed globally — BlobRoute.downloadBlob handles
// Range / If-Range / Last-Modified manually because BlobHandle wraps an
// already-open InputStream (POSIX inode-survival across concurrent commit
// orphan-deletes). LocalFileContent + PartialContent would re-open at
// response time, breaking that invariant.
// ConditionalHeaders is NOT installed globally — the module commit path
// handles If-Match/If-None-Match explicitly for optimistic concurrency control.
// Installing it globally would cause Ktor to intercept these headers before
// the route handler, returning 412 based on response ETag mismatches.
install(WebSockets) {
pingPeriod = 30.seconds
timeout = 60.seconds
maxFrameSize = 4096
}
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
serializersModule = serializers
})
}
install(StatusPages) {
exception<CancellationException> { _, cause -> throw cause }
exception<BadRequestException> { call, cause ->
log(TAG, WARN) { "Bad request: ${cause.message}" }
if (!call.response.isCommitted) {
call.respond(HttpStatusCode.BadRequest, "Bad request")
}
}
exception<PayloadTooLargeException> { call, _ ->
if (!call.response.isCommitted) {
call.respond(HttpStatusCode.PayloadTooLarge)
}
}
exception<Throwable> { call, cause ->
if (cause is Error) throw cause
log(TAG, ERROR) { "Unhandled exception: ${cause.asLog()}" }
if (!call.response.isCommitted) {
call.respond(HttpStatusCode.InternalServerError, "Internal server error")
}
}
}
attributes.put(IpDeviceTrackerKey, ipDeviceTracker)
attributes.put(AccountRateLimiterKey, accountRateLimiter)
attributes.put(TrustedProxyIpsKey, config.trustedProxyIps)
attributes.put(DeviceClientIdentityTrackerKey, deviceClientIdentityTracker)
config.rateLimit
?.let { installRateLimit(it, ipDeviceTracker, config.trustedProxyIps) }
?: log(TAG, WARN) { "rateLimit is not configured" }
routing {
// Default body limit for all routes. Individual routes can override
// by installing RequestBodyLimit on a child route() block.
config.payloadLimit?.let { limit ->
install(RequestBodyLimit) { bodyLimit { limit } }
}
statusRoute.setup(this)
accountRoute.setup(this)
accountStorageRoute.setup(this)
shareRoute.setup(this)
deviceRoute.setup(this)
moduleRoute.setup(this)
blobRoute.setup(this)
wsRoute.setup(this)
myIpRoute.setup(this)
}
}
}
private var isRunning = false
fun start() {
log(TAG, INFO) { "Server is starting..." }
server.monitor.apply {
subscribe(ApplicationStarted) {
log(TAG, INFO) { "Server is ready" }
isRunning = true
}
subscribe(ApplicationStopping) {
log(TAG, INFO) { "Server is stopping..." }
isRunning = false
}
}
server.start(wait = true)
}
fun stop() {
log(TAG, INFO) { "Server is stopping..." }
server.stop(gracePeriodMillis = 1000, timeoutMillis = 5000)
log(TAG, INFO) { "Server stopped" }
}
fun isRunning(): Boolean = isRunning
companion object {
private val TAG = logTag("Server")
}
}