mirror of
https://github.com/aljazceru/pubky-core-ffi.git
synced 2025-12-17 06:14:21 +01:00
feat: add base project scripts and bindings
Adds base project, build scripts and bindings.
This commit is contained in:
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
3140
Cargo.lock
generated
Normal file
3140
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "react_native_pubky"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
crate_type = ["cdylib"]
|
||||
name = "pubkymobile"
|
||||
|
||||
[[bin]]
|
||||
name = "testing"
|
||||
path = "testing/main.rs"
|
||||
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
|
||||
[dependencies]
|
||||
uniffi = { version = "0.25.3", features = [ "cli" ] }
|
||||
serde_json = "1.0.114"
|
||||
hex = "0.4.3"
|
||||
sha2 = "0.10.8"
|
||||
serde = { version = "^1.0.209", features = ["derive"] }
|
||||
tokio = "1.40.0"
|
||||
url = "2.5.2"
|
||||
pkarr = { git = "https://github.com/Pubky/pkarr", branch = "v3", features = ["async", "rand"] }
|
||||
pubky = { version = "0.1.0", path = "pubky/pubky" }
|
||||
pubky-common = { version = "0.1.0", path = "pubky/pubky-common" }
|
||||
pubky_homeserver = { version = "0.1.0", path = "pubky/pubky-homeserver" }
|
||||
base64 = "0.22.1"
|
||||
once_cell = "1.19.0"
|
||||
15
README.md
15
README.md
@@ -1,2 +1,17 @@
|
||||
# pubky-core-mobile-sdk
|
||||
Pubky Core Mobile SDK
|
||||
|
||||
### To build both iOS and Android bindings:
|
||||
```
|
||||
./build.sh all
|
||||
```
|
||||
|
||||
### To build only iOS bindings:
|
||||
```
|
||||
./build.sh ios
|
||||
```
|
||||
|
||||
### To build only Android bindings:
|
||||
```
|
||||
./build.sh android
|
||||
```
|
||||
BIN
bindings/android/jniLibs/arm64-v8a/libpubkymobile.so
Executable file
BIN
bindings/android/jniLibs/arm64-v8a/libpubkymobile.so
Executable file
Binary file not shown.
BIN
bindings/android/jniLibs/armeabi-v7a/libpubkymobile.so
Executable file
BIN
bindings/android/jniLibs/armeabi-v7a/libpubkymobile.so
Executable file
Binary file not shown.
BIN
bindings/android/jniLibs/x86/libpubkymobile.so
Executable file
BIN
bindings/android/jniLibs/x86/libpubkymobile.so
Executable file
Binary file not shown.
BIN
bindings/android/jniLibs/x86_64/libpubkymobile.so
Executable file
BIN
bindings/android/jniLibs/x86_64/libpubkymobile.so
Executable file
Binary file not shown.
1292
bindings/android/pubkymobile.kt
Normal file
1292
bindings/android/pubkymobile.kt
Normal file
File diff suppressed because it is too large
Load Diff
6
bindings/ios/module.modulemap
Normal file
6
bindings/ios/module.modulemap
Normal file
@@ -0,0 +1,6 @@
|
||||
// This file was autogenerated by some hot garbage in the `uniffi` crate.
|
||||
// Trust me, you don't want to mess with it!
|
||||
module pubkymobileFFI {
|
||||
header "pubkymobileFFI.h"
|
||||
export *
|
||||
}
|
||||
872
bindings/ios/pubkymobile.swift
Normal file
872
bindings/ios/pubkymobile.swift
Normal file
@@ -0,0 +1,872 @@
|
||||
// This file was autogenerated by some hot garbage in the `uniffi` crate.
|
||||
// Trust me, you don't want to mess with it!
|
||||
import Foundation
|
||||
|
||||
// Depending on the consumer's build setup, the low-level FFI code
|
||||
// might be in a separate module, or it might be compiled inline into
|
||||
// this module. This is a bit of light hackery to work with both.
|
||||
#if canImport(pubkymobileFFI)
|
||||
import pubkymobileFFI
|
||||
#endif
|
||||
|
||||
fileprivate extension RustBuffer {
|
||||
// Allocate a new buffer, copying the contents of a `UInt8` array.
|
||||
init(bytes: [UInt8]) {
|
||||
let rbuf = bytes.withUnsafeBufferPointer { ptr in
|
||||
RustBuffer.from(ptr)
|
||||
}
|
||||
self.init(capacity: rbuf.capacity, len: rbuf.len, data: rbuf.data)
|
||||
}
|
||||
|
||||
static func from(_ ptr: UnsafeBufferPointer<UInt8>) -> RustBuffer {
|
||||
try! rustCall { ffi_pubkymobile_rustbuffer_from_bytes(ForeignBytes(bufferPointer: ptr), $0) }
|
||||
}
|
||||
|
||||
// Frees the buffer in place.
|
||||
// The buffer must not be used after this is called.
|
||||
func deallocate() {
|
||||
try! rustCall { ffi_pubkymobile_rustbuffer_free(self, $0) }
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension ForeignBytes {
|
||||
init(bufferPointer: UnsafeBufferPointer<UInt8>) {
|
||||
self.init(len: Int32(bufferPointer.count), data: bufferPointer.baseAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// For every type used in the interface, we provide helper methods for conveniently
|
||||
// lifting and lowering that type from C-compatible data, and for reading and writing
|
||||
// values of that type in a buffer.
|
||||
|
||||
// Helper classes/extensions that don't change.
|
||||
// Someday, this will be in a library of its own.
|
||||
|
||||
fileprivate extension Data {
|
||||
init(rustBuffer: RustBuffer) {
|
||||
// TODO: This copies the buffer. Can we read directly from a
|
||||
// Rust buffer?
|
||||
self.init(bytes: rustBuffer.data!, count: Int(rustBuffer.len))
|
||||
}
|
||||
}
|
||||
|
||||
// Define reader functionality. Normally this would be defined in a class or
|
||||
// struct, but we use standalone functions instead in order to make external
|
||||
// types work.
|
||||
//
|
||||
// With external types, one swift source file needs to be able to call the read
|
||||
// method on another source file's FfiConverter, but then what visibility
|
||||
// should Reader have?
|
||||
// - If Reader is fileprivate, then this means the read() must also
|
||||
// be fileprivate, which doesn't work with external types.
|
||||
// - If Reader is internal/public, we'll get compile errors since both source
|
||||
// files will try define the same type.
|
||||
//
|
||||
// Instead, the read() method and these helper functions input a tuple of data
|
||||
|
||||
fileprivate func createReader(data: Data) -> (data: Data, offset: Data.Index) {
|
||||
(data: data, offset: 0)
|
||||
}
|
||||
|
||||
// Reads an integer at the current offset, in big-endian order, and advances
|
||||
// the offset on success. Throws if reading the integer would move the
|
||||
// offset past the end of the buffer.
|
||||
fileprivate func readInt<T: FixedWidthInteger>(_ reader: inout (data: Data, offset: Data.Index)) throws -> T {
|
||||
let range = reader.offset..<reader.offset + MemoryLayout<T>.size
|
||||
guard reader.data.count >= range.upperBound else {
|
||||
throw UniffiInternalError.bufferOverflow
|
||||
}
|
||||
if T.self == UInt8.self {
|
||||
let value = reader.data[reader.offset]
|
||||
reader.offset += 1
|
||||
return value as! T
|
||||
}
|
||||
var value: T = 0
|
||||
let _ = withUnsafeMutableBytes(of: &value, { reader.data.copyBytes(to: $0, from: range)})
|
||||
reader.offset = range.upperBound
|
||||
return value.bigEndian
|
||||
}
|
||||
|
||||
// Reads an arbitrary number of bytes, to be used to read
|
||||
// raw bytes, this is useful when lifting strings
|
||||
fileprivate func readBytes(_ reader: inout (data: Data, offset: Data.Index), count: Int) throws -> Array<UInt8> {
|
||||
let range = reader.offset..<(reader.offset+count)
|
||||
guard reader.data.count >= range.upperBound else {
|
||||
throw UniffiInternalError.bufferOverflow
|
||||
}
|
||||
var value = [UInt8](repeating: 0, count: count)
|
||||
value.withUnsafeMutableBufferPointer({ buffer in
|
||||
reader.data.copyBytes(to: buffer, from: range)
|
||||
})
|
||||
reader.offset = range.upperBound
|
||||
return value
|
||||
}
|
||||
|
||||
// Reads a float at the current offset.
|
||||
fileprivate func readFloat(_ reader: inout (data: Data, offset: Data.Index)) throws -> Float {
|
||||
return Float(bitPattern: try readInt(&reader))
|
||||
}
|
||||
|
||||
// Reads a float at the current offset.
|
||||
fileprivate func readDouble(_ reader: inout (data: Data, offset: Data.Index)) throws -> Double {
|
||||
return Double(bitPattern: try readInt(&reader))
|
||||
}
|
||||
|
||||
// Indicates if the offset has reached the end of the buffer.
|
||||
fileprivate func hasRemaining(_ reader: (data: Data, offset: Data.Index)) -> Bool {
|
||||
return reader.offset < reader.data.count
|
||||
}
|
||||
|
||||
// Define writer functionality. Normally this would be defined in a class or
|
||||
// struct, but we use standalone functions instead in order to make external
|
||||
// types work. See the above discussion on Readers for details.
|
||||
|
||||
fileprivate func createWriter() -> [UInt8] {
|
||||
return []
|
||||
}
|
||||
|
||||
fileprivate func writeBytes<S>(_ writer: inout [UInt8], _ byteArr: S) where S: Sequence, S.Element == UInt8 {
|
||||
writer.append(contentsOf: byteArr)
|
||||
}
|
||||
|
||||
// Writes an integer in big-endian order.
|
||||
//
|
||||
// Warning: make sure what you are trying to write
|
||||
// is in the correct type!
|
||||
fileprivate func writeInt<T: FixedWidthInteger>(_ writer: inout [UInt8], _ value: T) {
|
||||
var value = value.bigEndian
|
||||
withUnsafeBytes(of: &value) { writer.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
fileprivate func writeFloat(_ writer: inout [UInt8], _ value: Float) {
|
||||
writeInt(&writer, value.bitPattern)
|
||||
}
|
||||
|
||||
fileprivate func writeDouble(_ writer: inout [UInt8], _ value: Double) {
|
||||
writeInt(&writer, value.bitPattern)
|
||||
}
|
||||
|
||||
// Protocol for types that transfer other types across the FFI. This is
|
||||
// analogous go the Rust trait of the same name.
|
||||
fileprivate protocol FfiConverter {
|
||||
associatedtype FfiType
|
||||
associatedtype SwiftType
|
||||
|
||||
static func lift(_ value: FfiType) throws -> SwiftType
|
||||
static func lower(_ value: SwiftType) -> FfiType
|
||||
static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType
|
||||
static func write(_ value: SwiftType, into buf: inout [UInt8])
|
||||
}
|
||||
|
||||
// Types conforming to `Primitive` pass themselves directly over the FFI.
|
||||
fileprivate protocol FfiConverterPrimitive: FfiConverter where FfiType == SwiftType { }
|
||||
|
||||
extension FfiConverterPrimitive {
|
||||
public static func lift(_ value: FfiType) throws -> SwiftType {
|
||||
return value
|
||||
}
|
||||
|
||||
public static func lower(_ value: SwiftType) -> FfiType {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// Types conforming to `FfiConverterRustBuffer` lift and lower into a `RustBuffer`.
|
||||
// Used for complex types where it's hard to write a custom lift/lower.
|
||||
fileprivate protocol FfiConverterRustBuffer: FfiConverter where FfiType == RustBuffer {}
|
||||
|
||||
extension FfiConverterRustBuffer {
|
||||
public static func lift(_ buf: RustBuffer) throws -> SwiftType {
|
||||
var reader = createReader(data: Data(rustBuffer: buf))
|
||||
let value = try read(from: &reader)
|
||||
if hasRemaining(reader) {
|
||||
throw UniffiInternalError.incompleteData
|
||||
}
|
||||
buf.deallocate()
|
||||
return value
|
||||
}
|
||||
|
||||
public static func lower(_ value: SwiftType) -> RustBuffer {
|
||||
var writer = createWriter()
|
||||
write(value, into: &writer)
|
||||
return RustBuffer(bytes: writer)
|
||||
}
|
||||
}
|
||||
// An error type for FFI errors. These errors occur at the UniFFI level, not
|
||||
// the library level.
|
||||
fileprivate enum UniffiInternalError: LocalizedError {
|
||||
case bufferOverflow
|
||||
case incompleteData
|
||||
case unexpectedOptionalTag
|
||||
case unexpectedEnumCase
|
||||
case unexpectedNullPointer
|
||||
case unexpectedRustCallStatusCode
|
||||
case unexpectedRustCallError
|
||||
case unexpectedStaleHandle
|
||||
case rustPanic(_ message: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .bufferOverflow: return "Reading the requested value would read past the end of the buffer"
|
||||
case .incompleteData: return "The buffer still has data after lifting its containing value"
|
||||
case .unexpectedOptionalTag: return "Unexpected optional tag; should be 0 or 1"
|
||||
case .unexpectedEnumCase: return "Raw enum value doesn't match any cases"
|
||||
case .unexpectedNullPointer: return "Raw pointer value was null"
|
||||
case .unexpectedRustCallStatusCode: return "Unexpected RustCallStatus code"
|
||||
case .unexpectedRustCallError: return "CALL_ERROR but no errorClass specified"
|
||||
case .unexpectedStaleHandle: return "The object in the handle map has been dropped already"
|
||||
case let .rustPanic(message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let CALL_SUCCESS: Int8 = 0
|
||||
fileprivate let CALL_ERROR: Int8 = 1
|
||||
fileprivate let CALL_PANIC: Int8 = 2
|
||||
fileprivate let CALL_CANCELLED: Int8 = 3
|
||||
|
||||
fileprivate extension RustCallStatus {
|
||||
init() {
|
||||
self.init(
|
||||
code: CALL_SUCCESS,
|
||||
errorBuf: RustBuffer.init(
|
||||
capacity: 0,
|
||||
len: 0,
|
||||
data: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func rustCall<T>(_ callback: (UnsafeMutablePointer<RustCallStatus>) -> T) throws -> T {
|
||||
try makeRustCall(callback, errorHandler: nil)
|
||||
}
|
||||
|
||||
private func rustCallWithError<T>(
|
||||
_ errorHandler: @escaping (RustBuffer) throws -> Error,
|
||||
_ callback: (UnsafeMutablePointer<RustCallStatus>) -> T) throws -> T {
|
||||
try makeRustCall(callback, errorHandler: errorHandler)
|
||||
}
|
||||
|
||||
private func makeRustCall<T>(
|
||||
_ callback: (UnsafeMutablePointer<RustCallStatus>) -> T,
|
||||
errorHandler: ((RustBuffer) throws -> Error)?
|
||||
) throws -> T {
|
||||
uniffiEnsureInitialized()
|
||||
var callStatus = RustCallStatus.init()
|
||||
let returnedVal = callback(&callStatus)
|
||||
try uniffiCheckCallStatus(callStatus: callStatus, errorHandler: errorHandler)
|
||||
return returnedVal
|
||||
}
|
||||
|
||||
private func uniffiCheckCallStatus(
|
||||
callStatus: RustCallStatus,
|
||||
errorHandler: ((RustBuffer) throws -> Error)?
|
||||
) throws {
|
||||
switch callStatus.code {
|
||||
case CALL_SUCCESS:
|
||||
return
|
||||
|
||||
case CALL_ERROR:
|
||||
if let errorHandler = errorHandler {
|
||||
throw try errorHandler(callStatus.errorBuf)
|
||||
} else {
|
||||
callStatus.errorBuf.deallocate()
|
||||
throw UniffiInternalError.unexpectedRustCallError
|
||||
}
|
||||
|
||||
case CALL_PANIC:
|
||||
// When the rust code sees a panic, it tries to construct a RustBuffer
|
||||
// with the message. But if that code panics, then it just sends back
|
||||
// an empty buffer.
|
||||
if callStatus.errorBuf.len > 0 {
|
||||
throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf))
|
||||
} else {
|
||||
callStatus.errorBuf.deallocate()
|
||||
throw UniffiInternalError.rustPanic("Rust panic")
|
||||
}
|
||||
|
||||
case CALL_CANCELLED:
|
||||
throw CancellationError()
|
||||
|
||||
default:
|
||||
throw UniffiInternalError.unexpectedRustCallStatusCode
|
||||
}
|
||||
}
|
||||
|
||||
// Public interface members begin here.
|
||||
|
||||
|
||||
fileprivate struct FfiConverterString: FfiConverter {
|
||||
typealias SwiftType = String
|
||||
typealias FfiType = RustBuffer
|
||||
|
||||
public static func lift(_ value: RustBuffer) throws -> String {
|
||||
defer {
|
||||
value.deallocate()
|
||||
}
|
||||
if value.data == nil {
|
||||
return String()
|
||||
}
|
||||
let bytes = UnsafeBufferPointer<UInt8>(start: value.data!, count: Int(value.len))
|
||||
return String(bytes: bytes, encoding: String.Encoding.utf8)!
|
||||
}
|
||||
|
||||
public static func lower(_ value: String) -> RustBuffer {
|
||||
return value.utf8CString.withUnsafeBufferPointer { ptr in
|
||||
// The swift string gives us int8_t, we want uint8_t.
|
||||
ptr.withMemoryRebound(to: UInt8.self) { ptr in
|
||||
// The swift string gives us a trailing null byte, we don't want it.
|
||||
let buf = UnsafeBufferPointer(rebasing: ptr.prefix(upTo: ptr.count - 1))
|
||||
return RustBuffer.from(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> String {
|
||||
let len: Int32 = try readInt(&buf)
|
||||
return String(bytes: try readBytes(&buf, count: Int(len)), encoding: String.Encoding.utf8)!
|
||||
}
|
||||
|
||||
public static func write(_ value: String, into buf: inout [UInt8]) {
|
||||
let len = Int32(value.utf8.count)
|
||||
writeInt(&buf, len)
|
||||
writeBytes(&buf, value.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public protocol EventNotifierProtocol {
|
||||
|
||||
}
|
||||
|
||||
public class EventNotifier: EventNotifierProtocol {
|
||||
fileprivate let pointer: UnsafeMutableRawPointer
|
||||
|
||||
// TODO: We'd like this to be `private` but for Swifty reasons,
|
||||
// we can't implement `FfiConverter` without making this `required` and we can't
|
||||
// make it `required` without making it `public`.
|
||||
required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) {
|
||||
self.pointer = pointer
|
||||
}
|
||||
|
||||
deinit {
|
||||
try! rustCall { uniffi_pubkymobile_fn_free_eventnotifier(pointer, $0) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public struct FfiConverterTypeEventNotifier: FfiConverter {
|
||||
typealias FfiType = UnsafeMutableRawPointer
|
||||
typealias SwiftType = EventNotifier
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> EventNotifier {
|
||||
let v: UInt64 = try readInt(&buf)
|
||||
// The Rust code won't compile if a pointer won't fit in a UInt64.
|
||||
// We have to go via `UInt` because that's the thing that's the size of a pointer.
|
||||
let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v))
|
||||
if (ptr == nil) {
|
||||
throw UniffiInternalError.unexpectedNullPointer
|
||||
}
|
||||
return try lift(ptr!)
|
||||
}
|
||||
|
||||
public static func write(_ value: EventNotifier, into buf: inout [UInt8]) {
|
||||
// This fiddling is because `Int` is the thing that's the same size as a pointer.
|
||||
// The Rust code won't compile if a pointer won't fit in a `UInt64`.
|
||||
writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value)))))
|
||||
}
|
||||
|
||||
public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> EventNotifier {
|
||||
return EventNotifier(unsafeFromRawPointer: pointer)
|
||||
}
|
||||
|
||||
public static func lower(_ value: EventNotifier) -> UnsafeMutableRawPointer {
|
||||
return value.pointer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func FfiConverterTypeEventNotifier_lift(_ pointer: UnsafeMutableRawPointer) throws -> EventNotifier {
|
||||
return try FfiConverterTypeEventNotifier.lift(pointer)
|
||||
}
|
||||
|
||||
public func FfiConverterTypeEventNotifier_lower(_ value: EventNotifier) -> UnsafeMutableRawPointer {
|
||||
return FfiConverterTypeEventNotifier.lower(value)
|
||||
}
|
||||
|
||||
fileprivate extension NSLock {
|
||||
func withLock<T>(f: () throws -> T) rethrows -> T {
|
||||
self.lock()
|
||||
defer { self.unlock() }
|
||||
return try f()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate typealias UniFFICallbackHandle = UInt64
|
||||
fileprivate class UniFFICallbackHandleMap<T> {
|
||||
private var leftMap: [UniFFICallbackHandle: T] = [:]
|
||||
private var counter: [UniFFICallbackHandle: UInt64] = [:]
|
||||
private var rightMap: [ObjectIdentifier: UniFFICallbackHandle] = [:]
|
||||
|
||||
private let lock = NSLock()
|
||||
private var currentHandle: UniFFICallbackHandle = 0
|
||||
private let stride: UniFFICallbackHandle = 1
|
||||
|
||||
func insert(obj: T) -> UniFFICallbackHandle {
|
||||
lock.withLock {
|
||||
let id = ObjectIdentifier(obj as AnyObject)
|
||||
let handle = rightMap[id] ?? {
|
||||
currentHandle += stride
|
||||
let handle = currentHandle
|
||||
leftMap[handle] = obj
|
||||
rightMap[id] = handle
|
||||
return handle
|
||||
}()
|
||||
counter[handle] = (counter[handle] ?? 0) + 1
|
||||
return handle
|
||||
}
|
||||
}
|
||||
|
||||
func get(handle: UniFFICallbackHandle) -> T? {
|
||||
lock.withLock {
|
||||
leftMap[handle]
|
||||
}
|
||||
}
|
||||
|
||||
func delete(handle: UniFFICallbackHandle) {
|
||||
remove(handle: handle)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(handle: UniFFICallbackHandle) -> T? {
|
||||
lock.withLock {
|
||||
defer { counter[handle] = (counter[handle] ?? 1) - 1 }
|
||||
guard counter[handle] == 1 else { return leftMap[handle] }
|
||||
let obj = leftMap.removeValue(forKey: handle)
|
||||
if let obj = obj {
|
||||
rightMap.removeValue(forKey: ObjectIdentifier(obj as AnyObject))
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Magic number for the Rust proxy to call using the same mechanism as every other method,
|
||||
// to free the callback once it's dropped by Rust.
|
||||
private let IDX_CALLBACK_FREE: Int32 = 0
|
||||
// Callback return codes
|
||||
private let UNIFFI_CALLBACK_SUCCESS: Int32 = 0
|
||||
private let UNIFFI_CALLBACK_ERROR: Int32 = 1
|
||||
private let UNIFFI_CALLBACK_UNEXPECTED_ERROR: Int32 = 2
|
||||
|
||||
// Declaration and FfiConverters for EventListener Callback Interface
|
||||
|
||||
public protocol EventListener : AnyObject {
|
||||
func onEventOccurred(eventData: String)
|
||||
|
||||
}
|
||||
|
||||
// The ForeignCallback that is passed to Rust.
|
||||
fileprivate let foreignCallbackCallbackInterfaceEventListener : ForeignCallback =
|
||||
{ (handle: UniFFICallbackHandle, method: Int32, argsData: UnsafePointer<UInt8>, argsLen: Int32, out_buf: UnsafeMutablePointer<RustBuffer>) -> Int32 in
|
||||
|
||||
|
||||
func invokeOnEventOccurred(_ swiftCallbackInterface: EventListener, _ argsData: UnsafePointer<UInt8>, _ argsLen: Int32, _ out_buf: UnsafeMutablePointer<RustBuffer>) throws -> Int32 {
|
||||
var reader = createReader(data: Data(bytes: argsData, count: Int(argsLen)))
|
||||
func makeCall() throws -> Int32 {
|
||||
try swiftCallbackInterface.onEventOccurred(
|
||||
eventData: try FfiConverterString.read(from: &reader)
|
||||
)
|
||||
return UNIFFI_CALLBACK_SUCCESS
|
||||
}
|
||||
return try makeCall()
|
||||
}
|
||||
|
||||
|
||||
switch method {
|
||||
case IDX_CALLBACK_FREE:
|
||||
FfiConverterCallbackInterfaceEventListener.drop(handle: handle)
|
||||
// Sucessful return
|
||||
// See docs of ForeignCallback in `uniffi_core/src/ffi/foreigncallbacks.rs`
|
||||
return UNIFFI_CALLBACK_SUCCESS
|
||||
case 1:
|
||||
let cb: EventListener
|
||||
do {
|
||||
cb = try FfiConverterCallbackInterfaceEventListener.lift(handle)
|
||||
} catch {
|
||||
out_buf.pointee = FfiConverterString.lower("EventListener: Invalid handle")
|
||||
return UNIFFI_CALLBACK_UNEXPECTED_ERROR
|
||||
}
|
||||
do {
|
||||
return try invokeOnEventOccurred(cb, argsData, argsLen, out_buf)
|
||||
} catch let error {
|
||||
out_buf.pointee = FfiConverterString.lower(String(describing: error))
|
||||
return UNIFFI_CALLBACK_UNEXPECTED_ERROR
|
||||
}
|
||||
|
||||
// This should never happen, because an out of bounds method index won't
|
||||
// ever be used. Once we can catch errors, we should return an InternalError.
|
||||
// https://github.com/mozilla/uniffi-rs/issues/351
|
||||
default:
|
||||
// An unexpected error happened.
|
||||
// See docs of ForeignCallback in `uniffi_core/src/ffi/foreigncallbacks.rs`
|
||||
return UNIFFI_CALLBACK_UNEXPECTED_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
// FfiConverter protocol for callback interfaces
|
||||
fileprivate struct FfiConverterCallbackInterfaceEventListener {
|
||||
private static let initCallbackOnce: () = {
|
||||
// Swift ensures this initializer code will once run once, even when accessed by multiple threads.
|
||||
try! rustCall { (err: UnsafeMutablePointer<RustCallStatus>) in
|
||||
uniffi_pubkymobile_fn_init_callback_eventlistener(foreignCallbackCallbackInterfaceEventListener, err)
|
||||
}
|
||||
}()
|
||||
|
||||
private static func ensureCallbackinitialized() {
|
||||
_ = initCallbackOnce
|
||||
}
|
||||
|
||||
static func drop(handle: UniFFICallbackHandle) {
|
||||
handleMap.remove(handle: handle)
|
||||
}
|
||||
|
||||
private static var handleMap = UniFFICallbackHandleMap<EventListener>()
|
||||
}
|
||||
|
||||
extension FfiConverterCallbackInterfaceEventListener : FfiConverter {
|
||||
typealias SwiftType = EventListener
|
||||
// We can use Handle as the FfiType because it's a typealias to UInt64
|
||||
typealias FfiType = UniFFICallbackHandle
|
||||
|
||||
public static func lift(_ handle: UniFFICallbackHandle) throws -> SwiftType {
|
||||
ensureCallbackinitialized();
|
||||
guard let callback = handleMap.get(handle: handle) else {
|
||||
throw UniffiInternalError.unexpectedStaleHandle
|
||||
}
|
||||
return callback
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
|
||||
ensureCallbackinitialized();
|
||||
let handle: UniFFICallbackHandle = try readInt(&buf)
|
||||
return try lift(handle)
|
||||
}
|
||||
|
||||
public static func lower(_ v: SwiftType) -> UniFFICallbackHandle {
|
||||
ensureCallbackinitialized();
|
||||
return handleMap.insert(obj: v)
|
||||
}
|
||||
|
||||
public static func write(_ v: SwiftType, into buf: inout [UInt8]) {
|
||||
ensureCallbackinitialized();
|
||||
writeInt(&buf, lower(v))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer {
|
||||
typealias SwiftType = [String]
|
||||
|
||||
public static func write(_ value: [String], into buf: inout [UInt8]) {
|
||||
let len = Int32(value.count)
|
||||
writeInt(&buf, len)
|
||||
for item in value {
|
||||
FfiConverterString.write(item, into: &buf)
|
||||
}
|
||||
}
|
||||
|
||||
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [String] {
|
||||
let len: Int32 = try readInt(&buf)
|
||||
var seq = [String]()
|
||||
seq.reserveCapacity(Int(len))
|
||||
for _ in 0 ..< len {
|
||||
seq.append(try FfiConverterString.read(from: &buf))
|
||||
}
|
||||
return seq
|
||||
}
|
||||
}
|
||||
|
||||
public func auth(url: String, secretKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_auth(
|
||||
FfiConverterString.lower(url),
|
||||
FfiConverterString.lower(secretKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func createRecoveryFile(secretKey: String, passphrase: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_create_recovery_file(
|
||||
FfiConverterString.lower(secretKey),
|
||||
FfiConverterString.lower(passphrase),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func decryptRecoveryFile(recoveryFile: String, passphrase: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_decrypt_recovery_file(
|
||||
FfiConverterString.lower(recoveryFile),
|
||||
FfiConverterString.lower(passphrase),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func deleteFile(url: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_delete_file(
|
||||
FfiConverterString.lower(url),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func generateSecretKey() -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_generate_secret_key($0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func get(url: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_get(
|
||||
FfiConverterString.lower(url),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func getPublicKeyFromSecretKey(secretKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_get_public_key_from_secret_key(
|
||||
FfiConverterString.lower(secretKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func list(url: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_list(
|
||||
FfiConverterString.lower(url),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func parseAuthUrl(url: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_parse_auth_url(
|
||||
FfiConverterString.lower(url),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func publish(recordName: String, recordContent: String, secretKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_publish(
|
||||
FfiConverterString.lower(recordName),
|
||||
FfiConverterString.lower(recordContent),
|
||||
FfiConverterString.lower(secretKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func publishHttps(recordName: String, target: String, secretKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_publish_https(
|
||||
FfiConverterString.lower(recordName),
|
||||
FfiConverterString.lower(target),
|
||||
FfiConverterString.lower(secretKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func put(url: String, content: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_put(
|
||||
FfiConverterString.lower(url),
|
||||
FfiConverterString.lower(content),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func removeEventListener() {
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_remove_event_listener($0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public func resolve(publicKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_resolve(
|
||||
FfiConverterString.lower(publicKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func resolveHttps(publicKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_resolve_https(
|
||||
FfiConverterString.lower(publicKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func session(pubky: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_session(
|
||||
FfiConverterString.lower(pubky),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func setEventListener(listener: EventListener) {
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_set_event_listener(
|
||||
FfiConverterCallbackInterfaceEventListener.lower(listener),$0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public func signIn(secretKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_sign_in(
|
||||
FfiConverterString.lower(secretKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func signOut(secretKey: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_sign_out(
|
||||
FfiConverterString.lower(secretKey),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func signUp(secretKey: String, homeserver: String) -> [String] {
|
||||
return try! FfiConverterSequenceString.lift(
|
||||
try! rustCall() {
|
||||
uniffi_pubkymobile_fn_func_sign_up(
|
||||
FfiConverterString.lower(secretKey),
|
||||
FfiConverterString.lower(homeserver),$0)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private enum InitializationResult {
|
||||
case ok
|
||||
case contractVersionMismatch
|
||||
case apiChecksumMismatch
|
||||
}
|
||||
// Use a global variables to perform the versioning checks. Swift ensures that
|
||||
// the code inside is only computed once.
|
||||
private var initializationResult: InitializationResult {
|
||||
// Get the bindings contract version from our ComponentInterface
|
||||
let bindings_contract_version = 24
|
||||
// Get the scaffolding contract version by calling the into the dylib
|
||||
let scaffolding_contract_version = ffi_pubkymobile_uniffi_contract_version()
|
||||
if bindings_contract_version != scaffolding_contract_version {
|
||||
return InitializationResult.contractVersionMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_auth() != 61378) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_create_recovery_file() != 55903) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_decrypt_recovery_file() != 59688) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_delete_file() != 57905) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_generate_secret_key() != 63116) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_get() != 21596) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_get_public_key_from_secret_key() != 23603) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_list() != 8522) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_parse_auth_url() != 29088) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_publish() != 20156) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_publish_https() != 14705) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_put() != 51107) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_remove_event_listener() != 6794) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_resolve() != 18303) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_resolve_https() != 34593) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_session() != 65177) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_set_event_listener() != 19468) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_sign_in() != 21006) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_sign_out() != 59116) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_func_sign_up() != 58756) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
if (uniffi_pubkymobile_checksum_method_eventlistener_on_event_occurred() != 39865) {
|
||||
return InitializationResult.apiChecksumMismatch
|
||||
}
|
||||
|
||||
return InitializationResult.ok
|
||||
}
|
||||
|
||||
private func uniffiEnsureInitialized() {
|
||||
switch initializationResult {
|
||||
case .ok:
|
||||
break
|
||||
case .contractVersionMismatch:
|
||||
fatalError("UniFFI contract version mismatch: try cleaning and rebuilding your project")
|
||||
case .apiChecksumMismatch:
|
||||
fatalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
|
||||
}
|
||||
}
|
||||
292
bindings/ios/pubkymobileFFI.h
Normal file
292
bindings/ios/pubkymobileFFI.h
Normal file
@@ -0,0 +1,292 @@
|
||||
// This file was autogenerated by some hot garbage in the `uniffi` crate.
|
||||
// Trust me, you don't want to mess with it!
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// The following structs are used to implement the lowest level
|
||||
// of the FFI, and thus useful to multiple uniffied crates.
|
||||
// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H.
|
||||
#ifdef UNIFFI_SHARED_H
|
||||
// We also try to prevent mixing versions of shared uniffi header structs.
|
||||
// If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4
|
||||
#ifndef UNIFFI_SHARED_HEADER_V4
|
||||
#error Combining helper code from multiple versions of uniffi is not supported
|
||||
#endif // ndef UNIFFI_SHARED_HEADER_V4
|
||||
#else
|
||||
#define UNIFFI_SHARED_H
|
||||
#define UNIFFI_SHARED_HEADER_V4
|
||||
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
||||
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
||||
|
||||
typedef struct RustBuffer
|
||||
{
|
||||
int32_t capacity;
|
||||
int32_t len;
|
||||
uint8_t *_Nullable data;
|
||||
} RustBuffer;
|
||||
|
||||
typedef int32_t (*ForeignCallback)(uint64_t, int32_t, const uint8_t *_Nonnull, int32_t, RustBuffer *_Nonnull);
|
||||
|
||||
// Task defined in Rust that Swift executes
|
||||
typedef void (*UniFfiRustTaskCallback)(const void * _Nullable, int8_t);
|
||||
|
||||
// Callback to execute Rust tasks using a Swift Task
|
||||
//
|
||||
// Args:
|
||||
// executor: ForeignExecutor lowered into a size_t value
|
||||
// delay: Delay in MS
|
||||
// task: UniFfiRustTaskCallback to call
|
||||
// task_data: data to pass the task callback
|
||||
typedef int8_t (*UniFfiForeignExecutorCallback)(size_t, uint32_t, UniFfiRustTaskCallback _Nullable, const void * _Nullable);
|
||||
|
||||
typedef struct ForeignBytes
|
||||
{
|
||||
int32_t len;
|
||||
const uint8_t *_Nullable data;
|
||||
} ForeignBytes;
|
||||
|
||||
// Error definitions
|
||||
typedef struct RustCallStatus {
|
||||
int8_t code;
|
||||
RustBuffer errorBuf;
|
||||
} RustCallStatus;
|
||||
|
||||
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
||||
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
||||
#endif // def UNIFFI_SHARED_H
|
||||
|
||||
// Continuation callback for UniFFI Futures
|
||||
typedef void (*UniFfiRustFutureContinuation)(void * _Nonnull, int8_t);
|
||||
|
||||
// Scaffolding functions
|
||||
void uniffi_pubkymobile_fn_free_eventnotifier(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void uniffi_pubkymobile_fn_init_callback_eventlistener(ForeignCallback _Nonnull callback_stub, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_auth(RustBuffer url, RustBuffer secret_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_create_recovery_file(RustBuffer secret_key, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_decrypt_recovery_file(RustBuffer recovery_file, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_delete_file(RustBuffer url, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_generate_secret_key(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_get(RustBuffer url, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_get_public_key_from_secret_key(RustBuffer secret_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_list(RustBuffer url, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_parse_auth_url(RustBuffer url, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_publish(RustBuffer record_name, RustBuffer record_content, RustBuffer secret_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_publish_https(RustBuffer record_name, RustBuffer target, RustBuffer secret_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_put(RustBuffer url, RustBuffer content, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void uniffi_pubkymobile_fn_func_remove_event_listener(RustCallStatus *_Nonnull out_status
|
||||
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_resolve(RustBuffer public_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_resolve_https(RustBuffer public_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_session(RustBuffer pubky, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void uniffi_pubkymobile_fn_func_set_event_listener(uint64_t listener, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_sign_in(RustBuffer secret_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_sign_out(RustBuffer secret_key, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer uniffi_pubkymobile_fn_func_sign_up(RustBuffer secret_key, RustBuffer homeserver, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer ffi_pubkymobile_rustbuffer_alloc(int32_t size, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer ffi_pubkymobile_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
RustBuffer ffi_pubkymobile_rustbuffer_reserve(RustBuffer buf, int32_t additional, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_continuation_callback_set(UniFfiRustFutureContinuation _Nonnull callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_u8(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_u8(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_u8(void* _Nonnull handle
|
||||
);
|
||||
uint8_t ffi_pubkymobile_rust_future_complete_u8(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_i8(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_i8(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_i8(void* _Nonnull handle
|
||||
);
|
||||
int8_t ffi_pubkymobile_rust_future_complete_i8(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_u16(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_u16(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_u16(void* _Nonnull handle
|
||||
);
|
||||
uint16_t ffi_pubkymobile_rust_future_complete_u16(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_i16(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_i16(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_i16(void* _Nonnull handle
|
||||
);
|
||||
int16_t ffi_pubkymobile_rust_future_complete_i16(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_u32(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_u32(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_u32(void* _Nonnull handle
|
||||
);
|
||||
uint32_t ffi_pubkymobile_rust_future_complete_u32(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_i32(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_i32(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_i32(void* _Nonnull handle
|
||||
);
|
||||
int32_t ffi_pubkymobile_rust_future_complete_i32(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_u64(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_u64(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_u64(void* _Nonnull handle
|
||||
);
|
||||
uint64_t ffi_pubkymobile_rust_future_complete_u64(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_i64(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_i64(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_i64(void* _Nonnull handle
|
||||
);
|
||||
int64_t ffi_pubkymobile_rust_future_complete_i64(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_f32(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_f32(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_f32(void* _Nonnull handle
|
||||
);
|
||||
float ffi_pubkymobile_rust_future_complete_f32(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_f64(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_f64(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_f64(void* _Nonnull handle
|
||||
);
|
||||
double ffi_pubkymobile_rust_future_complete_f64(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_pointer(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_pointer(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_pointer(void* _Nonnull handle
|
||||
);
|
||||
void*_Nonnull ffi_pubkymobile_rust_future_complete_pointer(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_rust_buffer(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_rust_buffer(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_rust_buffer(void* _Nonnull handle
|
||||
);
|
||||
RustBuffer ffi_pubkymobile_rust_future_complete_rust_buffer(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_poll_void(void* _Nonnull handle, void* _Nonnull uniffi_callback
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_cancel_void(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_free_void(void* _Nonnull handle
|
||||
);
|
||||
void ffi_pubkymobile_rust_future_complete_void(void* _Nonnull handle, RustCallStatus *_Nonnull out_status
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_auth(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_create_recovery_file(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_decrypt_recovery_file(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_delete_file(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_generate_secret_key(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_get(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_get_public_key_from_secret_key(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_list(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_parse_auth_url(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_publish(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_publish_https(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_put(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_remove_event_listener(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_resolve(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_resolve_https(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_session(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_set_event_listener(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_sign_in(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_sign_out(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_func_sign_up(void
|
||||
|
||||
);
|
||||
uint16_t uniffi_pubkymobile_checksum_method_eventlistener_on_event_occurred(void
|
||||
|
||||
);
|
||||
uint32_t ffi_pubkymobile_uniffi_contract_version(void
|
||||
|
||||
);
|
||||
|
||||
18
build.sh
Executable file
18
build.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Save as build.sh
|
||||
case "$1" in
|
||||
"ios")
|
||||
./build_ios.sh
|
||||
;;
|
||||
"android")
|
||||
./build_android.sh
|
||||
;;
|
||||
"all")
|
||||
./build_ios.sh && ./build_android.sh
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {ios|android|all}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
94
build_android.sh
Executable file
94
build_android.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit immediately if a command exits with a non-zero status.
|
||||
|
||||
echo "Starting Android build process..."
|
||||
|
||||
# Define output directories
|
||||
BASE_DIR="./bindings/android"
|
||||
JNILIBS_DIR="$BASE_DIR/jniLibs"
|
||||
|
||||
# Create output directories
|
||||
mkdir -p "$BASE_DIR"
|
||||
mkdir -p "$JNILIBS_DIR"
|
||||
|
||||
# Remove previous build
|
||||
echo "Removing previous build..."
|
||||
rm -rf bindings/android/
|
||||
|
||||
# Cargo Build
|
||||
echo "Building Rust libraries..."
|
||||
cargo build && cd pubky && cargo build && cd pubky && cargo build && cd ../ && cd pubky-common && cargo build && cd ../ && cd pubky-homeserver && cargo build && cd ../../
|
||||
|
||||
# Modify Cargo.toml
|
||||
echo "Updating Cargo.toml..."
|
||||
sed -i '' 's/crate_type = .*/crate_type = ["cdylib"]/' Cargo.toml
|
||||
|
||||
# Build release
|
||||
echo "Building release version..."
|
||||
cargo build --release
|
||||
|
||||
# Install cargo-ndk if not already installed
|
||||
if ! command -v cargo-ndk &> /dev/null; then
|
||||
echo "Installing cargo-ndk..."
|
||||
cargo install cargo-ndk
|
||||
fi
|
||||
|
||||
# Add Android targets
|
||||
echo "Adding Android targets..."
|
||||
rustup target add \
|
||||
aarch64-linux-android \
|
||||
armv7-linux-androideabi \
|
||||
i686-linux-android \
|
||||
x86_64-linux-android
|
||||
|
||||
# Build for all Android architectures
|
||||
echo "Building for Android architectures..."
|
||||
cargo ndk \
|
||||
-o "$JNILIBS_DIR" \
|
||||
--manifest-path ./Cargo.toml \
|
||||
-t armeabi-v7a \
|
||||
-t arm64-v8a \
|
||||
-t x86 \
|
||||
-t x86_64 \
|
||||
build --release
|
||||
|
||||
# Generate Kotlin bindings
|
||||
echo "Generating Kotlin bindings..."
|
||||
LIBRARY_PATH="./target/release/libpubkymobile.dylib"
|
||||
|
||||
# Check if the library file exists
|
||||
if [ ! -f "$LIBRARY_PATH" ]; then
|
||||
echo "Error: Library file not found at $LIBRARY_PATH"
|
||||
echo "Available files in target/release:"
|
||||
ls -l ./target/release/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a temporary directory for initial generation
|
||||
TMP_DIR=$(mktemp -d)
|
||||
|
||||
# Generate the bindings to temp directory first
|
||||
cargo run --bin uniffi-bindgen generate \
|
||||
--library "$LIBRARY_PATH" \
|
||||
--language kotlin \
|
||||
--out-dir "$TMP_DIR"
|
||||
|
||||
# Move the Kotlin file from the nested directory to the final location
|
||||
echo "Moving Kotlin file to final location..."
|
||||
find "$TMP_DIR" -name "pubkymobile.kt" -exec mv {} "$BASE_DIR/" \;
|
||||
|
||||
# Clean up temp directory and any remaining uniffi directories
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -rf "$TMP_DIR"
|
||||
rm -rf "$BASE_DIR/uniffi"
|
||||
|
||||
# Verify the file was moved correctly
|
||||
if [ ! -f "$BASE_DIR/pubkymobile.kt" ]; then
|
||||
echo "Error: Kotlin bindings were not moved correctly"
|
||||
echo "Contents of $BASE_DIR:"
|
||||
ls -la "$BASE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Android build process completed successfully!"
|
||||
96
build_ios.sh
Executable file
96
build_ios.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit immediately if a command exits with a non-zero status.
|
||||
|
||||
echo "Starting iOS build process..."
|
||||
|
||||
# Remove previous builds and ensure clean state
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf bindings/ios/*
|
||||
rm -rf ios/
|
||||
|
||||
# Create necessary directories
|
||||
echo "Creating build directories..."
|
||||
mkdir -p bindings/ios/
|
||||
|
||||
# Set iOS deployment target
|
||||
export IPHONEOS_DEPLOYMENT_TARGET=13.4
|
||||
|
||||
# Cargo Build
|
||||
echo "Building Rust libraries..."
|
||||
cargo build && cd pubky && cargo build && cd pubky && cargo build && cd ../ && cd pubky-common && cargo build && cd ../ && cd pubky-homeserver && cargo build && cd ../../
|
||||
|
||||
# Modify Cargo.toml
|
||||
echo "Updating Cargo.toml..."
|
||||
sed -i '' 's/crate_type = .*/crate_type = ["cdylib", "staticlib"]/' Cargo.toml
|
||||
|
||||
# Build release
|
||||
echo "Building release version..."
|
||||
cargo build --release
|
||||
|
||||
# Add iOS targets
|
||||
echo "Adding iOS targets..."
|
||||
rustup target add aarch64-apple-ios-sim aarch64-apple-ios
|
||||
|
||||
# Build for iOS simulator and device
|
||||
echo "Building for iOS targets..."
|
||||
cargo build --release --target=aarch64-apple-ios-sim
|
||||
cargo build --release --target=aarch64-apple-ios
|
||||
|
||||
# Generate Swift bindings
|
||||
echo "Generating Swift bindings..."
|
||||
# First, ensure any existing generated files are removed
|
||||
rm -rf ./bindings/ios/pubkymobile.swift
|
||||
rm -rf ./bindings/ios/pubkymobileFFI.h
|
||||
rm -rf ./bindings/ios/pubkymobileFFI.modulemap
|
||||
rm -rf ./bindings/ios/Headers
|
||||
rm -rf ./bindings/ios/ios-arm64
|
||||
rm -rf ./bindings/ios/ios-arm64-sim
|
||||
|
||||
cargo run --bin uniffi-bindgen generate \
|
||||
--library ./target/release/libpubkymobile.dylib \
|
||||
--language swift \
|
||||
--out-dir ./bindings/ios \
|
||||
|| { echo "Failed to generate Swift bindings"; exit 1; }
|
||||
|
||||
# Handle modulemap file
|
||||
echo "Handling modulemap file..."
|
||||
if [ -f bindings/ios/pubkymobileFFI.modulemap ]; then
|
||||
mv bindings/ios/pubkymobileFFI.modulemap bindings/ios/module.modulemap
|
||||
else
|
||||
echo "Warning: modulemap file not found"
|
||||
fi
|
||||
|
||||
# Clean up any existing XCFramework and temporary directories
|
||||
echo "Cleaning up existing XCFramework..."
|
||||
rm -rf "bindings/ios/PubkyMobile.xcframework"
|
||||
rm -rf "bindings/ios/Headers"
|
||||
rm -rf "bindings/ios/ios-arm64"
|
||||
rm -rf "bindings/ios/ios-arm64-sim"
|
||||
|
||||
# Create temporary directories for each architecture
|
||||
echo "Creating architecture-specific directories..."
|
||||
mkdir -p "bindings/ios/ios-arm64/Headers"
|
||||
mkdir -p "bindings/ios/ios-arm64-sim/Headers"
|
||||
|
||||
# Copy headers to architecture-specific directories
|
||||
echo "Copying headers to architecture directories..."
|
||||
cp bindings/ios/pubkymobileFFI.h "bindings/ios/ios-arm64/Headers/"
|
||||
cp bindings/ios/module.modulemap "bindings/ios/ios-arm64/Headers/"
|
||||
cp bindings/ios/pubkymobileFFI.h "bindings/ios/ios-arm64-sim/Headers/"
|
||||
cp bindings/ios/module.modulemap "bindings/ios/ios-arm64-sim/Headers/"
|
||||
|
||||
# Create XCFramework
|
||||
echo "Creating XCFramework..."
|
||||
xcodebuild -create-xcframework \
|
||||
-library ./target/aarch64-apple-ios-sim/release/libpubkymobile.a -headers "bindings/ios/ios-arm64-sim/Headers" \
|
||||
-library ./target/aarch64-apple-ios/release/libpubkymobile.a -headers "bindings/ios/ios-arm64/Headers" \
|
||||
-output "bindings/ios/PubkyMobile.xcframework" \
|
||||
|| { echo "Failed to create XCFramework"; exit 1; }
|
||||
|
||||
# Clean up temporary directories
|
||||
echo "Cleaning up temporary directories..."
|
||||
rm -rf "bindings/ios/ios-arm64"
|
||||
rm -rf "bindings/ios/ios-arm64-sim"
|
||||
|
||||
echo "iOS build process completed successfully!"
|
||||
2811
pubky/Cargo.lock
generated
Normal file
2811
pubky/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
pubky/Cargo.toml
Normal file
18
pubky/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"pubky",
|
||||
"pubky-*",
|
||||
|
||||
"examples/authz/authenticator"
|
||||
]
|
||||
|
||||
# See: https://github.com/rust-lang/rust/issues/90148#issuecomment-949194352
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
pkarr = { git = "https://github.com/Pubky/pkarr", branch = "v3", package = "pkarr", features = ["async"] }
|
||||
serde = { version = "^1.0.209", features = ["derive"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = 'z'
|
||||
21
pubky/LICENSE
Normal file
21
pubky/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2023
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
8
pubky/README.md
Normal file
8
pubky/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Pubky
|
||||
|
||||
> The Web, long centralized, must decentralize; Long decentralized, must centralize.
|
||||
|
||||
> [!WARNING]
|
||||
> Pubky is still under heavy development and should be considered an alpha software.
|
||||
>
|
||||
> Features might be added, removed, or changed. Data might be lost.
|
||||
24
pubky/examples/authz/3rd-party-app/.gitignore
vendored
Normal file
24
pubky/examples/authz/3rd-party-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
34
pubky/examples/authz/3rd-party-app/index.html
Normal file
34
pubky/examples/authz/3rd-party-app/index.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/pubky.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pubky Auth Demo</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script type="module">
|
||||
import "@synonymdev/pubky"
|
||||
</script>
|
||||
<script type="module" src="/src/pubky-auth-widget.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<pubky-auth-widget
|
||||
id="widget"
|
||||
relay="https://demo.httprelay.io/link/"
|
||||
>
|
||||
</pubky-auth-widget>
|
||||
|
||||
<main>
|
||||
<h1>Third Party app!</h1>
|
||||
<p>this is a demo for using Pubky Auth in an unhosted (no backend) app.</p>
|
||||
<form>
|
||||
<label style="display:block">
|
||||
<input type="checkbox" onChange="document.getElementById('widget').switchTestnet()">testnet (use local test network)</input>
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<input type="checkbox" onChange="let w = document.getElementById('widget'); w.caps.length > 0 ? w.setCapabilities(null) : w.setCapabilities('/pub/pubky.app/:rw,/pub/example.com/nested:rw')">Authz (Authorization, set if your pubky has an account on a Homeserver)</input>
|
||||
</label>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
1136
pubky/examples/authz/3rd-party-app/package-lock.json
generated
Normal file
1136
pubky/examples/authz/3rd-party-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
pubky/examples/authz/3rd-party-app/package.json
Normal file
20
pubky/examples/authz/3rd-party-app/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pubky-auth-3rd-party",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npm run dev",
|
||||
"dev": "vite --host --open",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@synonymdev/pubky": "^0.1.16",
|
||||
"lit": "^3.2.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
9
pubky/examples/authz/3rd-party-app/public/pubky.svg
Normal file
9
pubky/examples/authz/3rd-party-app/public/pubky.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 452 690">
|
||||
<style>
|
||||
path { fill: black; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: white; }
|
||||
}
|
||||
</style>
|
||||
<path fill-rule="evenodd" class="a" d="m0.1 84.7l80.5 17.1 15.8-74.5 73.8 44.2 54.7-71.5 55.2 71.5 70.3-44.2 19.4 74.5 81.6-17.1-74.5 121.5c-40.5-35.3-93.5-56.6-151.4-56.6-57.8 0-110.7 21.3-151.2 56.4zm398.4 293.8c0 40.6-14 78-37.4 107.4l67 203.8h-403.1l66.2-202.3c-24.1-29.7-38.6-67.6-38.6-108.9 0-95.5 77.4-172.8 173-172.8 95.5 0 172.9 77.3 172.9 172.8zm-212.9 82.4l-48.2 147.3h178.1l-48.6-148 2.9-1.6c28.2-15.6 47.3-45.6 47.3-80.1 0-50.5-41-91.4-91.5-91.4-50.6 0-91.6 40.9-91.6 91.4 0 35 19.7 65.4 48.6 80.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 724 B |
48
pubky/examples/authz/3rd-party-app/src/index.css
Normal file
48
pubky/examples/authz/3rd-party-app/src/index.css
Normal file
@@ -0,0 +1,48 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
color: white;
|
||||
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
transparent 20%,
|
||||
#151718 20%,
|
||||
#151718 80%,
|
||||
transparent 80%,
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
circle,
|
||||
transparent 20%,
|
||||
#151718 20%,
|
||||
#151718 80%,
|
||||
transparent 80%,
|
||||
transparent
|
||||
)
|
||||
25px 25px,
|
||||
linear-gradient(#202020 1px, transparent 2px) 0 -1px,
|
||||
linear-gradient(90deg, #202020 1px, #151718 2px) -1px 0;
|
||||
background-size: 50px 50px, 50px 50px, 25px 25px, 25px 25px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 3.2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
391
pubky/examples/authz/3rd-party-app/src/pubky-auth-widget.js
Normal file
391
pubky/examples/authz/3rd-party-app/src/pubky-auth-widget.js
Normal file
@@ -0,0 +1,391 @@
|
||||
import { LitElement, css, html } from 'lit'
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const DEFAULT_HTTP_RELAY = "https://demo.httprelay.io/link"
|
||||
|
||||
/**
|
||||
*/
|
||||
export class PubkyAuthWidget extends LitElement {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
// === Config ===
|
||||
|
||||
/**
|
||||
* Relay endpoint for the widget to receive Pubky AuthTokens
|
||||
*
|
||||
* Internally, a random channel ID will be generated and a
|
||||
* GET request made for `${realy url}/${channelID}`
|
||||
*
|
||||
* If no relay is passed, the widget will use a default relay:
|
||||
* https://demo.httprelay.io/link
|
||||
*/
|
||||
relay: { type: String },
|
||||
/**
|
||||
* Capabilities requested or this application encoded as a string.
|
||||
*/
|
||||
caps: { type: String },
|
||||
|
||||
// === State ===
|
||||
|
||||
/**
|
||||
* Widget's state (open or closed)
|
||||
*/
|
||||
open: { type: Boolean },
|
||||
/**
|
||||
* Show "copied to clipboard" note
|
||||
*/
|
||||
showCopied: { type: Boolean },
|
||||
|
||||
// === Internal ===
|
||||
testnet: { type: Boolean },
|
||||
pubky: { type: Object }
|
||||
}
|
||||
}
|
||||
|
||||
canvasRef = createRef();
|
||||
|
||||
constructor() {
|
||||
if (!window.pubky) {
|
||||
throw new Error("window.pubky is unavailable, make sure to import `@synonymdev/pubky` before this web component.")
|
||||
}
|
||||
|
||||
super()
|
||||
|
||||
this.testnet = false;
|
||||
this.open = false;
|
||||
|
||||
/** @type {import("@synonymdev/pubky").PubkyClient} */
|
||||
this.pubkyClient = new window.pubky.PubkyClient();
|
||||
|
||||
this.caps = this.caps || ""
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
|
||||
this._generateURL()
|
||||
}
|
||||
|
||||
switchTestnet() {
|
||||
this.testnet = !this.testnet;
|
||||
|
||||
console.debug("Switching testnet");
|
||||
|
||||
if (this.testnet) {
|
||||
this.pubkyClient = window.pubky.PubkyClient.testnet()
|
||||
} else {
|
||||
this.pubkyClient = new window.pubky.PubkyClient();
|
||||
}
|
||||
|
||||
console.debug("Pkarr Relays: " + this.pubkyClient.getPkarrRelays())
|
||||
|
||||
this._generateURL()
|
||||
}
|
||||
|
||||
setCapabilities(caps) {
|
||||
this.caps = caps || ""
|
||||
|
||||
this._generateURL(this.caps);
|
||||
console.debug("Updated capabilities");
|
||||
}
|
||||
|
||||
|
||||
_generateURL() {
|
||||
let [url, promise] = this.pubkyClient.authRequest(this.relay || DEFAULT_HTTP_RELAY, this.caps);
|
||||
|
||||
promise.then(pubky => {
|
||||
this.pubky = pubky.z32();
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
this.authUrl = url
|
||||
|
||||
this._updateQr();
|
||||
}
|
||||
|
||||
_updateQr() {
|
||||
if (this.canvas) {
|
||||
this._setQr(this.canvas);
|
||||
}
|
||||
}
|
||||
|
||||
_setQr(canvas) {
|
||||
this.canvas = canvas
|
||||
QRCode.toCanvas(canvas, this.authUrl, {
|
||||
margin: 2,
|
||||
scale: 8,
|
||||
|
||||
color: {
|
||||
light: '#fff',
|
||||
dark: '#000',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_switchOpen() {
|
||||
this.open = !this.open
|
||||
setTimeout(() => { this.pubky = null }, 80)
|
||||
}
|
||||
|
||||
async _copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.authUrl);
|
||||
this.showCopied = true;
|
||||
setTimeout(() => { this.showCopied = false }, 1000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
id="widget"
|
||||
class=${this.open ? "open" : ""}
|
||||
>
|
||||
<button class="header" @click=${this._switchOpen}>
|
||||
<div class="header-content">
|
||||
<svg id="pubky-icon" xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 452 690">
|
||||
<style>
|
||||
path { fill: black; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: white; }
|
||||
}
|
||||
</style>
|
||||
<path fill-rule="evenodd" class="a" d="m0.1 84.7l80.5 17.1 15.8-74.5 73.8 44.2 54.7-71.5 55.2 71.5 70.3-44.2 19.4 74.5 81.6-17.1-74.5 121.5c-40.5-35.3-93.5-56.6-151.4-56.6-57.8 0-110.7 21.3-151.2 56.4zm398.4 293.8c0 40.6-14 78-37.4 107.4l67 203.8h-403.1l66.2-202.3c-24.1-29.7-38.6-67.6-38.6-108.9 0-95.5 77.4-172.8 173-172.8 95.5 0 172.9 77.3 172.9 172.8zm-212.9 82.4l-48.2 147.3h178.1l-48.6-148 2.9-1.6c28.2-15.6 47.3-45.6 47.3-80.1 0-50.5-41-91.4-91.5-91.4-50.6 0-91.6 40.9-91.6 91.4 0 35 19.7 65.4 48.6 80.8z"/>
|
||||
</svg>
|
||||
<span class="text">
|
||||
Pubky Auth
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="line"></div>
|
||||
<div id="widget-content">
|
||||
${this.pubky
|
||||
? this.caps.length > 0
|
||||
? html`
|
||||
<p>Successfully authorized: </p>
|
||||
<p>${this.pubky}</p>
|
||||
<p>With capabilities</p>
|
||||
${this.caps.split(",").map(cap => html`
|
||||
<p>${cap}</p>
|
||||
`)
|
||||
}
|
||||
`
|
||||
: html`
|
||||
<p>Successfully authenticated to: </p>
|
||||
<p>${this.pubky}</p>
|
||||
`
|
||||
: html`
|
||||
<p>Scan or copy Pubky auth URL</p>
|
||||
<div class="card">
|
||||
<canvas id="qr" ${ref(this._setQr)}></canvas>
|
||||
</div>
|
||||
<button class="card url" @click=${this._copyToClipboard}>
|
||||
<div class="copied ${this.showCopied ? "show" : ""}">Copied to Clipboard</div>
|
||||
<p>${this.authUrl}</p>
|
||||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="10" height="12" rx="2" fill="white"></rect><rect x="3" y="3" width="10" height="12" rx="2" fill="white" stroke="#3B3B3B"></rect></svg>
|
||||
</button>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
_renderWidgetContentBase() {
|
||||
}
|
||||
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
--full-width: 22rem;
|
||||
--full-height: 31rem;
|
||||
--header-height: 3rem;
|
||||
--closed-width: 3rem;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/** End reset */
|
||||
|
||||
#widget {
|
||||
color: white;
|
||||
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
|
||||
background-color:red;
|
||||
|
||||
z-index: 99999;
|
||||
overflow: hidden;
|
||||
background: rgba(43, 43, 43, .7372549019607844);
|
||||
border: 1px solid #3c3c3c;
|
||||
box-shadow: 0 10px 34px -10px rgba(236, 243, 222, .05);
|
||||
border-radius: 8px;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
width: var(--closed-width);
|
||||
height: var(--header-height);
|
||||
|
||||
will-change: height,width;
|
||||
transition-property: height, width;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
#widget.open{
|
||||
width: var(--full-width);
|
||||
height: var(--full-height);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
column-gap: .5rem;
|
||||
}
|
||||
|
||||
#widget
|
||||
.header .text {
|
||||
display: none;
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
#widget.open
|
||||
.header .text {
|
||||
display: block
|
||||
}
|
||||
|
||||
#widget.open
|
||||
.header {
|
||||
width: var(--full-width);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#pubky-icon {
|
||||
height: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#widget.open
|
||||
#pubky-icon {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#widget-content{
|
||||
width: var(--full-width);
|
||||
padding: 0 1rem
|
||||
}
|
||||
|
||||
#widget p {
|
||||
font-size: .87rem;
|
||||
line-height: 1rem;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
opacity: .5;
|
||||
|
||||
/* Fix flash wrap in open animation */
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
#qr {
|
||||
width: 18em !important;
|
||||
height: 18em !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: #3b3b3b;
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card.url {
|
||||
padding: .625rem;
|
||||
justify-content: space-between;
|
||||
max-width:100%;
|
||||
}
|
||||
|
||||
.url p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
line-height: 1!important;
|
||||
width: 93%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 1px;
|
||||
background-color: #3b3b3b;
|
||||
flex: 1 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.copied {
|
||||
will-change: opacity;
|
||||
transition-property: opacity;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -1.6rem;
|
||||
font-size: 0.9em;
|
||||
background: rgb(43 43 43 / 98%);
|
||||
padding: .5rem;
|
||||
border-radius: .3rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.copied.show {
|
||||
opacity:1
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('pubky-auth-widget', PubkyAuthWidget)
|
||||
29
pubky/examples/authz/README.md
Normal file
29
pubky/examples/authz/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Pubky Auth Example
|
||||
|
||||
This example shows 3rd party authorization in Pubky.
|
||||
|
||||
It consists of 2 parts:
|
||||
|
||||
1. [3rd party app](./3rd-party-app): A web component showing the how to implement a Pubky Auth widget.
|
||||
2. [Authenticator CLI](./authenticator): A CLI showing the authenticator (key chain) asking user for consent and generating the AuthToken.
|
||||
|
||||
## Usage
|
||||
|
||||
First you need to be running a local testnet Homeserver, in the root of this repo run
|
||||
|
||||
```bash
|
||||
cargo run --bin pubky_homeserver -- --testnet
|
||||
```
|
||||
|
||||
Run the frontend of the 3rd party app
|
||||
|
||||
```bash
|
||||
cd ./3rd-party-app
|
||||
npm start
|
||||
```
|
||||
|
||||
Copy the Pubky Auth URL from the frontend.
|
||||
|
||||
Finally run the CLI to paste the Pubky Auth in.
|
||||
|
||||
You should see the frontend reacting by showing the success of authorization and session details.
|
||||
14
pubky/examples/authz/authenticator/Cargo.toml
Normal file
14
pubky/examples/authz/authenticator/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "authenticator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
base64 = "0.22.1"
|
||||
clap = { version = "4.5.16", features = ["derive"] }
|
||||
pubky = { version = "0.1.0", path = "../../../pubky" }
|
||||
pubky-common = { version = "0.1.0", path = "../../../pubky-common" }
|
||||
rpassword = "7.3.1"
|
||||
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }
|
||||
url = "2.5.2"
|
||||
80
pubky/examples/authz/authenticator/src/main.rs
Normal file
80
pubky/examples/authz/authenticator/src/main.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use pubky::PubkyClient;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use pubky_common::{capabilities::Capability, crypto::PublicKey};
|
||||
|
||||
/// local testnet HOMESERVER
|
||||
const HOMESERVER: &str = "8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to a recovery_file of the Pubky you want to sign in with
|
||||
recovery_file: PathBuf,
|
||||
|
||||
/// Pubky Auth url
|
||||
url: Url,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let recovery_file = std::fs::read(&cli.recovery_file)?;
|
||||
println!("\nSuccessfully opened recovery file");
|
||||
|
||||
let url = cli.url;
|
||||
|
||||
let caps = url
|
||||
.query_pairs()
|
||||
.filter_map(|(key, value)| {
|
||||
if key == "caps" {
|
||||
return Some(
|
||||
value
|
||||
.split(',')
|
||||
.filter_map(|cap| Capability::try_from(cap).ok())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
};
|
||||
None
|
||||
})
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
|
||||
if !caps.is_empty() {
|
||||
println!("\nRequired Capabilities:");
|
||||
}
|
||||
|
||||
for cap in &caps {
|
||||
println!(" {} : {:?}", cap.scope, cap.actions);
|
||||
}
|
||||
|
||||
// === Consent form ===
|
||||
|
||||
println!("\nEnter your recovery_file's passphrase to confirm:");
|
||||
let passphrase = rpassword::read_password()?;
|
||||
|
||||
let keypair = pubky_common::recovery_file::decrypt_recovery_file(&recovery_file, &passphrase)?;
|
||||
|
||||
println!("Successfully decrypted recovery file...");
|
||||
println!("PublicKey: {}", keypair.public_key());
|
||||
|
||||
let client = PubkyClient::testnet();
|
||||
|
||||
// For the purposes of this demo, we need to make sure
|
||||
// the user has an account on the local homeserver.
|
||||
if client.signin(&keypair).await.is_err() {
|
||||
client
|
||||
.signup(&keypair, &PublicKey::try_from(HOMESERVER).unwrap())
|
||||
.await?;
|
||||
};
|
||||
|
||||
println!("Sending AuthToken to the 3rd party app...");
|
||||
|
||||
client.send_auth_token(&keypair, url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
33
pubky/pubky-common/Cargo.toml
Normal file
33
pubky/pubky-common/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "pubky-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
base32 = "0.5.0"
|
||||
blake3 = "1.5.1"
|
||||
ed25519-dalek = "2.1.1"
|
||||
once_cell = "1.19.0"
|
||||
pkarr = { workspace = true }
|
||||
rand = "0.8.5"
|
||||
thiserror = "1.0.60"
|
||||
postcard = { version = "1.0.8", features = ["alloc"] }
|
||||
crypto_secretbox = { version = "0.1.1", features = ["std"] }
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3.69"
|
||||
|
||||
[dev-dependencies]
|
||||
postcard = "1.0.8"
|
||||
|
||||
[features]
|
||||
|
||||
serde = ["dep:serde", "ed25519-dalek/serde", "pkarr/serde"]
|
||||
full = ['serde']
|
||||
|
||||
default = ['full']
|
||||
279
pubky/pubky-common/src/auth.rs
Normal file
279
pubky/pubky-common/src/auth.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
//! Client-server Authentication using signed timesteps
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
capabilities::{Capabilities, Capability},
|
||||
crypto::{Keypair, PublicKey, Signature},
|
||||
namespaces::PUBKY_AUTH,
|
||||
timestamp::Timestamp,
|
||||
};
|
||||
|
||||
// 30 seconds
|
||||
const TIME_INTERVAL: u64 = 30 * 1_000_000;
|
||||
|
||||
const CURRENT_VERSION: u8 = 0;
|
||||
// 45 seconds in the past or the future
|
||||
const TIMESTAMP_WINDOW: i64 = 45 * 1_000_000;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuthToken {
|
||||
/// Signature over the token.
|
||||
signature: Signature,
|
||||
/// A namespace to ensure this signature can't be used for any
|
||||
/// other purposes that share the same message structurea by accident.
|
||||
namespace: [u8; 10],
|
||||
/// Version of the [AuthToken], in case we need to upgrade it to support unforseen usecases.
|
||||
///
|
||||
/// Version 0:
|
||||
/// - Signer is implicitly the same as the root keypair for
|
||||
/// the [AuthToken::pubky], without any delegation.
|
||||
/// - Capabilities are only meant for resoucres on the homeserver.
|
||||
version: u8,
|
||||
/// Timestamp
|
||||
timestamp: Timestamp,
|
||||
/// The [PublicKey] of the owner of the resources being accessed by this token.
|
||||
pubky: PublicKey,
|
||||
// Variable length capabilities
|
||||
capabilities: Capabilities,
|
||||
}
|
||||
|
||||
impl AuthToken {
|
||||
pub fn sign(keypair: &Keypair, capabilities: impl Into<Capabilities>) -> Self {
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
let mut token = Self {
|
||||
signature: Signature::from_bytes(&[0; 64]),
|
||||
namespace: *PUBKY_AUTH,
|
||||
version: 0,
|
||||
timestamp,
|
||||
pubky: keypair.public_key(),
|
||||
capabilities: capabilities.into(),
|
||||
};
|
||||
|
||||
let serialized = token.serialize();
|
||||
|
||||
token.signature = keypair.sign(&serialized[65..]);
|
||||
|
||||
token
|
||||
}
|
||||
|
||||
pub fn capabilities(&self) -> &[Capability] {
|
||||
&self.capabilities.0
|
||||
}
|
||||
|
||||
pub fn verify(bytes: &[u8]) -> Result<Self, Error> {
|
||||
if bytes[75] > CURRENT_VERSION {
|
||||
return Err(Error::UnknownVersion);
|
||||
}
|
||||
|
||||
let token = AuthToken::deserialize(bytes)?;
|
||||
|
||||
match token.version {
|
||||
0 => {
|
||||
let now = Timestamp::now();
|
||||
|
||||
// Chcek timestamp;
|
||||
let diff = token.timestamp.difference(&now);
|
||||
if diff > TIMESTAMP_WINDOW {
|
||||
return Err(Error::TooFarInTheFuture);
|
||||
}
|
||||
if diff < -TIMESTAMP_WINDOW {
|
||||
return Err(Error::Expired);
|
||||
}
|
||||
|
||||
token
|
||||
.pubky
|
||||
.verify(AuthToken::signable(token.version, bytes), &token.signature)
|
||||
.map_err(|_| Error::InvalidSignature)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
postcard::to_allocvec(self).unwrap()
|
||||
}
|
||||
|
||||
pub fn deserialize(bytes: &[u8]) -> Result<Self, Error> {
|
||||
Ok(postcard::from_bytes(bytes)?)
|
||||
}
|
||||
|
||||
pub fn pubky(&self) -> &PublicKey {
|
||||
&self.pubky
|
||||
}
|
||||
|
||||
/// A unique ID for this [AuthToken], which is a concatenation of
|
||||
/// [AuthToken::pubky] and [AuthToken::timestamp].
|
||||
///
|
||||
/// Assuming that [AuthToken::timestamp] is unique for every [AuthToken::pubky].
|
||||
fn id(version: u8, bytes: &[u8]) -> Box<[u8]> {
|
||||
match version {
|
||||
0 => bytes[75..115].into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn signable(version: u8, bytes: &[u8]) -> &[u8] {
|
||||
match version {
|
||||
0 => bytes[65..].into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// Keeps track of used AuthToken until they expire.
|
||||
pub struct AuthVerifier {
|
||||
seen: Arc<Mutex<Vec<Box<[u8]>>>>,
|
||||
}
|
||||
|
||||
impl AuthVerifier {
|
||||
pub fn verify(&self, bytes: &[u8]) -> Result<AuthToken, Error> {
|
||||
self.gc();
|
||||
|
||||
let token = AuthToken::verify(bytes)?;
|
||||
|
||||
let mut seen = self.seen.lock().unwrap();
|
||||
|
||||
let id = AuthToken::id(token.version, bytes);
|
||||
|
||||
match seen.binary_search_by(|element| element.cmp(&id)) {
|
||||
Ok(_) => Err(Error::AlreadyUsed),
|
||||
Err(index) => {
|
||||
seen.insert(index, id);
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Private Methods ===
|
||||
|
||||
/// Remove all tokens older than two time intervals in the past.
|
||||
fn gc(&self) {
|
||||
let threshold = ((Timestamp::now().into_inner() / TIME_INTERVAL) - 2).to_be_bytes();
|
||||
|
||||
let mut inner = self.seen.lock().unwrap();
|
||||
|
||||
match inner.binary_search_by(|element| element[0..8].cmp(&threshold)) {
|
||||
Ok(index) | Err(index) => {
|
||||
inner.drain(0..index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("Unknown version")]
|
||||
UnknownVersion,
|
||||
#[error("AuthToken has a timestamp that is more than 45 seconds in the future")]
|
||||
TooFarInTheFuture,
|
||||
#[error("AuthToken has a timestamp that is more than 45 seconds in the past")]
|
||||
Expired,
|
||||
#[error("Invalid Signature")]
|
||||
InvalidSignature,
|
||||
#[error(transparent)]
|
||||
Postcard(#[from] postcard::Error),
|
||||
#[error("AuthToken already used")]
|
||||
AlreadyUsed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
auth::TIMESTAMP_WINDOW, capabilities::Capability, crypto::Keypair, timestamp::Timestamp,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v0_id_signable() {
|
||||
let signer = Keypair::random();
|
||||
let capabilities = vec![Capability::root()];
|
||||
|
||||
let token = AuthToken::sign(&signer, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
let mut id = vec![];
|
||||
id.extend_from_slice(&token.timestamp.to_bytes());
|
||||
id.extend_from_slice(signer.public_key().as_bytes());
|
||||
|
||||
assert_eq!(AuthToken::id(token.version, serialized), id.into());
|
||||
|
||||
assert_eq!(
|
||||
AuthToken::signable(token.version, serialized),
|
||||
&serialized[65..]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_verify() {
|
||||
let signer = Keypair::random();
|
||||
let capabilities = vec![Capability::root()];
|
||||
|
||||
let verifier = AuthVerifier::default();
|
||||
|
||||
let token = AuthToken::sign(&signer, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
verifier.verify(serialized).unwrap();
|
||||
|
||||
assert_eq!(token.capabilities, capabilities.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired() {
|
||||
let signer = Keypair::random();
|
||||
let capabilities = Capabilities(vec![Capability::root()]);
|
||||
|
||||
let verifier = AuthVerifier::default();
|
||||
|
||||
let timestamp = (&Timestamp::now()) - (TIMESTAMP_WINDOW as u64);
|
||||
|
||||
let mut signable = vec![];
|
||||
signable.extend_from_slice(signer.public_key().as_bytes());
|
||||
signable.extend_from_slice(&postcard::to_allocvec(&capabilities).unwrap());
|
||||
|
||||
let signature = signer.sign(&signable);
|
||||
|
||||
let token = AuthToken {
|
||||
signature,
|
||||
namespace: *PUBKY_AUTH,
|
||||
version: 0,
|
||||
timestamp,
|
||||
pubky: signer.public_key(),
|
||||
capabilities,
|
||||
};
|
||||
|
||||
let serialized = token.serialize();
|
||||
|
||||
let result = verifier.verify(&serialized);
|
||||
|
||||
assert_eq!(result, Err(Error::Expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_used() {
|
||||
let signer = Keypair::random();
|
||||
let capabilities = vec![Capability::root()];
|
||||
|
||||
let verifier = AuthVerifier::default();
|
||||
|
||||
let token = AuthToken::sign(&signer, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
verifier.verify(serialized).unwrap();
|
||||
|
||||
assert_eq!(token.capabilities, capabilities.into());
|
||||
|
||||
assert_eq!(verifier.verify(serialized), Err(Error::AlreadyUsed));
|
||||
}
|
||||
}
|
||||
237
pubky/pubky-common/src/capabilities.rs
Normal file
237
pubky/pubky-common/src/capabilities.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Capability {
|
||||
pub scope: String,
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Create a root [Capability] at the `/` path with all the available [PubkyAbility]
|
||||
pub fn root() -> Self {
|
||||
Capability {
|
||||
scope: "/".to_string(),
|
||||
actions: vec![Action::Read, Action::Write],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Can read the scope at the specified path (GET requests).
|
||||
Read,
|
||||
/// Can write to the scope at the specified path (PUT/POST/DELETE requests).
|
||||
Write,
|
||||
/// Unknown ability
|
||||
Unknown(char),
|
||||
}
|
||||
|
||||
impl From<&Action> for char {
|
||||
fn from(value: &Action) -> Self {
|
||||
match value {
|
||||
Action::Read => 'r',
|
||||
Action::Write => 'w',
|
||||
Action::Unknown(char) => char.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<char> for Action {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: char) -> Result<Self, Error> {
|
||||
match value {
|
||||
'r' => Ok(Self::Read),
|
||||
'w' => Ok(Self::Write),
|
||||
_ => Err(Error::InvalidAction),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Capability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}:{}",
|
||||
self.scope,
|
||||
self.actions.iter().map(char::from).collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Capability {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Error> {
|
||||
value.as_str().try_into()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Capability {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Error> {
|
||||
if value.matches(':').count() != 1 {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
|
||||
if !value.starts_with('/') {
|
||||
return Err(Error::InvalidScope);
|
||||
}
|
||||
|
||||
let actions_str = value.rsplit(':').next().unwrap_or("");
|
||||
|
||||
let mut actions = Vec::new();
|
||||
|
||||
for char in actions_str.chars() {
|
||||
let ability = Action::try_from(char)?;
|
||||
|
||||
match actions.binary_search_by(|element| char::from(element).cmp(&char)) {
|
||||
Ok(_) => {}
|
||||
Err(index) => {
|
||||
actions.insert(index, ability);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scope = value[0..value.len() - actions_str.len() - 1].to_string();
|
||||
|
||||
Ok(Capability { scope, actions })
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Capability {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let string = self.to_string();
|
||||
|
||||
string.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capability {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let string: String = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
string.try_into().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("Capability: Invalid scope: does not start with `/`")]
|
||||
InvalidScope,
|
||||
#[error("Capability: Invalid format should be <scope>:<abilities>")]
|
||||
InvalidFormat,
|
||||
#[error("Capability: Invalid Action")]
|
||||
InvalidAction,
|
||||
#[error("Capabilities: Invalid capabilities format")]
|
||||
InvalidCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq, Eq)]
|
||||
/// A wrapper around `Vec<Capability>` to enable serialization without
|
||||
/// a varint. Useful when [Capabilities] are at the end of a struct.
|
||||
pub struct Capabilities(pub Vec<Capability>);
|
||||
|
||||
impl Capabilities {
|
||||
pub fn contains(&self, capability: &Capability) -> bool {
|
||||
self.0.contains(capability)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Capability>> for Capabilities {
|
||||
fn from(value: Vec<Capability>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Capabilities> for Vec<Capability> {
|
||||
fn from(value: Capabilities) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Capabilities {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
let mut caps = vec![];
|
||||
|
||||
for s in value.split(',') {
|
||||
if let Ok(cap) = Capability::try_from(s) {
|
||||
caps.push(cap);
|
||||
};
|
||||
}
|
||||
|
||||
Ok(Capabilities(caps))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Capabilities {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let string = self
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
write!(f, "{}", string)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Capabilities {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
self.to_string().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capabilities {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let string: String = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
let mut caps = vec![];
|
||||
|
||||
for s in string.split(',') {
|
||||
if let Ok(cap) = Capability::try_from(s) {
|
||||
caps.push(cap);
|
||||
};
|
||||
}
|
||||
|
||||
Ok(Capabilities(caps))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pubky_caps() {
|
||||
let cap = Capability {
|
||||
scope: "/pub/pubky.app/".to_string(),
|
||||
actions: vec![Action::Read, Action::Write],
|
||||
};
|
||||
|
||||
// Read and write withing directory `/pub/pubky.app/`.
|
||||
let expected_string = "/pub/pubky.app/:rw";
|
||||
|
||||
assert_eq!(cap.to_string(), expected_string);
|
||||
|
||||
assert_eq!(Capability::try_from(expected_string), Ok(cap))
|
||||
}
|
||||
}
|
||||
71
pubky/pubky-common/src/crypto.rs
Normal file
71
pubky/pubky-common/src/crypto.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use crypto_secretbox::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
XSalsa20Poly1305,
|
||||
};
|
||||
use rand::prelude::Rng;
|
||||
|
||||
pub use pkarr::{Keypair, PublicKey};
|
||||
|
||||
pub use ed25519_dalek::Signature;
|
||||
|
||||
pub type Hash = blake3::Hash;
|
||||
|
||||
pub use blake3::hash;
|
||||
|
||||
pub use blake3::Hasher;
|
||||
|
||||
pub fn random_hash() -> Hash {
|
||||
let mut rng = rand::thread_rng();
|
||||
Hash::from_bytes(rng.gen())
|
||||
}
|
||||
|
||||
pub fn random_bytes<const N: usize>() -> [u8; N] {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut arr = [0u8; N];
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0..N {
|
||||
arr[i] = rng.gen();
|
||||
}
|
||||
arr
|
||||
}
|
||||
|
||||
pub fn encrypt(plain_text: &[u8], encryption_key: &[u8; 32]) -> Result<Vec<u8>, Error> {
|
||||
let cipher = XSalsa20Poly1305::new(encryption_key.into());
|
||||
let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng); // unique per message
|
||||
let ciphertext = cipher.encrypt(&nonce, plain_text)?;
|
||||
|
||||
let mut out: Vec<u8> = Vec::with_capacity(nonce.len() + ciphertext.len());
|
||||
out.extend_from_slice(nonce.as_slice());
|
||||
out.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn decrypt(bytes: &[u8], encryption_key: &[u8; 32]) -> Result<Vec<u8>, Error> {
|
||||
let cipher = XSalsa20Poly1305::new(encryption_key.into());
|
||||
|
||||
Ok(cipher.decrypt(bytes[..24].into(), &bytes[24..])?)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
SecretBox(#[from] crypto_secretbox::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt() {
|
||||
let plain_text = "Plain text!";
|
||||
let encryption_key = [0; 32];
|
||||
|
||||
let encrypted = encrypt(plain_text.as_bytes(), &encryption_key).unwrap();
|
||||
let decrypted = decrypt(&encrypted, &encryption_key).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plain_text.as_bytes())
|
||||
}
|
||||
}
|
||||
7
pubky/pubky-common/src/lib.rs
Normal file
7
pubky/pubky-common/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod capabilities;
|
||||
pub mod crypto;
|
||||
pub mod namespaces;
|
||||
pub mod recovery_file;
|
||||
pub mod session;
|
||||
pub mod timestamp;
|
||||
1
pubky/pubky-common/src/namespaces.rs
Normal file
1
pubky/pubky-common/src/namespaces.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub const PUBKY_AUTH: &[u8; 10] = b"PUBKY:AUTH";
|
||||
102
pubky/pubky-common/src/recovery_file.rs
Normal file
102
pubky/pubky-common/src/recovery_file.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use argon2::Argon2;
|
||||
use pkarr::Keypair;
|
||||
|
||||
use crate::crypto::{decrypt, encrypt};
|
||||
|
||||
static SPEC_NAME: &str = "recovery";
|
||||
static SPEC_LINE: &str = "pubky.org/recovery";
|
||||
|
||||
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair, Error> {
|
||||
let encryption_key = recovery_file_encryption_key_from_passphrase(passphrase)?;
|
||||
|
||||
let newline_index = recovery_file
|
||||
.iter()
|
||||
.position(|&r| r == 10)
|
||||
.ok_or(())
|
||||
.map_err(|_| Error::RecoveryFileMissingSpecLine)?;
|
||||
|
||||
let spec_line = &recovery_file[..newline_index];
|
||||
|
||||
if !(spec_line.starts_with(SPEC_LINE.as_bytes())
|
||||
|| spec_line.starts_with(b"pkarr.org/recovery"))
|
||||
{
|
||||
return Err(Error::RecoveryFileVersionNotSupported);
|
||||
}
|
||||
|
||||
let encrypted = &recovery_file[newline_index + 1..];
|
||||
|
||||
if encrypted.is_empty() {
|
||||
return Err(Error::RecoverFileMissingEncryptedSecretKey);
|
||||
};
|
||||
|
||||
let decrypted = decrypt(encrypted, &encryption_key)?;
|
||||
let length = decrypted.len();
|
||||
let secret_key: [u8; 32] = decrypted
|
||||
.try_into()
|
||||
.map_err(|_| Error::RecoverFileInvalidSecretKeyLength(length))?;
|
||||
|
||||
Ok(Keypair::from_secret_key(&secret_key))
|
||||
}
|
||||
|
||||
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Vec<u8>, Error> {
|
||||
let encryption_key = recovery_file_encryption_key_from_passphrase(passphrase)?;
|
||||
let secret_key = keypair.secret_key();
|
||||
|
||||
let encrypted_secret_key = encrypt(&secret_key, &encryption_key)?;
|
||||
|
||||
let mut out = Vec::with_capacity(SPEC_LINE.len() + 1 + encrypted_secret_key.len());
|
||||
|
||||
out.extend_from_slice(SPEC_LINE.as_bytes());
|
||||
out.extend_from_slice(b"\n");
|
||||
out.extend_from_slice(&encrypted_secret_key);
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn recovery_file_encryption_key_from_passphrase(passphrase: &str) -> Result<[u8; 32], Error> {
|
||||
let argon2id = Argon2::default();
|
||||
|
||||
let mut out = [0; 32];
|
||||
|
||||
argon2id.hash_password_into(passphrase.as_bytes(), SPEC_NAME.as_bytes(), &mut out)?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
// === Recovery file ==
|
||||
#[error("Recovery file should start with a spec line, followed by a new line character")]
|
||||
RecoveryFileMissingSpecLine,
|
||||
|
||||
#[error("Recovery file should start with a spec line, followed by a new line character")]
|
||||
RecoveryFileVersionNotSupported,
|
||||
|
||||
#[error("Recovery file should contain an encrypted secret key after the new line character")]
|
||||
RecoverFileMissingEncryptedSecretKey,
|
||||
|
||||
#[error("Recovery file encrypted secret key should be 32 bytes, got {0}")]
|
||||
RecoverFileInvalidSecretKeyLength(usize),
|
||||
|
||||
#[error(transparent)]
|
||||
Argon(#[from] argon2::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Crypto(#[from] crate::crypto::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_recovery_file() {
|
||||
let passphrase = "very secure password";
|
||||
let keypair = Keypair::random();
|
||||
|
||||
let recovery_file = create_recovery_file(&keypair, passphrase).unwrap();
|
||||
let recovered = decrypt_recovery_file(&recovery_file, passphrase).unwrap();
|
||||
|
||||
assert_eq!(recovered.public_key(), keypair.public_key());
|
||||
}
|
||||
}
|
||||
139
pubky/pubky-common/src/session.rs
Normal file
139
pubky/pubky-common/src/session.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use pkarr::PublicKey;
|
||||
use postcard::{from_bytes, to_allocvec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
extern crate alloc;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::{auth::AuthToken, capabilities::Capability, timestamp::Timestamp};
|
||||
|
||||
// TODO: add IP address?
|
||||
// TODO: use https://crates.io/crates/user-agent-parser to parse the session
|
||||
// and get more informations from the user-agent.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct Session {
|
||||
version: usize,
|
||||
pubky: PublicKey,
|
||||
created_at: u64,
|
||||
/// User specified name, defaults to the user-agent.
|
||||
name: String,
|
||||
user_agent: String,
|
||||
capabilities: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(token: &AuthToken, user_agent: Option<String>) -> Self {
|
||||
Self {
|
||||
version: 0,
|
||||
pubky: token.pubky().to_owned(),
|
||||
created_at: Timestamp::now().into_inner(),
|
||||
capabilities: token.capabilities().to_vec(),
|
||||
user_agent: user_agent.as_deref().unwrap_or("").to_string(),
|
||||
name: user_agent.as_deref().unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
pub fn pubky(&self) -> &PublicKey {
|
||||
&self.pubky
|
||||
}
|
||||
|
||||
pub fn capabilities(&self) -> &Vec<Capability> {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
// === Setters ===
|
||||
|
||||
pub fn set_user_agent(&mut self, user_agent: String) -> &mut Self {
|
||||
self.user_agent = user_agent;
|
||||
|
||||
if self.name.is_empty() {
|
||||
self.name.clone_from(&self.user_agent)
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_capabilities(&mut self, capabilities: Vec<Capability>) -> &mut Self {
|
||||
self.capabilities = capabilities;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
// === Public Methods ===
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
to_allocvec(self).expect("Session::serialize")
|
||||
}
|
||||
|
||||
pub fn deserialize(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.is_empty() {
|
||||
return Err(Error::EmptyPayload);
|
||||
}
|
||||
|
||||
if bytes[0] > 0 {
|
||||
return Err(Error::UnknownVersion);
|
||||
}
|
||||
|
||||
Ok(from_bytes(bytes)?)
|
||||
}
|
||||
|
||||
// TODO: add `can_read()`, `can_write()` and `is_root()` methods
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(thiserror::Error, Debug, PartialEq)]
|
||||
pub enum Error {
|
||||
#[error("Empty payload")]
|
||||
EmptyPayload,
|
||||
#[error("Unknown version")]
|
||||
UnknownVersion,
|
||||
#[error(transparent)]
|
||||
Postcard(#[from] postcard::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::crypto::Keypair;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialize() {
|
||||
let keypair = Keypair::from_secret_key(&[0; 32]);
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
let session = Session {
|
||||
user_agent: "foo".to_string(),
|
||||
capabilities: vec![Capability::root()],
|
||||
created_at: 0,
|
||||
pubky,
|
||||
version: 0,
|
||||
name: "".to_string(),
|
||||
};
|
||||
|
||||
let serialized = session.serialize();
|
||||
|
||||
assert_eq!(
|
||||
serialized,
|
||||
[
|
||||
0, 59, 106, 39, 188, 206, 182, 164, 45, 98, 163, 168, 208, 42, 111, 13, 115, 101,
|
||||
50, 21, 119, 29, 226, 67, 166, 58, 192, 72, 161, 139, 89, 218, 41, 0, 0, 3, 102,
|
||||
111, 111, 1, 4, 47, 58, 114, 119
|
||||
]
|
||||
);
|
||||
|
||||
let deseiralized = Session::deserialize(&serialized).unwrap();
|
||||
|
||||
assert_eq!(deseiralized, session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize() {
|
||||
let result = Session::deserialize(&[]);
|
||||
|
||||
assert_eq!(result, Err(Error::EmptyPayload));
|
||||
}
|
||||
}
|
||||
280
pubky/pubky-common/src/timestamp.rs
Normal file
280
pubky/pubky-common/src/timestamp.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! Strictly monotonic unix timestamp in microseconds
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::{
|
||||
ops::{Add, Sub},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::Rng;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// ~4% chance of none of 10 clocks have matching id.
|
||||
const CLOCK_MASK: u64 = (1 << 8) - 1;
|
||||
const TIME_MASK: u64 = !0 >> 8;
|
||||
|
||||
pub struct TimestampFactory {
|
||||
clock_id: u64,
|
||||
last_time: u64,
|
||||
}
|
||||
|
||||
impl TimestampFactory {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
clock_id: rand::thread_rng().gen::<u64>() & CLOCK_MASK,
|
||||
last_time: system_time() & TIME_MASK,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now(&mut self) -> Timestamp {
|
||||
// Ensure strict monotonicity.
|
||||
self.last_time = (system_time() & TIME_MASK).max(self.last_time + CLOCK_MASK + 1);
|
||||
|
||||
// Add clock_id to the end of the timestamp
|
||||
Timestamp(self.last_time | self.clock_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TimestampFactory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
static DEFAULT_FACTORY: Lazy<Mutex<TimestampFactory>> =
|
||||
Lazy::new(|| Mutex::new(TimestampFactory::default()));
|
||||
|
||||
/// STrictly monotonic timestamp since [SystemTime::UNIX_EPOCH] in microseconds as u64.
|
||||
///
|
||||
/// The purpose of this timestamp is to unique per "user", not globally,
|
||||
/// it achieves this by:
|
||||
/// 1. Override the last byte with a random `clock_id`, reducing the probability
|
||||
/// of two matching timestamps across multiple machines/threads.
|
||||
/// 2. Gurantee that the remaining 3 bytes are ever increasing (strictly monotonic) within
|
||||
/// the same thread regardless of the wall clock value
|
||||
///
|
||||
/// This timestamp is also serialized as BE bytes to remain sortable.
|
||||
/// If a `utf-8` encoding is necessary, it is encoded as [base32::Alphabet::Crockford]
|
||||
/// to act as a sortable Id.
|
||||
///
|
||||
/// U64 of microseconds is valid for the next 500 thousand years!
|
||||
#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
|
||||
pub struct Timestamp(u64);
|
||||
|
||||
impl Timestamp {
|
||||
pub fn now() -> Self {
|
||||
DEFAULT_FACTORY.lock().unwrap().now()
|
||||
}
|
||||
|
||||
/// Return big endian bytes
|
||||
pub fn to_bytes(&self) -> [u8; 8] {
|
||||
self.0.to_be_bytes()
|
||||
}
|
||||
|
||||
pub fn difference(&self, rhs: &Timestamp) -> i64 {
|
||||
(self.0 as i64) - (rhs.0 as i64)
|
||||
}
|
||||
|
||||
pub fn into_inner(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Timestamp {
|
||||
fn default() -> Self {
|
||||
Timestamp::now()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Timestamp {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let bytes: [u8; 8] = self.into();
|
||||
f.write_str(&base32::encode(base32::Alphabet::Crockford, &bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Timestamp {
|
||||
type Error = TimestampError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match base32::decode(base32::Alphabet::Crockford, &value) {
|
||||
Some(vec) => {
|
||||
let bytes: [u8; 8] = vec
|
||||
.try_into()
|
||||
.map_err(|_| TimestampError::InvalidEncoding)?;
|
||||
|
||||
Ok(bytes.into())
|
||||
}
|
||||
None => Err(TimestampError::InvalidEncoding),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&[u8]> for Timestamp {
|
||||
type Error = TimestampError;
|
||||
|
||||
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
|
||||
let bytes: [u8; 8] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| TimestampError::InvalidBytesLength(bytes.len()))?;
|
||||
|
||||
Ok(bytes.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Timestamp> for [u8; 8] {
|
||||
fn from(timestamp: &Timestamp) -> Self {
|
||||
timestamp.0.to_be_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; 8]> for Timestamp {
|
||||
fn from(bytes: [u8; 8]) -> Self {
|
||||
Self(u64::from_be_bytes(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// === U64 conversion ===
|
||||
|
||||
impl From<Timestamp> for u64 {
|
||||
fn from(value: Timestamp) -> Self {
|
||||
value.into_inner()
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<u64> for &Timestamp {
|
||||
type Output = Timestamp;
|
||||
|
||||
fn add(self, rhs: u64) -> Self::Output {
|
||||
Timestamp(self.0 + rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<u64> for &Timestamp {
|
||||
type Output = Timestamp;
|
||||
|
||||
fn sub(self, rhs: u64) -> Self::Output {
|
||||
Timestamp(self.0 - rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Timestamp {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let bytes = self.to_bytes();
|
||||
bytes.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Timestamp {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let bytes: [u8; 8] = Deserialize::deserialize(deserializer)?;
|
||||
Ok(Timestamp(u64::from_be_bytes(bytes)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Return the number of microseconds since [SystemTime::UNIX_EPOCH]
|
||||
fn system_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("time drift")
|
||||
.as_micros() as u64
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
/// Return the number of microseconds since [SystemTime::UNIX_EPOCH]
|
||||
pub fn system_time() -> u64 {
|
||||
// Won't be an issue for more than 5000 years!
|
||||
(js_sys::Date::now() as u64 )
|
||||
// Turn miliseconds to microseconds
|
||||
* 1000
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum TimestampError {
|
||||
#[error("Invalid bytes length, Timestamp should be encoded as 8 bytes, got {0}")]
|
||||
InvalidBytesLength(usize),
|
||||
#[error("Invalid timestamp encoding")]
|
||||
InvalidEncoding,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strictly_monotonic() {
|
||||
const COUNT: usize = 100;
|
||||
|
||||
let mut set = HashSet::with_capacity(COUNT);
|
||||
let mut vec = Vec::with_capacity(COUNT);
|
||||
|
||||
for _ in 0..COUNT {
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
set.insert(timestamp.clone());
|
||||
vec.push(timestamp);
|
||||
}
|
||||
|
||||
let mut ordered = vec.clone();
|
||||
ordered.sort();
|
||||
|
||||
assert_eq!(set.len(), COUNT, "unique");
|
||||
assert_eq!(ordered, vec, "ordered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings() {
|
||||
const COUNT: usize = 100;
|
||||
|
||||
let mut set = HashSet::with_capacity(COUNT);
|
||||
let mut vec = Vec::with_capacity(COUNT);
|
||||
|
||||
for _ in 0..COUNT {
|
||||
let string = Timestamp::now().to_string();
|
||||
|
||||
set.insert(string.clone());
|
||||
vec.push(string)
|
||||
}
|
||||
|
||||
let mut ordered = vec.clone();
|
||||
ordered.sort();
|
||||
|
||||
assert_eq!(set.len(), COUNT, "unique");
|
||||
assert_eq!(ordered, vec, "ordered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_from_string() {
|
||||
let timestamp = Timestamp::now();
|
||||
let string = timestamp.to_string();
|
||||
let decoded: Timestamp = string.try_into().unwrap();
|
||||
|
||||
assert_eq!(decoded, timestamp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde() {
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
let serialized = postcard::to_allocvec(×tamp).unwrap();
|
||||
|
||||
assert_eq!(serialized, timestamp.to_bytes());
|
||||
|
||||
let deserialized: Timestamp = postcard::from_bytes(&serialized).unwrap();
|
||||
|
||||
assert_eq!(deserialized, timestamp);
|
||||
}
|
||||
}
|
||||
27
pubky/pubky-homeserver/Cargo.toml
Normal file
27
pubky/pubky-homeserver/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "pubky_homeserver"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.82"
|
||||
axum = { version = "0.7.5", features = ["macros"] }
|
||||
axum-extra = { version = "0.9.3", features = ["typed-header", "async-read-body"] }
|
||||
base32 = "0.5.1"
|
||||
bytes = "^1.7.1"
|
||||
clap = { version = "4.5.11", features = ["derive"] }
|
||||
dirs-next = "2.0.0"
|
||||
flume = "0.11.0"
|
||||
futures-util = "0.3.30"
|
||||
heed = "0.20.3"
|
||||
hex = "0.4.3"
|
||||
pkarr = { workspace = true }
|
||||
postcard = { version = "1.0.8", features = ["alloc"] }
|
||||
pubky-common = { version = "0.1.0", path = "../pubky-common" }
|
||||
serde = { workspace = true }
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
toml = "0.8.19"
|
||||
tower-cookies = "0.10.0"
|
||||
tower-http = { version = "0.5.2", features = ["cors", "trace"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
23
pubky/pubky-homeserver/README.md
Normal file
23
pubky/pubky-homeserver/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Pubky Homeserver
|
||||
|
||||
## Usage
|
||||
|
||||
Use `cargo run`
|
||||
|
||||
```bash
|
||||
cargo run -- --config=./src/config.toml
|
||||
```
|
||||
|
||||
Or Build first then run from target.
|
||||
|
||||
Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Run with an optional config file
|
||||
|
||||
```bash
|
||||
../target/release/pubky-homeserver --config=./src/config.toml
|
||||
```
|
||||
248
pubky/pubky-homeserver/src/config.rs
Normal file
248
pubky/pubky-homeserver/src/config.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! Configuration for the server
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use pkarr::Keypair;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use pubky_common::timestamp::Timestamp;
|
||||
|
||||
const DEFAULT_HOMESERVER_PORT: u16 = 6287;
|
||||
const DEFAULT_STORAGE_DIR: &str = "pubky";
|
||||
|
||||
pub const DEFAULT_LIST_LIMIT: u16 = 100;
|
||||
pub const DEFAULT_MAX_LIST_LIMIT: u16 = 1000;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq)]
|
||||
struct ConfigToml {
|
||||
testnet: Option<bool>,
|
||||
port: Option<u16>,
|
||||
bootstrap: Option<Vec<String>>,
|
||||
domain: Option<String>,
|
||||
storage: Option<PathBuf>,
|
||||
secret_key: Option<String>,
|
||||
dht_request_timeout: Option<Duration>,
|
||||
default_list_limit: Option<u16>,
|
||||
max_list_limit: Option<u16>,
|
||||
}
|
||||
|
||||
/// Server configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Whether or not this server is running in a testnet.
|
||||
testnet: bool,
|
||||
/// The configured port for this server.
|
||||
port: Option<u16>,
|
||||
/// Bootstrapping DHT nodes.
|
||||
///
|
||||
/// Helpful to run the server locally or in testnet.
|
||||
bootstrap: Option<Vec<String>>,
|
||||
/// A public domain for this server
|
||||
/// necessary for web browsers running in https environment.
|
||||
domain: Option<String>,
|
||||
/// Path to the storage directory.
|
||||
///
|
||||
/// Defaults to a directory in the OS data directory
|
||||
storage: PathBuf,
|
||||
/// Server keypair.
|
||||
///
|
||||
/// Defaults to a random keypair.
|
||||
keypair: Keypair,
|
||||
dht_request_timeout: Option<Duration>,
|
||||
/// The default limit of a list api if no `limit` query parameter is provided.
|
||||
///
|
||||
/// Defaults to `100`
|
||||
default_list_limit: u16,
|
||||
/// The maximum limit of a list api, even if a `limit` query parameter is provided.
|
||||
///
|
||||
/// Defaults to `1000`
|
||||
max_list_limit: u16,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn try_from_str(value: &str) -> Result<Self> {
|
||||
let config_toml: ConfigToml = toml::from_str(value)?;
|
||||
|
||||
let keypair = if let Some(secret_key) = config_toml.secret_key {
|
||||
let secret_key = deserialize_secret_key(secret_key)?;
|
||||
Keypair::from_secret_key(&secret_key)
|
||||
} else {
|
||||
Keypair::random()
|
||||
};
|
||||
|
||||
let storage = {
|
||||
let dir = if let Some(storage) = config_toml.storage {
|
||||
storage
|
||||
} else {
|
||||
let path = dirs_next::data_dir().ok_or_else(|| {
|
||||
anyhow!("operating environment provides no directory for application data")
|
||||
})?;
|
||||
path.join(DEFAULT_STORAGE_DIR)
|
||||
};
|
||||
|
||||
dir.join("homeserver")
|
||||
};
|
||||
|
||||
let config = Config {
|
||||
testnet: config_toml.testnet.unwrap_or(false),
|
||||
port: config_toml.port,
|
||||
bootstrap: config_toml.bootstrap,
|
||||
domain: config_toml.domain,
|
||||
keypair,
|
||||
storage,
|
||||
dht_request_timeout: config_toml.dht_request_timeout,
|
||||
default_list_limit: config_toml.default_list_limit.unwrap_or(DEFAULT_LIST_LIMIT),
|
||||
max_list_limit: config_toml
|
||||
.default_list_limit
|
||||
.unwrap_or(DEFAULT_MAX_LIST_LIMIT),
|
||||
};
|
||||
|
||||
if config.testnet {
|
||||
let testnet_config = Config::testnet();
|
||||
|
||||
return Ok(Config {
|
||||
bootstrap: testnet_config.bootstrap,
|
||||
..config
|
||||
});
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load the config from a file.
|
||||
pub async fn load(path: impl AsRef<Path>) -> Result<Config> {
|
||||
let s = tokio::fs::read_to_string(path.as_ref())
|
||||
.await
|
||||
.with_context(|| format!("failed to read {}", path.as_ref().to_string_lossy()))?;
|
||||
|
||||
Config::try_from_str(&s)
|
||||
}
|
||||
|
||||
/// Testnet configurations
|
||||
pub fn testnet() -> Self {
|
||||
let testnet = pkarr::mainline::Testnet::new(10);
|
||||
info!(?testnet.bootstrap, "Testnet bootstrap nodes");
|
||||
|
||||
let bootstrap = Some(testnet.bootstrap.to_owned());
|
||||
let storage = std::env::temp_dir()
|
||||
.join(Timestamp::now().to_string())
|
||||
.join(DEFAULT_STORAGE_DIR);
|
||||
|
||||
Self {
|
||||
bootstrap,
|
||||
storage,
|
||||
port: Some(15411),
|
||||
dht_request_timeout: Some(Duration::from_millis(10)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test configurations
|
||||
pub fn test(testnet: &pkarr::mainline::Testnet) -> Self {
|
||||
let bootstrap = Some(testnet.bootstrap.to_owned());
|
||||
let storage = std::env::temp_dir()
|
||||
.join(Timestamp::now().to_string())
|
||||
.join(DEFAULT_STORAGE_DIR);
|
||||
|
||||
Self {
|
||||
bootstrap,
|
||||
storage,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port.unwrap_or(DEFAULT_HOMESERVER_PORT)
|
||||
}
|
||||
|
||||
pub fn bootstsrap(&self) -> Option<Vec<String>> {
|
||||
self.bootstrap.to_owned()
|
||||
}
|
||||
|
||||
pub fn domain(&self) -> &Option<String> {
|
||||
&self.domain
|
||||
}
|
||||
|
||||
pub fn keypair(&self) -> &Keypair {
|
||||
&self.keypair
|
||||
}
|
||||
|
||||
pub fn default_list_limit(&self) -> u16 {
|
||||
self.default_list_limit
|
||||
}
|
||||
|
||||
pub fn max_list_limit(&self) -> u16 {
|
||||
self.max_list_limit
|
||||
}
|
||||
|
||||
/// Get the path to the storage directory
|
||||
pub fn storage(&self) -> &PathBuf {
|
||||
&self.storage
|
||||
}
|
||||
|
||||
pub(crate) fn dht_request_timeout(&self) -> Option<Duration> {
|
||||
self.dht_request_timeout
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
testnet: false,
|
||||
port: Some(0),
|
||||
bootstrap: None,
|
||||
domain: None,
|
||||
storage: storage(None)
|
||||
.expect("operating environment provides no directory for application data"),
|
||||
keypair: Keypair::random(),
|
||||
dht_request_timeout: None,
|
||||
default_list_limit: DEFAULT_LIST_LIMIT,
|
||||
max_list_limit: DEFAULT_MAX_LIST_LIMIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_secret_key(s: String) -> anyhow::Result<[u8; 32]> {
|
||||
let bytes =
|
||||
hex::decode(s).map_err(|_| anyhow!("secret_key in config.toml should hex encoded"))?;
|
||||
|
||||
if bytes.len() != 32 {
|
||||
return Err(anyhow!(format!(
|
||||
"secret_key in config.toml should be 32 bytes in hex (64 characters), got: {}",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
fn storage(storage: Option<String>) -> Result<PathBuf> {
|
||||
let dir = if let Some(storage) = storage {
|
||||
PathBuf::from(storage)
|
||||
} else {
|
||||
let path = dirs_next::data_dir().ok_or_else(|| {
|
||||
anyhow!("operating environment provides no directory for application data")
|
||||
})?;
|
||||
path.join(DEFAULT_STORAGE_DIR)
|
||||
};
|
||||
|
||||
Ok(dir.join("homeserver"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_empty() {
|
||||
Config::try_from_str("").unwrap();
|
||||
}
|
||||
}
|
||||
10
pubky/pubky-homeserver/src/config.toml
Normal file
10
pubky/pubky-homeserver/src/config.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
# Use testnet network (local DHT) for testing.
|
||||
testnet = true
|
||||
# Secret key (in hex) to generate the Homeserver's Keypair
|
||||
secret_key = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
# Domain to be published in Pkarr records for this server to be accessible by.
|
||||
domain = "localhost"
|
||||
# Port for the Homeserver to listen on.
|
||||
port = 6287
|
||||
# Storage directory Defaults to <System's Data Directory>
|
||||
# storage = ""
|
||||
93
pubky/pubky-homeserver/src/database.rs
Normal file
93
pubky/pubky-homeserver/src/database.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use std::fs;
|
||||
|
||||
use heed::{Env, EnvOpenOptions};
|
||||
|
||||
mod migrations;
|
||||
pub mod tables;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
use tables::{Tables, TABLES_COUNT};
|
||||
|
||||
pub struct MapSize(u64);
|
||||
|
||||
impl MapSize {
|
||||
pub const fn new(size: u64) -> Self {
|
||||
Self(size)
|
||||
}
|
||||
|
||||
pub fn as_usize(&self) -> usize {
|
||||
self.0.try_into().unwrap_or(usize::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_MAP_SIZE: MapSize = MapSize::new(10_995_116_277_760);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DB {
|
||||
pub(crate) env: Env,
|
||||
pub(crate) tables: Tables,
|
||||
pub(crate) config: Config,
|
||||
}
|
||||
|
||||
impl DB {
|
||||
pub fn open(config: Config) -> anyhow::Result<Self> {
|
||||
fs::create_dir_all(config.storage())?;
|
||||
|
||||
let env = unsafe {
|
||||
EnvOpenOptions::new()
|
||||
.max_dbs(TABLES_COUNT)
|
||||
// TODO: Add a configuration option?
|
||||
.map_size(DEFAULT_MAP_SIZE.as_usize())
|
||||
.open(config.storage())
|
||||
}?;
|
||||
|
||||
let tables = migrations::run(&env)?;
|
||||
|
||||
let db = DB {
|
||||
env,
|
||||
tables,
|
||||
config,
|
||||
};
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bytes::Bytes;
|
||||
use pkarr::{mainline::Testnet, Keypair};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
use super::DB;
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries() {
|
||||
let db = DB::open(Config::test(&Testnet::new(0))).unwrap();
|
||||
|
||||
let keypair = Keypair::random();
|
||||
let path = "/pub/foo.txt";
|
||||
|
||||
let (tx, rx) = flume::bounded::<Bytes>(0);
|
||||
|
||||
let mut cloned = db.clone();
|
||||
let cloned_keypair = keypair.clone();
|
||||
|
||||
let done = tokio::task::spawn_blocking(move || {
|
||||
cloned
|
||||
.put_entry(&cloned_keypair.public_key(), path, rx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
tx.send(vec![1, 2, 3, 4, 5].into()).unwrap();
|
||||
drop(tx);
|
||||
|
||||
done.await.unwrap();
|
||||
|
||||
let blob = db.get_blob(&keypair.public_key(), path).unwrap().unwrap();
|
||||
|
||||
assert_eq!(blob, Bytes::from(vec![1, 2, 3, 4, 5]));
|
||||
}
|
||||
}
|
||||
17
pubky/pubky-homeserver/src/database/migrations.rs
Normal file
17
pubky/pubky-homeserver/src/database/migrations.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use heed::Env;
|
||||
|
||||
mod m0;
|
||||
|
||||
use super::tables::Tables;
|
||||
|
||||
pub fn run(env: &Env) -> anyhow::Result<Tables> {
|
||||
let mut wtxn = env.write_txn()?;
|
||||
|
||||
m0::run(env, &mut wtxn)?;
|
||||
|
||||
let tables = Tables::new(env, &mut wtxn)?;
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
Ok(tables)
|
||||
}
|
||||
17
pubky/pubky-homeserver/src/database/migrations/m0.rs
Normal file
17
pubky/pubky-homeserver/src/database/migrations/m0.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use heed::{Env, RwTxn};
|
||||
|
||||
use crate::database::tables::{blobs, entries, events, sessions, users};
|
||||
|
||||
pub fn run(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<()> {
|
||||
let _: users::UsersTable = env.create_database(wtxn, Some(users::USERS_TABLE))?;
|
||||
|
||||
let _: sessions::SessionsTable = env.create_database(wtxn, Some(sessions::SESSIONS_TABLE))?;
|
||||
|
||||
let _: blobs::BlobsTable = env.create_database(wtxn, Some(blobs::BLOBS_TABLE))?;
|
||||
|
||||
let _: entries::EntriesTable = env.create_database(wtxn, Some(entries::ENTRIES_TABLE))?;
|
||||
|
||||
let _: events::EventsTable = env.create_database(wtxn, Some(events::EVENTS_TABLE))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
49
pubky/pubky-homeserver/src/database/tables.rs
Normal file
49
pubky/pubky-homeserver/src/database/tables.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
pub mod blobs;
|
||||
pub mod entries;
|
||||
pub mod events;
|
||||
pub mod sessions;
|
||||
pub mod users;
|
||||
|
||||
use heed::{Env, RwTxn};
|
||||
|
||||
use blobs::{BlobsTable, BLOBS_TABLE};
|
||||
use entries::{EntriesTable, ENTRIES_TABLE};
|
||||
|
||||
use self::{
|
||||
events::{EventsTable, EVENTS_TABLE},
|
||||
sessions::{SessionsTable, SESSIONS_TABLE},
|
||||
users::{UsersTable, USERS_TABLE},
|
||||
};
|
||||
|
||||
pub const TABLES_COUNT: u32 = 5;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tables {
|
||||
pub users: UsersTable,
|
||||
pub sessions: SessionsTable,
|
||||
pub blobs: BlobsTable,
|
||||
pub entries: EntriesTable,
|
||||
pub events: EventsTable,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
pub fn new(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
users: env
|
||||
.open_database(wtxn, Some(USERS_TABLE))?
|
||||
.expect("Users table already created"),
|
||||
sessions: env
|
||||
.open_database(wtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Sessions table already created"),
|
||||
blobs: env
|
||||
.open_database(wtxn, Some(BLOBS_TABLE))?
|
||||
.expect("Blobs table already created"),
|
||||
entries: env
|
||||
.open_database(wtxn, Some(ENTRIES_TABLE))?
|
||||
.expect("Entries table already created"),
|
||||
events: env
|
||||
.open_database(wtxn, Some(EVENTS_TABLE))?
|
||||
.expect("Events table already created"),
|
||||
})
|
||||
}
|
||||
}
|
||||
38
pubky/pubky-homeserver/src/database/tables/blobs.rs
Normal file
38
pubky/pubky-homeserver/src/database/tables/blobs.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use heed::{types::Bytes, Database};
|
||||
use pkarr::PublicKey;
|
||||
|
||||
use crate::database::DB;
|
||||
|
||||
use super::entries::Entry;
|
||||
|
||||
/// hash of the blob => bytes.
|
||||
pub type BlobsTable = Database<Bytes, Bytes>;
|
||||
|
||||
pub const BLOBS_TABLE: &str = "blobs";
|
||||
|
||||
impl DB {
|
||||
pub fn get_blob(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
path: &str,
|
||||
) -> anyhow::Result<Option<bytes::Bytes>> {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
|
||||
let key = format!("{public_key}/{path}");
|
||||
|
||||
let result = if let Some(bytes) = self.tables.entries.get(&rtxn, &key)? {
|
||||
let entry = Entry::deserialize(bytes)?;
|
||||
|
||||
self.tables
|
||||
.blobs
|
||||
.get(&rtxn, entry.content_hash())?
|
||||
.map(|blob| bytes::Bytes::from(blob[8..].to_vec()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
rtxn.commit()?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
315
pubky/pubky-homeserver/src/database/tables/entries.rs
Normal file
315
pubky/pubky-homeserver/src/database/tables/entries.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
use pkarr::PublicKey;
|
||||
use postcard::{from_bytes, to_allocvec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
|
||||
use heed::{
|
||||
types::{Bytes, Str},
|
||||
Database, RoTxn,
|
||||
};
|
||||
|
||||
use pubky_common::{
|
||||
crypto::{Hash, Hasher},
|
||||
timestamp::Timestamp,
|
||||
};
|
||||
|
||||
use crate::database::DB;
|
||||
|
||||
use super::events::Event;
|
||||
|
||||
/// full_path(pubky/*path) => Entry.
|
||||
pub type EntriesTable = Database<Str, Bytes>;
|
||||
|
||||
pub const ENTRIES_TABLE: &str = "entries";
|
||||
|
||||
impl DB {
|
||||
pub fn put_entry(
|
||||
&mut self,
|
||||
public_key: &PublicKey,
|
||||
path: &str,
|
||||
rx: flume::Receiver<bytes::Bytes>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut wtxn = self.env.write_txn()?;
|
||||
|
||||
let mut hasher = Hasher::new();
|
||||
let mut bytes = vec![];
|
||||
let mut length = 0;
|
||||
|
||||
while let Ok(chunk) = rx.recv() {
|
||||
hasher.update(&chunk);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
length += chunk.len();
|
||||
}
|
||||
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let key = hash.as_bytes();
|
||||
|
||||
let mut bytes_with_ref_count = Vec::with_capacity(bytes.len() + 8);
|
||||
bytes_with_ref_count.extend_from_slice(&u64::to_be_bytes(0));
|
||||
bytes_with_ref_count.extend_from_slice(&bytes);
|
||||
|
||||
// TODO: For now, we set the first 8 bytes to a reference counter
|
||||
let exists = self
|
||||
.tables
|
||||
.blobs
|
||||
.get(&wtxn, key)?
|
||||
.unwrap_or(bytes_with_ref_count.as_slice());
|
||||
|
||||
let new_count = u64::from_be_bytes(exists[0..8].try_into().unwrap()) + 1;
|
||||
|
||||
bytes_with_ref_count[0..8].copy_from_slice(&u64::to_be_bytes(new_count));
|
||||
|
||||
self.tables
|
||||
.blobs
|
||||
.put(&mut wtxn, hash.as_bytes(), &bytes_with_ref_count)?;
|
||||
|
||||
let mut entry = Entry::new();
|
||||
|
||||
entry.set_content_hash(hash);
|
||||
entry.set_content_length(length);
|
||||
|
||||
let key = format!("{public_key}/{path}");
|
||||
|
||||
self.tables
|
||||
.entries
|
||||
.put(&mut wtxn, &key, &entry.serialize())?;
|
||||
|
||||
if path.starts_with("pub/") {
|
||||
let url = format!("pubky://{key}");
|
||||
let event = Event::put(&url);
|
||||
let value = event.serialize();
|
||||
|
||||
let key = entry.timestamp.to_string();
|
||||
|
||||
self.tables.events.put(&mut wtxn, &key, &value)?;
|
||||
|
||||
// TODO: delete older events.
|
||||
// TODO: move to events.rs
|
||||
}
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_entry(&mut self, public_key: &PublicKey, path: &str) -> anyhow::Result<bool> {
|
||||
let mut wtxn = self.env.write_txn()?;
|
||||
|
||||
let key = format!("{public_key}/{path}");
|
||||
|
||||
let deleted = if let Some(bytes) = self.tables.entries.get(&wtxn, &key)? {
|
||||
let entry = Entry::deserialize(bytes)?;
|
||||
|
||||
let mut bytes_with_ref_count = self
|
||||
.tables
|
||||
.blobs
|
||||
.get(&wtxn, entry.content_hash())?
|
||||
.map_or(vec![], |s| s.to_vec());
|
||||
|
||||
let arr: [u8; 8] = bytes_with_ref_count[0..8].try_into().unwrap_or([0; 8]);
|
||||
let reference_count = u64::from_be_bytes(arr);
|
||||
|
||||
let deleted_blobs = if reference_count > 1 {
|
||||
// decrement reference count
|
||||
|
||||
bytes_with_ref_count[0..8].copy_from_slice(&(reference_count - 1).to_be_bytes());
|
||||
|
||||
self.tables
|
||||
.blobs
|
||||
.put(&mut wtxn, entry.content_hash(), &bytes_with_ref_count)?;
|
||||
|
||||
true
|
||||
} else {
|
||||
self.tables.blobs.delete(&mut wtxn, entry.content_hash())?
|
||||
};
|
||||
|
||||
let deleted_entry = self.tables.entries.delete(&mut wtxn, &key)?;
|
||||
|
||||
// create DELETE event
|
||||
if path.starts_with("pub/") {
|
||||
let url = format!("pubky://{key}");
|
||||
|
||||
let event = Event::delete(&url);
|
||||
let value = event.serialize();
|
||||
|
||||
let key = Timestamp::now().to_string();
|
||||
|
||||
self.tables.events.put(&mut wtxn, &key, &value)?;
|
||||
|
||||
// TODO: delete older events.
|
||||
// TODO: move to events.rs
|
||||
}
|
||||
|
||||
deleted_entry && deleted_blobs
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub fn contains_directory(&self, txn: &RoTxn, path: &str) -> anyhow::Result<bool> {
|
||||
Ok(self.tables.entries.get_greater_than(txn, path)?.is_some())
|
||||
}
|
||||
|
||||
/// Return a list of pubky urls.
|
||||
///
|
||||
/// - limit defaults to [Config::default_list_limit] and capped by [Config::max_list_limit]
|
||||
pub fn list(
|
||||
&self,
|
||||
txn: &RoTxn,
|
||||
path: &str,
|
||||
reverse: bool,
|
||||
limit: Option<u16>,
|
||||
cursor: Option<String>,
|
||||
shallow: bool,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
// Vector to store results
|
||||
let mut results = Vec::new();
|
||||
|
||||
let limit = limit
|
||||
.unwrap_or(self.config.default_list_limit())
|
||||
.min(self.config.max_list_limit());
|
||||
|
||||
// TODO: make this more performant than split and allocations?
|
||||
|
||||
let mut threshold = cursor
|
||||
.map(|cursor| {
|
||||
// Removing leading forward slashes
|
||||
let mut file_or_directory = cursor.trim_start_matches('/');
|
||||
|
||||
if cursor.starts_with("pubky://") {
|
||||
file_or_directory = cursor.split(path).last().expect("should not be reachable")
|
||||
};
|
||||
|
||||
next_threshold(
|
||||
path,
|
||||
file_or_directory,
|
||||
file_or_directory.ends_with('/'),
|
||||
reverse,
|
||||
shallow,
|
||||
)
|
||||
})
|
||||
.unwrap_or(next_threshold(path, "", false, reverse, shallow));
|
||||
|
||||
for _ in 0..limit {
|
||||
if let Some((key, _)) = if reverse {
|
||||
self.tables.entries.get_lower_than(txn, &threshold)?
|
||||
} else {
|
||||
self.tables.entries.get_greater_than(txn, &threshold)?
|
||||
} {
|
||||
if !key.starts_with(path) {
|
||||
break;
|
||||
}
|
||||
|
||||
if shallow {
|
||||
let mut split = key[path.len()..].split('/');
|
||||
let file_or_directory = split.next().expect("should not be reachable");
|
||||
|
||||
let is_directory = split.next().is_some();
|
||||
|
||||
threshold =
|
||||
next_threshold(path, file_or_directory, is_directory, reverse, shallow);
|
||||
|
||||
results.push(format!(
|
||||
"pubky://{path}{file_or_directory}{}",
|
||||
if is_directory { "/" } else { "" }
|
||||
));
|
||||
} else {
|
||||
threshold = key.to_string();
|
||||
results.push(format!("pubky://{}", key))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the next threshold
|
||||
#[instrument]
|
||||
fn next_threshold(
|
||||
path: &str,
|
||||
file_or_directory: &str,
|
||||
is_directory: bool,
|
||||
reverse: bool,
|
||||
shallow: bool,
|
||||
) -> String {
|
||||
format!(
|
||||
"{path}{file_or_directory}{}",
|
||||
if file_or_directory.is_empty() {
|
||||
// No file_or_directory, early return
|
||||
if reverse {
|
||||
// `path/to/dir/\x7f` to catch all paths than `path/to/dir/`
|
||||
"\x7f"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else if shallow & is_directory {
|
||||
if reverse {
|
||||
// threshold = `path/to/dir\x2e`, since `\x2e` is lower than `/`
|
||||
"\x2e"
|
||||
} else {
|
||||
//threshold = `path/to/dir\x7f`, since `\x7f` is greater than `/`
|
||||
"\x7f"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// Encoding version
|
||||
version: usize,
|
||||
/// Modified at
|
||||
timestamp: Timestamp,
|
||||
content_hash: [u8; 32],
|
||||
content_length: usize,
|
||||
content_type: String,
|
||||
// user_metadata: ?
|
||||
}
|
||||
|
||||
// TODO: get headers like Etag
|
||||
|
||||
impl Entry {
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
// === Setters ===
|
||||
|
||||
pub fn set_content_hash(&mut self, content_hash: Hash) -> &mut Self {
|
||||
content_hash.as_bytes().clone_into(&mut self.content_hash);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_content_length(&mut self, content_length: usize) -> &mut Self {
|
||||
self.content_length = content_length;
|
||||
self
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
pub fn content_hash(&self) -> &[u8; 32] {
|
||||
&self.content_hash
|
||||
}
|
||||
|
||||
// === Public Method ===
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
to_allocvec(self).expect("Session::serialize")
|
||||
}
|
||||
|
||||
pub fn deserialize(bytes: &[u8]) -> core::result::Result<Self, postcard::Error> {
|
||||
if bytes[0] > 0 {
|
||||
panic!("Unknown Entry version");
|
||||
}
|
||||
|
||||
from_bytes(bytes)
|
||||
}
|
||||
}
|
||||
105
pubky/pubky-homeserver/src/database/tables/events.rs
Normal file
105
pubky/pubky-homeserver/src/database/tables/events.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Server events (Put and Delete entries)
|
||||
//!
|
||||
//! Useful as a realtime sync with Indexers until
|
||||
//! we implement more self-authenticated merkle data.
|
||||
|
||||
use heed::{
|
||||
types::{Bytes, Str},
|
||||
Database,
|
||||
};
|
||||
use postcard::{from_bytes, to_allocvec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::database::DB;
|
||||
|
||||
/// Event [Timestamp] base32 => Encoded event.
|
||||
pub type EventsTable = Database<Str, Bytes>;
|
||||
|
||||
pub const EVENTS_TABLE: &str = "events";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub enum Event {
|
||||
Put(String),
|
||||
Delete(String),
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn put(url: &str) -> Self {
|
||||
Self::Put(url.to_string())
|
||||
}
|
||||
|
||||
pub fn delete(url: &str) -> Self {
|
||||
Self::Delete(url.to_string())
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
to_allocvec(self).expect("Session::serialize")
|
||||
}
|
||||
|
||||
pub fn deserialize(bytes: &[u8]) -> core::result::Result<Self, postcard::Error> {
|
||||
if bytes[0] > 1 {
|
||||
panic!("Unknown Event version");
|
||||
}
|
||||
|
||||
from_bytes(bytes)
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
match self {
|
||||
Event::Put(url) => url,
|
||||
Event::Delete(url) => url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn operation(&self) -> &str {
|
||||
match self {
|
||||
Event::Put(_) => "PUT",
|
||||
Event::Delete(_) => "DEL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DB {
|
||||
/// Returns a list of events formatted as `<OP> <url>`.
|
||||
///
|
||||
/// - limit defaults to [Config::default_list_limit] and capped by [Config::max_list_limit]
|
||||
/// - cursor is a 13 character string encoding of a timestamp
|
||||
pub fn list_events(
|
||||
&self,
|
||||
limit: Option<u16>,
|
||||
cursor: Option<String>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let txn = self.env.read_txn()?;
|
||||
|
||||
let limit = limit
|
||||
.unwrap_or(self.config.default_list_limit())
|
||||
.min(self.config.max_list_limit());
|
||||
|
||||
let cursor = cursor.unwrap_or("0000000000000".to_string());
|
||||
|
||||
let mut result: Vec<String> = vec![];
|
||||
let mut next_cursor = cursor.to_string();
|
||||
|
||||
for _ in 0..limit {
|
||||
match self.tables.events.get_greater_than(&txn, &next_cursor)? {
|
||||
Some((timestamp, event_bytes)) => {
|
||||
let event = Event::deserialize(event_bytes)?;
|
||||
|
||||
let line = format!("{} {}", event.operation(), event.url());
|
||||
next_cursor = timestamp.to_string();
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
if !result.is_empty() {
|
||||
result.push(format!("cursor: {next_cursor}"))
|
||||
}
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
51
pubky/pubky-homeserver/src/database/tables/sessions.rs
Normal file
51
pubky/pubky-homeserver/src/database/tables/sessions.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use heed::{
|
||||
types::{Bytes, Str},
|
||||
Database,
|
||||
};
|
||||
use pkarr::PublicKey;
|
||||
use pubky_common::session::Session;
|
||||
use tower_cookies::Cookies;
|
||||
|
||||
use crate::database::DB;
|
||||
|
||||
/// session secret => Session.
|
||||
pub type SessionsTable = Database<Str, Bytes>;
|
||||
|
||||
pub const SESSIONS_TABLE: &str = "sessions";
|
||||
|
||||
impl DB {
|
||||
pub fn get_session(
|
||||
&mut self,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
) -> anyhow::Result<Option<Session>> {
|
||||
if let Some(bytes) = self.get_session_bytes(cookies, public_key)? {
|
||||
return Ok(Some(Session::deserialize(&bytes)?));
|
||||
};
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn get_session_bytes(
|
||||
&mut self,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
if let Some(cookie) = cookies.get(&public_key.to_string()) {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
|
||||
let sessions: SessionsTable = self
|
||||
.env
|
||||
.open_database(&rtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Session table already created");
|
||||
|
||||
let session = sessions.get(&rtxn, cookie.value())?.map(|s| s.to_vec());
|
||||
|
||||
rtxn.commit()?;
|
||||
|
||||
return Ok(session);
|
||||
};
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
58
pubky/pubky-homeserver/src/database/tables/users.rs
Normal file
58
pubky/pubky-homeserver/src/database/tables/users.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use postcard::{from_bytes, to_allocvec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use heed::{BoxedError, BytesDecode, BytesEncode, Database};
|
||||
use pkarr::PublicKey;
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
/// PublicKey => User.
|
||||
pub type UsersTable = Database<PublicKeyCodec, User>;
|
||||
|
||||
pub const USERS_TABLE: &str = "users";
|
||||
|
||||
// TODO: add more adminstration metadata like quota, invitation links, etc..
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
pub struct User {
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl<'a> BytesEncode<'a> for User {
|
||||
type EItem = Self;
|
||||
|
||||
fn bytes_encode(user: &Self::EItem) -> Result<Cow<[u8]>, BoxedError> {
|
||||
let vec = to_allocvec(user).unwrap();
|
||||
|
||||
Ok(Cow::Owned(vec))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BytesDecode<'a> for User {
|
||||
type DItem = Self;
|
||||
|
||||
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, BoxedError> {
|
||||
let user: User = from_bytes(bytes).unwrap();
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PublicKeyCodec {}
|
||||
|
||||
impl<'a> BytesEncode<'a> for PublicKeyCodec {
|
||||
type EItem = PublicKey;
|
||||
|
||||
fn bytes_encode(pubky: &Self::EItem) -> Result<Cow<[u8]>, BoxedError> {
|
||||
Ok(Cow::Borrowed(pubky.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BytesDecode<'a> for PublicKeyCodec {
|
||||
type DItem = PublicKey;
|
||||
|
||||
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, BoxedError> {
|
||||
Ok(PublicKey::try_from(bytes)?)
|
||||
}
|
||||
}
|
||||
128
pubky/pubky-homeserver/src/error.rs
Normal file
128
pubky/pubky-homeserver/src/error.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! Server error
|
||||
|
||||
use axum::{
|
||||
extract::rejection::{ExtensionRejection, PathRejection, QueryRejection},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
pub type Result<T, E = Error> = core::result::Result<T, E>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Error {
|
||||
// #[serde(with = "serde_status_code")]
|
||||
status: StatusCode,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Error {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn with_status(status: StatusCode) -> Error {
|
||||
Self {
|
||||
status,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new [`Error`].
|
||||
pub fn new(status_code: StatusCode, message: Option<impl ToString>) -> Error {
|
||||
Self {
|
||||
status: status_code,
|
||||
detail: message.map(|m| m.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self.detail {
|
||||
Some(detail) => (self.status, detail).into_response(),
|
||||
_ => (self.status,).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QueryRejection> for Error {
|
||||
fn from(error: QueryRejection) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExtensionRejection> for Error {
|
||||
fn from(error: ExtensionRejection) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PathRejection> for Error {
|
||||
fn from(error: PathRejection) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
// === Pubky specific errors ===
|
||||
|
||||
impl From<pubky_common::auth::Error> for Error {
|
||||
fn from(error: pubky_common::auth::Error) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, Some(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pkarr::Error> for Error {
|
||||
fn from(error: pkarr::Error) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, Some(error))
|
||||
}
|
||||
}
|
||||
|
||||
// === INTERNAL_SERVER_ERROR ===
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
debug!(?error);
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<heed::Error> for Error {
|
||||
fn from(error: heed::Error) -> Self {
|
||||
debug!(?error);
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
debug!(?error);
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<postcard::Error> for Error {
|
||||
fn from(error: postcard::Error) -> Self {
|
||||
debug!(?error);
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<axum::Error> for Error {
|
||||
fn from(error: axum::Error) -> Self {
|
||||
debug!(?error);
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<flume::SendError<T>> for Error {
|
||||
fn from(error: flume::SendError<T>) -> Self {
|
||||
debug!(?error);
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.into())
|
||||
}
|
||||
}
|
||||
123
pubky/pubky-homeserver/src/extractors.rs
Normal file
123
pubky/pubky-homeserver/src/extractors.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRequestParts, Path, Query},
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
RequestPartsExt,
|
||||
};
|
||||
|
||||
use pkarr::PublicKey;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Pubky(PublicKey);
|
||||
|
||||
impl Pubky {
|
||||
pub fn public_key(&self) -> &PublicKey {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for Pubky
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let params: Path<HashMap<String, String>> =
|
||||
parts.extract().await.map_err(IntoResponse::into_response)?;
|
||||
|
||||
let pubky_id = params
|
||||
.get("pubky")
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "pubky param missing").into_response())?;
|
||||
|
||||
let public_key = PublicKey::try_from(pubky_id.to_string())
|
||||
.map_err(Error::try_from)
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
|
||||
// TODO: return 404 if the user doesn't exist, but exclude signups.
|
||||
|
||||
Ok(Pubky(public_key))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntryPath(pub(crate) String);
|
||||
|
||||
impl EntryPath {
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for EntryPath
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let params: Path<HashMap<String, String>> =
|
||||
parts.extract().await.map_err(IntoResponse::into_response)?;
|
||||
|
||||
// TODO: enforce path limits like no trailing '/'
|
||||
|
||||
let path = params
|
||||
.get("path")
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "entry path missing").into_response())?;
|
||||
|
||||
Ok(EntryPath(path.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ListQueryParams {
|
||||
pub limit: Option<u16>,
|
||||
pub cursor: Option<String>,
|
||||
pub reverse: bool,
|
||||
pub shallow: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for ListQueryParams
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let params: Query<HashMap<String, String>> =
|
||||
parts.extract().await.map_err(IntoResponse::into_response)?;
|
||||
|
||||
let reverse = params.contains_key("reverse");
|
||||
let shallow = params.contains_key("shallow");
|
||||
let limit = params
|
||||
.get("limit")
|
||||
// Treat `limit=` as None
|
||||
.and_then(|l| if l.is_empty() { None } else { Some(l) })
|
||||
.and_then(|l| l.parse::<u16>().ok());
|
||||
let cursor = params
|
||||
.get("cursor")
|
||||
.map(|c| c.as_str())
|
||||
// Treat `cursor=` as None
|
||||
.and_then(|c| {
|
||||
if c.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(c.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ListQueryParams {
|
||||
reverse,
|
||||
shallow,
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
}
|
||||
}
|
||||
9
pubky/pubky-homeserver/src/lib.rs
Normal file
9
pubky/pubky-homeserver/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod config;
|
||||
mod database;
|
||||
mod error;
|
||||
mod extractors;
|
||||
mod pkarr;
|
||||
mod routes;
|
||||
mod server;
|
||||
|
||||
pub use server::Homeserver;
|
||||
46
pubky/pubky-homeserver/src/main.rs
Normal file
46
pubky/pubky-homeserver/src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use pubky_homeserver::{config::Config, Homeserver};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Cli {
|
||||
/// [tracing_subscriber::EnvFilter]
|
||||
#[clap(short, long)]
|
||||
tracing_env_filter: Option<String>,
|
||||
|
||||
/// Run Homeserver in a local testnet
|
||||
#[clap(long)]
|
||||
testnet: bool,
|
||||
|
||||
/// Optional Path to config file.
|
||||
#[clap(short, long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Cli::parse();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
args.tracing_env_filter
|
||||
.unwrap_or("pubky_homeserver=debug,tower_http=debug".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let server = Homeserver::start(if args.testnet {
|
||||
Config::testnet()
|
||||
} else if let Some(config_path) = args.config {
|
||||
Config::load(config_path).await?
|
||||
} else {
|
||||
Config::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
server.run_until_done().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
46
pubky/pubky-homeserver/src/pkarr.rs
Normal file
46
pubky/pubky-homeserver/src/pkarr.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Pkarr related task
|
||||
|
||||
use pkarr::{
|
||||
dns::{rdata::SVCB, Packet},
|
||||
Keypair, PkarrClientAsync, SignedPacket,
|
||||
};
|
||||
|
||||
pub(crate) async fn publish_server_packet(
|
||||
pkarr_client: &PkarrClientAsync,
|
||||
keypair: &Keypair,
|
||||
domain: &str,
|
||||
port: u16,
|
||||
) -> anyhow::Result<()> {
|
||||
// TODO: Try to resolve first before publishing.
|
||||
|
||||
let mut packet = Packet::new_reply(0);
|
||||
|
||||
let mut svcb = SVCB::new(0, domain.try_into()?);
|
||||
|
||||
// Publishing port only for localhost domain,
|
||||
// assuming any other domain will point to a reverse proxy
|
||||
// at the conventional ports.
|
||||
if domain == "localhost" {
|
||||
svcb.priority = 1;
|
||||
svcb.set_port(port);
|
||||
|
||||
// TODO: Add more parameteres like the signer key!
|
||||
// svcb.set_param(key, value)
|
||||
};
|
||||
|
||||
// TODO: announce A/AAAA records as well for Noise connections?
|
||||
// Or maybe Iroh's magic socket
|
||||
|
||||
packet.answers.push(pkarr::dns::ResourceRecord::new(
|
||||
"@".try_into().unwrap(),
|
||||
pkarr::dns::CLASS::IN,
|
||||
60 * 60,
|
||||
pkarr::dns::rdata::RData::SVCB(svcb),
|
||||
));
|
||||
|
||||
let signed_packet = SignedPacket::from_packet(keypair, &packet)?;
|
||||
|
||||
pkarr_client.publish(&signed_packet).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
43
pubky/pubky-homeserver/src/routes.rs
Normal file
43
pubky/pubky-homeserver/src/routes.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
|
||||
use crate::server::AppState;
|
||||
|
||||
use self::pkarr::pkarr_router;
|
||||
|
||||
mod auth;
|
||||
mod feed;
|
||||
mod pkarr;
|
||||
mod public;
|
||||
mod root;
|
||||
|
||||
fn base(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root::handler))
|
||||
.route("/signup", post(auth::signup))
|
||||
.route("/session", post(auth::signin))
|
||||
.route("/:pubky/session", get(auth::session))
|
||||
.route("/:pubky/session", delete(auth::signout))
|
||||
.route("/:pubky/*path", put(public::put))
|
||||
.route("/:pubky/*path", get(public::get))
|
||||
.route("/:pubky/*path", delete(public::delete))
|
||||
.route("/events/", get(feed::feed))
|
||||
.layer(CookieManagerLayer::new())
|
||||
// TODO: revisit if we enable streaming big payloads
|
||||
// TODO: maybe add to a separate router (drive router?).
|
||||
.layer(DefaultBodyLimit::max(16 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub fn create_app(state: AppState) -> Router {
|
||||
base(state.clone())
|
||||
// TODO: Only enable this for test environments?
|
||||
.nest("/pkarr", pkarr_router(state))
|
||||
.layer(CorsLayer::very_permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
138
pubky/pubky-homeserver/src/routes/auth.rs
Normal file
138
pubky/pubky-homeserver/src/routes/auth.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use axum::{
|
||||
debug_handler,
|
||||
extract::State,
|
||||
http::{uri::Scheme, StatusCode, Uri},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum_extra::{headers::UserAgent, TypedHeader};
|
||||
use bytes::Bytes;
|
||||
use tower_cookies::{cookie::SameSite, Cookie, Cookies};
|
||||
|
||||
use pubky_common::{crypto::random_bytes, session::Session, timestamp::Timestamp};
|
||||
|
||||
use crate::{
|
||||
database::tables::{
|
||||
sessions::{SessionsTable, SESSIONS_TABLE},
|
||||
users::User,
|
||||
},
|
||||
error::{Error, Result},
|
||||
extractors::Pubky,
|
||||
server::AppState,
|
||||
};
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn signup(
|
||||
State(state): State<AppState>,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
cookies: Cookies,
|
||||
uri: Uri,
|
||||
body: Bytes,
|
||||
) -> Result<impl IntoResponse> {
|
||||
// TODO: Verify invitation link.
|
||||
// TODO: add errors in case of already axisting user.
|
||||
signin(State(state), user_agent, cookies, uri, body).await
|
||||
}
|
||||
|
||||
pub async fn session(
|
||||
State(state): State<AppState>,
|
||||
cookies: Cookies,
|
||||
pubky: Pubky,
|
||||
) -> Result<impl IntoResponse> {
|
||||
if let Some(cookie) = cookies.get(&pubky.public_key().to_string()) {
|
||||
let rtxn = state.db.env.read_txn()?;
|
||||
|
||||
let sessions: SessionsTable = state
|
||||
.db
|
||||
.env
|
||||
.open_database(&rtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Session table already created");
|
||||
|
||||
if let Some(session) = sessions.get(&rtxn, cookie.value())? {
|
||||
let session = session.to_owned();
|
||||
rtxn.commit()?;
|
||||
|
||||
// TODO: add content-type
|
||||
return Ok(session);
|
||||
};
|
||||
|
||||
rtxn.commit()?;
|
||||
};
|
||||
|
||||
Err(Error::with_status(StatusCode::NOT_FOUND))
|
||||
}
|
||||
|
||||
pub async fn signout(
|
||||
State(state): State<AppState>,
|
||||
cookies: Cookies,
|
||||
pubky: Pubky,
|
||||
) -> Result<impl IntoResponse> {
|
||||
if let Some(cookie) = cookies.get(&pubky.public_key().to_string()) {
|
||||
let mut wtxn = state.db.env.write_txn()?;
|
||||
|
||||
let sessions: SessionsTable = state
|
||||
.db
|
||||
.env
|
||||
.open_database(&wtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Session table already created");
|
||||
|
||||
let _ = sessions.delete(&mut wtxn, cookie.value());
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
Err(Error::with_status(StatusCode::UNAUTHORIZED))
|
||||
}
|
||||
|
||||
pub async fn signin(
|
||||
State(state): State<AppState>,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
cookies: Cookies,
|
||||
uri: Uri,
|
||||
body: Bytes,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let token = state.verifier.verify(&body)?;
|
||||
|
||||
let public_key = token.pubky();
|
||||
|
||||
let mut wtxn = state.db.env.write_txn()?;
|
||||
|
||||
let users = state.db.tables.users;
|
||||
if let Some(existing) = users.get(&wtxn, public_key)? {
|
||||
users.put(&mut wtxn, public_key, &existing)?;
|
||||
} else {
|
||||
users.put(
|
||||
&mut wtxn,
|
||||
public_key,
|
||||
&User {
|
||||
created_at: Timestamp::now().into_inner(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
let session_secret = base32::encode(base32::Alphabet::Crockford, &random_bytes::<16>());
|
||||
|
||||
let session = Session::new(&token, user_agent.map(|ua| ua.to_string())).serialize();
|
||||
|
||||
state
|
||||
.db
|
||||
.tables
|
||||
.sessions
|
||||
.put(&mut wtxn, &session_secret, &session)?;
|
||||
|
||||
let mut cookie = Cookie::new(public_key.to_string(), session_secret);
|
||||
|
||||
cookie.set_path("/");
|
||||
if *uri.scheme().unwrap_or(&Scheme::HTTP) == Scheme::HTTPS {
|
||||
cookie.set_secure(true);
|
||||
cookie.set_same_site(SameSite::None);
|
||||
}
|
||||
cookie.set_http_only(true);
|
||||
|
||||
cookies.add(cookie);
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
41
pubky/pubky-homeserver/src/routes/feed.rs
Normal file
41
pubky/pubky-homeserver/src/routes/feed.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use pubky_common::timestamp::{Timestamp, TimestampError};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
extractors::ListQueryParams,
|
||||
server::AppState,
|
||||
};
|
||||
|
||||
pub async fn feed(
|
||||
State(state): State<AppState>,
|
||||
params: ListQueryParams,
|
||||
) -> Result<impl IntoResponse> {
|
||||
if let Some(ref cursor) = params.cursor {
|
||||
if let Err(timestmap_error) = Timestamp::try_from(cursor.to_string()) {
|
||||
let cause = match timestmap_error {
|
||||
TimestampError::InvalidEncoding => {
|
||||
"Cursor should be valid base32 Crockford encoding of a timestamp"
|
||||
}
|
||||
TimestampError::InvalidBytesLength(size) => {
|
||||
&format!("Cursor should be 13 characters long, got: {size}")
|
||||
}
|
||||
};
|
||||
|
||||
Err(Error::new(StatusCode::BAD_REQUEST, cause.into()))?
|
||||
}
|
||||
}
|
||||
|
||||
let result = state.db.list_events(params.limit, params.cursor)?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.body(Body::from(result.join("\n")))
|
||||
.unwrap())
|
||||
}
|
||||
58
pubky/pubky-homeserver/src/routes/pkarr.rs
Normal file
58
pubky/pubky-homeserver/src/routes/pkarr.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, put},
|
||||
Router,
|
||||
};
|
||||
use futures_util::stream::StreamExt;
|
||||
|
||||
use pkarr::SignedPacket;
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
extractors::Pubky,
|
||||
server::AppState,
|
||||
};
|
||||
|
||||
/// Pkarr relay, helpful for testing.
|
||||
///
|
||||
/// For real productioin, you should use a [production ready
|
||||
/// relay](https://github.com/pubky/pkarr/server).
|
||||
pub fn pkarr_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/:pubky", put(pkarr_put))
|
||||
.route("/:pubky", get(pkarr_get))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub async fn pkarr_put(
|
||||
State(state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
body: Body,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let mut bytes = Vec::with_capacity(1104);
|
||||
|
||||
let mut stream = body.into_data_stream();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
bytes.extend_from_slice(&chunk?)
|
||||
}
|
||||
|
||||
let public_key = pubky.public_key().to_owned();
|
||||
|
||||
let signed_packet = SignedPacket::from_relay_payload(&public_key, &Bytes::from(bytes))?;
|
||||
|
||||
state.pkarr_client.publish(&signed_packet).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pkarr_get(State(state): State<AppState>, pubky: Pubky) -> Result<impl IntoResponse> {
|
||||
if let Some(signed_packet) = state.pkarr_client.resolve(pubky.public_key()).await? {
|
||||
return Ok(signed_packet.to_relay_payload());
|
||||
}
|
||||
|
||||
Err(Error::with_status(StatusCode::NOT_FOUND))
|
||||
}
|
||||
174
pubky/pubky-homeserver/src/routes/public.rs
Normal file
174
pubky/pubky-homeserver/src/routes/public.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::State,
|
||||
http::{header, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures_util::stream::StreamExt;
|
||||
use pkarr::PublicKey;
|
||||
use tower_cookies::Cookies;
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
extractors::{EntryPath, ListQueryParams, Pubky},
|
||||
server::AppState,
|
||||
};
|
||||
|
||||
pub async fn put(
|
||||
State(mut state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
path: EntryPath,
|
||||
cookies: Cookies,
|
||||
body: Body,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let public_key = pubky.public_key().clone();
|
||||
let path = path.as_str();
|
||||
|
||||
verify(path)?;
|
||||
authorize(&mut state, cookies, &public_key, path)?;
|
||||
|
||||
let mut stream = body.into_data_stream();
|
||||
|
||||
let (tx, rx) = flume::bounded::<Bytes>(1);
|
||||
|
||||
let path = path.to_string();
|
||||
|
||||
// TODO: refactor Database to clean up this scope.
|
||||
let done = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// TODO: this is a blocking operation, which is ok for small
|
||||
// payloads (we have 16 kb limit for now) but later we need
|
||||
// to stream this to filesystem, and keep track of any failed
|
||||
// writes to GC these files later.
|
||||
|
||||
state.db.put_entry(&public_key, &path, rx)?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
while let Some(next) = stream.next().await {
|
||||
let chunk = next?;
|
||||
|
||||
tx.send(chunk)?;
|
||||
}
|
||||
|
||||
drop(tx);
|
||||
done.await.expect("join error")?;
|
||||
|
||||
// TODO: return relevant headers, like Etag?
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
path: EntryPath,
|
||||
params: ListQueryParams,
|
||||
) -> Result<impl IntoResponse> {
|
||||
verify(path.as_str())?;
|
||||
let public_key = pubky.public_key();
|
||||
|
||||
let path = path.as_str();
|
||||
|
||||
if path.ends_with('/') {
|
||||
let txn = state.db.env.read_txn()?;
|
||||
|
||||
let path = format!("{public_key}/{path}");
|
||||
|
||||
if !state.db.contains_directory(&txn, &path)? {
|
||||
return Err(Error::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"Directory Not Found".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Handle listing
|
||||
let vec = state.db.list(
|
||||
&txn,
|
||||
&path,
|
||||
params.reverse,
|
||||
params.limit,
|
||||
params.cursor,
|
||||
params.shallow,
|
||||
)?;
|
||||
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.body(Body::from(vec.join("\n")))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
// TODO: Enable streaming
|
||||
|
||||
match state.db.get_blob(public_key, path) {
|
||||
Err(error) => Err(error)?,
|
||||
Ok(Some(bytes)) => Ok(Response::builder().body(Body::from(bytes)).unwrap()),
|
||||
Ok(None) => Err(Error::new(StatusCode::NOT_FOUND, "File Not Found".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(mut state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
path: EntryPath,
|
||||
cookies: Cookies,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let public_key = pubky.public_key().clone();
|
||||
let path = path.as_str();
|
||||
|
||||
authorize(&mut state, cookies, &public_key, path)?;
|
||||
verify(path)?;
|
||||
|
||||
let deleted = state.db.delete_entry(&public_key, path)?;
|
||||
|
||||
if !deleted {
|
||||
// TODO: if the path ends with `/` return a `CONFLICT` error?
|
||||
return Err(Error::with_status(StatusCode::NOT_FOUND));
|
||||
}
|
||||
|
||||
// TODO: return relevant headers, like Etag?
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authorize write (PUT or DELETE) for Public paths.
|
||||
fn authorize(
|
||||
state: &mut AppState,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
// TODO: can we move this logic to the extractor or a layer
|
||||
// to perform this validation?
|
||||
let session = state
|
||||
.db
|
||||
.get_session(cookies, public_key)?
|
||||
.ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
if session.pubky() == public_key
|
||||
&& session.capabilities().iter().any(|cap| {
|
||||
path.starts_with(&cap.scope[1..])
|
||||
&& cap
|
||||
.actions
|
||||
.contains(&pubky_common::capabilities::Action::Write)
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(Error::with_status(StatusCode::FORBIDDEN))
|
||||
}
|
||||
|
||||
fn verify(path: &str) -> Result<()> {
|
||||
if !path.starts_with("pub/") {
|
||||
return Err(Error::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Writing to directories other than '/pub/' is forbidden".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// TODO: should we forbid paths ending with `/`?
|
||||
|
||||
Ok(())
|
||||
}
|
||||
5
pubky/pubky-homeserver/src/routes/root.rs
Normal file
5
pubky/pubky-homeserver/src/routes/root.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
pub async fn handler() -> Result<impl IntoResponse, String> {
|
||||
Ok("This a Pubky homeserver.".to_string())
|
||||
}
|
||||
169
pubky/pubky-homeserver/src/server.rs
Normal file
169
pubky/pubky-homeserver/src/server.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use std::{future::IntoFuture, net::SocketAddr};
|
||||
|
||||
use anyhow::{Error, Result};
|
||||
use pubky_common::auth::AuthVerifier;
|
||||
use tokio::{net::TcpListener, signal, task::JoinSet};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use pkarr::{
|
||||
mainline::dht::{DhtSettings, Testnet},
|
||||
PkarrClient, PkarrClientAsync, PublicKey, Settings,
|
||||
};
|
||||
|
||||
use crate::{config::Config, database::DB, pkarr::publish_server_packet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Homeserver {
|
||||
state: AppState,
|
||||
tasks: JoinSet<std::io::Result<()>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AppState {
|
||||
pub(crate) verifier: AuthVerifier,
|
||||
pub(crate) db: DB,
|
||||
pub(crate) pkarr_client: PkarrClientAsync,
|
||||
pub(crate) config: Config,
|
||||
pub(crate) port: u16,
|
||||
}
|
||||
|
||||
impl Homeserver {
|
||||
pub async fn start(config: Config) -> Result<Self> {
|
||||
debug!(?config);
|
||||
|
||||
let db = DB::open(config.clone())?;
|
||||
|
||||
let pkarr_client = PkarrClient::new(Settings {
|
||||
dht: DhtSettings {
|
||||
bootstrap: config.bootstsrap(),
|
||||
request_timeout: config.dht_request_timeout(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})?
|
||||
.as_async();
|
||||
|
||||
let mut tasks = JoinSet::new();
|
||||
|
||||
let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], config.port()))).await?;
|
||||
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let state = AppState {
|
||||
verifier: AuthVerifier::default(),
|
||||
db,
|
||||
pkarr_client,
|
||||
config: config.clone(),
|
||||
port,
|
||||
};
|
||||
|
||||
let app = crate::routes::create_app(state.clone());
|
||||
|
||||
// Spawn http server task
|
||||
tasks.spawn(
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.into_future(),
|
||||
);
|
||||
|
||||
info!("Homeserver listening on http://localhost:{port}");
|
||||
|
||||
publish_server_packet(
|
||||
&state.pkarr_client,
|
||||
config.keypair(),
|
||||
&state
|
||||
.config
|
||||
.domain()
|
||||
.clone()
|
||||
.unwrap_or("localhost".to_string()),
|
||||
port,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Homeserver listening on pubky://{}",
|
||||
config.keypair().public_key()
|
||||
);
|
||||
|
||||
Ok(Self { tasks, state })
|
||||
}
|
||||
|
||||
/// Test version of [Homeserver::start], using mainline Testnet, and a temporary storage.
|
||||
pub async fn start_test(testnet: &Testnet) -> Result<Self> {
|
||||
info!("Running testnet..");
|
||||
|
||||
Homeserver::start(Config::test(testnet)).await
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.state.port
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.state.config.keypair().public_key()
|
||||
}
|
||||
|
||||
// === Public Methods ===
|
||||
|
||||
/// Shutdown the server and wait for all tasks to complete.
|
||||
pub async fn shutdown(mut self) -> Result<()> {
|
||||
self.tasks.abort_all();
|
||||
self.run_until_done().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for all tasks to complete.
|
||||
///
|
||||
/// Runs forever unless tasks fail.
|
||||
pub async fn run_until_done(mut self) -> Result<()> {
|
||||
let mut final_res: Result<()> = Ok(());
|
||||
while let Some(res) = self.tasks.join_next().await {
|
||||
match res {
|
||||
Ok(Ok(())) => {}
|
||||
Err(err) if err.is_cancelled() => {}
|
||||
Ok(Err(err)) => {
|
||||
warn!(?err, "task failed");
|
||||
final_res = Err(Error::from(err));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(?err, "task panicked");
|
||||
final_res = Err(err.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
final_res
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
fn graceful_shutdown() {
|
||||
info!("Gracefully Shutting down..");
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => graceful_shutdown(),
|
||||
_ = terminate => graceful_shutdown(),
|
||||
}
|
||||
}
|
||||
44
pubky/pubky/Cargo.toml
Normal file
44
pubky/pubky/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "pubky"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Pubky client"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/pubky/pubky"
|
||||
keywords = ["web", "dht", "dns", "decentralized", "identity"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1.0.62"
|
||||
wasm-bindgen = "0.2.92"
|
||||
url = "2.5.2"
|
||||
bytes = "^1.7.1"
|
||||
base64 = "0.22.1"
|
||||
|
||||
pubky-common = { version = "0.1.0", path = "../pubky-common" }
|
||||
pkarr = { workspace = true, features = ["async"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
reqwest = { version = "0.12.5", features = ["cookies", "rustls-tls"], default-features = false }
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
reqwest = { version = "0.12.5", default-features = false }
|
||||
|
||||
js-sys = "0.3.69"
|
||||
wasm-bindgen = "0.2.92"
|
||||
wasm-bindgen-futures = "0.4.42"
|
||||
|
||||
[dev-dependencies]
|
||||
pubky_homeserver = { path = "../pubky-homeserver" }
|
||||
tokio = "1.37.0"
|
||||
|
||||
[features]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ['-g', '-O']
|
||||
6
pubky/pubky/pkg/.gitignore
vendored
Normal file
6
pubky/pubky/pkg/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
index.cjs
|
||||
browser.js
|
||||
coverage
|
||||
node_modules
|
||||
package-lock.json
|
||||
pubky*
|
||||
21
pubky/pubky/pkg/LICENSE
Normal file
21
pubky/pubky/pkg/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2023
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
266
pubky/pubky/pkg/README.md
Normal file
266
pubky/pubky/pkg/README.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Pubky
|
||||
|
||||
JavaScript implementation of [Pubky](https://github.com/pubky/pubky).
|
||||
|
||||
## Table of Contents
|
||||
- [Install](#install)
|
||||
- [Getting Started](#getting-started)
|
||||
- [API](#api)
|
||||
- [Test and Development](#test-and-development)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @synonymdev/pubky
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
For Nodejs, you need Node v20 or later.
|
||||
|
||||
## Getting started
|
||||
|
||||
```js
|
||||
import { PubkyClient, Keypair, PublicKey } from '../index.js'
|
||||
|
||||
// Initialize PubkyClient with Pkarr relay(s).
|
||||
let client = new PubkyClient();
|
||||
|
||||
// Generate a keypair
|
||||
let keypair = Keypair.random();
|
||||
|
||||
// Create a new account
|
||||
let homeserver = PublicKey.from("8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo");
|
||||
|
||||
await client.signup(keypair, homeserver)
|
||||
|
||||
const publicKey = keypair.publicKey();
|
||||
|
||||
// Pubky URL
|
||||
let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`;
|
||||
|
||||
// Verify that you are signed in.
|
||||
const session = await client.session(publicKey)
|
||||
|
||||
const body = Buffer.from(JSON.stringify({ foo: 'bar' }))
|
||||
|
||||
// PUT public data, by authorized client
|
||||
await client.put(url, body);
|
||||
|
||||
// GET public data without signup or signin
|
||||
{
|
||||
const client = new PubkyClient();
|
||||
|
||||
let response = await client.get(url);
|
||||
}
|
||||
|
||||
// Delete public data, by authorized client
|
||||
await client.delete(url);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### PubkyClient
|
||||
|
||||
#### constructor
|
||||
```js
|
||||
let client = new PubkyClient()
|
||||
```
|
||||
|
||||
#### signup
|
||||
```js
|
||||
await client.signup(keypair, homeserver)
|
||||
```
|
||||
- keypair: An instance of [Keypair](#keypair).
|
||||
- homeserver: An instance of [PublicKey](#publickey) representing the homeserver.
|
||||
|
||||
Returns:
|
||||
- session: An instance of [Session](#session).
|
||||
|
||||
#### signin
|
||||
```js
|
||||
let session = await client.signin(keypair)
|
||||
```
|
||||
- keypair: An instance of [Keypair](#keypair).
|
||||
|
||||
Returns:
|
||||
- An instance of [Session](#session).
|
||||
|
||||
#### signout
|
||||
```js
|
||||
await client.signout(publicKey)
|
||||
```
|
||||
- publicKey: An instance of [PublicKey](#publicKey).
|
||||
|
||||
#### authRequest
|
||||
```js
|
||||
let [pubkyauthUrl, sessionPromise] = client.authRequest(relay, capabilities);
|
||||
|
||||
showQr(pubkyauthUrl);
|
||||
|
||||
let pubky = await sessionPromise;
|
||||
```
|
||||
|
||||
Sign in to a user's Homeserver, without access to their [Keypair](#keypair), nor even [PublicKey](#publickey),
|
||||
instead request permissions (showing the user pubkyauthUrl), and await a Session after the user consenting to that request.
|
||||
|
||||
- relay: A URL to an [HTTP relay](https://httprelay.io/features/link/) endpoint.
|
||||
- capabilities: A list of capabilities required for the app for example `/pub/pubky.app/:rw,/pub/example.com/:r`.
|
||||
|
||||
Returns:
|
||||
- pubkyauthUrl: A url to show to the user to scan or paste into an Authenticator app holding the user [Keypair](#keypair)
|
||||
- sessionPromise: A promise that resolves into a [PublicKey](#publickey) on success, which you can use in `client.session(pubky)` to resolve more information about the Session.
|
||||
|
||||
#### sendAuthToken
|
||||
```js
|
||||
await client.sendAuthToken(keypair, pubkyauthUrl);
|
||||
```
|
||||
Consenting to authentication or authorization according to the required capabilities in the `pubkyauthUrl` , and sign and send an auth token to the requester.
|
||||
|
||||
- keypair: An instance of [KeyPair](#keypair)
|
||||
- pubkyauthUrl: A string `pubkyauth://` url
|
||||
|
||||
#### session {#session-method}
|
||||
```js
|
||||
let session = await client.session(publicKey)
|
||||
```
|
||||
- publicKey: An instance of [PublicKey](#publickey).
|
||||
- Returns: A [Session](#session) object if signed in, or undefined if not.
|
||||
|
||||
#### put
|
||||
```js
|
||||
let response = await client.put(url, body);
|
||||
```
|
||||
- url: A string representing the Pubky URL.
|
||||
- body: A Buffer containing the data to be stored.
|
||||
|
||||
### get
|
||||
```js
|
||||
let response = await client.get(url)
|
||||
```
|
||||
- url: A string representing the Pubky URL.
|
||||
- Returns: A Uint8Array object containing the requested data, or `undefined` if `NOT_FOUND`.
|
||||
|
||||
### delete
|
||||
|
||||
```js
|
||||
let response = await client.delete(url);
|
||||
```
|
||||
- url: A string representing the Pubky URL.
|
||||
|
||||
### list
|
||||
```js
|
||||
let response = await client.list(url, cursor, reverse, limit)
|
||||
```
|
||||
- url: A string representing the Pubky URL. The path in that url is the prefix that you want to list files within.
|
||||
- cursor: Usually the last URL from previous calls. List urls after/before (depending on `reverse`) the cursor.
|
||||
- reverse: Whether or not return urls in reverse order.
|
||||
- limit: Number of urls to return.
|
||||
- Returns: A list of URLs of the files in the `url` you passed.
|
||||
|
||||
### Keypair
|
||||
|
||||
#### random
|
||||
```js
|
||||
let keypair = Keypair.random()
|
||||
```
|
||||
- Returns: A new random Keypair.
|
||||
|
||||
#### fromSecretKey
|
||||
```js
|
||||
let keypair = Keypair.fromSecretKey(secretKey)
|
||||
```
|
||||
- secretKey: A 32 bytes Uint8array.
|
||||
- Returns: A new Keypair.
|
||||
|
||||
|
||||
#### publicKey {#publickey-method}
|
||||
```js
|
||||
let publicKey = keypair.publicKey()
|
||||
```
|
||||
- Returns: The [PublicKey](#publickey) associated with the Keypair.
|
||||
|
||||
#### secretKey
|
||||
```js
|
||||
let secretKey = keypair.secretKey()
|
||||
```
|
||||
- Returns: The Uint8array secret key associated with the Keypair.
|
||||
|
||||
### PublicKey
|
||||
|
||||
#### from
|
||||
|
||||
```js
|
||||
let publicKey = PublicKey.from(string);
|
||||
```
|
||||
- string: A string representing the public key.
|
||||
- Returns: A new PublicKey instance.
|
||||
|
||||
#### z32
|
||||
```js
|
||||
let pubky = publicKey.z32();
|
||||
```
|
||||
Returns: The z-base-32 encoded string representation of the PublicKey.
|
||||
|
||||
### Session
|
||||
|
||||
#### pubky
|
||||
```js
|
||||
let pubky = session.pubky();
|
||||
```
|
||||
Returns an instance of [PublicKey](#publickey)
|
||||
|
||||
#### capabilities
|
||||
```js
|
||||
let capabilities = session.capabilities();
|
||||
```
|
||||
Returns an array of capabilities, for example `["/pub/pubky.app/:rw"]`
|
||||
|
||||
### Helper functions
|
||||
|
||||
#### createRecoveryFile
|
||||
```js
|
||||
let recoveryFile = createRecoveryFile(keypair, passphrase)
|
||||
```
|
||||
- keypair: An instance of [Keypair](#keypair).
|
||||
- passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/).
|
||||
- Returns: A recovery file with a spec line and an encrypted secret key.
|
||||
|
||||
#### createRecoveryFile
|
||||
```js
|
||||
let keypair = decryptRecoveryfile(recoveryFile, passphrase)
|
||||
```
|
||||
- recoveryFile: An instance of Uint8Array containing the recovery file blob.
|
||||
- passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/).
|
||||
- Returns: An instance of [Keypair](#keypair).
|
||||
|
||||
## Test and Development
|
||||
|
||||
For test and development, you can run a local homeserver in a test network.
|
||||
|
||||
If you don't have Cargo Installed, start by installing it:
|
||||
|
||||
```bash
|
||||
curl https://sh.rustup.rs -sSf | sh
|
||||
```
|
||||
|
||||
Clone the Pubky repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pubky/pubky
|
||||
cd pubky/pkg
|
||||
```
|
||||
|
||||
Run the local testnet server
|
||||
|
||||
```bash
|
||||
npm run testnet
|
||||
```
|
||||
|
||||
Use the logged addresses as inputs to `PubkyClient`
|
||||
|
||||
```js
|
||||
import { PubkyClient } from '../index.js'
|
||||
|
||||
const client = PubkyClient().testnet();
|
||||
```
|
||||
41
pubky/pubky/pkg/package.json
Normal file
41
pubky/pubky/pkg/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@synonymdev/pubky",
|
||||
"type": "module",
|
||||
"description": "Pubky client",
|
||||
"version": "0.1.16",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pubky/pubky"
|
||||
},
|
||||
"scripts": {
|
||||
"testnet": "cargo run -p pubky_homeserver -- --testnet",
|
||||
"test": "npm run test-nodejs && npm run test-browser",
|
||||
"test-nodejs": "tape test/*.js -cov",
|
||||
"test-browser": "browserify test/*.js -p esmify | npx tape-run",
|
||||
"build": "cargo run --bin bundle_pubky_npm",
|
||||
"prepublishOnly": "npm run build && npm run test"
|
||||
},
|
||||
"files": [
|
||||
"index.cjs",
|
||||
"browser.js",
|
||||
"pubky.d.ts",
|
||||
"pubky_bg.wasm"
|
||||
],
|
||||
"main": "index.cjs",
|
||||
"browser": "browser.js",
|
||||
"types": "pubky.d.ts",
|
||||
"keywords": [
|
||||
"web",
|
||||
"dht",
|
||||
"dns",
|
||||
"decentralized",
|
||||
"identity"
|
||||
],
|
||||
"devDependencies": {
|
||||
"browser-resolve": "^2.0.0",
|
||||
"esmify": "^2.1.1",
|
||||
"tape": "^5.8.1",
|
||||
"tape-run": "^11.0.0"
|
||||
}
|
||||
}
|
||||
65
pubky/pubky/pkg/test/auth.js
Normal file
65
pubky/pubky/pkg/test/auth.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from 'tape'
|
||||
|
||||
import { PubkyClient, Keypair, PublicKey } from '../index.cjs'
|
||||
|
||||
const Homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
|
||||
test('auth', async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.ok(session, "signup")
|
||||
|
||||
{
|
||||
await client.signout(publicKey)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.notOk(session, "singout")
|
||||
}
|
||||
|
||||
{
|
||||
await client.signin(keypair)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.ok(session, "signin")
|
||||
}
|
||||
})
|
||||
|
||||
test("3rd party signin", async (t) => {
|
||||
let keypair = Keypair.random();
|
||||
let pubky = keypair.publicKey().z32();
|
||||
|
||||
// Third party app side
|
||||
let capabilities = "/pub/pubky.app/:rw,/pub/foo.bar/file:r";
|
||||
let client = PubkyClient.testnet();
|
||||
let [pubkyauth_url, pubkyauthResponse] = client
|
||||
.authRequest("https://demo.httprelay.io/link", capabilities);
|
||||
|
||||
if (globalThis.document) {
|
||||
// Skip `sendAuthToken` in browser
|
||||
// TODO: figure out why does it fail in browser unit tests
|
||||
// but not in real browser (check pubky-auth-widget.js commented part)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticator side
|
||||
{
|
||||
let client = PubkyClient.testnet();
|
||||
|
||||
await client.signup(keypair, Homeserver);
|
||||
|
||||
await client.sendAuthToken(keypair, pubkyauth_url)
|
||||
}
|
||||
|
||||
let authedPubky = await pubkyauthResponse;
|
||||
|
||||
t.is(authedPubky.z32(), pubky);
|
||||
|
||||
let session = await client.session(authedPubky);
|
||||
t.deepEqual(session.capabilities(), capabilities.split(','))
|
||||
})
|
||||
21
pubky/pubky/pkg/test/keys.js
Normal file
21
pubky/pubky/pkg/test/keys.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import test from 'tape'
|
||||
|
||||
import { Keypair } from '../index.cjs'
|
||||
|
||||
test('generate keys from a seed', async (t) => {
|
||||
const secretkey = Buffer.from('5aa93b299a343aa2691739771f2b5b85e740ca14c685793d67870f88fa89dc51', 'hex')
|
||||
|
||||
const keypair = Keypair.fromSecretKey(secretkey)
|
||||
|
||||
const publicKey = keypair.publicKey()
|
||||
|
||||
t.is(publicKey.z32(), 'gcumbhd7sqit6nn457jxmrwqx9pyymqwamnarekgo3xppqo6a19o')
|
||||
})
|
||||
|
||||
test('fromSecretKey error', async (t) => {
|
||||
const secretkey = Buffer.from('5aa93b299a343aa2691739771f2b5b', 'hex')
|
||||
|
||||
|
||||
t.throws(() => Keypair.fromSecretKey(null), /Expected secret_key to be an instance of Uint8Array/)
|
||||
t.throws(() => Keypair.fromSecretKey(secretkey), /Expected secret_key to be 32 bytes, got 15/)
|
||||
})
|
||||
351
pubky/pubky/pkg/test/public.js
Normal file
351
pubky/pubky/pkg/test/public.js
Normal file
@@ -0,0 +1,351 @@
|
||||
import test from 'tape'
|
||||
|
||||
import { PubkyClient, Keypair, PublicKey } from '../index.cjs'
|
||||
|
||||
const Homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo');
|
||||
|
||||
test('public: put/get', async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random();
|
||||
|
||||
await client.signup(keypair, Homeserver);
|
||||
|
||||
const publicKey = keypair.publicKey();
|
||||
|
||||
let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`;
|
||||
|
||||
const body = Buffer.from(JSON.stringify({ foo: 'bar' }))
|
||||
|
||||
// PUT public data, by authorized client
|
||||
await client.put(url, body);
|
||||
|
||||
const otherClient = PubkyClient.testnet();
|
||||
|
||||
// GET public data without signup or signin
|
||||
{
|
||||
let response = await otherClient.get(url);
|
||||
|
||||
t.ok(Buffer.from(response).equals(body))
|
||||
}
|
||||
|
||||
// DELETE public data, by authorized client
|
||||
await client.delete(url);
|
||||
|
||||
|
||||
// GET public data without signup or signin
|
||||
{
|
||||
let response = await otherClient.get(url);
|
||||
|
||||
t.notOk(response)
|
||||
}
|
||||
})
|
||||
|
||||
test("not found", async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
|
||||
const keypair = Keypair.random();
|
||||
|
||||
await client.signup(keypair, Homeserver);
|
||||
|
||||
const publicKey = keypair.publicKey();
|
||||
|
||||
let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`;
|
||||
|
||||
let result = await client.get(url).catch(e => e);
|
||||
|
||||
t.notOk(result);
|
||||
})
|
||||
|
||||
test("unauthorized", async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.ok(session, "signup")
|
||||
|
||||
await client.signout(publicKey)
|
||||
|
||||
const body = Buffer.from(JSON.stringify({ foo: 'bar' }))
|
||||
|
||||
let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`;
|
||||
|
||||
// PUT public data, by authorized client
|
||||
let result = await client.put(url, body).catch(e => e);
|
||||
|
||||
t.ok(result instanceof Error);
|
||||
t.is(
|
||||
result.message,
|
||||
`HTTP status client error (401 Unauthorized) for url (http://localhost:15411/${publicKey.z32()}/pub/example.com/arbitrary)`
|
||||
)
|
||||
})
|
||||
|
||||
test("forbidden", async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
const session = await client.session(publicKey)
|
||||
t.ok(session, "signup")
|
||||
|
||||
const body = Buffer.from(JSON.stringify({ foo: 'bar' }))
|
||||
|
||||
let url = `pubky://${publicKey.z32()}/priv/example.com/arbitrary`;
|
||||
|
||||
// PUT public data, by authorized client
|
||||
let result = await client.put(url, body).catch(e => e);
|
||||
|
||||
t.ok(result instanceof Error);
|
||||
t.is(
|
||||
result.message,
|
||||
`HTTP status client error (403 Forbidden) for url (http://localhost:15411/${publicKey.z32()}/priv/example.com/arbitrary)`
|
||||
)
|
||||
})
|
||||
|
||||
test("list", async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
const pubky = publicKey.z32()
|
||||
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
|
||||
|
||||
let urls = [
|
||||
`pubky://${pubky}/pub/a.wrong/a.txt`,
|
||||
`pubky://${pubky}/pub/example.com/a.txt`,
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
`pubky://${pubky}/pub/example.wrong/a.txt`,
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
`pubky://${pubky}/pub/example.com/d.txt`,
|
||||
`pubky://${pubky}/pub/z.wrong/a.txt`,
|
||||
]
|
||||
|
||||
for (let url of urls) {
|
||||
await client.put(url, Buffer.from(""));
|
||||
}
|
||||
|
||||
let url = `pubky://${pubky}/pub/example.com/`;
|
||||
|
||||
{
|
||||
let list = await client.list(url);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.com/a.txt`,
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
`pubky://${pubky}/pub/example.com/d.txt`,
|
||||
|
||||
],
|
||||
"normal list with no limit or cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, null, null, 2);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.com/a.txt`,
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
|
||||
],
|
||||
"normal list with limit but no cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, "a.txt", null, 2);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
|
||||
],
|
||||
"normal list with limit and a suffix cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, `pubky://${pubky}/pub/example.com/a.txt`, null, 2);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
|
||||
],
|
||||
"normal list with limit and a full url cursor"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
let list = await client.list(url, null, true);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.com/d.txt`,
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
`pubky://${pubky}/pub/example.com/a.txt`,
|
||||
|
||||
],
|
||||
"reverse list with no limit or cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, null, true, 2);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.com/d.txt`,
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
|
||||
],
|
||||
"reverse list with limit but no cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, "d.txt", true, 2);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
|
||||
],
|
||||
"reverse list with limit and a suffix cursor"
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
test('list shallow', async (t) => {
|
||||
const client = PubkyClient.testnet();
|
||||
|
||||
const keypair = Keypair.random()
|
||||
const publicKey = keypair.publicKey()
|
||||
const pubky = publicKey.z32()
|
||||
|
||||
await client.signup(keypair, Homeserver)
|
||||
|
||||
let urls = [
|
||||
`pubky://${pubky}/pub/a.com/a.txt`,
|
||||
`pubky://${pubky}/pub/example.com/a.txt`,
|
||||
`pubky://${pubky}/pub/example.com/b.txt`,
|
||||
`pubky://${pubky}/pub/example.com/c.txt`,
|
||||
`pubky://${pubky}/pub/example.com/d.txt`,
|
||||
`pubky://${pubky}/pub/example.con/d.txt`,
|
||||
`pubky://${pubky}/pub/example.con`,
|
||||
`pubky://${pubky}/pub/file`,
|
||||
`pubky://${pubky}/pub/file2`,
|
||||
`pubky://${pubky}/pub/z.com/a.txt`,
|
||||
]
|
||||
|
||||
for (let url of urls) {
|
||||
await client.put(url, Buffer.from(""));
|
||||
}
|
||||
|
||||
let url = `pubky://${pubky}/pub/`;
|
||||
|
||||
{
|
||||
let list = await client.list(url, null, false, null, true);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/a.com/`,
|
||||
`pubky://${pubky}/pub/example.com/`,
|
||||
`pubky://${pubky}/pub/example.con`,
|
||||
`pubky://${pubky}/pub/example.con/`,
|
||||
`pubky://${pubky}/pub/file`,
|
||||
`pubky://${pubky}/pub/file2`,
|
||||
`pubky://${pubky}/pub/z.com/`,
|
||||
],
|
||||
"normal list shallow"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, null, false, 3, true);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/a.com/`,
|
||||
`pubky://${pubky}/pub/example.com/`,
|
||||
`pubky://${pubky}/pub/example.con`,
|
||||
],
|
||||
"normal list shallow with limit"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, `example.com/`, false, null, true);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/example.con`,
|
||||
`pubky://${pubky}/pub/example.con/`,
|
||||
`pubky://${pubky}/pub/file`,
|
||||
`pubky://${pubky}/pub/file2`,
|
||||
`pubky://${pubky}/pub/z.com/`,
|
||||
],
|
||||
"normal list shallow with cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, null, true, null, true);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/z.com/`,
|
||||
`pubky://${pubky}/pub/file2`,
|
||||
`pubky://${pubky}/pub/file`,
|
||||
`pubky://${pubky}/pub/example.con/`,
|
||||
`pubky://${pubky}/pub/example.con`,
|
||||
`pubky://${pubky}/pub/example.com/`,
|
||||
`pubky://${pubky}/pub/a.com/`,
|
||||
],
|
||||
"normal list shallow"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = await client.list(url, null, true, 3, true);
|
||||
|
||||
t.deepEqual(
|
||||
list,
|
||||
[
|
||||
`pubky://${pubky}/pub/z.com/`,
|
||||
`pubky://${pubky}/pub/file2`,
|
||||
`pubky://${pubky}/pub/file`,
|
||||
],
|
||||
"normal list shallow with limit"
|
||||
);
|
||||
}
|
||||
})
|
||||
19
pubky/pubky/pkg/test/recovery.js
Normal file
19
pubky/pubky/pkg/test/recovery.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from 'tape'
|
||||
|
||||
import { Keypair, createRecoveryFile, decryptRecoveryFile } from '../index.cjs'
|
||||
|
||||
test('recovery', async (t) => {
|
||||
const keypair = Keypair.random();
|
||||
|
||||
const recoveryFile = createRecoveryFile(keypair, 'very secure password');
|
||||
|
||||
t.is(recoveryFile.length, 91)
|
||||
t.deepEqual(
|
||||
Array.from(recoveryFile.slice(0, 19)),
|
||||
[112, 117, 98, 107, 121, 46, 111, 114, 103, 47, 114, 101, 99, 111, 118, 101, 114, 121, 10]
|
||||
)
|
||||
|
||||
const recovered = decryptRecoveryFile(recoveryFile, 'very secure password')
|
||||
|
||||
t.is(recovered.publicKey().z32(), keypair.publicKey().z32())
|
||||
})
|
||||
65
pubky/pubky/src/bin/bundle_pubky_npm.rs
Normal file
65
pubky/pubky/src/bin/bundle_pubky_npm.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::env;
|
||||
use std::io;
|
||||
use std::process::{Command, ExitStatus};
|
||||
|
||||
// If the process hangs, try `cargo clean` to remove all locks.
|
||||
|
||||
fn main() {
|
||||
println!("Building wasm for pubky...");
|
||||
|
||||
build_wasm("nodejs").unwrap();
|
||||
patch().unwrap();
|
||||
}
|
||||
|
||||
fn build_wasm(target: &str) -> io::Result<ExitStatus> {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
|
||||
|
||||
let output = Command::new("wasm-pack")
|
||||
.args([
|
||||
"build",
|
||||
&manifest_dir,
|
||||
"--release",
|
||||
"--target",
|
||||
target,
|
||||
"--out-dir",
|
||||
&format!("pkg/{}", target),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
println!(
|
||||
"wasm-pack {target} output: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"wasm-pack failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(output.status)
|
||||
}
|
||||
|
||||
fn patch() -> io::Result<ExitStatus> {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
|
||||
|
||||
println!("{manifest_dir}/src/bin/patch.mjs");
|
||||
let output = Command::new("node")
|
||||
.args([format!("{manifest_dir}/src/bin/patch.mjs")])
|
||||
.output()?;
|
||||
|
||||
println!(
|
||||
"patch.mjs output: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"patch.mjs failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(output.status)
|
||||
}
|
||||
66
pubky/pubky/src/bin/patch.mjs
Normal file
66
pubky/pubky/src/bin/patch.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
// This script is used to generate isomorphic code for web and nodejs
|
||||
//
|
||||
// Based on hacks from [this issue](https://github.com/rustwasm/wasm-pack/issues/1334)
|
||||
|
||||
import { readFile, writeFile, rename } from "node:fs/promises";
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path, { dirname } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const cargoTomlContent = await readFile(path.join(__dirname, "../../Cargo.toml"), "utf8");
|
||||
const cargoPackageName = /\[package\]\nname = "(.*?)"/.exec(cargoTomlContent)[1]
|
||||
const name = cargoPackageName.replace(/-/g, '_')
|
||||
|
||||
const content = await readFile(path.join(__dirname, `../../pkg/nodejs/${name}.js`), "utf8");
|
||||
|
||||
const patched = content
|
||||
// use global TextDecoder TextEncoder
|
||||
.replace("require(`util`)", "globalThis")
|
||||
// attach to `imports` instead of module.exports
|
||||
.replace("= module.exports", "= imports")
|
||||
// Export classes
|
||||
.replace(/\nclass (.*?) \{/g, "\n export class $1 {")
|
||||
// Export functions
|
||||
.replace(/\nmodule.exports.(.*?) = function/g, "\nimports.$1 = $1;\nexport function $1")
|
||||
// Add exports to 'imports'
|
||||
.replace(/\nmodule\.exports\.(.*?)\s+/g, "\nimports.$1")
|
||||
// Export default
|
||||
.replace(/$/, 'export default imports')
|
||||
// inline wasm bytes
|
||||
.replace(
|
||||
/\nconst path.*\nconst bytes.*\n/,
|
||||
`
|
||||
var __toBinary = /* @__PURE__ */ (() => {
|
||||
var table = new Uint8Array(128);
|
||||
for (var i = 0; i < 64; i++)
|
||||
table[i < 26 ? i + 65 : i < 52 ? i + 71 : i < 62 ? i - 4 : i * 4 - 205] = i;
|
||||
return (base64) => {
|
||||
var n = base64.length, bytes = new Uint8Array((n - (base64[n - 1] == "=") - (base64[n - 2] == "=")) * 3 / 4 | 0);
|
||||
for (var i2 = 0, j = 0; i2 < n; ) {
|
||||
var c0 = table[base64.charCodeAt(i2++)], c1 = table[base64.charCodeAt(i2++)];
|
||||
var c2 = table[base64.charCodeAt(i2++)], c3 = table[base64.charCodeAt(i2++)];
|
||||
bytes[j++] = c0 << 2 | c1 >> 4;
|
||||
bytes[j++] = c1 << 4 | c2 >> 2;
|
||||
bytes[j++] = c2 << 6 | c3;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
})();
|
||||
|
||||
const bytes = __toBinary(${JSON.stringify(await readFile(path.join(__dirname, `../../pkg/nodejs/${name}_bg.wasm`), "base64"))
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
await writeFile(path.join(__dirname, `../../pkg/browser.js`), patched + "\nglobalThis['pubky'] = imports");
|
||||
|
||||
// Move outside of nodejs
|
||||
|
||||
await Promise.all([".js", ".d.ts", "_bg.wasm"].map(suffix =>
|
||||
rename(
|
||||
path.join(__dirname, `../../pkg/nodejs/${name}${suffix}`),
|
||||
path.join(__dirname, `../../pkg/${suffix === '.js' ? "index.cjs" : (name + suffix)}`),
|
||||
))
|
||||
)
|
||||
56
pubky/pubky/src/error.rs
Normal file
56
pubky/pubky/src/error.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Main Crate Error
|
||||
|
||||
use pkarr::dns::SimpleDnsError;
|
||||
|
||||
// Alias Result to be the crate Result.
|
||||
pub type Result<T, E = Error> = core::result::Result<T, E>;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
/// Pk common Error
|
||||
pub enum Error {
|
||||
/// For starter, to remove as code matures.
|
||||
#[error("Generic error: {0}")]
|
||||
Generic(String),
|
||||
|
||||
#[error("Could not resolve endpoint for {0}")]
|
||||
ResolveEndpoint(String),
|
||||
|
||||
#[error("Could not convert the passed type into a Url")]
|
||||
InvalidUrl,
|
||||
|
||||
// === Transparent ===
|
||||
#[error(transparent)]
|
||||
Dns(#[from] SimpleDnsError),
|
||||
|
||||
#[error(transparent)]
|
||||
Pkarr(#[from] pkarr::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Url(#[from] url::ParseError),
|
||||
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Session(#[from] pubky_common::session::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Crypto(#[from] pubky_common::crypto::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
RecoveryFile(#[from] pubky_common::recovery_file::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
AuthToken(#[from] pubky_common::auth::Error),
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl From<Error> for JsValue {
|
||||
fn from(error: Error) -> JsValue {
|
||||
let error_message = error.to_string();
|
||||
js_sys::Error::new(&error_message).into()
|
||||
}
|
||||
}
|
||||
36
pubky/pubky/src/lib.rs
Normal file
36
pubky/pubky/src/lib.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
mod error;
|
||||
mod shared;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use ::pkarr::PkarrClientAsync;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use crate::shared::list_builder::ListBuilder;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[wasm_bindgen]
|
||||
pub struct PubkyClient {
|
||||
http: reqwest::Client,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) pkarr: PkarrClientAsync,
|
||||
/// A cookie jar for nodejs fetch.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) session_cookies: Arc<RwLock<HashSet<String>>>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) pkarr_relays: Vec<String>,
|
||||
}
|
||||
258
pubky/pubky/src/native.rs
Normal file
258
pubky/pubky/src/native.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use pubky_common::{
|
||||
capabilities::Capabilities,
|
||||
recovery_file::{create_recovery_file, decrypt_recovery_file},
|
||||
session::Session,
|
||||
};
|
||||
use reqwest::{RequestBuilder, Response};
|
||||
use tokio::sync::oneshot;
|
||||
use url::Url;
|
||||
|
||||
use pkarr::{Keypair, PkarrClientAsync};
|
||||
|
||||
use ::pkarr::{mainline::dht::Testnet, PkarrClient, PublicKey, SignedPacket};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
shared::list_builder::ListBuilder,
|
||||
PubkyClient,
|
||||
};
|
||||
|
||||
static DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PubkyClientBuilder {
|
||||
pkarr_settings: pkarr::Settings,
|
||||
}
|
||||
|
||||
impl PubkyClientBuilder {
|
||||
/// Set Pkarr client [pkarr::Settings].
|
||||
pub fn pkarr_settings(mut self, settings: pkarr::Settings) -> Self {
|
||||
self.pkarr_settings = settings;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the bootstrap nodes of a testnet, as the bootstrap nodes and
|
||||
/// resolvers in the internal Pkarr client.
|
||||
pub fn testnet(mut self, testnet: &Testnet) -> Self {
|
||||
self.pkarr_settings.dht.bootstrap = testnet.bootstrap.to_vec().into();
|
||||
|
||||
self.pkarr_settings.resolvers = testnet
|
||||
.bootstrap
|
||||
.iter()
|
||||
.flat_map(|resolver| resolver.to_socket_addrs())
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the request_timeout of the UDP socket in the Mainline DHT client in
|
||||
/// the internal Pkarr client.
|
||||
///
|
||||
/// Useful to speed unit tests.
|
||||
/// Defaults to 2 seconds.
|
||||
pub fn dht_request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.pkarr_settings.dht.request_timeout = timeout.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Build [PubkyClient]
|
||||
pub fn build(self) -> PubkyClient {
|
||||
PubkyClient {
|
||||
http: reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.user_agent(DEFAULT_USER_AGENT)
|
||||
.build()
|
||||
.unwrap(),
|
||||
pkarr: PkarrClient::new(self.pkarr_settings).unwrap().as_async(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PubkyClient {
|
||||
fn default() -> Self {
|
||||
PubkyClient::builder().build()
|
||||
}
|
||||
}
|
||||
|
||||
// === Public API ===
|
||||
|
||||
impl PubkyClient {
|
||||
/// Returns a builder to edit settings before creating [PubkyClient].
|
||||
pub fn builder() -> PubkyClientBuilder {
|
||||
PubkyClientBuilder::default()
|
||||
}
|
||||
|
||||
/// Create a client connected to the local network
|
||||
/// with the bootstrapping node: `localhost:6881`
|
||||
pub fn testnet() -> Self {
|
||||
Self::test(&Testnet {
|
||||
bootstrap: vec!["localhost:6881".to_string()],
|
||||
nodes: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a [PubkyClient] with:
|
||||
/// - DHT bootstrap nodes set to the `testnet` bootstrap nodes.
|
||||
/// - DHT request timout set to 500 milliseconds. (unless in CI, then it is left as default 2000)
|
||||
///
|
||||
/// For more control, you can use [PubkyClientBuilder::testnet]
|
||||
pub fn test(testnet: &Testnet) -> PubkyClient {
|
||||
let mut builder = PubkyClient::builder().testnet(testnet);
|
||||
|
||||
if std::env::var("CI").is_err() {
|
||||
builder = builder.dht_request_timeout(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
// === Getters ===
|
||||
|
||||
/// Returns a reference to the internal [pkarr] Client.
|
||||
pub fn pkarr(&self) -> &PkarrClientAsync {
|
||||
&self.pkarr
|
||||
}
|
||||
|
||||
// === Auth ===
|
||||
|
||||
/// Signup to a homeserver and update Pkarr accordingly.
|
||||
///
|
||||
/// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key
|
||||
/// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"
|
||||
pub async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result<Session> {
|
||||
self.inner_signup(keypair, homeserver).await
|
||||
}
|
||||
|
||||
/// Check the current sesison for a given Pubky in its homeserver.
|
||||
///
|
||||
/// Returns [Session] or `None` (if recieved `404 NOT_FOUND`),
|
||||
/// or [reqwest::Error] if the response has any other `>=400` status code.
|
||||
pub async fn session(&self, pubky: &PublicKey) -> Result<Option<Session>> {
|
||||
self.inner_session(pubky).await
|
||||
}
|
||||
|
||||
/// Signout from a homeserver.
|
||||
pub async fn signout(&self, pubky: &PublicKey) -> Result<()> {
|
||||
self.inner_signout(pubky).await
|
||||
}
|
||||
|
||||
/// Signin to a homeserver.
|
||||
pub async fn signin(&self, keypair: &Keypair) -> Result<Session> {
|
||||
self.inner_signin(keypair).await
|
||||
}
|
||||
|
||||
// === Public data ===
|
||||
|
||||
/// Upload a small payload to a given path.
|
||||
pub async fn put<T: TryInto<Url>>(&self, url: T, content: &[u8]) -> Result<()> {
|
||||
self.inner_put(url, content).await
|
||||
}
|
||||
|
||||
/// Download a small payload from a given path relative to a pubky author.
|
||||
pub async fn get<T: TryInto<Url>>(&self, url: T) -> Result<Option<Bytes>> {
|
||||
self.inner_get(url).await
|
||||
}
|
||||
|
||||
/// Delete a file at a path relative to a pubky author.
|
||||
pub async fn delete<T: TryInto<Url>>(&self, url: T) -> Result<()> {
|
||||
self.inner_delete(url).await
|
||||
}
|
||||
|
||||
/// Returns a [ListBuilder] to help pass options before calling [ListBuilder::send].
|
||||
///
|
||||
/// `url` sets the path you want to lest within.
|
||||
pub fn list<T: TryInto<Url>>(&self, url: T) -> Result<ListBuilder> {
|
||||
self.inner_list(url)
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Vec<u8>> {
|
||||
Ok(create_recovery_file(keypair, passphrase)?)
|
||||
}
|
||||
|
||||
/// Recover a keypair from a recovery file by decrypting the secret key using `passphrase`.
|
||||
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair> {
|
||||
Ok(decrypt_recovery_file(recovery_file, passphrase)?)
|
||||
}
|
||||
|
||||
/// Return `pubkyauth://` url and wait for the incoming [AuthToken]
|
||||
/// verifying that AuthToken, and if capabilities were requested, signing in to
|
||||
/// the Pubky's homeserver and returning the [Session] information.
|
||||
pub fn auth_request(
|
||||
&self,
|
||||
relay: impl TryInto<Url>,
|
||||
capabilities: &Capabilities,
|
||||
) -> Result<(Url, tokio::sync::oneshot::Receiver<PublicKey>)> {
|
||||
let mut relay: Url = relay
|
||||
.try_into()
|
||||
.map_err(|_| Error::Generic("Invalid relay Url".into()))?;
|
||||
|
||||
let (pubkyauth_url, client_secret) = self.create_auth_request(&mut relay, capabilities)?;
|
||||
|
||||
let (tx, rx) = oneshot::channel::<PublicKey>();
|
||||
|
||||
let this = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let to_send = this
|
||||
.subscribe_to_auth_response(relay, &client_secret)
|
||||
.await?;
|
||||
|
||||
tx.send(to_send)
|
||||
.map_err(|_| Error::Generic("Failed to send the session after signing in with token, since the receiver is dropped".into()))?;
|
||||
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
|
||||
Ok((pubkyauth_url, rx))
|
||||
}
|
||||
|
||||
/// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the
|
||||
/// source of the pubkyauth request url.
|
||||
pub async fn send_auth_token<T: TryInto<Url>>(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
pubkyauth_url: T,
|
||||
) -> Result<()> {
|
||||
let url: Url = pubkyauth_url.try_into().map_err(|_| Error::InvalidUrl)?;
|
||||
|
||||
self.inner_send_auth_token(keypair, url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// === Internals ===
|
||||
|
||||
impl PubkyClient {
|
||||
// === Pkarr ===
|
||||
|
||||
pub(crate) async fn pkarr_resolve(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<Option<SignedPacket>> {
|
||||
Ok(self.pkarr.resolve(public_key).await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn pkarr_publish(&self, signed_packet: &SignedPacket) -> Result<()> {
|
||||
Ok(self.pkarr.publish(signed_packet).await?)
|
||||
}
|
||||
|
||||
// === HTTP ===
|
||||
|
||||
pub(crate) fn request(&self, method: reqwest::Method, url: Url) -> RequestBuilder {
|
||||
self.http.request(method, url)
|
||||
}
|
||||
|
||||
pub(crate) fn store_session(&self, _: &Response) {}
|
||||
pub(crate) fn remove_session(&self, _: &PublicKey) {}
|
||||
}
|
||||
341
pubky/pubky/src/shared/auth.rs
Normal file
341
pubky/pubky/src/shared/auth.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use base64::{alphabet::URL_SAFE, engine::general_purpose::NO_PAD, Engine};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use url::Url;
|
||||
|
||||
use pkarr::{Keypair, PublicKey};
|
||||
use pubky_common::{
|
||||
auth::AuthToken,
|
||||
capabilities::{Capabilities, Capability},
|
||||
crypto::{decrypt, encrypt, hash, random_bytes},
|
||||
session::Session,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
PubkyClient,
|
||||
};
|
||||
|
||||
use super::pkarr::Endpoint;
|
||||
|
||||
impl PubkyClient {
|
||||
/// Signup to a homeserver and update Pkarr accordingly.
|
||||
///
|
||||
/// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key
|
||||
/// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"
|
||||
pub(crate) async fn inner_signup(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
homeserver: &PublicKey,
|
||||
) -> Result<Session> {
|
||||
let homeserver = homeserver.to_string();
|
||||
|
||||
let Endpoint { mut url, .. } = self.resolve_endpoint(&homeserver).await?;
|
||||
|
||||
url.set_path("/signup");
|
||||
|
||||
let body = AuthToken::sign(keypair, vec![Capability::root()]).serialize();
|
||||
|
||||
let response = self
|
||||
.request(Method::POST, url.clone())
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
self.store_session(&response);
|
||||
|
||||
self.publish_pubky_homeserver(keypair, &homeserver).await?;
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
Ok(Session::deserialize(&bytes)?)
|
||||
}
|
||||
|
||||
/// Check the current sesison for a given Pubky in its homeserver.
|
||||
///
|
||||
/// Returns None if not signed in, or [reqwest::Error]
|
||||
/// if the response has any other `>=404` status code.
|
||||
pub(crate) async fn inner_session(&self, pubky: &PublicKey) -> Result<Option<Session>> {
|
||||
let Endpoint { mut url, .. } = self.resolve_pubky_homeserver(pubky).await?;
|
||||
|
||||
url.set_path(&format!("/{}/session", pubky));
|
||||
|
||||
let res = self.request(Method::GET, url).send().await?;
|
||||
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !res.status().is_success() {
|
||||
res.error_for_status_ref()?;
|
||||
};
|
||||
|
||||
let bytes = res.bytes().await?;
|
||||
|
||||
Ok(Some(Session::deserialize(&bytes)?))
|
||||
}
|
||||
|
||||
/// Signout from a homeserver.
|
||||
pub(crate) async fn inner_signout(&self, pubky: &PublicKey) -> Result<()> {
|
||||
let Endpoint { mut url, .. } = self.resolve_pubky_homeserver(pubky).await?;
|
||||
|
||||
url.set_path(&format!("/{}/session", pubky));
|
||||
|
||||
self.request(Method::DELETE, url).send().await?;
|
||||
|
||||
self.remove_session(pubky);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Signin to a homeserver.
|
||||
pub(crate) async fn inner_signin(&self, keypair: &Keypair) -> Result<Session> {
|
||||
let token = AuthToken::sign(keypair, vec![Capability::root()]);
|
||||
|
||||
self.signin_with_authtoken(&token).await
|
||||
}
|
||||
|
||||
pub(crate) async fn inner_send_auth_token(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
pubkyauth_url: Url,
|
||||
) -> Result<()> {
|
||||
let query_params: HashMap<String, String> =
|
||||
pubkyauth_url.query_pairs().into_owned().collect();
|
||||
|
||||
let relay = query_params
|
||||
.get("relay")
|
||||
.map(|r| url::Url::parse(r).expect("Relay query param to be valid URL"))
|
||||
.expect("Missing relay query param");
|
||||
|
||||
let client_secret = query_params
|
||||
.get("secret")
|
||||
.map(|s| {
|
||||
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
||||
let bytes = engine.decode(s).expect("invalid client_secret");
|
||||
let arr: [u8; 32] = bytes.try_into().expect("invalid client_secret");
|
||||
|
||||
arr
|
||||
})
|
||||
.expect("Missing client secret");
|
||||
|
||||
let capabilities = query_params
|
||||
.get("caps")
|
||||
.map(|caps_string| {
|
||||
caps_string
|
||||
.split(',')
|
||||
.filter_map(|cap| Capability::try_from(cap).ok())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = AuthToken::sign(keypair, capabilities);
|
||||
|
||||
let encrypted_token = encrypt(&token.serialize(), &client_secret)?;
|
||||
|
||||
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
||||
|
||||
let mut callback = relay.clone();
|
||||
let mut path_segments = callback.path_segments_mut().unwrap();
|
||||
path_segments.pop_if_empty();
|
||||
let channel_id = engine.encode(hash(&client_secret).as_bytes());
|
||||
path_segments.push(&channel_id);
|
||||
drop(path_segments);
|
||||
|
||||
self.request(Method::POST, callback)
|
||||
.body(encrypted_token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn inner_third_party_signin(
|
||||
&self,
|
||||
encrypted_token: &[u8],
|
||||
client_secret: &[u8; 32],
|
||||
) -> Result<PublicKey> {
|
||||
let decrypted = decrypt(encrypted_token, client_secret)?;
|
||||
let token = AuthToken::deserialize(&decrypted)?;
|
||||
|
||||
self.signin_with_authtoken(&token).await?;
|
||||
|
||||
Ok(token.pubky().to_owned())
|
||||
}
|
||||
|
||||
pub async fn signin_with_authtoken(&self, token: &AuthToken) -> Result<Session> {
|
||||
let mut url = Url::parse(&format!("https://{}/session", token.pubky()))?;
|
||||
|
||||
self.resolve_url(&mut url).await?;
|
||||
|
||||
let response = self
|
||||
.request(Method::POST, url)
|
||||
.body(token.serialize())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
self.store_session(&response);
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
Ok(Session::deserialize(&bytes)?)
|
||||
}
|
||||
|
||||
pub(crate) fn create_auth_request(
|
||||
&self,
|
||||
relay: &mut Url,
|
||||
capabilities: &Capabilities,
|
||||
) -> Result<(Url, [u8; 32])> {
|
||||
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
||||
|
||||
let client_secret: [u8; 32] = random_bytes::<32>();
|
||||
|
||||
let pubkyauth_url = Url::parse(&format!(
|
||||
"pubkyauth:///?caps={capabilities}&secret={}&relay={relay}",
|
||||
engine.encode(client_secret)
|
||||
))?;
|
||||
|
||||
let mut segments = relay
|
||||
.path_segments_mut()
|
||||
.map_err(|_| Error::Generic("Invalid relay".into()))?;
|
||||
// remove trailing slash if any.
|
||||
segments.pop_if_empty();
|
||||
let channel_id = &engine.encode(hash(&client_secret).as_bytes());
|
||||
segments.push(channel_id);
|
||||
drop(segments);
|
||||
|
||||
Ok((pubkyauth_url, client_secret))
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribe_to_auth_response(
|
||||
&self,
|
||||
relay: Url,
|
||||
client_secret: &[u8; 32],
|
||||
) -> Result<PublicKey> {
|
||||
let response = self.http.request(Method::GET, relay).send().await?;
|
||||
let encrypted_token = response.bytes().await?;
|
||||
let token_bytes = decrypt(&encrypted_token, client_secret)?;
|
||||
let token = AuthToken::verify(&token_bytes)?;
|
||||
|
||||
if !token.capabilities().is_empty() {
|
||||
self.signin_with_authtoken(&token).await?;
|
||||
}
|
||||
|
||||
Ok(token.pubky().clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::*;
|
||||
|
||||
use pkarr::{mainline::Testnet, Keypair};
|
||||
use pubky_common::capabilities::{Capabilities, Capability};
|
||||
use pubky_homeserver::Homeserver;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn basic_authn() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let keypair = Keypair::random();
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
let session = client
|
||||
.session(&keypair.public_key())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(session.capabilities().contains(&Capability::root()));
|
||||
|
||||
client.signout(&keypair.public_key()).await.unwrap();
|
||||
|
||||
{
|
||||
let session = client.session(&keypair.public_key()).await.unwrap();
|
||||
|
||||
assert!(session.is_none());
|
||||
}
|
||||
|
||||
client.signin(&keypair).await.unwrap();
|
||||
|
||||
{
|
||||
let session = client
|
||||
.session(&keypair.public_key())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session.pubky(), &keypair.public_key());
|
||||
assert!(session.capabilities().contains(&Capability::root()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authz() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let keypair = Keypair::random();
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
// Third party app side
|
||||
let capabilities: Capabilities =
|
||||
"/pub/pubky.app/:rw,/pub/foo.bar/file:r".try_into().unwrap();
|
||||
let client = PubkyClient::test(&testnet);
|
||||
let (pubkyauth_url, pubkyauth_response) = client
|
||||
.auth_request("https://demo.httprelay.io/link", &capabilities)
|
||||
.unwrap();
|
||||
|
||||
// Authenticator side
|
||||
{
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
client
|
||||
.send_auth_token(&keypair, pubkyauth_url)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let public_key = pubkyauth_response.await.unwrap();
|
||||
|
||||
assert_eq!(&public_key, &pubky);
|
||||
|
||||
// Test access control enforcement
|
||||
|
||||
client
|
||||
.put(format!("pubky://{pubky}/pub/pubky.app/foo").as_str(), &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client
|
||||
.put(format!("pubky://{pubky}/pub/pubky.app").as_str(), &[])
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::Error::Reqwest(e) => e.status(),
|
||||
_ => None,
|
||||
}),
|
||||
Err(Some(StatusCode::FORBIDDEN))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
client
|
||||
.put(format!("pubky://{pubky}/pub/foo.bar/file").as_str(), &[])
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::Error::Reqwest(e) => e.status(),
|
||||
_ => None,
|
||||
}),
|
||||
Err(Some(StatusCode::FORBIDDEN))
|
||||
);
|
||||
}
|
||||
}
|
||||
105
pubky/pubky/src/shared/list_builder.rs
Normal file
105
pubky/pubky/src/shared/list_builder.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use reqwest::Method;
|
||||
use url::Url;
|
||||
|
||||
use crate::{error::Result, PubkyClient};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ListBuilder<'a> {
|
||||
url: Url,
|
||||
reverse: bool,
|
||||
limit: Option<u16>,
|
||||
cursor: Option<&'a str>,
|
||||
client: &'a PubkyClient,
|
||||
shallow: bool,
|
||||
}
|
||||
|
||||
impl<'a> ListBuilder<'a> {
|
||||
/// Create a new List request builder
|
||||
pub(crate) fn new(client: &'a PubkyClient, url: Url) -> Self {
|
||||
Self {
|
||||
client,
|
||||
url,
|
||||
limit: None,
|
||||
cursor: None,
|
||||
reverse: false,
|
||||
shallow: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the `reverse` option.
|
||||
pub fn reverse(mut self, reverse: bool) -> Self {
|
||||
self.reverse = reverse;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the `limit` value.
|
||||
pub fn limit(mut self, limit: u16) -> Self {
|
||||
self.limit = limit.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the `cursor` value.
|
||||
///
|
||||
/// Either a full `pubky://` Url (from previous list response),
|
||||
/// or a path (to a file or directory) relative to the `url`
|
||||
pub fn cursor(mut self, cursor: &'a str) -> Self {
|
||||
self.cursor = cursor.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shallow(mut self, shallow: bool) -> Self {
|
||||
self.shallow = shallow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Send the list request.
|
||||
///
|
||||
/// Returns a list of Pubky URLs of the files in the path of the `url`
|
||||
/// respecting [ListBuilder::reverse], [ListBuilder::limit] and [ListBuilder::cursor]
|
||||
/// options.
|
||||
pub async fn send(self) -> Result<Vec<String>> {
|
||||
let mut url = self.client.pubky_to_http(self.url).await?;
|
||||
|
||||
if !url.path().ends_with('/') {
|
||||
let path = url.path().to_string();
|
||||
let mut parts = path.split('/').collect::<Vec<&str>>();
|
||||
parts.pop();
|
||||
|
||||
let path = format!("{}/", parts.join("/"));
|
||||
|
||||
url.set_path(&path)
|
||||
}
|
||||
|
||||
let mut query = url.query_pairs_mut();
|
||||
|
||||
if self.reverse {
|
||||
query.append_key_only("reverse");
|
||||
}
|
||||
|
||||
if self.shallow {
|
||||
query.append_key_only("shallow");
|
||||
}
|
||||
|
||||
if let Some(limit) = self.limit {
|
||||
query.append_pair("limit", &limit.to_string());
|
||||
}
|
||||
|
||||
if let Some(cursor) = self.cursor {
|
||||
query.append_pair("cursor", cursor);
|
||||
}
|
||||
|
||||
drop(query);
|
||||
|
||||
let response = self.client.request(Method::GET, url).send().await?;
|
||||
|
||||
response.error_for_status_ref()?;
|
||||
|
||||
// TODO: bail on too large files.
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&bytes)
|
||||
.lines()
|
||||
.map(String::from)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
4
pubky/pubky/src/shared/mod.rs
Normal file
4
pubky/pubky/src/shared/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod list_builder;
|
||||
pub mod pkarr;
|
||||
pub mod public;
|
||||
336
pubky/pubky/src/shared/pkarr.rs
Normal file
336
pubky/pubky/src/shared/pkarr.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
use url::Url;
|
||||
|
||||
use pkarr::{
|
||||
dns::{rdata::SVCB, Packet},
|
||||
Keypair, PublicKey, SignedPacket,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
PubkyClient,
|
||||
};
|
||||
|
||||
const MAX_ENDPOINT_RESOLUTION_RECURSION: u8 = 3;
|
||||
|
||||
impl PubkyClient {
|
||||
/// Publish the SVCB record for `_pubky.<public_key>`.
|
||||
pub(crate) async fn publish_pubky_homeserver(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
host: &str,
|
||||
) -> Result<()> {
|
||||
let existing = self.pkarr_resolve(&keypair.public_key()).await?;
|
||||
|
||||
let mut packet = Packet::new_reply(0);
|
||||
|
||||
if let Some(existing) = existing {
|
||||
for answer in existing.packet().answers.iter().cloned() {
|
||||
if !answer.name.to_string().starts_with("_pubky") {
|
||||
packet.answers.push(answer.into_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let svcb = SVCB::new(0, host.try_into()?);
|
||||
|
||||
packet.answers.push(pkarr::dns::ResourceRecord::new(
|
||||
"_pubky".try_into().unwrap(),
|
||||
pkarr::dns::CLASS::IN,
|
||||
60 * 60,
|
||||
pkarr::dns::rdata::RData::SVCB(svcb),
|
||||
));
|
||||
|
||||
let signed_packet = SignedPacket::from_packet(keypair, &packet)?;
|
||||
|
||||
self.pkarr_publish(&signed_packet).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the homeserver for a pubky.
|
||||
pub(crate) async fn resolve_pubky_homeserver(&self, pubky: &PublicKey) -> Result<Endpoint> {
|
||||
let target = format!("_pubky.{pubky}");
|
||||
|
||||
self.resolve_endpoint(&target)
|
||||
.await
|
||||
.map_err(|_| Error::Generic("Could not resolve homeserver".to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a service's public_key and "non-pkarr url" from a Pubky domain
|
||||
///
|
||||
/// "non-pkarr" url is any URL where the hostname isn't a 52 z-base32 character,
|
||||
/// usually an IPv4, IPv6 or ICANN domain, but could also be any other unknown hostname.
|
||||
///
|
||||
/// Recursively resolve SVCB and HTTPS endpoints, with [MAX_ENDPOINT_RESOLUTION_RECURSION] limit.
|
||||
pub(crate) async fn resolve_endpoint(&self, target: &str) -> Result<Endpoint> {
|
||||
let original_target = target;
|
||||
// TODO: cache the result of this function?
|
||||
|
||||
let mut target = target.to_string();
|
||||
|
||||
let mut endpoint_public_key = None;
|
||||
let mut origin = target.clone();
|
||||
|
||||
let mut step = 0;
|
||||
|
||||
// PublicKey is very good at extracting the Pkarr TLD from a string.
|
||||
while let Ok(public_key) = PublicKey::try_from(target.clone()) {
|
||||
if step >= MAX_ENDPOINT_RESOLUTION_RECURSION {
|
||||
break;
|
||||
};
|
||||
step += 1;
|
||||
|
||||
if let Some(signed_packet) = self
|
||||
.pkarr_resolve(&public_key)
|
||||
.await
|
||||
.map_err(|_| Error::ResolveEndpoint(original_target.into()))?
|
||||
{
|
||||
// Choose most prior SVCB record
|
||||
let svcb = signed_packet.resource_records(&target).fold(
|
||||
None,
|
||||
|prev: Option<SVCB>, answer| {
|
||||
if let Some(svcb) = match &answer.rdata {
|
||||
pkarr::dns::rdata::RData::SVCB(svcb) => Some(svcb),
|
||||
pkarr::dns::rdata::RData::HTTPS(curr) => Some(&curr.0),
|
||||
_ => None,
|
||||
} {
|
||||
let curr = svcb.clone();
|
||||
|
||||
if curr.priority == 0 {
|
||||
return Some(curr);
|
||||
}
|
||||
if let Some(prev) = &prev {
|
||||
// TODO return random if priority is the same
|
||||
if curr.priority >= prev.priority {
|
||||
return Some(curr);
|
||||
}
|
||||
} else {
|
||||
return Some(curr);
|
||||
}
|
||||
}
|
||||
|
||||
prev
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(svcb) = svcb {
|
||||
endpoint_public_key = Some(public_key.clone());
|
||||
target = svcb.target.to_string();
|
||||
|
||||
if let Some(port) = svcb.get_param(pkarr::dns::rdata::SVCB::PORT) {
|
||||
if port.len() < 2 {
|
||||
// TODO: debug! Error encoding port!
|
||||
}
|
||||
let port = u16::from_be_bytes([port[0], port[1]]);
|
||||
|
||||
origin = format!("{target}:{port}");
|
||||
} else {
|
||||
origin.clone_from(&target);
|
||||
};
|
||||
|
||||
if step >= MAX_ENDPOINT_RESOLUTION_RECURSION {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if PublicKey::try_from(origin.as_str()).is_ok() {
|
||||
return Err(Error::ResolveEndpoint(original_target.into()));
|
||||
}
|
||||
|
||||
if endpoint_public_key.is_some() {
|
||||
let url = Url::parse(&format!(
|
||||
"{}://{}",
|
||||
if origin.starts_with("localhost") {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
},
|
||||
origin
|
||||
))?;
|
||||
|
||||
return Ok(Endpoint { url });
|
||||
}
|
||||
|
||||
Err(Error::ResolveEndpoint(original_target.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_url(&self, url: &mut Url) -> Result<()> {
|
||||
if let Some(Ok(pubky)) = url.host_str().map(PublicKey::try_from) {
|
||||
let Endpoint { url: x, .. } = self.resolve_endpoint(&format!("_pubky.{pubky}")).await?;
|
||||
|
||||
url.set_host(x.host_str())?;
|
||||
url.set_port(x.port()).expect("should work!");
|
||||
url.set_scheme(x.scheme()).expect("should work!");
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Endpoint {
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use pkarr::{
|
||||
dns::{
|
||||
rdata::{HTTPS, SVCB},
|
||||
Packet,
|
||||
},
|
||||
mainline::{dht::DhtSettings, Testnet},
|
||||
Keypair, PkarrClient, Settings, SignedPacket,
|
||||
};
|
||||
use pubky_homeserver::Homeserver;
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_endpoint_https() {
|
||||
let testnet = Testnet::new(10);
|
||||
|
||||
let pkarr_client = PkarrClient::new(Settings {
|
||||
dht: DhtSettings {
|
||||
bootstrap: Some(testnet.bootstrap.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
.as_async();
|
||||
|
||||
let domain = "example.com";
|
||||
let mut target;
|
||||
|
||||
// Server
|
||||
{
|
||||
let keypair = Keypair::random();
|
||||
|
||||
let https = HTTPS(SVCB::new(0, domain.try_into().unwrap()));
|
||||
|
||||
let mut packet = Packet::new_reply(0);
|
||||
|
||||
packet.answers.push(pkarr::dns::ResourceRecord::new(
|
||||
"foo".try_into().unwrap(),
|
||||
pkarr::dns::CLASS::IN,
|
||||
60 * 60,
|
||||
pkarr::dns::rdata::RData::HTTPS(https),
|
||||
));
|
||||
|
||||
let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap();
|
||||
|
||||
pkarr_client.publish(&signed_packet).await.unwrap();
|
||||
|
||||
target = format!("foo.{}", keypair.public_key());
|
||||
}
|
||||
|
||||
// intermediate
|
||||
{
|
||||
let keypair = Keypair::random();
|
||||
|
||||
let svcb = SVCB::new(0, target.as_str().try_into().unwrap());
|
||||
|
||||
let mut packet = Packet::new_reply(0);
|
||||
|
||||
packet.answers.push(pkarr::dns::ResourceRecord::new(
|
||||
"bar".try_into().unwrap(),
|
||||
pkarr::dns::CLASS::IN,
|
||||
60 * 60,
|
||||
pkarr::dns::rdata::RData::SVCB(svcb),
|
||||
));
|
||||
|
||||
let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap();
|
||||
|
||||
pkarr_client.publish(&signed_packet).await.unwrap();
|
||||
|
||||
target = format!("bar.{}", keypair.public_key())
|
||||
}
|
||||
|
||||
{
|
||||
let keypair = Keypair::random();
|
||||
|
||||
let svcb = SVCB::new(0, target.as_str().try_into().unwrap());
|
||||
|
||||
let mut packet = Packet::new_reply(0);
|
||||
|
||||
packet.answers.push(pkarr::dns::ResourceRecord::new(
|
||||
"pubky".try_into().unwrap(),
|
||||
pkarr::dns::CLASS::IN,
|
||||
60 * 60,
|
||||
pkarr::dns::rdata::RData::SVCB(svcb),
|
||||
));
|
||||
|
||||
let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap();
|
||||
|
||||
pkarr_client.publish(&signed_packet).await.unwrap();
|
||||
|
||||
target = format!("pubky.{}", keypair.public_key())
|
||||
}
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let endpoint = client.resolve_endpoint(&target).await.unwrap();
|
||||
|
||||
assert_eq!(endpoint.url.host_str().unwrap(), domain);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_homeserver() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
// Publish an intermediate controller of the homeserver
|
||||
let pkarr_client = PkarrClient::new(Settings {
|
||||
dht: DhtSettings {
|
||||
bootstrap: Some(testnet.bootstrap.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
.as_async();
|
||||
|
||||
let intermediate = Keypair::random();
|
||||
|
||||
let mut packet = Packet::new_reply(0);
|
||||
|
||||
let server_tld = server.public_key().to_string();
|
||||
|
||||
let svcb = SVCB::new(0, server_tld.as_str().try_into().unwrap());
|
||||
|
||||
packet.answers.push(pkarr::dns::ResourceRecord::new(
|
||||
"pubky".try_into().unwrap(),
|
||||
pkarr::dns::CLASS::IN,
|
||||
60 * 60,
|
||||
pkarr::dns::rdata::RData::SVCB(svcb),
|
||||
));
|
||||
|
||||
let signed_packet = SignedPacket::from_packet(&intermediate, &packet).unwrap();
|
||||
|
||||
pkarr_client.publish(&signed_packet).await.unwrap();
|
||||
|
||||
{
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let pubky = Keypair::random();
|
||||
|
||||
client
|
||||
.publish_pubky_homeserver(&pubky, &format!("pubky.{}", &intermediate.public_key()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let Endpoint { url, .. } = client
|
||||
.resolve_pubky_homeserver(&pubky.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(url.host_str(), Some("localhost"));
|
||||
assert_eq!(url.port(), Some(server.port()));
|
||||
}
|
||||
}
|
||||
}
|
||||
824
pubky/pubky/src/shared/public.rs
Normal file
824
pubky/pubky/src/shared/public.rs
Normal file
@@ -0,0 +1,824 @@
|
||||
use bytes::Bytes;
|
||||
|
||||
use pkarr::PublicKey;
|
||||
use reqwest::{Method, StatusCode};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
PubkyClient,
|
||||
};
|
||||
|
||||
use super::{list_builder::ListBuilder, pkarr::Endpoint};
|
||||
|
||||
impl PubkyClient {
|
||||
pub(crate) async fn inner_put<T: TryInto<Url>>(&self, url: T, content: &[u8]) -> Result<()> {
|
||||
let url = self.pubky_to_http(url).await?;
|
||||
|
||||
let response = self
|
||||
.request(Method::PUT, url)
|
||||
.body(content.to_owned())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
response.error_for_status()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn inner_get<T: TryInto<Url>>(&self, url: T) -> Result<Option<Bytes>> {
|
||||
let url = self.pubky_to_http(url).await?;
|
||||
|
||||
let response = self.request(Method::GET, url).send().await?;
|
||||
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
response.error_for_status_ref()?;
|
||||
|
||||
// TODO: bail on too large files.
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
pub(crate) async fn inner_delete<T: TryInto<Url>>(&self, url: T) -> Result<()> {
|
||||
let url = self.pubky_to_http(url).await?;
|
||||
|
||||
let response = self.request(Method::DELETE, url).send().await?;
|
||||
|
||||
response.error_for_status_ref()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn inner_list<T: TryInto<Url>>(&self, url: T) -> Result<ListBuilder> {
|
||||
Ok(ListBuilder::new(
|
||||
self,
|
||||
url.try_into().map_err(|_| Error::InvalidUrl)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn pubky_to_http<T: TryInto<Url>>(&self, url: T) -> Result<Url> {
|
||||
let original_url: Url = url.try_into().map_err(|_| Error::InvalidUrl)?;
|
||||
|
||||
let pubky = original_url
|
||||
.host_str()
|
||||
.ok_or(Error::Generic("Missing Pubky Url host".to_string()))?;
|
||||
|
||||
if let Ok(public_key) = PublicKey::try_from(pubky) {
|
||||
let Endpoint { mut url, .. } = self.resolve_pubky_homeserver(&public_key).await?;
|
||||
|
||||
// TODO: remove if we move to subdomains instead of paths.
|
||||
if original_url.scheme() == "pubky" {
|
||||
let path = original_url.path_segments();
|
||||
|
||||
let mut split = url.path_segments_mut().unwrap();
|
||||
split.push(pubky);
|
||||
if let Some(segments) = path {
|
||||
for segment in segments {
|
||||
split.push(segment);
|
||||
}
|
||||
}
|
||||
drop(split);
|
||||
}
|
||||
|
||||
return Ok(url);
|
||||
}
|
||||
|
||||
Ok(original_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use core::panic;
|
||||
|
||||
use crate::*;
|
||||
|
||||
use pkarr::{mainline::Testnet, Keypair};
|
||||
use pubky_homeserver::Homeserver;
|
||||
use reqwest::{Method, StatusCode};
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_delete() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let keypair = Keypair::random();
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
let url = format!("pubky://{}/pub/foo.txt", keypair.public_key());
|
||||
let url = url.as_str();
|
||||
|
||||
client.put(url, &[0, 1, 2, 3, 4]).await.unwrap();
|
||||
|
||||
let response = client.get(url).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4]));
|
||||
|
||||
client.delete(url).await.unwrap();
|
||||
|
||||
let response = client.get(url).await.unwrap();
|
||||
|
||||
assert_eq!(response, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_put_delete() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let keypair = Keypair::random();
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
let public_key = keypair.public_key();
|
||||
|
||||
let url = format!("pubky://{public_key}/pub/foo.txt");
|
||||
let url = url.as_str();
|
||||
|
||||
let other_client = PubkyClient::test(&testnet);
|
||||
{
|
||||
let other = Keypair::random();
|
||||
|
||||
// TODO: remove extra client after switching to subdomains.
|
||||
other_client
|
||||
.signup(&other, &server.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = other_client.put(url, &[0, 1, 2, 3, 4]).await;
|
||||
|
||||
match response {
|
||||
Err(Error::Reqwest(error)) => {
|
||||
assert!(error.status() == Some(StatusCode::UNAUTHORIZED))
|
||||
}
|
||||
_ => {
|
||||
panic!("expected error StatusCode::UNAUTHORIZED")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.put(url, &[0, 1, 2, 3, 4]).await.unwrap();
|
||||
|
||||
{
|
||||
let other = Keypair::random();
|
||||
|
||||
// TODO: remove extra client after switching to subdomains.
|
||||
other_client
|
||||
.signup(&other, &server.public_key())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = other_client.delete(url).await;
|
||||
|
||||
match response {
|
||||
Err(Error::Reqwest(error)) => {
|
||||
assert!(error.status() == Some(StatusCode::UNAUTHORIZED))
|
||||
}
|
||||
_ => {
|
||||
panic!("expected error StatusCode::UNAUTHORIZED")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = client.get(url).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let keypair = Keypair::random();
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
let urls = vec![
|
||||
format!("pubky://{pubky}/pub/a.wrong/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
|
||||
format!("pubky://{pubky}/pub/example.wrong/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/d.txt"),
|
||||
format!("pubky://{pubky}/pub/z.wrong/a.txt"),
|
||||
];
|
||||
|
||||
for url in urls {
|
||||
client.put(url.as_str(), &[0]).await.unwrap();
|
||||
}
|
||||
|
||||
let url = format!("pubky://{pubky}/pub/example.com/extra");
|
||||
let url = url.as_str();
|
||||
|
||||
{
|
||||
let list = client.list(url).unwrap().send().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/d.txt"),
|
||||
],
|
||||
"normal list with no limit or cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client.list(url).unwrap().limit(2).send().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
],
|
||||
"normal list with limit but no cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.limit(2)
|
||||
.cursor("a.txt")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
],
|
||||
"normal list with limit and a file cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.limit(2)
|
||||
.cursor("cc-nested/")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/d.txt"),
|
||||
],
|
||||
"normal list with limit and a directory cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.limit(2)
|
||||
.cursor(&format!("pubky://{pubky}/pub/example.com/a.txt"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
],
|
||||
"normal list with limit and a full url cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.limit(2)
|
||||
.cursor("/a.txt")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
],
|
||||
"normal list with limit and a leading / cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.reverse(true)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/d.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/a.txt"),
|
||||
],
|
||||
"reverse list with no limit or cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.reverse(true)
|
||||
.limit(2)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/d.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
|
||||
],
|
||||
"reverse list with limit but no cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.reverse(true)
|
||||
.limit(2)
|
||||
.cursor("d.txt")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
],
|
||||
"reverse list with limit and cursor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_shallow() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let keypair = Keypair::random();
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
let urls = vec![
|
||||
format!("pubky://{pubky}/pub/a.com/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/d.txt"),
|
||||
format!("pubky://{pubky}/pub/example.con/d.txt"),
|
||||
format!("pubky://{pubky}/pub/example.con"),
|
||||
format!("pubky://{pubky}/pub/file"),
|
||||
format!("pubky://{pubky}/pub/file2"),
|
||||
format!("pubky://{pubky}/pub/z.com/a.txt"),
|
||||
];
|
||||
|
||||
for url in urls {
|
||||
client.put(url.as_str(), &[0]).await.unwrap();
|
||||
}
|
||||
|
||||
let url = format!("pubky://{pubky}/pub/");
|
||||
let url = url.as_str();
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.shallow(true)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/a.com/"),
|
||||
format!("pubky://{pubky}/pub/example.com/"),
|
||||
format!("pubky://{pubky}/pub/example.con"),
|
||||
format!("pubky://{pubky}/pub/example.con/"),
|
||||
format!("pubky://{pubky}/pub/file"),
|
||||
format!("pubky://{pubky}/pub/file2"),
|
||||
format!("pubky://{pubky}/pub/z.com/"),
|
||||
],
|
||||
"normal list shallow"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.shallow(true)
|
||||
.limit(2)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/a.com/"),
|
||||
format!("pubky://{pubky}/pub/example.com/"),
|
||||
],
|
||||
"normal list shallow with limit but no cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.shallow(true)
|
||||
.limit(2)
|
||||
.cursor("example.com/a.txt")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.com/"),
|
||||
format!("pubky://{pubky}/pub/example.con"),
|
||||
],
|
||||
"normal list shallow with limit and a file cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.shallow(true)
|
||||
.limit(3)
|
||||
.cursor("example.com/")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.con"),
|
||||
format!("pubky://{pubky}/pub/example.con/"),
|
||||
format!("pubky://{pubky}/pub/file"),
|
||||
],
|
||||
"normal list shallow with limit and a directory cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.reverse(true)
|
||||
.shallow(true)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/z.com/"),
|
||||
format!("pubky://{pubky}/pub/file2"),
|
||||
format!("pubky://{pubky}/pub/file"),
|
||||
format!("pubky://{pubky}/pub/example.con/"),
|
||||
format!("pubky://{pubky}/pub/example.con"),
|
||||
format!("pubky://{pubky}/pub/example.com/"),
|
||||
format!("pubky://{pubky}/pub/a.com/"),
|
||||
],
|
||||
"reverse list shallow"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.reverse(true)
|
||||
.shallow(true)
|
||||
.limit(2)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/z.com/"),
|
||||
format!("pubky://{pubky}/pub/file2"),
|
||||
],
|
||||
"reverse list shallow with limit but no cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.shallow(true)
|
||||
.reverse(true)
|
||||
.limit(2)
|
||||
.cursor("file2")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/file"),
|
||||
format!("pubky://{pubky}/pub/example.con/"),
|
||||
],
|
||||
"reverse list shallow with limit and a file cursor"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let list = client
|
||||
.list(url)
|
||||
.unwrap()
|
||||
.shallow(true)
|
||||
.reverse(true)
|
||||
.limit(2)
|
||||
.cursor("example.con/")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![
|
||||
format!("pubky://{pubky}/pub/example.con"),
|
||||
format!("pubky://{pubky}/pub/example.com/"),
|
||||
],
|
||||
"reverse list shallow with limit and a directory cursor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_events() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let keypair = Keypair::random();
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
let urls = vec![
|
||||
format!("pubky://{pubky}/pub/a.com/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/a.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/c.txt"),
|
||||
format!("pubky://{pubky}/pub/example.com/d.txt"),
|
||||
format!("pubky://{pubky}/pub/example.con/d.txt"),
|
||||
format!("pubky://{pubky}/pub/example.con"),
|
||||
format!("pubky://{pubky}/pub/file"),
|
||||
format!("pubky://{pubky}/pub/file2"),
|
||||
format!("pubky://{pubky}/pub/z.com/a.txt"),
|
||||
];
|
||||
|
||||
for url in urls {
|
||||
client.put(url.as_str(), &[0]).await.unwrap();
|
||||
client.delete(url.as_str()).await.unwrap();
|
||||
}
|
||||
|
||||
let feed_url = format!("http://localhost:{}/events/", server.port());
|
||||
let feed_url = feed_url.as_str();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let cursor;
|
||||
|
||||
{
|
||||
let response = client
|
||||
.request(
|
||||
Method::GET,
|
||||
format!("{feed_url}?limit=10").as_str().try_into().unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = response.text().await.unwrap();
|
||||
let lines = text.split('\n').collect::<Vec<_>>();
|
||||
|
||||
cursor = lines.last().unwrap().split(" ").last().unwrap().to_string();
|
||||
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
format!("PUT pubky://{pubky}/pub/a.com/a.txt"),
|
||||
format!("DEL pubky://{pubky}/pub/a.com/a.txt"),
|
||||
format!("PUT pubky://{pubky}/pub/example.com/a.txt"),
|
||||
format!("DEL pubky://{pubky}/pub/example.com/a.txt"),
|
||||
format!("PUT pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("DEL pubky://{pubky}/pub/example.com/b.txt"),
|
||||
format!("PUT pubky://{pubky}/pub/example.com/c.txt"),
|
||||
format!("DEL pubky://{pubky}/pub/example.com/c.txt"),
|
||||
format!("PUT pubky://{pubky}/pub/example.com/d.txt"),
|
||||
format!("DEL pubky://{pubky}/pub/example.com/d.txt"),
|
||||
format!("cursor: {cursor}",)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let response = client
|
||||
.request(
|
||||
Method::GET,
|
||||
format!("{feed_url}?limit=10&cursor={cursor}")
|
||||
.as_str()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = response.text().await.unwrap();
|
||||
let lines = text.split('\n').collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
format!("PUT pubky://{pubky}/pub/example.con/d.txt"),
|
||||
format!("DEL pubky://{pubky}/pub/example.con/d.txt"),
|
||||
format!("PUT pubky://{pubky}/pub/example.con"),
|
||||
format!("DEL pubky://{pubky}/pub/example.con"),
|
||||
format!("PUT pubky://{pubky}/pub/file"),
|
||||
format!("DEL pubky://{pubky}/pub/file"),
|
||||
format!("PUT pubky://{pubky}/pub/file2"),
|
||||
format!("DEL pubky://{pubky}/pub/file2"),
|
||||
format!("PUT pubky://{pubky}/pub/z.com/a.txt"),
|
||||
format!("DEL pubky://{pubky}/pub/z.com/a.txt"),
|
||||
lines.last().unwrap().to_string()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_after_event() {
|
||||
let testnet = Testnet::new(10);
|
||||
let server = Homeserver::start_test(&testnet).await.unwrap();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let keypair = Keypair::random();
|
||||
|
||||
client.signup(&keypair, &server.public_key()).await.unwrap();
|
||||
|
||||
let pubky = keypair.public_key();
|
||||
|
||||
let url = format!("pubky://{pubky}/pub/a.com/a.txt");
|
||||
|
||||
client.put(url.as_str(), &[0]).await.unwrap();
|
||||
|
||||
let feed_url = format!("http://localhost:{}/events/", server.port());
|
||||
let feed_url = feed_url.as_str();
|
||||
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
{
|
||||
let response = client
|
||||
.request(
|
||||
Method::GET,
|
||||
format!("{feed_url}?limit=10").as_str().try_into().unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = response.text().await.unwrap();
|
||||
let lines = text.split('\n').collect::<Vec<_>>();
|
||||
|
||||
let cursor = lines.last().unwrap().split(" ").last().unwrap().to_string();
|
||||
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
format!("PUT pubky://{pubky}/pub/a.com/a.txt"),
|
||||
format!("cursor: {cursor}",)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
let get = client.get(url.as_str()).await.unwrap();
|
||||
dbg!(get);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dont_delete_shared_blobs() {
|
||||
let testnet = Testnet::new(10);
|
||||
let homeserver = Homeserver::start_test(&testnet).await.unwrap();
|
||||
let client = PubkyClient::test(&testnet);
|
||||
|
||||
let homeserver_pubky = homeserver.public_key();
|
||||
|
||||
let user_1 = Keypair::random();
|
||||
let user_2 = Keypair::random();
|
||||
|
||||
client.signup(&user_1, &homeserver_pubky).await.unwrap();
|
||||
client.signup(&user_2, &homeserver_pubky).await.unwrap();
|
||||
|
||||
let user_1_id = user_1.public_key();
|
||||
let user_2_id = user_2.public_key();
|
||||
|
||||
let url_1 = format!("pubky://{user_1_id}/pub/pubky.app/file/file_1");
|
||||
let url_2 = format!("pubky://{user_2_id}/pub/pubky.app/file/file_1");
|
||||
|
||||
let file = vec![1];
|
||||
client.put(url_1.as_str(), &file).await.unwrap();
|
||||
client.put(url_2.as_str(), &file).await.unwrap();
|
||||
|
||||
// Delete file 1
|
||||
client.delete(url_1.as_str()).await.unwrap();
|
||||
|
||||
let blob = client.get(url_2.as_str()).await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(blob, file);
|
||||
|
||||
let feed_url = format!("http://localhost:{}/events/", homeserver.port());
|
||||
|
||||
let response = client
|
||||
.request(
|
||||
Method::GET,
|
||||
format!("{feed_url}").as_str().try_into().unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = response.text().await.unwrap();
|
||||
let lines = text.split('\n').collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
format!("PUT pubky://{user_1_id}/pub/pubky.app/file/file_1",),
|
||||
format!("PUT pubky://{user_2_id}/pub/pubky.app/file/file_1",),
|
||||
format!("DEL pubky://{user_1_id}/pub/pubky.app/file/file_1",),
|
||||
lines.last().unwrap().to_string()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
250
pubky/pubky/src/wasm.rs
Normal file
250
pubky/pubky/src/wasm.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use js_sys::{Array, Uint8Array};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use pubky_common::capabilities::Capabilities;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::PubkyClient;
|
||||
|
||||
mod http;
|
||||
mod keys;
|
||||
mod pkarr;
|
||||
mod recovery_file;
|
||||
mod session;
|
||||
|
||||
use keys::{Keypair, PublicKey};
|
||||
use session::Session;
|
||||
|
||||
impl Default for PubkyClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
static DEFAULT_RELAYS: [&str; 1] = ["https://relay.pkarr.org"];
|
||||
static TESTNET_RELAYS: [&str; 1] = ["http://localhost:15411/pkarr"];
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl PubkyClient {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::builder().build().unwrap(),
|
||||
session_cookies: Arc::new(RwLock::new(HashSet::new())),
|
||||
pkarr_relays: DEFAULT_RELAYS.into_iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client with with configurations appropriate for local testing:
|
||||
/// - set Pkarr relays to `["http://localhost:15411/pkarr"]` instead of default relay.
|
||||
#[wasm_bindgen]
|
||||
pub fn testnet() -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::builder().build().unwrap(),
|
||||
session_cookies: Arc::new(RwLock::new(HashSet::new())),
|
||||
pkarr_relays: TESTNET_RELAYS.into_iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set Pkarr relays used for publishing and resolving Pkarr packets.
|
||||
///
|
||||
/// By default, [PubkyClient] will use `["https://relay.pkarr.org"]`
|
||||
#[wasm_bindgen(js_name = "setPkarrRelays")]
|
||||
pub fn set_pkarr_relays(mut self, relays: Vec<String>) -> Self {
|
||||
self.pkarr_relays = relays;
|
||||
self
|
||||
}
|
||||
|
||||
// Read the set of pkarr relays used by this client.
|
||||
#[wasm_bindgen(js_name = "getPkarrRelays")]
|
||||
pub fn get_pkarr_relays(&self) -> Vec<String> {
|
||||
self.pkarr_relays.clone()
|
||||
}
|
||||
|
||||
/// Signup to a homeserver and update Pkarr accordingly.
|
||||
///
|
||||
/// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key
|
||||
/// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"
|
||||
#[wasm_bindgen]
|
||||
pub async fn signup(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
homeserver: &PublicKey,
|
||||
) -> Result<Session, JsValue> {
|
||||
Ok(Session(
|
||||
self.inner_signup(keypair.as_inner(), homeserver.as_inner())
|
||||
.await
|
||||
.map_err(JsValue::from)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Check the current sesison for a given Pubky in its homeserver.
|
||||
///
|
||||
/// Returns [Session] or `None` (if recieved `404 NOT_FOUND`),
|
||||
/// or throws the recieved error if the response has any other `>=400` status code.
|
||||
#[wasm_bindgen]
|
||||
pub async fn session(&self, pubky: &PublicKey) -> Result<Option<Session>, JsValue> {
|
||||
self.inner_session(pubky.as_inner())
|
||||
.await
|
||||
.map(|s| s.map(Session))
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Signout from a homeserver.
|
||||
#[wasm_bindgen]
|
||||
pub async fn signout(&self, pubky: &PublicKey) -> Result<(), JsValue> {
|
||||
self.inner_signout(pubky.as_inner())
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Signin to a homeserver using the root Keypair.
|
||||
#[wasm_bindgen]
|
||||
pub async fn signin(&self, keypair: &Keypair) -> Result<(), JsValue> {
|
||||
self.inner_signin(keypair.as_inner())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Return `pubkyauth://` url and wait for the incoming [AuthToken]
|
||||
/// verifying that AuthToken, and if capabilities were requested, signing in to
|
||||
/// the Pubky's homeserver and returning the [Session] information.
|
||||
///
|
||||
/// Returns a tuple of [pubkyAuthUrl, Promise<Session>]
|
||||
#[wasm_bindgen(js_name = "authRequest")]
|
||||
pub fn auth_request(&self, relay: &str, capabilities: &str) -> Result<js_sys::Array, JsValue> {
|
||||
let mut relay: Url = relay
|
||||
.try_into()
|
||||
.map_err(|_| Error::Generic("Invalid relay Url".into()))?;
|
||||
|
||||
let (pubkyauth_url, client_secret) = self.create_auth_request(
|
||||
&mut relay,
|
||||
&Capabilities::try_from(capabilities).map_err(|_| "Invalid capaiblities")?,
|
||||
)?;
|
||||
|
||||
let this = self.clone();
|
||||
|
||||
let future = async move {
|
||||
this.subscribe_to_auth_response(relay, &client_secret)
|
||||
.await
|
||||
.map(|pubky| JsValue::from(PublicKey(pubky)))
|
||||
.map_err(|err| JsValue::from_str(&format!("{:?}", err)))
|
||||
};
|
||||
|
||||
let promise = wasm_bindgen_futures::future_to_promise(future);
|
||||
|
||||
// Return the URL and the promise
|
||||
let js_tuple = js_sys::Array::new();
|
||||
js_tuple.push(&JsValue::from_str(pubkyauth_url.as_ref()));
|
||||
js_tuple.push(&promise);
|
||||
|
||||
Ok(js_tuple)
|
||||
}
|
||||
|
||||
/// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the
|
||||
/// source of the pubkyauth request url.
|
||||
#[wasm_bindgen(js_name = "sendAuthToken")]
|
||||
pub async fn send_auth_token(
|
||||
&self,
|
||||
keypair: &Keypair,
|
||||
pubkyauth_url: &str,
|
||||
) -> Result<(), JsValue> {
|
||||
let pubkyauth_url: Url = pubkyauth_url
|
||||
.try_into()
|
||||
.map_err(|_| Error::Generic("Invalid relay Url".into()))?;
|
||||
|
||||
self.inner_send_auth_token(keypair.as_inner(), pubkyauth_url)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Public data ===
|
||||
|
||||
#[wasm_bindgen]
|
||||
/// Upload a small payload to a given path.
|
||||
pub async fn put(&self, url: &str, content: &[u8]) -> Result<(), JsValue> {
|
||||
self.inner_put(url, content).await.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Download a small payload from a given path relative to a pubky author.
|
||||
#[wasm_bindgen]
|
||||
pub async fn get(&self, url: &str) -> Result<Option<Uint8Array>, JsValue> {
|
||||
self.inner_get(url)
|
||||
.await
|
||||
.map(|b| b.map(|b| (&*b).into()))
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Delete a file at a path relative to a pubky author.
|
||||
#[wasm_bindgen]
|
||||
pub async fn delete(&self, url: &str) -> Result<(), JsValue> {
|
||||
self.inner_delete(url).await.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Returns a list of Pubky urls (as strings).
|
||||
///
|
||||
/// - `url`: The Pubky url (string) to the directory you want to list its content.
|
||||
/// - `cursor`: Either a full `pubky://` Url (from previous list response),
|
||||
/// or a path (to a file or directory) relative to the `url`
|
||||
/// - `reverse`: List in reverse order
|
||||
/// - `limit` Limit the number of urls in the response
|
||||
/// - `shallow`: List directories and files, instead of flat list of files.
|
||||
#[wasm_bindgen]
|
||||
pub async fn list(
|
||||
&self,
|
||||
url: &str,
|
||||
cursor: Option<String>,
|
||||
reverse: Option<bool>,
|
||||
limit: Option<u16>,
|
||||
shallow: Option<bool>,
|
||||
) -> Result<Array, JsValue> {
|
||||
// TODO: try later to return Vec<String> from async function.
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
return self
|
||||
.inner_list(url)?
|
||||
.reverse(reverse.unwrap_or(false))
|
||||
.limit(limit.unwrap_or(u16::MAX))
|
||||
.cursor(&cursor)
|
||||
.shallow(shallow.unwrap_or(false))
|
||||
.send()
|
||||
.await
|
||||
.map(|urls| {
|
||||
let js_array = Array::new();
|
||||
|
||||
for url in urls {
|
||||
js_array.push(&JsValue::from_str(&url));
|
||||
}
|
||||
|
||||
js_array
|
||||
})
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
|
||||
self.inner_list(url)?
|
||||
.reverse(reverse.unwrap_or(false))
|
||||
.limit(limit.unwrap_or(u16::MAX))
|
||||
.shallow(shallow.unwrap_or(false))
|
||||
.send()
|
||||
.await
|
||||
.map(|urls| {
|
||||
let js_array = Array::new();
|
||||
|
||||
for url in urls {
|
||||
js_array.push(&JsValue::from_str(&url));
|
||||
}
|
||||
|
||||
js_array
|
||||
})
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
40
pubky/pubky/src/wasm/http.rs
Normal file
40
pubky/pubky/src/wasm/http.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use crate::PubkyClient;
|
||||
|
||||
use reqwest::{Method, RequestBuilder, Response};
|
||||
use url::Url;
|
||||
|
||||
impl PubkyClient {
|
||||
pub(crate) fn request(&self, method: Method, url: Url) -> RequestBuilder {
|
||||
let mut request = self.http.request(method, url).fetch_credentials_include();
|
||||
|
||||
for cookie in self.session_cookies.read().unwrap().iter() {
|
||||
request = request.header("Cookie", cookie);
|
||||
}
|
||||
|
||||
request
|
||||
}
|
||||
|
||||
// Support cookies for nodejs
|
||||
|
||||
pub(crate) fn store_session(&self, response: &Response) {
|
||||
if let Some(cookie) = response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.split(';').next())
|
||||
{
|
||||
self.session_cookies
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(cookie.to_string());
|
||||
}
|
||||
}
|
||||
pub(crate) fn remove_session(&self, pubky: &pkarr::PublicKey) {
|
||||
let key = pubky.to_string();
|
||||
|
||||
self.session_cookies
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|cookie| !cookie.starts_with(&key));
|
||||
}
|
||||
}
|
||||
99
pubky/pubky/src/wasm/keys.rs
Normal file
99
pubky/pubky/src/wasm/keys.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Keypair(pkarr::Keypair);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Keypair {
|
||||
#[wasm_bindgen]
|
||||
/// Generate a random [Keypair]
|
||||
pub fn random() -> Self {
|
||||
Self(pkarr::Keypair::random())
|
||||
}
|
||||
|
||||
/// Generate a [Keypair] from a secret key.
|
||||
#[wasm_bindgen(js_name = "fromSecretKey")]
|
||||
pub fn from_secret_key(secret_key: js_sys::Uint8Array) -> Result<Keypair, JsValue> {
|
||||
if !js_sys::Uint8Array::instanceof(&secret_key) {
|
||||
return Err("Expected secret_key to be an instance of Uint8Array".into());
|
||||
}
|
||||
|
||||
let len = secret_key.byte_length();
|
||||
if len != 32 {
|
||||
return Err(format!("Expected secret_key to be 32 bytes, got {len}"))?;
|
||||
}
|
||||
|
||||
let mut bytes = [0; 32];
|
||||
secret_key.copy_to(&mut bytes);
|
||||
|
||||
Ok(Self(pkarr::Keypair::from_secret_key(&bytes)))
|
||||
}
|
||||
|
||||
/// Returns the secret key of this keypair.
|
||||
#[wasm_bindgen(js_name = "secretKey")]
|
||||
pub fn secret_key(&self) -> js_sys::Uint8Array {
|
||||
self.0.secret_key().as_slice().into()
|
||||
}
|
||||
|
||||
/// Returns the [PublicKey] of this keypair.
|
||||
#[wasm_bindgen(js_name = "publicKey")]
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
PublicKey(self.0.public_key())
|
||||
}
|
||||
}
|
||||
|
||||
impl Keypair {
|
||||
pub fn as_inner(&self) -> &pkarr::Keypair {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pkarr::Keypair> for Keypair {
|
||||
fn from(keypair: pkarr::Keypair) -> Self {
|
||||
Self(keypair)
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct PublicKey(pub(crate) pkarr::PublicKey);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl PublicKey {
|
||||
#[wasm_bindgen]
|
||||
/// Convert the PublicKey to Uint8Array
|
||||
pub fn to_uint8array(&self) -> js_sys::Uint8Array {
|
||||
js_sys::Uint8Array::from(self.0.as_bytes().as_slice())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
/// Returns the z-base32 encoding of this public key
|
||||
pub fn z32(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "from")]
|
||||
/// @throws
|
||||
pub fn try_from(value: JsValue) -> Result<PublicKey, JsValue> {
|
||||
let string = value
|
||||
.as_string()
|
||||
.ok_or("Couldn't create a PublicKey from this type of value")?;
|
||||
|
||||
Ok(PublicKey(
|
||||
pkarr::PublicKey::try_from(string).map_err(Error::Pkarr)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl PublicKey {
|
||||
pub fn as_inner(&self) -> &pkarr::PublicKey {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pkarr::PublicKey> for PublicKey {
|
||||
fn from(value: pkarr::PublicKey) -> Self {
|
||||
PublicKey(value)
|
||||
}
|
||||
}
|
||||
48
pubky/pubky/src/wasm/pkarr.rs
Normal file
48
pubky/pubky/src/wasm/pkarr.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use reqwest::StatusCode;
|
||||
|
||||
pub use pkarr::{PublicKey, SignedPacket};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::PubkyClient;
|
||||
|
||||
// TODO: Add an in memory cache of packets
|
||||
|
||||
impl PubkyClient {
|
||||
//TODO: migrate to pkarr::PkarrRelayClient
|
||||
pub(crate) async fn pkarr_resolve(
|
||||
&self,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<Option<SignedPacket>> {
|
||||
//TODO: Allow multiple relays in parallel
|
||||
let relay = self.pkarr_relays.first().expect("initialized with relays");
|
||||
|
||||
let res = self
|
||||
.http
|
||||
.get(format!("{relay}/{}", public_key))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// TODO: guard against too large responses.
|
||||
let bytes = res.bytes().await?;
|
||||
|
||||
let existing = SignedPacket::from_relay_payload(public_key, &bytes)?;
|
||||
|
||||
Ok(Some(existing))
|
||||
}
|
||||
|
||||
pub(crate) async fn pkarr_publish(&self, signed_packet: &SignedPacket) -> Result<()> {
|
||||
let relay = self.pkarr_relays.first().expect("initialized with relays");
|
||||
|
||||
self.http
|
||||
.put(format!("{relay}/{}", signed_packet.public_key()))
|
||||
.body(signed_packet.to_relay_payload())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
24
pubky/pubky/src/wasm/recovery_file.rs
Normal file
24
pubky/pubky/src/wasm/recovery_file.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use js_sys::Uint8Array;
|
||||
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::keys::Keypair;
|
||||
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
#[wasm_bindgen(js_name = "createRecoveryFile")]
|
||||
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Uint8Array, JsValue> {
|
||||
pubky_common::recovery_file::create_recovery_file(keypair.as_inner(), passphrase)
|
||||
.map(|b| b.as_slice().into())
|
||||
.map_err(|e| Error::from(e).into())
|
||||
}
|
||||
|
||||
/// Create a recovery file of the `keypair`, containing the secret key encrypted
|
||||
/// using the `passphrase`.
|
||||
#[wasm_bindgen(js_name = "decryptRecoveryFile")]
|
||||
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair, JsValue> {
|
||||
pubky_common::recovery_file::decrypt_recovery_file(recovery_file, passphrase)
|
||||
.map(Keypair::from)
|
||||
.map_err(|e| Error::from(e).into())
|
||||
}
|
||||
27
pubky/pubky/src/wasm/session.rs
Normal file
27
pubky/pubky/src/wasm/session.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use pubky_common::session;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::keys::PublicKey;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Session(pub(crate) session::Session);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Session {
|
||||
/// Return the [PublicKey] of this session
|
||||
#[wasm_bindgen]
|
||||
pub fn pubky(&self) -> PublicKey {
|
||||
self.0.pubky().clone().into()
|
||||
}
|
||||
|
||||
/// Return the capabilities that this session has.
|
||||
#[wasm_bindgen]
|
||||
pub fn capabilities(&self) -> Vec<String> {
|
||||
self.0
|
||||
.capabilities()
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
80
src/auth.rs
Normal file
80
src/auth.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use crate::keypair::get_keypair_from_secret_key;
|
||||
use crate::{PubkyAuthDetails, Capability};
|
||||
use crate::utils::create_response_vector;
|
||||
use std::collections::HashMap;
|
||||
use pubky::PubkyClient;
|
||||
use serde_json;
|
||||
use url::Url;
|
||||
|
||||
pub async fn authorize(url: String, secret_key: String) -> Vec<String> {
|
||||
let client = PubkyClient::testnet();
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
|
||||
let parsed_url = match Url::parse(&url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
|
||||
match client.send_auth_token(&keypair, parsed_url).await {
|
||||
Ok(_) => create_response_vector(false, "send_auth_token success".to_string()),
|
||||
Err(error) => create_response_vector(true, format!("send_auth_token failure: {}", error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pubky_auth_details_to_json(details: &PubkyAuthDetails) -> Result<String, String> {
|
||||
serde_json::to_string(details).map_err(|_| "Error serializing to JSON".to_string())
|
||||
}
|
||||
|
||||
pub fn parse_pubky_auth_url(url_str: &str) -> Result<PubkyAuthDetails, String> {
|
||||
let url = Url::parse(url_str).map_err(|_| "Invalid URL".to_string())?;
|
||||
|
||||
if url.scheme() != "pubkyauth" {
|
||||
return Err("Invalid scheme, expected 'pubkyauth'".to_string());
|
||||
}
|
||||
|
||||
// Collect query pairs into a HashMap for efficient access
|
||||
let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
|
||||
let relay = query_params
|
||||
.get("relay")
|
||||
.cloned()
|
||||
.ok_or_else(|| "Missing relay".to_string())?;
|
||||
|
||||
let capabilities_str = query_params
|
||||
.get("capabilities")
|
||||
.or_else(|| query_params.get("caps"))
|
||||
.cloned()
|
||||
.ok_or_else(|| "Missing capabilities".to_string())?;
|
||||
|
||||
let secret = query_params
|
||||
.get("secret")
|
||||
.cloned()
|
||||
.ok_or_else(|| "Missing secret".to_string())?;
|
||||
|
||||
// Parse capabilities
|
||||
let capabilities = capabilities_str
|
||||
.split(',')
|
||||
.map(|capability| {
|
||||
let mut parts = capability.splitn(2, ':');
|
||||
let path = parts
|
||||
.next()
|
||||
.ok_or_else(|| format!("Invalid capability format in '{}'", capability))?;
|
||||
let permission = parts
|
||||
.next()
|
||||
.ok_or_else(|| format!("Invalid capability format in '{}'", capability))?;
|
||||
Ok(Capability {
|
||||
path: path.to_string(),
|
||||
permission: permission.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
|
||||
Ok(PubkyAuthDetails {
|
||||
relay,
|
||||
capabilities,
|
||||
secret,
|
||||
})
|
||||
}
|
||||
3
src/bin/uniffi-bindgen.rs
Normal file
3
src/bin/uniffi-bindgen.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main()
|
||||
}
|
||||
34
src/keypair.rs
Normal file
34
src/keypair.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use pkarr::Keypair;
|
||||
|
||||
/**
|
||||
* Get a keypair from a secret key
|
||||
*/
|
||||
pub fn get_keypair_from_secret_key(secret_key: &str) -> Result<Keypair, String> {
|
||||
let bytes = match hex::decode(&secret_key) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return Err("Failed to decode secret key".to_string())
|
||||
};
|
||||
|
||||
let secret_key_bytes: [u8; 32] = match bytes.try_into() {
|
||||
Ok(secret_key) => secret_key,
|
||||
Err(_) => {
|
||||
return Err("Failed to convert secret key to 32-byte array".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Keypair::from_secret_key(&secret_key_bytes))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the secret key from a keypair
|
||||
*/
|
||||
pub fn get_secret_key_from_keypair(keypair: &Keypair) -> String {
|
||||
hex::encode(keypair.secret_key())
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new keypair
|
||||
*/
|
||||
pub fn generate_keypair() -> Keypair {
|
||||
Keypair::random()
|
||||
}
|
||||
629
src/lib.rs
Normal file
629
src/lib.rs
Normal file
@@ -0,0 +1,629 @@
|
||||
mod types;
|
||||
mod keypair;
|
||||
mod auth;
|
||||
mod utils;
|
||||
|
||||
pub use types::*;
|
||||
pub use keypair::*;
|
||||
pub use auth::*;
|
||||
pub use utils::*;
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
use std::str;
|
||||
use std::collections::HashMap;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose;
|
||||
use pubky::PubkyClient;
|
||||
use hex;
|
||||
use hex::ToHex;
|
||||
use serde::Serialize;
|
||||
use url::Url;
|
||||
use tokio;
|
||||
use pkarr::{PkarrClient, SignedPacket, Keypair, dns, PublicKey};
|
||||
use pkarr::dns::rdata::{RData, HTTPS, SVCB};
|
||||
use pkarr::dns::{Packet, ResourceRecord};
|
||||
use serde_json::json;
|
||||
use utils::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use pkarr::bytes::Bytes;
|
||||
use pubky_common::session::Session;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::time;
|
||||
|
||||
static PUBKY_CLIENT: Lazy<Arc<PubkyClient>> = Lazy::new(|| {
|
||||
Arc::new(PubkyClient::testnet())
|
||||
});
|
||||
|
||||
static TOKIO_RUNTIME: Lazy<Arc<Runtime>> = Lazy::new(|| {
|
||||
Arc::new(
|
||||
Runtime::new().expect("Failed to create Tokio runtime")
|
||||
)
|
||||
});
|
||||
|
||||
// Define the EventListener trait
|
||||
#[uniffi::export(callback_interface)]
|
||||
pub trait EventListener: Send + Sync {
|
||||
fn on_event_occurred(&self, event_data: String);
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct EventNotifier {
|
||||
listener: Arc<Mutex<Option<Box<dyn EventListener>>>>,
|
||||
}
|
||||
|
||||
impl EventNotifier {
|
||||
#[uniffi::constructor]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
listener: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_listener(&self, listener: Box<dyn EventListener>) {
|
||||
let mut lock = self.listener.lock().unwrap();
|
||||
*lock = Some(listener);
|
||||
}
|
||||
|
||||
pub fn remove_listener(&self) {
|
||||
let mut lock = self.listener.lock().unwrap();
|
||||
*lock = None;
|
||||
}
|
||||
|
||||
pub fn notify_event(&self, event_data: String) {
|
||||
let lock = self.listener.lock().unwrap();
|
||||
if let Some(listener) = &*lock {
|
||||
listener.on_event_occurred(event_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static EVENT_NOTIFIER: Lazy<Arc<EventNotifier>> = Lazy::new(|| {
|
||||
Arc::new(EventNotifier::new())
|
||||
});
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn set_event_listener(listener: Box<dyn EventListener>) {
|
||||
EVENT_NOTIFIER.as_ref().set_listener(listener);
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn remove_event_listener() {
|
||||
EVENT_NOTIFIER.as_ref().remove_listener();
|
||||
}
|
||||
|
||||
pub fn start_internal_event_loop() {
|
||||
let event_notifier = EVENT_NOTIFIER.clone();
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.spawn(async move {
|
||||
let mut interval = time::interval(Duration::from_secs(2));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
event_notifier.as_ref().notify_event("Internal event triggered".to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn delete_file(url: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let parsed_url = match Url::parse(&url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
match client.delete(parsed_url).await {
|
||||
Ok(_) => create_response_vector(false, "Deleted successfully".to_string()),
|
||||
Err(error) => create_response_vector(true, format!("Failed to delete: {}", error)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn session(pubky: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let public_key = match PublicKey::try_from(pubky) {
|
||||
Ok(key) => key,
|
||||
Err(error) => return create_response_vector(true, format!("Invalid homeserver public key: {}", error)),
|
||||
};
|
||||
let result = match client.session(&public_key).await {
|
||||
Ok(session) => session,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to get session: {}", error)),
|
||||
};
|
||||
let session: Session = match result {
|
||||
Some(session) => session,
|
||||
None => return create_response_vector(true, "No session returned".to_string()),
|
||||
};
|
||||
|
||||
let json_obj = json!({
|
||||
"pubky": session.pubky().to_string(),
|
||||
"capabilities": session.capabilities().iter().map(|c| c.to_string()).collect::<Vec<String>>(),
|
||||
});
|
||||
|
||||
let json_str = match serde_json::to_string(&json_obj) {
|
||||
Ok(json) => json,
|
||||
Err(e) => return create_response_vector(true, format!("Failed to serialize JSON: {}", e)),
|
||||
};
|
||||
|
||||
create_response_vector(false, json_str)
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn generate_secret_key() -> Vec<String> {
|
||||
let keypair = generate_keypair();
|
||||
let secret_key = get_secret_key_from_keypair(&keypair);
|
||||
let public_key = keypair.public_key();
|
||||
let uri = public_key.to_uri_string();
|
||||
let json_obj = json!({
|
||||
"secret_key": secret_key,
|
||||
"public_key": public_key.to_string(),
|
||||
"uri": uri,
|
||||
});
|
||||
|
||||
let json_str = match serde_json::to_string(&json_obj) {
|
||||
Ok(json) => json,
|
||||
Err(e) => return create_response_vector(true, format!("Failed to serialize JSON: {}", e)),
|
||||
};
|
||||
start_internal_event_loop();
|
||||
create_response_vector(false, json_str)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn get_public_key_from_secret_key(secret_key: String) -> Vec<String> {
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
let public_key = keypair.public_key();
|
||||
let uri = public_key.to_uri_string();
|
||||
let json_obj = json!({
|
||||
"public_key": public_key.to_string(),
|
||||
"uri": uri,
|
||||
});
|
||||
|
||||
let json_str = match serde_json::to_string(&json_obj) {
|
||||
Ok(json) => json,
|
||||
Err(e) => return create_response_vector(true, format!("Failed to serialize JSON: {}", e)),
|
||||
};
|
||||
create_response_vector(false, json_str)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn publish_https(record_name: String, target: String, secret_key: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
|
||||
// Create SVCB record with the target domain
|
||||
let target = match target.as_str().try_into() {
|
||||
Ok(target) => target,
|
||||
Err(e) => return create_response_vector(true, format!("Invalid target: {}", e)),
|
||||
};
|
||||
let svcb = SVCB::new(0, target);
|
||||
|
||||
// Create HTTPS record
|
||||
let https_record = HTTPS(svcb);
|
||||
|
||||
// Create DNS packet
|
||||
let mut packet = Packet::new_reply(0);
|
||||
let dns_name = match dns::Name::new(&record_name) {
|
||||
Ok(name) => name,
|
||||
Err(e) => return create_response_vector(true, format!("Invalid DNS name: {}", e)),
|
||||
};
|
||||
|
||||
packet.answers.push(ResourceRecord::new(
|
||||
dns_name,
|
||||
dns::CLASS::IN,
|
||||
3600, // TTL in seconds
|
||||
dns::rdata::RData::HTTPS(https_record),
|
||||
));
|
||||
|
||||
let signed_packet = match SignedPacket::from_packet(&keypair, &packet) {
|
||||
Ok(signed_packet) => signed_packet,
|
||||
Err(e) => return create_response_vector(true, format!("Failed to create signed packet: {}", e)),
|
||||
};
|
||||
|
||||
match client.pkarr().publish(&signed_packet).await {
|
||||
Ok(()) => create_response_vector(false, keypair.public_key().to_string()),
|
||||
Err(e) => create_response_vector(true, format!("Failed to publish: {}", e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn resolve_https(public_key: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let public_key = match public_key.as_str().try_into() {
|
||||
Ok(key) => key,
|
||||
Err(e) => return create_response_vector(true, format!("Invalid public key: {}", e)),
|
||||
};
|
||||
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
|
||||
match client.pkarr().resolve(&public_key).await {
|
||||
Ok(Some(signed_packet)) => {
|
||||
// Extract HTTPS records from the signed packet
|
||||
let https_records: Vec<serde_json::Value> = signed_packet.packet().answers.iter()
|
||||
.filter_map(|record| {
|
||||
if let dns::rdata::RData::HTTPS(https) = &record.rdata {
|
||||
// Create a JSON object
|
||||
let mut https_json = serde_json::json!({
|
||||
"name": record.name.to_string(),
|
||||
"class": format!("{:?}", record.class),
|
||||
"ttl": record.ttl,
|
||||
"priority": https.0.priority,
|
||||
"target": https.0.target.to_string(),
|
||||
});
|
||||
|
||||
// Access specific parameters using the constants from SVCB
|
||||
if let Some(port_param) = https.0.get_param(SVCB::PORT) {
|
||||
if port_param.len() == 2 {
|
||||
let port = u16::from_be_bytes([port_param[0], port_param[1]]);
|
||||
https_json["port"] = serde_json::json!(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Access ALPN parameter if needed
|
||||
if let Some(alpn_param) = https.0.get_param(SVCB::ALPN) {
|
||||
// Parse ALPN protocols (list of character strings)
|
||||
let mut position = 0;
|
||||
let mut alpn_protocols = Vec::new();
|
||||
while position < alpn_param.len() {
|
||||
let length = alpn_param[position] as usize;
|
||||
position += 1;
|
||||
if position + length <= alpn_param.len() {
|
||||
let protocol = String::from_utf8_lossy(
|
||||
&alpn_param[position..position + length],
|
||||
);
|
||||
alpn_protocols.push(protocol.to_string());
|
||||
position += length;
|
||||
} else {
|
||||
break; // Malformed ALPN parameter
|
||||
}
|
||||
}
|
||||
https_json["alpn"] = serde_json::json!(alpn_protocols);
|
||||
}
|
||||
// TODO: Add other parameters as needed.
|
||||
Some(https_json)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if https_records.is_empty() {
|
||||
return create_response_vector(true, "No HTTPS records found".to_string());
|
||||
}
|
||||
|
||||
// Create JSON response
|
||||
let json_obj = json!({
|
||||
"public_key": public_key.to_string(),
|
||||
"https_records": https_records,
|
||||
"last_seen": signed_packet.last_seen(),
|
||||
"timestamp": signed_packet.timestamp(),
|
||||
});
|
||||
|
||||
let json_str = match serde_json::to_string(&json_obj) {
|
||||
Ok(json) => json,
|
||||
Err(e) => return create_response_vector(true, format!("Failed to serialize JSON: {}", e)),
|
||||
};
|
||||
|
||||
create_response_vector(false, json_str)
|
||||
},
|
||||
Ok(None) => create_response_vector(true, "No signed packet found".to_string()),
|
||||
Err(e) => create_response_vector(true, format!("Failed to resolve: {}", e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn sign_up(secret_key: String, homeserver: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
|
||||
let homeserver_public_key = match PublicKey::try_from(homeserver) {
|
||||
Ok(key) => key,
|
||||
Err(error) => return create_response_vector(true, format!("Invalid homeserver public key: {}", error)),
|
||||
};
|
||||
|
||||
match client.signup(&keypair, &homeserver_public_key).await {
|
||||
Ok(session) => create_response_vector(false, session.pubky().to_string()),
|
||||
Err(error) => create_response_vector(true, format!("signup failure: {}", error)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn sign_in(secret_key: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
match client.signin(&keypair).await {
|
||||
Ok(_) => create_response_vector(false, "Sign in success".to_string()),
|
||||
Err(error) => {
|
||||
create_response_vector(true, format!("Failed to sign in: {}", error))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn sign_out(secret_key: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
match client.signout(&keypair.public_key()).await {
|
||||
Ok(_) => create_response_vector(false, "Sign out success".to_string()),
|
||||
Err(error) => {
|
||||
create_response_vector(true, format!("Failed to sign out: {}", error))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn put(url: String, content: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let trimmed_url = url.trim_end_matches('/');
|
||||
let parsed_url = match Url::parse(&trimmed_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
match client.put(parsed_url, &content.as_bytes()).await {
|
||||
Ok(_) => create_response_vector(false, trimmed_url.to_string()),
|
||||
Err(error) => {
|
||||
create_response_vector(true, format!("Failed to put: {}", error))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn get(url: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let trimmed_url = url.trim_end_matches('/');
|
||||
let parsed_url = match Url::parse(&trimmed_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
let result: Option<Bytes> = match client.get(parsed_url).await {
|
||||
Ok(res) => res,
|
||||
Err(_) => return create_response_vector(true, "Request failed".to_string()),
|
||||
};
|
||||
let bytes = match result {
|
||||
Some(bytes) => bytes,
|
||||
None => return create_response_vector(true, "No data returned".to_string()),
|
||||
};
|
||||
let string = match str::from_utf8(&bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => return create_response_vector(true, "Invalid UTF-8 sequence".to_string()),
|
||||
};
|
||||
create_response_vector(false, string)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a signed packet from a public key
|
||||
* @param public_key The public key to resolve
|
||||
* @returns A vector with two elements: the first element is a boolean indicating success or failure,
|
||||
* and the second element is the response data (either an error message or the resolved signed packet)
|
||||
**/
|
||||
#[uniffi::export]
|
||||
pub fn resolve(public_key: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let public_key = match public_key.as_str().try_into() {
|
||||
Ok(key) => key,
|
||||
Err(e) => return create_response_vector(true, format!("Invalid zbase32 encoded key: {}", e)),
|
||||
};
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
|
||||
match client.pkarr().resolve(&public_key).await {
|
||||
Ok(Some(signed_packet)) => {
|
||||
// Collect references to ResourceRecords from the signed packet's answers
|
||||
let all_records: Vec<&ResourceRecord> = signed_packet.packet().answers.iter().collect();
|
||||
// Convert each ResourceRecord to a JSON value, handling errors appropriately
|
||||
let json_records: Vec<serde_json::Value> = all_records
|
||||
.iter()
|
||||
.filter_map(|record| {
|
||||
match resource_record_to_json(record) {
|
||||
Ok(json_value) => Some(json_value),
|
||||
Err(e) => {
|
||||
eprintln!("Error converting record to JSON: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let bytes = signed_packet.as_bytes();
|
||||
let public_key = &bytes[..32];
|
||||
let signature = &bytes[32..96];
|
||||
let timestamp = signed_packet.timestamp();
|
||||
let dns_packet = &bytes[104..];
|
||||
let hex: String = signed_packet.encode_hex();
|
||||
|
||||
let json_obj = json!({
|
||||
"signed_packet": hex,
|
||||
"public_key": general_purpose::STANDARD.encode(public_key),
|
||||
"signature": general_purpose::STANDARD.encode(signature),
|
||||
"timestamp": timestamp,
|
||||
"last_seen": signed_packet.last_seen(),
|
||||
"dns_packet": general_purpose::STANDARD.encode(dns_packet),
|
||||
"records": json_records
|
||||
});
|
||||
|
||||
let json_str = serde_json::to_string(&json_obj)
|
||||
.expect("Failed to convert JSON object to string");
|
||||
|
||||
create_response_vector(false, json_str)
|
||||
},
|
||||
Ok(None) => {
|
||||
create_response_vector(true, "No signed packet found".to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
create_response_vector(true, format!("Failed to resolve: {}", e))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn publish(record_name: String, record_content: String, secret_key: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
|
||||
let mut packet = dns::Packet::new_reply(0);
|
||||
|
||||
let dns_name = match dns::Name::new(&record_name) {
|
||||
Ok(name) => name,
|
||||
Err(e) => return create_response_vector(true, format!("Failed to create DNS name: {}", e)),
|
||||
};
|
||||
|
||||
let record_content_str: &str = record_content.as_str();
|
||||
|
||||
let txt_record = match record_content_str.try_into() {
|
||||
Ok(value) => RData::TXT(value),
|
||||
Err(e) => {
|
||||
return create_response_vector(true, format!("Failed to convert string to TXT record: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
packet.answers.push(dns::ResourceRecord::new(
|
||||
dns_name,
|
||||
dns::CLASS::IN,
|
||||
30,
|
||||
txt_record,
|
||||
));
|
||||
|
||||
match SignedPacket::from_packet(&keypair, &packet) {
|
||||
Ok(signed_packet) => {
|
||||
match client.pkarr().publish(&signed_packet).await {
|
||||
Ok(()) => {
|
||||
create_response_vector(false, keypair.public_key().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
create_response_vector(true, format!("Failed to publish: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
create_response_vector(true, format!("Failed to create signed packet: {}", e))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
#[uniffi::export]
|
||||
pub fn list(url: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(async {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let trimmed_url = url.trim_end_matches('/');
|
||||
let parsed_url = match Url::parse(&trimmed_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
let list_builder = match client.list(parsed_url) {
|
||||
Ok(list) => list,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to list: {}", error)),
|
||||
};
|
||||
// Execute the non-Send part synchronously
|
||||
let send_future = list_builder.send();
|
||||
let send_res = match send_future.await {
|
||||
Ok(res) => res,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to send list request: {}", error))
|
||||
};
|
||||
let json_string = match serde_json::to_string(&send_res) {
|
||||
Ok(json) => json,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to serialize JSON: {}", error)),
|
||||
};
|
||||
create_response_vector(false, json_string)
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn auth(url: String, secret_key: String) -> Vec<String> {
|
||||
let runtime = TOKIO_RUNTIME.clone();
|
||||
runtime.block_on(authorize(url, secret_key))
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn parse_auth_url(url: String) -> Vec<String> {
|
||||
let parsed_details = match parse_pubky_auth_url(&url) {
|
||||
Ok(details) => details,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
match pubky_auth_details_to_json(&parsed_details) {
|
||||
Ok(json) => create_response_vector(false, json),
|
||||
Err(error) => create_response_vector(true, error),
|
||||
}
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn create_recovery_file(secret_key: String, passphrase: String,) -> Vec<String> {
|
||||
if secret_key.is_empty() || passphrase.is_empty() {
|
||||
return create_response_vector(true, "Secret key and passphrase must not be empty".to_string());
|
||||
}
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
let recovery_file_bytes = match PubkyClient::create_recovery_file(&keypair, &passphrase) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return create_response_vector(true, "Failed to create recovery file".to_string()),
|
||||
};
|
||||
let recovery_file = base64::encode(&recovery_file_bytes);
|
||||
create_response_vector(false, recovery_file)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn decrypt_recovery_file(recovery_file: String, passphrase: String) -> Vec<String> {
|
||||
if recovery_file.is_empty() || passphrase.is_empty() {
|
||||
return create_response_vector(true, "Recovery file and passphrase must not be empty".to_string());
|
||||
}
|
||||
let recovery_file_bytes = match base64::decode(&recovery_file) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to decode recovery file: {}", error)),
|
||||
};
|
||||
let keypair = match PubkyClient::decrypt_recovery_file(&recovery_file_bytes, &passphrase) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, "Failed to decrypt recovery file".to_string()),
|
||||
};
|
||||
let secret_key = get_secret_key_from_keypair(&keypair);
|
||||
create_response_vector(false, secret_key)
|
||||
}
|
||||
14
src/types.rs
Normal file
14
src/types.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Capability {
|
||||
pub path: String,
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PubkyAuthDetails {
|
||||
pub relay: String,
|
||||
pub capabilities: Vec<Capability>,
|
||||
pub secret: String,
|
||||
}
|
||||
244
src/utils.rs
Normal file
244
src/utils.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use std::error::Error;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use serde_json::json;
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use pkarr::dns::rdata::RData;
|
||||
use pkarr::dns::ResourceRecord;
|
||||
|
||||
pub fn create_response_vector(error: bool, data: String) -> Vec<String> {
|
||||
if error {
|
||||
vec!["error".to_string(), data]
|
||||
} else {
|
||||
vec!["success".to_string(), data]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_rdata_for_json(record: &ResourceRecord) -> serde_json::Value {
|
||||
match &record.rdata {
|
||||
RData::TXT(txt) => {
|
||||
let attributes = txt.attributes();
|
||||
let strings: Vec<String> = attributes.into_iter()
|
||||
.map(|(key, value)| {
|
||||
match value {
|
||||
Some(v) => format!("{}={}", key, v),
|
||||
None => key,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"type": "TXT",
|
||||
"strings": strings
|
||||
})
|
||||
},
|
||||
RData::A(a) => {
|
||||
let ipv4 = Ipv4Addr::from(a.address);
|
||||
json!({
|
||||
"type": "A",
|
||||
"address": ipv4.to_string()
|
||||
})
|
||||
},
|
||||
RData::AAAA(aaaa) => {
|
||||
let ipv6 = Ipv6Addr::from(aaaa.address);
|
||||
json!({
|
||||
"type": "AAAA",
|
||||
"address": ipv6.to_string()
|
||||
})
|
||||
},
|
||||
RData::AFSDB(afsdb) => {
|
||||
json!({
|
||||
"type": "AFSDB",
|
||||
"subtype": afsdb.subtype,
|
||||
"hostname": afsdb.hostname.to_string()
|
||||
})
|
||||
},
|
||||
RData::CAA(caa) => {
|
||||
json!({
|
||||
"type": "CAA",
|
||||
"flag": caa.flag,
|
||||
"tag": caa.tag.to_string(),
|
||||
"value": caa.value.to_string()
|
||||
})
|
||||
},
|
||||
RData::HINFO(hinfo) => {
|
||||
json!({
|
||||
"type": "HINFO",
|
||||
"cpu": hinfo.cpu.to_string(),
|
||||
"os": hinfo.os.to_string()
|
||||
})
|
||||
},
|
||||
RData::ISDN(isdn) => {
|
||||
json!({
|
||||
"type": "ISDN",
|
||||
"address": isdn.address.to_string(),
|
||||
"sa": isdn.sa.to_string()
|
||||
})
|
||||
},
|
||||
RData::LOC(loc) => {
|
||||
json!({
|
||||
"type": "LOC",
|
||||
"version": loc.version,
|
||||
"size": loc.size,
|
||||
"horizontal_precision": loc.horizontal_precision,
|
||||
"vertical_precision": loc.vertical_precision,
|
||||
"latitude": loc.latitude,
|
||||
"longitude": loc.longitude,
|
||||
"altitude": loc.altitude
|
||||
})
|
||||
},
|
||||
RData::MINFO(minfo) => {
|
||||
json!({
|
||||
"type": "MINFO",
|
||||
"rmailbox": minfo.rmailbox.to_string(),
|
||||
"emailbox": minfo.emailbox.to_string()
|
||||
})
|
||||
},
|
||||
RData::MX(mx) => {
|
||||
json!({
|
||||
"type": "MX",
|
||||
"preference": mx.preference,
|
||||
"exchange": mx.exchange.to_string()
|
||||
})
|
||||
},
|
||||
RData::NAPTR(naptr) => {
|
||||
json!({
|
||||
"type": "NAPTR",
|
||||
"order": naptr.order,
|
||||
"preference": naptr.preference,
|
||||
"flags": naptr.flags.to_string(),
|
||||
"services": naptr.services.to_string(),
|
||||
"regexp": naptr.regexp.to_string(),
|
||||
"replacement": naptr.replacement.to_string()
|
||||
})
|
||||
},
|
||||
RData::NULL(_, null_record) => {
|
||||
json!({
|
||||
"type": "NULL",
|
||||
"data": base64::encode(null_record.get_data())
|
||||
})
|
||||
},
|
||||
RData::OPT(opt) => {
|
||||
json!({
|
||||
"type": "OPT",
|
||||
"udp_packet_size": opt.udp_packet_size,
|
||||
"version": opt.version,
|
||||
"opt_codes": opt.opt_codes.iter().map(|code| {
|
||||
json!({
|
||||
"code": code.code,
|
||||
"data": base64::encode(&code.data)
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
RData::RouteThrough(rt) => {
|
||||
json!({
|
||||
"type": "RT",
|
||||
"preference": rt.preference,
|
||||
"intermediate_host": rt.intermediate_host.to_string()
|
||||
})
|
||||
},
|
||||
RData::RP(rp) => {
|
||||
json!({
|
||||
"type": "RP",
|
||||
"mbox": rp.mbox.to_string(),
|
||||
"txt": rp.txt.to_string()
|
||||
})
|
||||
},
|
||||
RData::SOA(soa) => {
|
||||
json!({
|
||||
"type": "SOA",
|
||||
"mname": soa.mname.to_string(),
|
||||
"rname": soa.rname.to_string(),
|
||||
"serial": soa.serial,
|
||||
"refresh": soa.refresh,
|
||||
"retry": soa.retry,
|
||||
"expire": soa.expire,
|
||||
"minimum": soa.minimum
|
||||
})
|
||||
},
|
||||
RData::SRV(srv) => {
|
||||
json!({
|
||||
"type": "SRV",
|
||||
"priority": srv.priority,
|
||||
"weight": srv.weight,
|
||||
"port": srv.port,
|
||||
"target": srv.target.to_string()
|
||||
})
|
||||
},
|
||||
RData::SVCB(svcb) => {
|
||||
let mut params = serde_json::Map::new();
|
||||
for (key, value) in svcb.iter_params() {
|
||||
params.insert(key.to_string(), json!(base64::encode(value)));
|
||||
}
|
||||
json!({
|
||||
"type": "SVCB",
|
||||
"priority": svcb.priority,
|
||||
"target": svcb.target.to_string(),
|
||||
"params": params
|
||||
})
|
||||
},
|
||||
RData::WKS(wks) => {
|
||||
json!({
|
||||
"type": "WKS",
|
||||
"address": Ipv4Addr::from(wks.address).to_string(),
|
||||
"protocol": wks.protocol,
|
||||
"bit_map": base64::encode(&wks.bit_map)
|
||||
})
|
||||
},
|
||||
|
||||
_ => json!({
|
||||
"type": format!("{:?}", record.rdata.type_code()),
|
||||
"data": "Unhandled record type"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_record_to_json(record: &ResourceRecord) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
Ok(json!({
|
||||
"name": record.name.to_string(),
|
||||
"class": format!("{:?}", record.class),
|
||||
"ttl": record.ttl,
|
||||
"rdata": extract_rdata_for_json(record),
|
||||
"cache_flush": record.cache_flush
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn construct_pubky_url(public_key: &str, domain: &str, path_segments: &[&str]) -> String {
|
||||
// Construct the base URL
|
||||
let mut url = format!("pubky://{}/pub/{}", public_key, domain);
|
||||
|
||||
// Append each path segment, separated by '/'
|
||||
for segment in path_segments {
|
||||
if !segment.is_empty() {
|
||||
url.push('/');
|
||||
url.push_str(segment);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing slash if present
|
||||
if url.ends_with('/') {
|
||||
url.pop();
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract everything up to the first instance of "pub/" in a Pubky URL
|
||||
*
|
||||
* # Arguments
|
||||
* * `full_url` - The full URL
|
||||
*
|
||||
* # Returns
|
||||
* * `Some(String)` - The "pub/" part of the URL
|
||||
* * `None` - If "pub/" is not found in the URL
|
||||
*/
|
||||
pub fn get_list_url(full_url: &str) -> Option<String> {
|
||||
if let Some(index) = full_url.find("pub/") {
|
||||
let end_index = index + "pub/".len();
|
||||
let substring = &full_url[..end_index];
|
||||
Some(substring.to_string())
|
||||
} else {
|
||||
// "pub/" not found in the string
|
||||
None
|
||||
}
|
||||
}
|
||||
321
testing/main.rs
Normal file
321
testing/main.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use std::string::ToString;
|
||||
use std::sync::Arc;
|
||||
use once_cell::sync::Lazy;
|
||||
use pkarr::{dns, Keypair, PublicKey, SignedPacket};
|
||||
use pkarr::bytes::Bytes;
|
||||
use pkarr::dns::rdata::RData;
|
||||
use pubky::PubkyClient;
|
||||
use url::Url;
|
||||
use std::str;
|
||||
|
||||
static PUBKY_CLIENT: Lazy<Arc<PubkyClient>> = Lazy::new(|| {
|
||||
// let custom_testnet = Testnet {
|
||||
// bootstrap: vec!["http://localhost:6287".to_string()],
|
||||
// nodes: vec![],
|
||||
// };
|
||||
//
|
||||
// let client = PubkyClient::builder()
|
||||
// .testnet(&custom_testnet)
|
||||
// .build();
|
||||
let client = PubkyClient::testnet();
|
||||
|
||||
Arc::new(client)
|
||||
});
|
||||
|
||||
// static PUBKY_CLIENT: Lazy<Arc<PubkyClient>> = Lazy::new(|| {
|
||||
// let custom_bootstrap = vec!["localhost:64630".to_string()];
|
||||
//
|
||||
// let mut pkarr_settings = Settings::default();
|
||||
// pkarr_settings.dht.bootstrap = custom_bootstrap.clone().into();
|
||||
// pkarr_settings.resolvers = custom_bootstrap
|
||||
// .iter()
|
||||
// .flat_map(|resolver| resolver.to_socket_addrs())
|
||||
// .flatten()
|
||||
// .collect::<Vec<_>>()
|
||||
// .into();
|
||||
//
|
||||
// let client = PubkyClient::builder()
|
||||
// .pkarr_settings(pkarr_settings)
|
||||
// .build();
|
||||
//
|
||||
// Arc::new(client)
|
||||
// });
|
||||
|
||||
const HOMESERVER: &str = "pubky://8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo";
|
||||
const SECRET_KEY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let sign_in_res = signin_or_signup(SECRET_KEY, HOMESERVER).await;
|
||||
println!("Sign In/Up Response: {:?}", sign_in_res);
|
||||
// let res = publish("recordname".to_string(), "recordcontent".to_string(), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string()).await;
|
||||
// // println!("{:?}", res);
|
||||
let public_key = &sign_in_res[1];
|
||||
let url = construct_pubky_url(public_key, "mydomain.com", &[]);
|
||||
let put_res = put(&url, &"newcontent".to_string()).await;
|
||||
println!("Put Response: {:?}", put_res);
|
||||
let get_res = get(&url).await;
|
||||
println!("Get Response: {:?}", get_res);
|
||||
let list_res = list(url).await;
|
||||
println!("List Response: {:?}", list_res);
|
||||
let create_response = create_recovery_file(&SECRET_KEY, "password");
|
||||
println!("Create Response: {:?}", create_response);
|
||||
let recovery_file = create_response[1].clone();
|
||||
let decrypt_response = decrypt_recovery_file(&recovery_file, "password");
|
||||
println!("Decrypt Response: {:?}", decrypt_response);
|
||||
}
|
||||
|
||||
pub fn create_recovery_file(secret_key: &str, passphrase: &str,) -> Vec<String> {
|
||||
if secret_key.is_empty() || passphrase.is_empty() {
|
||||
return create_response_vector(true, "Secret key and passphrase must not be empty".to_string());
|
||||
}
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
let recovery_file_bytes = match PubkyClient::create_recovery_file(&keypair, &passphrase) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return create_response_vector(true, "Failed to create recovery file".to_string()),
|
||||
};
|
||||
let recovery_file = base64::encode(&recovery_file_bytes);
|
||||
create_response_vector(false, recovery_file)
|
||||
}
|
||||
|
||||
pub fn decrypt_recovery_file(recovery_file: &str, passphrase: &str) -> Vec<String> {
|
||||
if recovery_file.is_empty() || passphrase.is_empty() {
|
||||
return create_response_vector(true, "Recovery file and passphrase must not be empty".to_string());
|
||||
}
|
||||
let recovery_file_bytes = match base64::decode(&recovery_file) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to decode recovery file: {}", error)),
|
||||
};
|
||||
let keypair = match PubkyClient::decrypt_recovery_file(&recovery_file_bytes, &passphrase) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, "Failed to decrypt recovery file".to_string()),
|
||||
};
|
||||
let secret_key = get_secret_key_from_keypair(&keypair);
|
||||
create_response_vector(false, secret_key)
|
||||
}
|
||||
|
||||
|
||||
pub async fn signin_or_signup(secret_key: &str, homeserver: &str) -> Vec<String> {
|
||||
let sign_in_res = sign_in(secret_key).await;
|
||||
if sign_in_res[0] == "success" {
|
||||
return sign_in_res;
|
||||
}
|
||||
let sign_up_res = sign_up(secret_key, homeserver).await;
|
||||
sign_up_res
|
||||
}
|
||||
|
||||
pub async fn sign_up(secret_key: &str, homeserver: &str) -> Vec<String> {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
|
||||
let homeserver_public_key = match PublicKey::try_from(homeserver) {
|
||||
Ok(key) => key,
|
||||
Err(error) => return create_response_vector(true, format!("Invalid homeserver public key: {}", error)),
|
||||
};
|
||||
|
||||
match client.signup(&keypair, &homeserver_public_key).await {
|
||||
Ok(session) => create_response_vector(false, session.pubky().to_string()),
|
||||
Err(error) => create_response_vector(true, format!("signup failure: {}", error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sign_in(secret_key: &str) -> Vec<String> {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
match client.signin(&keypair).await {
|
||||
Ok(session) => {
|
||||
create_response_vector(false, session.pubky().to_string())
|
||||
},
|
||||
Err(error) => {
|
||||
create_response_vector(true, format!("Failed to sign in: {}", error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn publish(record_name: String, record_content: String, secret_key: String) -> Vec<String> {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
|
||||
let keypair = match get_keypair_from_secret_key(&secret_key) {
|
||||
Ok(keypair) => keypair,
|
||||
Err(error) => return create_response_vector(true, error),
|
||||
};
|
||||
|
||||
let mut packet = dns::Packet::new_reply(0);
|
||||
|
||||
let dns_name = match dns::Name::new(&record_name) {
|
||||
Ok(name) => name,
|
||||
Err(e) => return create_response_vector(true, format!("Failed to create DNS name: {}", e)),
|
||||
};
|
||||
|
||||
let record_content_str: &str = record_content.as_str();
|
||||
|
||||
let txt_record = match record_content_str.try_into() {
|
||||
Ok(value) => RData::TXT(value),
|
||||
Err(e) => {
|
||||
return create_response_vector(true, format!("Failed to convert string to TXT record: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
packet.answers.push(dns::ResourceRecord::new(
|
||||
dns_name,
|
||||
dns::CLASS::IN,
|
||||
30,
|
||||
txt_record,
|
||||
));
|
||||
|
||||
match SignedPacket::from_packet(&keypair, &packet) {
|
||||
Ok(signed_packet) => {
|
||||
match client.pkarr().publish(&signed_packet).await {
|
||||
Ok(()) => {
|
||||
create_response_vector(false, keypair.public_key().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
create_response_vector(true, format!("Failed to publish: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
create_response_vector(true, format!("Failed to create signed packet: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_keypair_from_secret_key(secret_key: &str) -> Result<Keypair, String> {
|
||||
let bytes = match hex::decode(&secret_key) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return Err("Failed to decode secret key".to_string())
|
||||
};
|
||||
|
||||
let secret_key_bytes: [u8; 32] = match bytes.try_into() {
|
||||
Ok(secret_key) => secret_key,
|
||||
Err(_) => {
|
||||
return Err("Failed to convert secret key to 32-byte array".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Keypair::from_secret_key(&secret_key_bytes))
|
||||
}
|
||||
|
||||
pub fn create_response_vector(error: bool, data: String) -> Vec<String> {
|
||||
if error {
|
||||
vec!["error".to_string(), data]
|
||||
} else {
|
||||
vec!["success".to_string(), data]
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put(url: &String, content: &String) -> Vec<String> {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let trimmed_url = url.trim_end_matches('/');
|
||||
let parsed_url = match Url::parse(&trimmed_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
match client.put(parsed_url, &content.as_bytes()).await {
|
||||
Ok(_) => create_response_vector(false, trimmed_url.to_string()),
|
||||
Err(error) => {
|
||||
create_response_vector(true, format!("Failed to put: {}", error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(url: &String) -> Vec<String> {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let trimmed_url = url.trim_end_matches('/');
|
||||
|
||||
// Parse the URL and return error early if it fails
|
||||
let parsed_url = match Url::parse(&trimmed_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
|
||||
// Perform the request and return error early if no data is returned
|
||||
let result: Option<Bytes> = match client.get(parsed_url).await {
|
||||
Ok(res) => res,
|
||||
Err(_) => return create_response_vector(true, "Request failed".to_string()),
|
||||
};
|
||||
|
||||
// If there are bytes, attempt to convert to UTF-8
|
||||
let bytes = match result {
|
||||
Some(bytes) => bytes,
|
||||
None => return create_response_vector(true, "No data returned".to_string()),
|
||||
};
|
||||
|
||||
// Try to convert bytes to string and return error if it fails
|
||||
let string = match str::from_utf8(&bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => return create_response_vector(true, "Invalid UTF-8 sequence".to_string()),
|
||||
};
|
||||
|
||||
// If everything is successful, return the formatted response
|
||||
create_response_vector(false, string)
|
||||
}
|
||||
|
||||
pub async fn list(url: String) -> Vec<String> {
|
||||
let client = PUBKY_CLIENT.clone();
|
||||
let trimmed_url = url.trim_end_matches('/');
|
||||
let parsed_url = match Url::parse(&trimmed_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return create_response_vector(true, "Failed to parse URL".to_string()),
|
||||
};
|
||||
let list_builder = match client.list(parsed_url) {
|
||||
Ok(list) => list,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to list: {}", error)),
|
||||
};
|
||||
// Execute the non-Send part synchronously
|
||||
let send_future = list_builder.send();
|
||||
let send_res = match send_future.await {
|
||||
Ok(res) => res,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to send list request: {}", error))
|
||||
};
|
||||
let json_string = match serde_json::to_string(&send_res) {
|
||||
Ok(json) => json,
|
||||
Err(error) => return create_response_vector(true, format!("Failed to serialize JSON: {}", error)),
|
||||
};
|
||||
create_response_vector(false, json_string)
|
||||
}
|
||||
|
||||
fn construct_pubky_url(public_key: &str, domain: &str, path_segments: &[&str]) -> String {
|
||||
// Construct the base URL
|
||||
let mut url = format!("pubky://{}/pub/{}", public_key, domain);
|
||||
|
||||
// Append each path segment, separated by '/'
|
||||
for segment in path_segments {
|
||||
if !segment.is_empty() {
|
||||
url.push('/');
|
||||
url.push_str(segment);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing slash if present
|
||||
if url.ends_with('/') {
|
||||
url.pop();
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn get_list_url(full_url: &str) -> Option<String> {
|
||||
if let Some(index) = full_url.find("pub/") {
|
||||
// Add length of "pub/" to include it in the substring
|
||||
let end_index = index + "pub/".len();
|
||||
let substring = &full_url[..end_index];
|
||||
Some(substring.to_string())
|
||||
} else {
|
||||
// "pub/" not found in the string
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_secret_key_from_keypair(keypair: &Keypair) -> String {
|
||||
hex::encode(keypair.secret_key())
|
||||
}
|
||||
Reference in New Issue
Block a user