Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CI

on:
push:
pull_request:

jobs:
linux:
# Every backend against a real server (SQLite uses a local file).
name: Linux (Swift 6.4, all backends)
runs-on: ubuntu-24.04
container: swift:6.4-resolute
services:
mongo:
image: mongo:8
postgres:
image: postgres:17
env:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: perfect_sessions_test
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 20
mysql:
image: mysql:8.4
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: perfect_sessions_test
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1" --health-interval 5s --health-timeout 5s --health-retries 30
redis:
image: redis:7
env:
MONGODB_TESTS: "1"
MONGODB_URI: mongodb://mongo:27017
PG_TESTS: "1"
PG_HOST: postgres
MYSQL_TESTS: "1"
MYSQL_HOST: mysql
REDIS_TESTS: "1"
REDIS_HOST: redis
SQLITE_TESTS: "1"
steps:
- uses: actions/checkout@v7

- name: Install client libraries
run: |
apt-get update
apt-get install -y --no-install-recommends pkg-config libmongoc-dev libbson-dev libpq-dev libmysqlclient-dev libsqlite3-dev

- name: Test
run: swift test

macos:
# GitHub's macOS runners have no database servers, so this covers the build plus the
# server-independent tests, including SQLite (a local file).
name: macOS (build, core and SQLite tests)
runs-on: macos-26
env:
SQLITE_TESTS: "1"
steps:
- uses: actions/checkout@v7

- name: Install client libraries
run: |
brew install mongo-c-driver mysql-client libpq
# Keg-only; older SwiftPM releases don't find libpq through its brew provider alone.
echo "PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig" >> "$GITHUB_ENV"

- name: Toolchain
run: swift --version

- name: Test
run: swift test
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let package = Package(
.package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", branch: "main"),
.package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", branch: "main"),
.package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", branch: "main"),
.package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.1"),
.package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.2"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
],
targets: [
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ repos into one package with a product per backend; those repos are superseded by
## Requirements

- Swift 6.2 or later (`swift-tools-version: 6.2`). Every target builds in Swift 6 language mode.
- macOS 12 or later, or Linux. On Linux (tested with Swift 6.4 on Ubuntu 24.04), the core and the MySQL,
PostgreSQL, Redis and MongoDB backends build. The **SQLite backend is macOS-only for now**, because
Perfect-SQLite uses the SQLite module from Apple's SDK.
- macOS 12 or later, or Linux. Every backend builds on Linux (tested with Swift 6.4 on Ubuntu 26.04).
- Each backend needs its database's client library; see the list below.

## Building
Expand Down Expand Up @@ -77,12 +75,26 @@ variables in that test file.
| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL client library (`libmysqlclient`) | `MYSQL_TESTS` |
| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` |
| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis), `swift-log` | — | `REDIS_TESTS` |
| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite), `swift-log` | macOS (SQLite from the SDK) | `SQLITE_TESTS` |
| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite), `swift-log` | macOS, Linux (`libsqlite3-dev` on Linux) | `SQLITE_TESTS` |
| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` |

The **MongoDB** backend stores each session as a document keyed by its token, with `data` as a subdocument.
Its `setup()` adds a TTL index, so MongoDB deletes expired sessions itself. Besides `MongoDBSessionConnector`
(`uri`, `database`, `collection`), you can pass a URI or an existing `MongoClientPool` to its initializer.
`save` only updates a session that still exists, so a save that races a logout can't bring the session back;
a session that was never stored (its `create` failed) or has already expired isn't stored by `save` either.
libmongoc rejects an empty key, and a key is cut short at a NUL, so keys in `data` are escaped at every depth:
`%` is stored as `%25`, NUL as `%00`, and the empty key as `%`. Other keys are stored as is, so queries such as
`data.role` keep working.

### Migrating from Perfect-Session-MongoDB

The archived `Perfect-Session-MongoDB` stored each session under its own `_id`, with the token in a `token`
field, `data` as JSON text, and no expiry date. The MongoDB backend can't read those documents, so existing
sessions end when you switch and users sign in again. The TTL index never removes them either, so if you point
the new driver at the old collection, `setup()` deletes them: documents with a string `token`, string `data`,
`created`, `updated` and `idle` fields, and no `expiresAt`. Run `setup()` again once no old instances are left
writing to the collection. To keep the old documents, use a different `collection`.

## Further information

Expand Down
17 changes: 13 additions & 4 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ Perfect 的会话存储与会话安全基础组件,针对 Swift 6 与严格并
## 环境要求

- Swift 6.2 或更新版本(`swift-tools-version: 6.2`)。所有目标都在 Swift 6 语言模式下编译。
- macOS 12 或更新版本,或 Linux。在 Linux 上(已用 Ubuntu 24.04 上的 Swift 6.4 测试),核心库以及 MySQL、
PostgreSQL、Redis 和 MongoDB 后端均可编译。**SQLite 后端目前仅支持 macOS**,因为 Perfect-SQLite 使用的是
Apple SDK 中的 SQLite 模块。
- macOS 12 或更新版本,或 Linux。所有后端都可以在 Linux 上编译(已用 Ubuntu 26.04 上的 Swift 6.4 测试)。
- 每个后端都需要对应数据库的客户端库,详见下方列表。

## 构建
Expand Down Expand Up @@ -73,12 +71,23 @@ Swift 6 版本尚未发布带标签的正式版本,因此请依赖 `main` 分
| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL 客户端库(`libmysqlclient`) | `MYSQL_TESTS` |
| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` |
| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis)、`swift-log` | — | `REDIS_TESTS` |
| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite)、`swift-log` | macOS(使用 SDK 中的 SQLite) | `SQLITE_TESTS` |
| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite)、`swift-log` | macOS、Linux(Linux 上需要 `libsqlite3-dev`) | `SQLITE_TESTS` |
| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` |

**MongoDB** 后端把每个会话存储为一个以其令牌为键的文档,`data` 以子文档形式存储。它的 `setup()` 会添加 TTL
索引,让 MongoDB 自动删除过期会话。除了 `MongoDBSessionConnector`(`uri`、`database`、`collection`)之外,
你也可以向其初始化方法传入 URI 或现有的 `MongoClientPool`。
`save` 只会更新仍然存在的会话,因此与注销同时发生的保存不会让会话复活;从未存储成功(其 `create` 失败)或已经过期的
会话,`save` 也不会再存储。libmongoc 拒绝空键,而键会在 NUL 处被截断,因此 `data` 中各层的键都会被转义:`%`
存为 `%25`,NUL 存为 `%00`,空键存为 `%`。其他键原样存储,因此 `data.role` 之类的查询照常可用。

### 从 Perfect-Session-MongoDB 迁移

已归档的 `Perfect-Session-MongoDB` 把每个会话存储在它自己的 `_id` 下,令牌放在 `token` 字段中,`data` 是 JSON
文本,并且没有过期时间。MongoDB 后端无法读取这些文档,因此切换后现有会话都会结束,用户需要重新登录。TTL 索引也
永远不会删除它们,所以如果你让新驱动使用旧的集合,`setup()` 会删除这些文档:即 `token` 为字符串、`data` 为字符串、
含有 `created`、`updated` 和 `idle` 字段且没有 `expiresAt` 的文档。等不再有旧实例写入该集合后,再运行一次
`setup()`。如果想保留旧文档,请使用另一个 `collection`。

## 更多信息

Expand Down
112 changes: 104 additions & 8 deletions Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ public struct MongoDBSessionConnector: Sendable {
/// { _id: <token>, userid, created, updated, idle, ipaddress, useragent,
/// data: { ... }, expiresAt: <Date> }
///
/// `data` is a real subdocument, not JSON text. `setup()` adds a TTL index on `expiresAt`
/// (`updated + idle`), so MongoDB deletes expired sessions on its own; `clean()` still
/// deletes them immediately for callers that rely on it. Like the other drivers, `resume`
/// returns expired sessions too: callers check `PerfectSession.isValid()`.
/// `data` is a real subdocument, not JSON text. Keys libmongoc won't store are escaped (see
/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`), so MongoDB
/// deletes expired sessions on its own; `clean()` still deletes them immediately for callers
/// that rely on it. Like the other drivers, `resume` returns expired sessions too: callers
/// check `PerfectSession.isValid()`.
///
/// `save` only updates an existing session, so a save racing a `destroy` (logout) can't bring
/// the session back. `setup()` also deletes documents left by the archived Perfect-Session-MongoDB
/// package (keyed by `token`, with no `expiresAt`): this driver can't resume them and the TTL
/// index never expires them.
///
/// All database work goes through a `MongoClientPool`, off Swift's cooperative thread pool.
public final class MongoDBSessionDriver: SessionDriver, Sendable {
Expand Down Expand Up @@ -65,6 +71,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
} catch {
logger.error("session setup failed: \(error)")
}
await purgeLegacySessions()
}

public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession {
Expand Down Expand Up @@ -103,8 +110,13 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
public func save(_ session: PerfectSession) async {
let document = SessionDocument(session)
do {
_ = try await withCollection { sessions in
try sessions.replaceOne(filter: Self.filter(token: document._id), with: document, upsert: true)
let matched = try await withCollection { sessions in
try sessions.replaceOne(filter: Self.filter(token: document._id), with: document, upsert: false)
}
if matched == 0 {
// Expected after a logout when middleware saves at the end of the request.
logger.debug("session save matched no session (destroyed or expired); not recreated",
metadata: ["eventid": "\(session.token)"])
}
} catch {
logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"])
Expand Down Expand Up @@ -136,6 +148,27 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {

// MARK: - Private helpers

/// Deletes documents written by the archived Perfect-Session-MongoDB: `{_id, token, userid,
/// created, updated, idle, data, ipaddress, useragent}` with `data` as JSON text and no
/// `expiresAt`. Documents this driver writes always have `expiresAt` and never a `token` field;
/// the rest of the shape keeps unrelated documents that happen to have a `token` safe.
private func purgeLegacySessions() async {
let filter = #"""
{"token": {"$type": "string"}, "data": {"$type": "string"}, "created": {"$exists": true},
"updated": {"$exists": true}, "idle": {"$exists": true}, "expiresAt": {"$exists": false}}
"""#
do {
let deleted = try await withCollection { sessions in
try sessions.deleteMany(filter: try BSON(json: filter))
}
if deleted > 0 {
logger.notice("deleted \(deleted) legacy Perfect-Session-MongoDB session(s)")
}
} catch {
logger.error("legacy session purge failed: \(error)")
}
}

private func withCollection<T: Sendable>(_ body: @Sendable @escaping (MongoCollection) throws -> T) async throws -> T {
let database = self.database
let collection = self.collection
Expand All @@ -156,6 +189,10 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
}

/// The stored form of a `PerfectSession`.
///
/// libmongoc rejects an empty key, and a key containing NUL would be cut short, so keys in
/// `data` (at any depth) are escaped: `%` → `%25`, NUL → `%00`, and the empty key → `%`.
/// Every other key is stored as is, so queries like `data.role` keep working.
struct SessionDocument: Codable, Sendable {
var _id: String
var userid: String
Expand All @@ -176,7 +213,8 @@ struct SessionDocument: Codable, Sendable {
ipaddress = session.ipaddress
useragent = session.useragent
// Through JSON text, not [String: Any] casts: on Darwin a JSON true and 1 are both NSNumber.
data = (try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:])
data = ((try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:]))
.mappingKeys(Self.escape)
expiresAt = Date(timeIntervalSince1970: TimeInterval(session.updated + session.idle))
}

Expand All @@ -189,11 +227,56 @@ struct SessionDocument: Codable, Sendable {
session.idle = idle
session.ipaddress = ipaddress
session.useragent = useragent
if let json = try? JSONEncoder().encode(data) {
if let json = try? JSONEncoder().encode(data.mappingKeys(Self.unescape)) {
session.fromjson(String(decoding: json, as: UTF8.self))
}
return session
}

static func escape(_ key: String) -> String {
guard !key.isEmpty else {
return "%"
}
guard key.utf8.contains(where: { $0 == 0x25 || $0 == 0 }) else {
return key
}
var bytes: [UInt8] = []
for byte in key.utf8 {
switch byte {
case 0x25: bytes += Array("%25".utf8)
case 0: bytes += Array("%00".utf8)
default: bytes.append(byte)
}
}
return String(decoding: bytes, as: UTF8.self)
}

static func unescape(_ key: String) -> String {
guard key != "%" else {
return ""
}
guard key.utf8.contains(0x25) else {
return key
}
let utf8 = Array(key.utf8)
var bytes: [UInt8] = []
var index = 0
while index < utf8.count {
let escape = utf8[index] == 0x25 && index + 2 < utf8.count ? (utf8[index + 1], utf8[index + 2]) : nil
switch escape {
case (0x32, 0x35)?: // %25
bytes.append(0x25)
index += 3
case (0x30, 0x30)?: // %00
bytes.append(0)
index += 3
default: // Not an escape this driver writes: keep it as is.
bytes.append(utf8[index])
index += 1
}
}
return String(decoding: bytes, as: UTF8.self)
}
}

/// A JSON value, so `PerfectSession.data` can be stored as a BSON subdocument.
Expand Down Expand Up @@ -225,6 +308,19 @@ enum JSONValue: Codable, Sendable, Equatable {
}
}

/// Rewrites object keys at every depth.
func mappingKeys(_ transform: (String) -> String) -> JSONValue {
switch self {
case .array(let values):
return .array(values.map { $0.mappingKeys(transform) })
case .object(let members):
return .object(Dictionary(members.map { (transform($0.key), $0.value.mappingKeys(transform)) },
uniquingKeysWith: { first, _ in first }))
default:
return self
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
Expand Down
Loading
Loading