Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec5cf8f9f9 | ||
|
|
4ddc41632b | ||
|
|
2e9a92996e | ||
|
|
ce1e12417b | ||
|
|
b9028e8f7a | ||
|
|
3867467f24 | ||
|
|
a71af41d9e | ||
|
|
a8db761a4b | ||
|
|
4ef050ae04 | ||
|
|
95e02b567b | ||
|
|
93470259d2 | ||
|
|
6a5228aed8 |
@@ -51,6 +51,40 @@ jobs:
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
# The iOS app's shell: the interface built the way the app carries it, and
|
||||
# copied into the Xcode project. No Xcode here — that build happens on a Mac —
|
||||
# but a config the CLI cannot read, a webDir that moved, or an app build that
|
||||
# stopped compiling all fail at this step rather than on the day somebody sits
|
||||
# down to ship.
|
||||
app:
|
||||
name: iOS app shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
(cd web && npm ci)
|
||||
(cd app && npm ci)
|
||||
|
||||
- name: Build and sync
|
||||
working-directory: app
|
||||
run: npm run sync
|
||||
|
||||
# The app build must not register the service worker — WKWebView has none
|
||||
# for the app's own scheme. The browser build registers it from index.html.
|
||||
- name: No service worker in the app build
|
||||
run: |
|
||||
if grep -qF 'serviceWorker.register' web/build/index.html; then
|
||||
echo "The app build registers a service worker in index.html."
|
||||
exit 1
|
||||
fi
|
||||
test -f app/ios/App/App/public/index.html
|
||||
echo "App build in the Xcode project: $(find app/ios/App/App/public -type f | wc -l) files."
|
||||
|
||||
spec:
|
||||
name: OpenAPI is valid
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -251,6 +251,7 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"appId": "dk.nolimit.verdande",
|
||||
"appName": "Verdande",
|
||||
"webDir": "../web/build",
|
||||
"ios": {
|
||||
"contentInset": "never",
|
||||
"scheme": "Verdande"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
@@ -0,0 +1,380 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = (
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3151FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
504EC3151FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
504EC3181FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "CapApp-SPM";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||
productName = "CapApp-SPM";
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,44 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(name: "Default Configuration",
|
||||
sessionRole: connectingSceneSession.role)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CAPACITOR_DEBUG</key>
|
||||
<string>$(CAPACITOR_DEBUG)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Verdande</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- A self-hosted Verdande on a home network may have no certificate:
|
||||
http://192.168.1.20:8080 or http://verdande.local. This allows plain
|
||||
http to local addresses only; anything on the internet must still be
|
||||
https. -->
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = CAPBridgeViewController()
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,25 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||
let package = Package(
|
||||
name: "CapApp-SPM",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapApp-SPM",
|
||||
targets: ["CapApp-SPM"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CapApp-SPM",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# CapApp-SPM
|
||||
|
||||
This package is used to host SPM dependencies for your Capacitor project
|
||||
|
||||
Do not modify the contents of it or there may be unintended consequences.
|
||||
@@ -0,0 +1 @@
|
||||
public let isCapacitorApp = true
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
Generated
+1239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "verdande-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "The iOS app: the web interface bundled into a native shell.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cd ../web && VERDANDE_TARGET=app npm run build",
|
||||
"sync": "npm run build && cap sync ios",
|
||||
"open": "cap open ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^8.5.3",
|
||||
"@capacitor/ios": "^8.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
|
||||
Any Go duration: `720h`, `30m`, `45s`.
|
||||
|
||||
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
|
||||
|
||||
## Mail
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# The iOS app
|
||||
|
||||
The iOS app is the web interface bundled into a native shell with
|
||||
[Capacitor](https://capacitorjs.com). It is not a second interface: the same
|
||||
Svelte build runs in both, and everything that differs between a browser and the
|
||||
app lives in `web/src/lib/platform.js`.
|
||||
|
||||
It talks to **any** Verdande server. The first screen asks for the address, checks
|
||||
it with `/api/v1/ping`, and then signs in there.
|
||||
|
||||
## How the app differs from a browser
|
||||
|
||||
| | Browser | App |
|
||||
|---|---|---|
|
||||
| Where the interface comes from | the server | inside the app, `capacitor://localhost` |
|
||||
| Which server | the one it was loaded from | the one its owner typed in |
|
||||
| Session | httpOnly cookie | a session token, sent as `Authorization: Bearer` |
|
||||
| Live sync | WebSocket with the cookie | WebSocket opened with a one-use ticket (`POST /ws-ticket`) |
|
||||
| Pictures and downloads | plain `<img>` and links | fetched with the token and shown from a blob (`nativemedia.js`) |
|
||||
| Service worker, Web Push | yes | no — WKWebView has neither |
|
||||
| Passkeys | yes | not yet — a passkey is bound to the server's domain |
|
||||
| Creating the first account | yes | no — done in a browser, then sign in from the app |
|
||||
|
||||
The server side of this is `client: "app"` on `/auth/login`, the
|
||||
`capacitor://localhost` CORS rule, and the ticket. All three are in
|
||||
`docs/openapi.yaml`.
|
||||
|
||||
An app session slides: every use moves its end forward, up to
|
||||
`VERDANDE_APP_SESSION_TTL` (180 days) unused. It is listed under *Enheder* in
|
||||
settings with the name the app gave itself, and can be signed out from there.
|
||||
|
||||
## Building
|
||||
|
||||
Everything up to Xcode runs anywhere, and runs in CI (`iOS app shell`):
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm ci
|
||||
npm run sync # builds web with VERDANDE_TARGET=app, copies it into ios/
|
||||
```
|
||||
|
||||
On a Mac with Xcode:
|
||||
|
||||
```sh
|
||||
npm run open # opens ios/App/App.xcodeproj
|
||||
```
|
||||
|
||||
Choose a simulator and press Run. In the app, type the address of a Verdande —
|
||||
`http://localhost:8080` reaches a dev server on the same Mac from the simulator.
|
||||
|
||||
Plain `http` is allowed to **local** addresses only (`NSAllowsLocalNetworking` in
|
||||
`Info.plist`), so a home server without a certificate works on the same network;
|
||||
anything on the internet has to be `https`.
|
||||
|
||||
`app/ios/` is committed. The web build inside it (`App/App/public`) and the
|
||||
generated config are not — `npm run sync` puts them there.
|
||||
|
||||
The icon and launch image come from `web/static/icon.svg`:
|
||||
|
||||
```sh
|
||||
cd web && node scripts/generate-ios-assets.js
|
||||
```
|
||||
|
||||
## Putting it on a phone
|
||||
|
||||
These steps are the account owner's — they involve Apple's terms, a payment and
|
||||
signing keys.
|
||||
|
||||
1. **Apple Developer Program** membership (99 USD a year) on the Apple ID that
|
||||
should own the app.
|
||||
2. In Xcode → *Signing & Capabilities*: choose that team. The bundle id is
|
||||
`dk.nolimit.verdande`; it can change until the first upload and never after.
|
||||
3. *Product → Archive*, then *Distribute App → TestFlight*.
|
||||
4. In App Store Connect, add testers to the TestFlight build.
|
||||
|
||||
## Not yet
|
||||
|
||||
- **Notifications.** Web Push does not exist in the app. Notifications on an
|
||||
iPhone go through Apple's APNs, and the key that sends them is bound to the
|
||||
app's bundle id — so it cannot live on every self-hosted server. That means a
|
||||
small relay run by the app's publisher, which every instance with an app user
|
||||
would talk to. That is a new connection from other people's servers, and it
|
||||
waits on a decision before it is built.
|
||||
- **The session token in the Keychain.** It is in the WebView's own storage for
|
||||
now, behind one object in `platform.js` so the Keychain plugin can replace it
|
||||
without anything else changing.
|
||||
- **Passkeys** in the app.
|
||||
- **Share sheet** (share text or a link *into* Verdande from another app).
|
||||
@@ -120,6 +120,19 @@ paths:
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
client:
|
||||
type: string
|
||||
enum: [web, app]
|
||||
description: |
|
||||
`app` for the iOS app. Its session comes back as
|
||||
`session_token` instead of a cookie and slides — every use
|
||||
moves its end forward, up to `VERDANDE_APP_SESSION_TTL`
|
||||
(180 days) unused. Left out, it is a browser login.
|
||||
device_name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description: "Required with `client: app`; shown in the list of sessions."
|
||||
example: Kristians iPhone
|
||||
responses:
|
||||
"200":
|
||||
description: |
|
||||
@@ -132,7 +145,14 @@ paths:
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/User" }
|
||||
totp_required: { type: boolean }
|
||||
session_token:
|
||||
type: string
|
||||
description: |
|
||||
An app login only, also while `totp_required` — the app
|
||||
sends it to `/auth/login/totp` too. Sent from then on as
|
||||
`Authorization: Bearer`. A browser never receives it.
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
|
||||
/api/v1/auth/login/totp:
|
||||
@@ -4004,14 +4024,51 @@ paths:
|
||||
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
|
||||
`task.moved`, `task.deleted`, `comment.created`, `notification`,
|
||||
`reminder`.
|
||||
|
||||
A browser opens it with its session cookie and from the instance's own
|
||||
origin. The iOS app cannot put a header on a handshake, so it asks
|
||||
`/ws-ticket` first and opens `/ws?ticket=…`; a handshake from the app's
|
||||
origin without one is refused.
|
||||
parameters:
|
||||
- name: ticket
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
description: From `/ws-ticket`. Good for one connection, within 30 seconds.
|
||||
responses:
|
||||
"101": { description: Switching protocols. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ws-ticket:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: A one-use ticket for opening the live-sync socket without a cookie.
|
||||
description: |
|
||||
For the iOS app, whose session is a bearer token that a WebSocket
|
||||
handshake cannot carry. Putting the token itself in the URL would leave a
|
||||
credential lasting months in every log the URL passes through; the ticket
|
||||
lasts thirty seconds and opens one connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ticket: { type: string }
|
||||
expires_in: { type: integer, description: Seconds., example: 30 }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ping:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: A liveness check that does not touch the database.
|
||||
description: |
|
||||
Also how the iOS app tells a Verdande from anything else at an address
|
||||
somebody typed in, before anyone has signed in, and which half of the
|
||||
interface to show. It carries no version: `/version` is behind a login on
|
||||
purpose, and a public number is a list of the instances running an old one.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -4022,6 +4079,9 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, const: ok }
|
||||
product: { type: string, const: verdande }
|
||||
edition: { type: string, enum: [full, notes] }
|
||||
api: { type: integer, const: 1, description: Raised when /api/v1 changes in a way an older app cannot follow. }
|
||||
|
||||
# --- data -----------------------------------------------------------------
|
||||
|
||||
@@ -4439,6 +4499,10 @@ components:
|
||||
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
|
||||
there is no separate service identity — and is refused on `/tokens`.
|
||||
|
||||
Or the iOS app's session, the `session_token` from an app login. That
|
||||
one is a session and reaches everything a browser session does. A
|
||||
browser's own session is never accepted here.
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: The body could not be read.
|
||||
@@ -4973,6 +5037,12 @@ components:
|
||||
it so anything the summary gets wrong is still visible.
|
||||
example: Chrome på macOS
|
||||
user_agent: { type: string }
|
||||
client:
|
||||
type: string
|
||||
enum: [web, app]
|
||||
description: |
|
||||
`app` for the iOS app, whose `device` is the name it gave itself and
|
||||
whose `expires_at` moves forward each time it is used.
|
||||
ip: { type: string }
|
||||
created_at: { type: string, format: date-time }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
|
||||
@@ -24,8 +24,12 @@ type Config struct {
|
||||
DataDir string
|
||||
|
||||
SessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
// AppSessionTTL is how long a session from the iOS app may sit unused before
|
||||
// it ends. It slides: every use moves the end forward, so a phone in daily use
|
||||
// stays signed in and one left in a drawer does not.
|
||||
AppSessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
|
||||
SMTP SMTP
|
||||
|
||||
@@ -65,6 +69,12 @@ type Config struct {
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
|
||||
// which is always included. Set by the end-to-end suite, which serves the
|
||||
// interface from a second host name to test it the way the app runs it. An
|
||||
// operator has no reason to set it.
|
||||
AppOrigins []string
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
@@ -161,11 +171,19 @@ func Load() (*Config, error) {
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
|
||||
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
|
||||
c.AppOrigins = append(c.AppOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.AppSessionTTL, err = envDuration("VERDANDE_APP_SESSION_TTL", 180*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
preflight := map[string]string{
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type",
|
||||
}
|
||||
|
||||
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("app preflight: status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app preflight: Allow-Origin %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
|
||||
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
|
||||
}
|
||||
|
||||
// An ordinary request from the app can be read.
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app GET: Allow-Origin %q", got)
|
||||
}
|
||||
|
||||
// Anybody else gets nothing, so the browser keeps them from reading.
|
||||
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
|
||||
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The app asks this before anyone has signed in, to tell a Verdande from a
|
||||
// router's login page.
|
||||
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ping: %d", resp.StatusCode)
|
||||
}
|
||||
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
|
||||
t.Errorf("ping = %v", body)
|
||||
}
|
||||
if _, ok := body["version"]; ok {
|
||||
t.Error("ping reveals the version to anybody")
|
||||
}
|
||||
}
|
||||
|
||||
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
|
||||
h := http.Header{}
|
||||
if origin != "" {
|
||||
h.Set("Origin", origin)
|
||||
}
|
||||
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
|
||||
}
|
||||
|
||||
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Without a ticket or a cookie: refused.
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Fatal("a socket opened with no credential")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("no credential: %v", resp)
|
||||
}
|
||||
|
||||
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket, _ := body["ticket"].(string)
|
||||
if resp.StatusCode != http.StatusOK || ticket == "" {
|
||||
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("dial with a ticket: %v", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// And it is the right person's stream: their own new project arrives on it.
|
||||
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(msg), "Live fra telefonen") {
|
||||
t.Errorf("first event was %s", msg)
|
||||
}
|
||||
|
||||
// Spent: the same ticket does not open a second socket.
|
||||
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
|
||||
t.Error("a ticket opened two sockets")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("reused ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATicketExpires(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket := body["ticket"].(string)
|
||||
|
||||
ts.api.wsTickets.mu.Lock()
|
||||
v := ts.api.wsTickets.m[ticket]
|
||||
v.expires = time.Now().Add(-time.Second)
|
||||
ts.api.wsTickets.m[ticket] = v
|
||||
ts.api.wsTickets.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Error("an expired ticket opened a socket")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie path is unchanged: a foreign page cannot open a socket that rides
|
||||
// on the cookie, and the instance's own page can.
|
||||
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dial := func(origin string) error {
|
||||
h := http.Header{}
|
||||
h.Set("Origin", origin)
|
||||
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
|
||||
if err == nil {
|
||||
conn.CloseNow()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := dial("https://evil.example"); err == nil {
|
||||
t.Error("a foreign page opened a socket on the cookie")
|
||||
}
|
||||
if err := dial("capacitor://localhost"); err == nil {
|
||||
t.Error("the app origin opened a socket on a cookie, without a ticket")
|
||||
}
|
||||
if err := dial("http://localhost"); err != nil {
|
||||
t.Errorf("control: the instance's own origin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end suite runs the interface from a second host name and tells the
|
||||
// server to count it as the app's. That origin is then the app's in every
|
||||
// respect, and capacitor://localhost stays one too.
|
||||
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.AppOrigins = []string{"http://app.localhost:8097"}
|
||||
})
|
||||
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Errorf("%s: Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
// Not a prefix match: a neighbour on another port is nobody.
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("a neighbouring origin was answered: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the iOS app needs from a server that a browser gets for free by being on
|
||||
// the same origin. The app loads its interface from capacitor://localhost and
|
||||
// talks to whichever server its owner typed in, so every request it makes is
|
||||
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
|
||||
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
|
||||
// has signed in.
|
||||
|
||||
// appCORS answers the app's origin, and nobody else's.
|
||||
//
|
||||
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
|
||||
// token, never a cookie, so a response to a request with a cookie on it is not
|
||||
// something this header ever needs to unlock. Every other origin gets no CORS
|
||||
// headers at all, exactly as before — the browser then keeps a foreign page from
|
||||
// reading anything, which is the default this must not loosen.
|
||||
func (s *Server) appCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if !s.isAppOrigin(origin) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
// Read by the client: a rate limit's wait, and a download's file name.
|
||||
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
|
||||
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- the live-sync socket without a cookie -----------------------------------
|
||||
|
||||
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
|
||||
// token has no way onto it. Putting the token in the URL would work and would
|
||||
// leave a credential that lasts months in every proxy log the URL passes. A
|
||||
// ticket is the usual answer: asked for with the token, good for one connection,
|
||||
// gone in thirty seconds.
|
||||
|
||||
const wsTicketTTL = 30 * time.Second
|
||||
|
||||
type wsTicket struct {
|
||||
userID string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type wsTickets struct {
|
||||
mu sync.Mutex
|
||||
m map[string]wsTicket
|
||||
}
|
||||
|
||||
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
|
||||
|
||||
func (t *wsTickets) issue(userID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := hex.EncodeToString(raw)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
// Swept on the way in, so the map is bounded by thirty seconds of asking.
|
||||
now := time.Now()
|
||||
for k, v := range t.m {
|
||||
if now.After(v.expires) {
|
||||
delete(t.m, k)
|
||||
}
|
||||
}
|
||||
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// redeem spends a ticket. It is deleted before it is checked, so a second
|
||||
// presentation finds nothing, whether the first one was in time or not.
|
||||
func (t *wsTickets) redeem(ticket string) (string, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
v, ok := t.m[ticket]
|
||||
delete(t.m, ticket)
|
||||
if !ok || time.Now().After(v.expires) {
|
||||
return "", false
|
||||
}
|
||||
return v.userID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
|
||||
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "issue websocket ticket", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ticket": ticket,
|
||||
"expires_in": int(wsTicketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
|
||||
// ordinary session check.
|
||||
//
|
||||
// A socket opened with a ticket skips the origin check, and that is the point
|
||||
// rather than a gap: the origin check exists because a browser attaches the
|
||||
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
|
||||
// be asked for with a credential and put in the URL on purpose — so there is no
|
||||
// ambient authority for a foreign page to borrow.
|
||||
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
// The app never holds a cookie, so a handshake from its origin without a
|
||||
// ticket is not the app. Refused here rather than left to the origin
|
||||
// check, which compares host names only: capacitor://localhost and an
|
||||
// instance served at http://localhost have the same one.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
|
||||
return
|
||||
}
|
||||
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
userID, ok := s.wsTickets.redeem(ticket)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
|
||||
return
|
||||
}
|
||||
user, err := s.db.UserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "load user", err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
|
||||
s.handleWebSocket(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// --- is this a Verdande? -----------------------------------------------------
|
||||
|
||||
// handlePing answers before anybody has signed in, which is when the app needs
|
||||
// it: the person has typed an address and the app has to tell "that is a
|
||||
// Verdande" from "that is a router's login page" before it shows a password
|
||||
// field for it. The edition decides which half of the interface to show.
|
||||
//
|
||||
// No version. /version sits behind a login on purpose, and a public number is a
|
||||
// list of the instances still running an old one.
|
||||
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"product": "verdande",
|
||||
"edition": s.edition(),
|
||||
"api": 1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The iOS app loads its interface from itself — capacitor://localhost — so it
|
||||
// never shares an origin with the server and never gets a cookie back. It signs
|
||||
// in like a browser does and carries the same kind of session, as a bearer token.
|
||||
|
||||
// appDo is a request the way the app makes it: no cookie jar, the token in a
|
||||
// header, and the browser engine reporting the request as cross-site, because to
|
||||
// WKWebView it is.
|
||||
func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, ts.URL+path, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Origin", "capacitor://localhost")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
var decoded map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
}
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
"client": "app", "device_name": "Kristians iPhone",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("app login: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
token, _ := body["session_token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("no session_token in %v", body)
|
||||
}
|
||||
if body["user"] == nil {
|
||||
t.Error("no user in the response")
|
||||
}
|
||||
|
||||
// The token is the whole credential, from a foreign origin, reads and writes.
|
||||
resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK || me["email"] != "kristian@example.dk" {
|
||||
t.Fatalf("me with the app token: %d %v", resp.StatusCode, me)
|
||||
}
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// It is a session, not an API token: it reaches what a token may not, and it
|
||||
// is in the list of devices under the name the app gave itself.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
var found bool
|
||||
for _, raw := range body["sessions"].([]any) {
|
||||
s := raw.(map[string]any)
|
||||
if s["current"] == true {
|
||||
found = true
|
||||
if s["device"] != "Kristians iPhone" || s["client"] != "app" {
|
||||
t.Errorf("the app's session reads %v / %v", s["device"], s["client"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the app's own session is not marked current")
|
||||
}
|
||||
|
||||
// And signing out ends it.
|
||||
if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("logout: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("the token still works after logout: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A browser's session never leaves its httpOnly cookie. Its value replayed as a
|
||||
// bearer must not work: that would turn the header into a second way past the
|
||||
// SameSite and CSRF locks the cookie sits behind.
|
||||
func TestABrowserSessionIsNotABearer(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
if cookie == "" {
|
||||
t.Fatal("bootstrap left no session cookie")
|
||||
}
|
||||
if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode)
|
||||
}
|
||||
// Control: the same value is still a perfectly good cookie.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("control: the cookie itself: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginWithASecondFactor(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{"password": "et langt kodeord"})
|
||||
secret, _ := body["secret"].(string)
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
token, _ := body["session_token"].(string)
|
||||
if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" {
|
||||
t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["user"] != nil {
|
||||
t.Error("the user came back before the code was given")
|
||||
}
|
||||
|
||||
// Half a login reaches nothing, as in a browser, and does not slide yet.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired {
|
||||
t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
session, _, err := ts.db.SessionByToken(t.Context(), token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute {
|
||||
t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL)
|
||||
}
|
||||
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("finishing an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("me after the code: %d", resp.StatusCode)
|
||||
}
|
||||
session, _, _ = ts.db.SessionByToken(t.Context(), token)
|
||||
if session.IdleTTL != 7*24*time.Hour {
|
||||
t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// A phone in daily use must not sign itself out; one left in a drawer must. The
|
||||
// end moves forward each time the session is used, and a browser's does not.
|
||||
func TestAnAppSessionSlides(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
|
||||
// Pretend it was last used a while ago and is a day from ending.
|
||||
soon := time.Now().Add(24 * time.Hour).Unix()
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("me: %d", resp.StatusCode)
|
||||
}
|
||||
var expires int64
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour {
|
||||
t.Errorf("using the app session left %v on it, want about the app TTL", left)
|
||||
}
|
||||
|
||||
// Control: a browser session used the same way keeps its fixed end.
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if expires != soon {
|
||||
t.Errorf("a browser session's end moved from %d to %d", soon, expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
field string
|
||||
}{
|
||||
{"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"},
|
||||
{"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"},
|
||||
} {
|
||||
tc.body["email"] = "kristian@example.dk"
|
||||
tc.body["password"] = "et langt kodeord"
|
||||
resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil {
|
||||
t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -159,11 +159,40 @@ func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
// Client is "app" when the iOS app signs in; empty is a browser. DeviceName is
|
||||
// what the app calls itself, shown in the list of signed-in devices.
|
||||
Client string `json:"client"`
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
User *userJSON `json:"user,omitempty"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
// SessionToken is set for an app login only, and instead of the cookie: the
|
||||
// app loads its interface from itself, so it never shares an origin with the
|
||||
// server and a cookie would not come back. It is sent from then on as
|
||||
// `Authorization: Bearer`. A browser never sees it — its session stays in an
|
||||
// httpOnly cookie no script can read.
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
}
|
||||
|
||||
// appClient reports whether a login asked for an app session, and refuses a value
|
||||
// it does not know rather than quietly giving it a browser session: a client that
|
||||
// asked for something else and got a cookie would look signed in until its next
|
||||
// request, and then not be.
|
||||
func appClient(w http.ResponseWriter, client, deviceName string) (app, ok bool) {
|
||||
switch client {
|
||||
case "", "web":
|
||||
return false, true
|
||||
case "app":
|
||||
if strings.TrimSpace(deviceName) == "" {
|
||||
writeFieldErrors(w, map[string]string{"device_name": "an app names the device it signs in from"})
|
||||
return false, false
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
writeFieldErrors(w, map[string]string{"client": `must be "app" or left out`})
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -171,6 +200,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
app, ok := appClient(w, req.Client, req.DeviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
||||
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
|
||||
@@ -215,22 +248,40 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
ttl := s.cfg.SessionTTL
|
||||
if app {
|
||||
ttl = s.cfg.AppSessionTTL
|
||||
}
|
||||
if user.TOTPEnabled {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
token, session, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
resp := loginResponse{TOTPRequired: user.TOTPEnabled}
|
||||
if app {
|
||||
// Sliding only once the login is finished; a pending one keeps its ten
|
||||
// minutes and handleLoginTOTP turns it on.
|
||||
idle := s.cfg.AppSessionTTL
|
||||
if user.TOTPEnabled {
|
||||
idle = 0
|
||||
}
|
||||
if err := s.db.MarkAppSession(r.Context(), session.ID, req.DeviceName, idle); err != nil {
|
||||
s.internal(w, r, "mark app session", err)
|
||||
return
|
||||
}
|
||||
resp.SessionToken = token
|
||||
} else {
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
uj := toUserJSON(user)
|
||||
resp.User = &uj
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type totpRequest struct {
|
||||
@@ -305,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
// An app's session finishes as a sliding one; it already holds its token and
|
||||
// gets no cookie.
|
||||
if session.Client == "app" {
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.AppSessionTTL, s.cfg.AppSessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL, 0); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,12 +51,13 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
cfg := &config.Config{
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
AppSessionTTL: 7 * 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
}
|
||||
if configure != nil {
|
||||
configure(cfg)
|
||||
|
||||
@@ -18,6 +18,8 @@ type ctxKey int
|
||||
const (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
|
||||
ctxTicket
|
||||
)
|
||||
|
||||
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
|
||||
@@ -126,9 +128,10 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// authenticate resolves a request to a user, by session cookie or by API token.
|
||||
// authenticate resolves a request to a user, by session cookie, by API token, or by
|
||||
// an app's session sent as a bearer token.
|
||||
//
|
||||
// Both are accepted everywhere. An API token belongs to a person and carries their
|
||||
// All are accepted everywhere. An API token belongs to a person and carries their
|
||||
// permissions exactly — there is no separate service identity to reason about, and
|
||||
// a script therefore cannot reach anything its owner could not.
|
||||
// errNoCredentials means the request carried nothing to authenticate with, as
|
||||
@@ -145,6 +148,21 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err
|
||||
return user, nil, nil
|
||||
}
|
||||
|
||||
// Any other bearer is an app session (see loginResponse.SessionToken). It is
|
||||
// accepted only for a session that was issued to the app: a browser's session
|
||||
// never leaves its httpOnly cookie, and a cookie value found somewhere and
|
||||
// replayed as a header must not become a second way to use it.
|
||||
if token := bearerToken(r); token != "" {
|
||||
session, user, err := s.db.SessionByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if session.Client != "app" {
|
||||
return nil, nil, store.ErrNotFound
|
||||
}
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(s.cookieName())
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, nil, errNoCredentials
|
||||
@@ -200,6 +218,14 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// The iOS app, before it has a token: its sign-in. A web page cannot send
|
||||
// this Origin — the browser writes the header, not the page — and an app
|
||||
// login sets no cookie, so there is no session here for a forged request
|
||||
// to ride on.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
|
||||
switch r.Header.Get("Sec-Fetch-Site") {
|
||||
@@ -220,6 +246,28 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// appOrigins are where the iOS app's interface is served from. Capacitor loads
|
||||
// the bundled build on this scheme inside WKWebView, so every request the app
|
||||
// makes to a server arrives from here, whichever server it is.
|
||||
var appOrigins = []string{"capacitor://localhost"}
|
||||
|
||||
func (s *Server) isAppOrigin(origin string) bool {
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range appOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, o := range s.cfg.AppOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) isOwnOrigin(origin string) bool {
|
||||
if strings.EqualFold(origin, s.cfg.BaseURL) {
|
||||
return true
|
||||
|
||||
@@ -45,11 +45,15 @@ func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
ticketed, _ := r.Context().Value(ctxTicket).(bool)
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// The socket is same-origin only. Without this, any page on the internet
|
||||
// could open a socket carrying the user's cookie and watch their tasks —
|
||||
// the same-origin policy does not apply to WebSocket handshakes.
|
||||
OriginPatterns: s.allowedOrigins(),
|
||||
// Except one opened with a ticket, which carries no cookie for a foreign
|
||||
// page to borrow: see handleWebSocketEntry.
|
||||
InsecureSkipVerify: ticketed,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("websocket handshake", "err", err)
|
||||
|
||||
@@ -60,6 +60,10 @@ type Server struct {
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// One-use tickets for opening the live-sync socket without a cookie — see
|
||||
// app_handlers.go.
|
||||
wsTickets *wsTickets
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -115,6 +119,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
wsTickets: newWSTickets(),
|
||||
}
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
@@ -273,11 +278,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Before the CSRF lock, so a preflight is answered and never reaches it.
|
||||
r.Use(s.appCORS)
|
||||
r.Use(s.requireCSRF)
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.Get("/ping", s.handlePing)
|
||||
|
||||
// The live-sync socket checks its own credential: a ticket, or the
|
||||
// session as everywhere else. See handleWebSocketEntry.
|
||||
r.Get("/ws", s.handleWebSocketEntry)
|
||||
|
||||
// The beacon's receiving end, and the number it produces. Both without a
|
||||
// session, because the caller is a stranger's installation — that is the
|
||||
@@ -378,7 +387,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
r.Post("/ws-ticket", s.handleWSTicket)
|
||||
|
||||
// The MCP endpoint. Reachable with a personal API token, so a connector
|
||||
// sees exactly what its owner sees.
|
||||
|
||||
@@ -23,8 +23,11 @@ type sessionJSON struct {
|
||||
// Device is a summary; UserAgent is what it was summarised from. Both, because
|
||||
// the summary is what you read and the original is what settles the question
|
||||
// when the summary is not enough.
|
||||
Device string `json:"device"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Device string `json:"device"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
// Client is "web" or "app". An app session slides — its expires_at moves
|
||||
// forward each time it is used — where a browser's is fixed.
|
||||
Client string `json:"client"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
@@ -47,11 +50,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
out := make([]sessionJSON, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
// The app names itself; a browser is described from its user agent.
|
||||
device := sess.DeviceName
|
||||
if device == "" {
|
||||
device = describeUserAgent(sess.UA)
|
||||
}
|
||||
out = append(out, sessionJSON{
|
||||
ID: sess.ID,
|
||||
Current: current != nil && sess.ID == current.ID,
|
||||
Device: describeUserAgent(sess.UA),
|
||||
Device: device,
|
||||
UserAgent: sess.UA,
|
||||
Client: sess.Client,
|
||||
IP: sess.IP,
|
||||
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
||||
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Sessioner fra en app.
|
||||
--
|
||||
-- En app logger ind som en browser gør, med adgangskode og evt. kode, og får en
|
||||
-- almindelig session. Forskellen er transporten: tokenet kommer i svaret og sendes
|
||||
-- som `Authorization: Bearer`, fordi en app, der henter sit UI fra sig selv, ikke
|
||||
-- deler origin med serveren og derfor aldrig får cookien tilbage. At det er en
|
||||
-- SESSION og ikke et API-token er hele pointen: den står i listen over enheder,
|
||||
-- kan logges ud derfra, og den når indstillingerne for 2FA og nøgler, som et
|
||||
-- token med vilje ikke gør.
|
||||
--
|
||||
-- device_name er det navn, appen giver sig selv ("Kristians iPhone"), så listen
|
||||
-- siger noget, en person genkender. idle_ttl er sekunder: større end 0 betyder, at
|
||||
-- sessionen glider — hver gang den bruges, flyttes udløbet frem. En browser får
|
||||
-- 0 og sin faste levetid som før; en telefon, man har i lommen hver dag, må ikke
|
||||
-- logge sig selv ud hver 30. dag, men en telefon, der ligger i en skuffe et halvt
|
||||
-- år, skal.
|
||||
ALTER TABLE sessions ADD COLUMN client TEXT NOT NULL DEFAULT 'web';
|
||||
ALTER TABLE sessions ADD COLUMN device_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN idle_ttl INTEGER NOT NULL DEFAULT 0;
|
||||
+70
-31
@@ -15,9 +15,36 @@ type Session struct {
|
||||
UA string
|
||||
IP string
|
||||
PendingTOTP bool
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
// Client is "web" for a browser and "app" for the iOS app, which carries its
|
||||
// session as a bearer token rather than a cookie. DeviceName is what the app
|
||||
// calls itself; a browser has none and is described from its user agent.
|
||||
Client string
|
||||
DeviceName string
|
||||
// IdleTTL above zero makes the session slide: each use moves ExpiresAt to
|
||||
// IdleTTL from now. Zero is the fixed lifetime a browser session has always had.
|
||||
IdleTTL time.Duration
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
const sessionColumns = `id, user_id, ua, ip, pending_totp, client, device_name, idle_ttl,
|
||||
created_at, last_seen_at, expires_at`
|
||||
|
||||
func scanSession(row interface{ Scan(...any) error }) (*Session, error) {
|
||||
var s Session
|
||||
var pending int
|
||||
var idle, created, lastSeen, expires int64
|
||||
if err := row.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &s.Client, &s.DeviceName, &idle,
|
||||
&created, &lastSeen, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PendingTOTP = pending == 1
|
||||
s.IdleTTL = time.Duration(idle) * time.Second
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// CreateSession issues a session and returns the value to put in the cookie.
|
||||
@@ -40,6 +67,7 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
|
||||
UA: truncate(ua, 500),
|
||||
IP: truncate(ip, 100),
|
||||
PendingTOTP: pendingTOTP,
|
||||
Client: "web",
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
@@ -63,13 +91,8 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
|
||||
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
|
||||
id := auth.HashToken(token)
|
||||
|
||||
var s Session
|
||||
var pending int
|
||||
var created, lastSeen, expires int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
||||
FROM sessions WHERE id = ?`, id).
|
||||
Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires)
|
||||
s, err := scanSession(db.QueryRowContext(ctx,
|
||||
`SELECT `+sessionColumns+` FROM sessions WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
@@ -77,11 +100,6 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.PendingTOTP = pending == 1
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
|
||||
if time.Now().After(s.ExpiresAt) {
|
||||
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
|
||||
return nil, nil, ErrNotFound
|
||||
@@ -95,20 +113,47 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
|
||||
// last_seen_at drives the session list in settings ("this device, 2 minutes
|
||||
// ago"). It is written at most once a minute: every request would be a write
|
||||
// per request, which on SQLite means a write lock per request.
|
||||
//
|
||||
// A sliding session moves its end in the same write, and only then: its
|
||||
// expiry is therefore accurate to the minute, which is far finer than the
|
||||
// months it is measured in.
|
||||
if time.Since(s.LastSeenAt) > time.Minute {
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
|
||||
now := time.Now()
|
||||
if s.IdleTTL > 0 && !s.PendingTOTP {
|
||||
s.ExpiresAt = now.Add(s.IdleTTL).UTC()
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
|
||||
now.Unix(), s.ExpiresAt.Unix(), s.ID)
|
||||
} else {
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, now.Unix(), s.ID)
|
||||
}
|
||||
}
|
||||
return &s, user, nil
|
||||
return s, user, nil
|
||||
}
|
||||
|
||||
// MarkAppSession records that a session belongs to the iOS app, what the device
|
||||
// calls itself, and how long it may sit unused (see Session.IdleTTL).
|
||||
//
|
||||
// A login still waiting on its code is marked with idle 0, so it keeps its ten
|
||||
// minutes; PromoteSession turns sliding on once the code is in.
|
||||
func (db *DB) MarkAppSession(ctx context.Context, sessionID, deviceName string, idle time.Duration) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE sessions SET client = 'app', device_name = ?, idle_ttl = ? WHERE id = ?`,
|
||||
truncate(deviceName, 100), int64(idle/time.Second), sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PromoteSession completes a two-step login: the session stops being pending and
|
||||
// its clock restarts, so the full lifetime is counted from finishing the login and
|
||||
// not from typing the password.
|
||||
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error {
|
||||
//
|
||||
// idle above zero makes the finished session slide (see Session.IdleTTL); a
|
||||
// browser passes zero.
|
||||
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl, idle time.Duration) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
|
||||
time.Now().Add(ttl).Unix(), sessionID)
|
||||
`UPDATE sessions SET pending_totp = 0, expires_at = ?, idle_ttl = ? WHERE id = ? AND pending_totp = 1`,
|
||||
time.Now().Add(ttl).Unix(), int64(idle/time.Second), sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -147,7 +192,7 @@ func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
||||
|
||||
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
||||
`SELECT `+sessionColumns+`
|
||||
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -156,17 +201,11 @@ func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error
|
||||
|
||||
var out []Session
|
||||
for rows.Next() {
|
||||
var s Session
|
||||
var pending int
|
||||
var created, lastSeen, expires int64
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
|
||||
s, err := scanSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PendingTOTP = pending == 1
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
out = append(out, s)
|
||||
out = append(out, *s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ nav:
|
||||
- Opening links: links.md
|
||||
- Filters: filters.md
|
||||
- Importing from Todoist: import.md
|
||||
- The iOS app: ios.md
|
||||
- Connecting things:
|
||||
- Calendar: calendar.md
|
||||
- Feeds and CalDAV: caldav.md
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USER } from './user.js';
|
||||
|
||||
/**
|
||||
* The interface the way the iOS app runs it: from a different origin than the
|
||||
* server, with no cookie, its session as a bearer token.
|
||||
*
|
||||
* The real app is served from capacitor://localhost, which a test browser cannot
|
||||
* open. `app.localhost` stands in for it — Chromium sends every *.localhost to
|
||||
* the loopback address, so it is this same server, but to the browser it is a
|
||||
* different origin, and that difference is the whole thing under test. The server
|
||||
* is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config),
|
||||
* and a stub of Capacitor's bridge makes the interface believe it is in the app.
|
||||
*
|
||||
* bypassCSP because the server's policy is written for its own pages
|
||||
* (`connect-src 'self'`); the app's pages are inside the app and never carry it.
|
||||
* Without this the browser would block exactly the cross-origin calls the test
|
||||
* exists to make, and the failure would read as the app not working.
|
||||
*/
|
||||
|
||||
const PORT = 8097;
|
||||
const SERVER = `http://localhost:${PORT}`;
|
||||
|
||||
test.use({
|
||||
baseURL: `http://app.localhost:${PORT}`,
|
||||
bypassCSP: true,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.Capacitor = { isNativePlatform: () => true };
|
||||
});
|
||||
});
|
||||
|
||||
/** The app's session, read the way the app reads it. */
|
||||
const storedToken = (page) =>
|
||||
page.evaluate(() => localStorage.getItem('verdande.app.session') ?? '');
|
||||
|
||||
test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// First the address — and an address with nothing behind it is said to be so,
|
||||
// rather than leading to a sign-in form that cannot succeed.
|
||||
await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible();
|
||||
await page.getByLabel(/Serveradresse/).fill('http://localhost:1');
|
||||
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||
await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Serveradresse/).fill(SERVER);
|
||||
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||
|
||||
// The sign-in form, saying where it signs in to.
|
||||
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
// Signed in by token, and by nothing else: no session cookie on either origin.
|
||||
const token = await storedToken(page);
|
||||
expect(token).not.toBe('');
|
||||
const cookies = await page.context().cookies();
|
||||
expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]);
|
||||
|
||||
// Live sync over a ticketed socket: a project made behind the interface's back
|
||||
// — straight to the API, as another device would — shows up in the sidebar
|
||||
// without a reload. Nothing but the socket can have put it there.
|
||||
const name = `Fra en anden enhed ${Date.now()}`;
|
||||
const status = await page.evaluate(
|
||||
async ({ server, token, name }) => {
|
||||
const res = await fetch(`${server}/api/v1/projects`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
{ server: SERVER, token, name }
|
||||
);
|
||||
expect(status).toBe(201);
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible();
|
||||
|
||||
// A picture from the server, which an <img> cannot fetch with a token. It is
|
||||
// shown from a blob, and the path it came from is kept for the note to save.
|
||||
const path = await page.evaluate(
|
||||
async ({ server, token }) => {
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
const note = await fetch(`${server}/api/v1/notes`, {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: '# Billede fra appen' })
|
||||
}).then((r) => r.json());
|
||||
// A one-pixel PNG.
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='
|
||||
),
|
||||
(c) => c.charCodeAt(0)
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||
const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: auth,
|
||||
body: form
|
||||
}).then((r) => r.json());
|
||||
const img = document.createElement('img');
|
||||
img.id = 'appbillede';
|
||||
img.setAttribute('src', saved.url);
|
||||
document.body.append(img);
|
||||
return saved.url;
|
||||
},
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
const img = page.locator('#appbillede');
|
||||
await expect(img).toHaveAttribute('data-src', path);
|
||||
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||
await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1);
|
||||
|
||||
// Signing out takes the token off the phone, and keeps the server.
|
||||
await page.evaluate(() => document.getElementById('appbillede')?.remove());
|
||||
// Two taps: the first asks, the second signs out.
|
||||
await page.getByRole('button', { name: /Log ud/ }).click();
|
||||
await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click();
|
||||
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||
expect(await storedToken(page)).toBe('');
|
||||
|
||||
// And the token is dead on the server too, not just forgotten here.
|
||||
const after = await page.evaluate(
|
||||
async ({ server, token }) =>
|
||||
(await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } }))
|
||||
.status,
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
expect(after).toBe(401);
|
||||
});
|
||||
|
||||
/**
|
||||
* Signs in the way the app does, outside the page, and hands the page the server
|
||||
* and the session as the app would have stored them.
|
||||
*/
|
||||
async function signedInApp(page) {
|
||||
const origin = { Origin: `http://app.localhost:${PORT}` };
|
||||
const res = await fetch(`${SERVER}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { ...origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: USER.email,
|
||||
password: USER.password,
|
||||
client: 'app',
|
||||
device_name: 'Prøve-iPhone'
|
||||
})
|
||||
});
|
||||
const { session_token: token } = await res.json();
|
||||
expect(token).toBeTruthy();
|
||||
await page.addInitScript(
|
||||
({ server, token }) => {
|
||||
localStorage.setItem('verdande.app.server', server);
|
||||
localStorage.setItem('verdande.app.session', token);
|
||||
},
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
const call = async (method, path, body) => {
|
||||
const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } };
|
||||
if (body instanceof FormData) init.body = body;
|
||||
else if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(`${SERVER}/api/v1${path}`, init);
|
||||
return r.status === 204 ? null : r.json();
|
||||
};
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture in a note is shown from a blob made on the phone. Editing the note
|
||||
* turns the page back into markdown, and what it must write is the path the
|
||||
* picture came from — a blob address saved into a note is a picture that is gone
|
||||
* the next time anybody opens it, on any device.
|
||||
*/
|
||||
test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({
|
||||
page
|
||||
}) => {
|
||||
const call = await signedInApp(page);
|
||||
|
||||
const note = await call('POST', '/notes', { body: '# Billednote fra appen' });
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||
const saved = await call('POST', `/notes/${note.id}/attachments`, form);
|
||||
await call('PATCH', `/notes/${note.id}`, {
|
||||
body: `# Billednote fra appen\n\n\n\nTekst under billedet`
|
||||
});
|
||||
|
||||
await page.goto('/noter?note=' + note.id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const img = ed.locator('img');
|
||||
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||
await expect(img).toHaveAttribute('data-src', saved.url);
|
||||
|
||||
// Touch the note so it saves, and wait for the save itself.
|
||||
const patched = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok()
|
||||
);
|
||||
await ed.getByText('Tekst under billedet').click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' og mere');
|
||||
await patched;
|
||||
|
||||
const after = await call('GET', `/notes/${note.id}`);
|
||||
expect(after.body).toContain('og mere');
|
||||
expect(after.body).toContain(`](${saved.url})`);
|
||||
expect(after.body).not.toContain('blob:');
|
||||
});
|
||||
@@ -5440,6 +5440,84 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* Et billede bliver større af at blive åbnet — ikke mindre.
|
||||
*
|
||||
* Rapporteret som "billederne er meget små, når man klikker på dem". Ruden havde
|
||||
* kun `max-width` og `max-height`, så den kunne gøre et billede mindre og aldrig
|
||||
* større. Målt i den rigtige app: et lille billede (600×400) stod 600 pixels bredt
|
||||
* i noten og 600 i ruden, og et højt skærmbillede fra en telefon stod 701 bredt i
|
||||
* noten og 357 i ruden. At klikke for at se billedet bedre gav et dårligere billede.
|
||||
*
|
||||
* Målt på kasserne, med tal: "billedet er synligt" var sandt hele tiden.
|
||||
*/
|
||||
test('et åbnet billede fylder skærmen og kan ses i fuld størrelse', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill('Billednote med mål');
|
||||
|
||||
const paste = (w, h) =>
|
||||
body.evaluate(async (el, [w, h]) => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = '#c33';
|
||||
g.fillRect(0, 0, w, h);
|
||||
const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
|
||||
const data = new DataTransfer();
|
||||
data.items.add(new File([blob], 'skud.png', { type: 'image/png' }));
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
|
||||
}, [w, h]);
|
||||
|
||||
const viewport = page.viewportSize();
|
||||
const shot = page.getByRole('dialog', { name: 'Billede' });
|
||||
const big = shot.locator('img');
|
||||
|
||||
// Et lille billede: ruden forstørrer det, til det fylder skærmen i den ene led.
|
||||
await paste(600, 400);
|
||||
const small = page.locator('[aria-label="Notens tekst"] img').last();
|
||||
await expect(small).toHaveJSProperty('complete', true);
|
||||
const inNote = await small.boundingBox();
|
||||
await small.click();
|
||||
await expect(big).toHaveClass(/sized/);
|
||||
const fit = await big.boundingBox();
|
||||
expect(fit.width).toBeGreaterThan(inNote.width * 1.5);
|
||||
expect(fit.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(fit.y + fit.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Fuld størrelse er aldrig mindre end det, man kom fra.
|
||||
await shot.getByRole('button', { name: 'Fuld størrelse' }).click();
|
||||
const full = await big.boundingBox();
|
||||
expect(full.width).toBeGreaterThanOrEqual(fit.width);
|
||||
await shot.getByRole('button', { name: 'Tilpas skærmen' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shot).toHaveCount(0);
|
||||
|
||||
// Et højt skærmbillede fra en telefon: i fuld størrelse er det mindst lige så
|
||||
// bredt som i noten, og ruden ruller, så hele billedet kan nås.
|
||||
await paste(1170, 2532);
|
||||
const tall = page.locator('[aria-label="Notens tekst"] img').last();
|
||||
await expect(tall).toHaveJSProperty('complete', true);
|
||||
const tallInNote = await tall.boundingBox();
|
||||
await tall.click();
|
||||
await expect(big).toHaveClass(/sized/);
|
||||
await big.click();
|
||||
const tallFull = await big.boundingBox();
|
||||
expect(tallFull.width).toBeGreaterThanOrEqual(tallInNote.width);
|
||||
expect(tallFull.width).toBeGreaterThanOrEqual(1170);
|
||||
const scroll = await shot.evaluate((d) => d.scrollHeight - d.clientHeight);
|
||||
expect(scroll).toBeGreaterThan(1000);
|
||||
// Et klik på billedet igen bringer det tilbage til skærmens mål.
|
||||
await big.click();
|
||||
const back = await big.boundingBox();
|
||||
expect(back.y + back.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En opgaves tekst kan læses færdig, også på en telefon.
|
||||
*
|
||||
|
||||
@@ -81,7 +81,8 @@ export default defineConfig({
|
||||
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
||||
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||
testIgnore: /edition\.spec\.js/,
|
||||
// Og app-prøven, der kører fladen fra en anden origin med sin egen login.
|
||||
testIgnore: /(edition|app)\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
@@ -120,6 +121,19 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||
},
|
||||
|
||||
/**
|
||||
* Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden
|
||||
* cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`,
|
||||
* fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens
|
||||
* cookie, for det er netop det, appen ikke har.
|
||||
*/
|
||||
{
|
||||
name: 'app-origin',
|
||||
testMatch: /app\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
@@ -164,7 +178,10 @@ export default defineConfig({
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
VERDANDE_UPDATE_CHECK: 'false',
|
||||
// Den origin, app-origin-projektet kører fladen fra, regnet som appens
|
||||
// egen — som capacitor://localhost er det i den rigtige app.
|
||||
VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}`
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Renders the iOS app's icon and launch image from static/icon.svg.
|
||||
*
|
||||
* node scripts/generate-ios-assets.js
|
||||
*
|
||||
* Same reasoning as generate-icons.js: a script, not a test, run when the icon
|
||||
* changes, with Chromium as the SVG renderer because it is already here.
|
||||
*
|
||||
* Two things differ from the web icons, and both are Apple's rules rather than
|
||||
* taste. The app icon is square with no transparency — iOS draws the rounded
|
||||
* corners itself, and rounded corners already in the image show as a dark ring
|
||||
* inside its own mask — so the tile's `rx` is dropped and the result flattened.
|
||||
* And the launch image is the rune small on the tile colour, the size
|
||||
* Capacitor's template asks for, so the app opens on the colour it is about to
|
||||
* draw instead of on white.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const ICONS = '../app/ios/App/App/Assets.xcassets';
|
||||
const svg = readFileSync('static/icon.svg', 'utf8').replace(/ rx="[^"]*"/, '');
|
||||
const tile = /<rect[^>]*fill="(#[0-9a-fA-F]{6})"/.exec(svg)?.[1];
|
||||
if (!tile) throw new Error('static/icon.svg has no tile colour to paint the launch image with');
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
async function render(file, size, markSize) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0;background:${tile}}` +
|
||||
`div{width:${size}px;height:${size}px;display:grid;place-items:center}` +
|
||||
`svg{display:block;width:${markSize}px;height:${markSize}px}</style><div>${svg}</div>`
|
||||
);
|
||||
writeFileSync(file, await page.locator('div').screenshot({ omitBackground: false }));
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
|
||||
await render(`${ICONS}/AppIcon.appiconset/AppIcon-512@2x.png`, 1024, 1024);
|
||||
// Capacitor's template names three files for the one launch image; all the same.
|
||||
for (const name of ['splash-2732x2732.png', 'splash-2732x2732-1.png', 'splash-2732x2732-2.png']) {
|
||||
await render(`${ICONS}/Splash.imageset/${name}`, 2732, 640);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
+33
-6
@@ -24,6 +24,7 @@ export class ApiError extends Error {
|
||||
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* How long a request keeps retrying a network failure before giving up.
|
||||
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {},
|
||||
// The session cookie is httpOnly and same-origin; nothing here handles a token.
|
||||
credentials: 'same-origin',
|
||||
signal: options.signal
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
// In a browser: the httpOnly session cookie, same-origin, and nothing here
|
||||
// handles a token. In the iOS app: its session token as a bearer. See
|
||||
// platform.js.
|
||||
authorize(init);
|
||||
|
||||
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
|
||||
form.append(field, file);
|
||||
|
||||
const res = await withRetry(() =>
|
||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
@@ -232,9 +235,30 @@ export const api = {
|
||||
// --- auth
|
||||
setupState: () => get('/auth/setup'),
|
||||
setup: (data) => post('/auth/setup', data),
|
||||
login: (email, password) => post('/auth/login', { email, password }),
|
||||
login: async (email, password) => {
|
||||
if (!isNativeApp()) return post('/auth/login', { email, password });
|
||||
// The app gets its session in the answer instead of a cookie, and keeps
|
||||
// it — also while a code is still owed, because the second step is sent
|
||||
// with it.
|
||||
const result = await post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
client: 'app',
|
||||
device_name: deviceName()
|
||||
});
|
||||
if (result?.session_token) appSession.setToken(result.session_token);
|
||||
return result;
|
||||
},
|
||||
loginTOTP: (code) => post('/auth/login/totp', { code }),
|
||||
logout: () => post('/auth/logout'),
|
||||
logout: async () => {
|
||||
try {
|
||||
return await post('/auth/logout');
|
||||
} finally {
|
||||
// Gone from the phone whether or not the server heard: a sign-out that
|
||||
// left the token behind because the network was down is not one.
|
||||
if (isNativeApp()) appSession.signOut();
|
||||
}
|
||||
},
|
||||
me: () => get('/auth/me'),
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
@@ -421,6 +445,9 @@ export const api = {
|
||||
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
||||
deleteToken: (id) => del(`/tokens/${id}`),
|
||||
|
||||
// --- live sync, for the app (see stores.svelte.js)
|
||||
wsTicket: () => post('/ws-ticket'),
|
||||
|
||||
// --- notifications and push
|
||||
listNotifications: () => get('/notifications'),
|
||||
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||
* is the file to change.
|
||||
*/
|
||||
import { tick } from 'svelte';
|
||||
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
@@ -756,6 +757,8 @@
|
||||
if (image) {
|
||||
event.preventDefault();
|
||||
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||
zoomed = false;
|
||||
natural = null;
|
||||
copied = false;
|
||||
copyError = '';
|
||||
return;
|
||||
@@ -769,6 +772,49 @@
|
||||
let copied = $state(false);
|
||||
let copyError = $state('');
|
||||
|
||||
// Ruden viste billedet med `max-width`/`max-height` og intet andet, så det kunne
|
||||
// blive mindre, men aldrig større. Det var mindre end i noten, det kom fra: et
|
||||
// skærmbillede fra en telefon på en skærm i bredformat stod 701 pixels bredt i
|
||||
// arket og 357 i ruden, og et lille billede blev slet ikke forstørret. Nu fylder
|
||||
// det skærmen — også når det skal forstørres for at gøre det — og et klik på
|
||||
// billedet viser det i sin egen størrelse, til at rulle rundt i.
|
||||
let zoomed = $state(false);
|
||||
/** @type {{ w: number, h: number } | null} */
|
||||
let natural = $state(null);
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let lightbox = $state(null);
|
||||
|
||||
/** @param {Event & { currentTarget: HTMLImageElement }} event */
|
||||
function measured(event) {
|
||||
const img = event.currentTarget;
|
||||
natural = { w: img.naturalWidth, h: img.naturalHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Skift mellem "hele skærmen" og "fuld størrelse".
|
||||
*
|
||||
* Fra et klik på billedet rulles der hen til det sted, man klikkede, så man
|
||||
* ikke lander i øverste venstre hjørne og skal lede efter det, man ville se.
|
||||
*
|
||||
* @param {MouseEvent} [event]
|
||||
*/
|
||||
async function toggleZoom(event) {
|
||||
const img = lightbox?.querySelector('.shot');
|
||||
let fx = 0.5;
|
||||
let fy = 0.5;
|
||||
if (event?.clientX !== undefined && event.detail > 0 && img) {
|
||||
const r = img.getBoundingClientRect();
|
||||
fx = (event.clientX - r.left) / r.width;
|
||||
fy = (event.clientY - r.top) / r.height;
|
||||
}
|
||||
zoomed = !zoomed;
|
||||
await tick();
|
||||
if (!zoomed || !img || !lightbox) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
lightbox.scrollLeft += r.left + r.width * fx - lightbox.clientWidth / 2;
|
||||
lightbox.scrollTop += r.top + r.height * fy - lightbox.clientHeight / 2;
|
||||
}
|
||||
|
||||
function closeImage() {
|
||||
showing = null;
|
||||
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||
@@ -1293,15 +1339,35 @@
|
||||
af netop det ark, det skal lægge sig over. -->
|
||||
{#if showing}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||
<div
|
||||
class="lightbox"
|
||||
class:zoomed
|
||||
bind:this={lightbox}
|
||||
onclick={closeImage}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('notes.imageTitle')}
|
||||
style:--w={natural?.w}
|
||||
style:--h={natural?.h}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="shot"
|
||||
class:sized={natural}
|
||||
src={showing.src}
|
||||
alt={showing.alt}
|
||||
onload={measured}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom(e);
|
||||
}}
|
||||
/>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||
<img src={showing.src} alt={showing.alt} />
|
||||
<div class="tools">
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
</div>
|
||||
<div class="tools" onclick={(e) => e.stopPropagation()}>
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<button onclick={toggleZoom}>{zoomed ? t('notes.imageZoomOut') : t('notes.imageZoomIn')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1318,43 +1384,77 @@
|
||||
|
||||
<style>
|
||||
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||
står alene — det er hele grunden til at åbne det. */
|
||||
står alene — det er hele grunden til at åbne det.
|
||||
|
||||
Centreret med `margin: auto` på billedet og ikke med `justify-content`: i
|
||||
fuld størrelse er billedet større end ruden, og et flex-element, der er
|
||||
centreret og for stort, stikker lige meget ud til begge sider — den venstre og
|
||||
den øverste del kan man så ikke rulle hen til. */
|
||||
.lightbox {
|
||||
--pad: var(--s4);
|
||||
--bar: 4rem;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
background: rgb(0 0 0 / 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
padding: var(--pad) var(--pad) calc(var(--bar) + var(--pad));
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* På en telefon bryder de fire knapper over i to rækker. */
|
||||
@media (max-width: 600px) {
|
||||
.lightbox {
|
||||
--pad: var(--s2);
|
||||
--bar: 6.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||
passe sig ind i den. */
|
||||
.shot img {
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 8rem);
|
||||
object-fit: contain;
|
||||
margin: auto;
|
||||
flex: none;
|
||||
max-width: calc(100vw - 2 * var(--pad));
|
||||
max-height: calc(100dvh - 2 * var(--pad) - var(--bar));
|
||||
border-radius: var(--radius);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Så snart billedets egne mål kendes, fylder det skærmen: den største bredde,
|
||||
der både er inden for skærmens bredde og — ved billedets form — inden for dens
|
||||
højde. Det forstørrer et lille billede, hvad `max-width` alene aldrig gjorde. */
|
||||
.shot.sized {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
width: min(
|
||||
calc(100vw - 2 * var(--pad)),
|
||||
calc((100dvh - 2 * var(--pad) - var(--bar)) * var(--w) / var(--h))
|
||||
);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Fuld størrelse: billedets egne pixels, og ruden ruller. Aldrig smallere end
|
||||
skærmen — et lille billede ville ellers blive MINDRE af at blive forstørret, og
|
||||
et højt skærmbillede fra en telefon skal kunne læses i hele skærmens bredde. */
|
||||
.zoomed .shot.sized {
|
||||
width: max(calc(var(--w) * 1px), calc(100vw - 2 * var(--pad)));
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
/* Knapperne ligger fast nederst, så de er der, uanset hvor man har rullet hen
|
||||
i et billede i fuld størrelse. */
|
||||
.tools {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: var(--s3);
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2 * var(--s3));
|
||||
}
|
||||
|
||||
.tools button,
|
||||
@@ -1374,6 +1474,8 @@
|
||||
}
|
||||
|
||||
.bad {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
|
||||
@@ -21,9 +21,56 @@
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { available, signIn } from '$lib/passkey.js';
|
||||
import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
// loading | server | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
|
||||
// --- which server (the iOS app only) ------------------------------------------
|
||||
|
||||
const native = isNativeApp();
|
||||
let server = $state(native ? appSession.server() : '');
|
||||
let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : '');
|
||||
|
||||
/**
|
||||
* The app has no server of its own. Before a password field is shown for an
|
||||
* address somebody typed, the address has to be a Verdande: asked with /ping,
|
||||
* which answers without a login and names the product. A router's login page,
|
||||
* a typo that lands on somebody's blog, a server that is down — each gets its
|
||||
* own sentence here instead of a sign-in form that cannot succeed.
|
||||
*/
|
||||
async function chooseServer() {
|
||||
const url = normaliseServer(server);
|
||||
if (!url) {
|
||||
error = t('app.serverInvalid');
|
||||
return;
|
||||
}
|
||||
let answer;
|
||||
try {
|
||||
const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' });
|
||||
answer = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') });
|
||||
return;
|
||||
}
|
||||
if (answer?.product !== 'verdande') {
|
||||
error = t('app.notVerdande');
|
||||
return;
|
||||
}
|
||||
appSession.setServer(url);
|
||||
server = url;
|
||||
await enterServer();
|
||||
}
|
||||
|
||||
/** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */
|
||||
async function enterServer() {
|
||||
try {
|
||||
const state = await api.setupState();
|
||||
mode = state.needs_setup ? 'setup' : 'login';
|
||||
} catch {
|
||||
mode = 'login';
|
||||
}
|
||||
}
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
@@ -37,6 +84,9 @@
|
||||
let passkeyReady = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Not in the app: a passkey is bound to the server's own domain, and the
|
||||
// app's pages are not on it. Offered there it would fail every time.
|
||||
if (native) return;
|
||||
available().then((ok) => (passkeyReady = ok));
|
||||
});
|
||||
|
||||
@@ -94,10 +144,11 @@
|
||||
mode = linkMode;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.setupState()
|
||||
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
||||
.catch(() => (mode = 'login'));
|
||||
if (native && !appSession.server()) {
|
||||
mode = 'server';
|
||||
return;
|
||||
}
|
||||
enterServer();
|
||||
});
|
||||
|
||||
async function run(fn) {
|
||||
@@ -120,6 +171,10 @@
|
||||
|
||||
run(async () => {
|
||||
switch (mode) {
|
||||
case 'server':
|
||||
await chooseServer();
|
||||
break;
|
||||
|
||||
case 'setup':
|
||||
await api.setup({
|
||||
email,
|
||||
@@ -190,6 +245,34 @@
|
||||
|
||||
{#if mode === 'loading'}
|
||||
<p class="lede"> </p>
|
||||
{:else if mode === 'server'}
|
||||
<p class="lede">{t('app.serverPrompt')}</p>
|
||||
|
||||
<label>
|
||||
{t('app.server')}
|
||||
<input
|
||||
bind:value={server}
|
||||
use:focusOnMount
|
||||
type="url"
|
||||
inputmode="url"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
placeholder="verdande.example.dk"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{t('app.serverHint')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>{t('app.connect')}</button>
|
||||
{:else if mode === 'setup' && native}
|
||||
<!-- The first account starts a browser session, and the app has no use
|
||||
for a cookie. An instance this new is being set up by whoever runs
|
||||
it, at a desk; they do it there, and the app signs in after. -->
|
||||
<p class="lede">{t('app.setupInBrowser', { host: serverHost })}</p>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
{:else if mode === 'setup'}
|
||||
<p class="lede">{t('auth.firstAccount')}</p>
|
||||
|
||||
@@ -303,6 +386,15 @@
|
||||
{:else}
|
||||
<p class="lede">{t('auth.signInPrompt')}</p>
|
||||
|
||||
{#if native}
|
||||
<p class="server-line">
|
||||
<span class="mono">{serverHost}</span>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
@@ -463,6 +555,23 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Which server the app is signing in to, and the way back to choosing. */
|
||||
.server-line {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.server-line .mono {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
@@ -177,6 +177,8 @@ export const da = {
|
||||
'notes.imageCopy': 'Kopiér billedet',
|
||||
'notes.imageCopied': 'Kopieret',
|
||||
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||
'notes.imageZoomIn': 'Fuld størrelse',
|
||||
'notes.imageZoomOut': 'Tilpas skærmen',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
@@ -734,6 +736,15 @@ export const da = {
|
||||
'auth.savePassword': 'Gem adgangskoden',
|
||||
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
|
||||
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
|
||||
'app.serverPrompt': 'Hvilken Verdande vil du logge ind på?',
|
||||
'app.server': 'Serveradresse',
|
||||
'app.serverHint': 'Den adresse, du åbner Verdande på i en browser.',
|
||||
'app.connect': 'Fortsæt',
|
||||
'app.serverInvalid': 'Det ligner ikke en adresse.',
|
||||
'app.serverUnreachable': 'Kunne ikke nå {host}. Tjek adressen og din forbindelse.',
|
||||
'app.notVerdande': 'Der svarer noget på den adresse, men det er ikke en Verdande.',
|
||||
'app.changeServer': 'Skift server',
|
||||
'app.setupInBrowser': '{host} har ingen konti endnu. Opret den første i en browser, og log så ind her.',
|
||||
'auth.signInPrompt': 'Log ind for at fortsætte.',
|
||||
'auth.signIn': 'Log ind',
|
||||
'auth.forgot': 'Glemt adgangskode?',
|
||||
|
||||
@@ -174,6 +174,8 @@ export const en = {
|
||||
'notes.imageCopy': 'Copy the image',
|
||||
'notes.imageCopied': 'Copied',
|
||||
'notes.imageOpen': 'Open in a new tab',
|
||||
'notes.imageZoomIn': 'Full size',
|
||||
'notes.imageZoomOut': 'Fit the screen',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
@@ -728,6 +730,15 @@ export const en = {
|
||||
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
|
||||
'auth.resetSent':
|
||||
'If that address has an account, a link is on its way. Check the spam filter too.',
|
||||
'app.serverPrompt': 'Which Verdande do you want to sign in to?',
|
||||
'app.server': 'Server address',
|
||||
'app.serverHint': 'The address you open Verdande at in a browser.',
|
||||
'app.connect': 'Continue',
|
||||
'app.serverInvalid': 'That does not look like an address.',
|
||||
'app.serverUnreachable': 'Could not reach {host}. Check the address and your connection.',
|
||||
'app.notVerdande': 'Something answers at that address, but it is not a Verdande.',
|
||||
'app.changeServer': 'Change server',
|
||||
'app.setupInBrowser': '{host} has no accounts yet. Create the first one in a browser, then sign in here.',
|
||||
'auth.signInPrompt': 'Sign in to continue.',
|
||||
'auth.signIn': 'Sign in',
|
||||
'auth.forgot': 'Forgotten your password?',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pictures and files from the server, inside the iOS app.
|
||||
*
|
||||
* Attachments are addressed as `/api/v1/attachments/<id>` — in a note's text, in
|
||||
* a task's file list, in the export links — and in a browser that is the whole
|
||||
* story: the path is on the page's own origin and the cookie goes with it. In the
|
||||
* app neither holds. The path would resolve against capacitor://localhost, and an
|
||||
* `<img>` or a link cannot carry the bearer token even when pointed at the right
|
||||
* server.
|
||||
*
|
||||
* So in the app, and only there, this watches the page: an image whose source is
|
||||
* an API path is fetched with the token and shown from a blob, and a tap on a
|
||||
* link to an API path is answered the same way. Done once for the whole document
|
||||
* rather than in each component, because the paths come from many places — a
|
||||
* note's own markdown among them — and a component that forgot would show a
|
||||
* broken picture only in the app, where nobody runs the tests.
|
||||
*
|
||||
* The original path is kept in `data-src`. The note editor turns the page back
|
||||
* into markdown, and a blob address saved into a note is a picture that is gone
|
||||
* the next time anybody opens it; `richtext.js` reads `data-src` first.
|
||||
*/
|
||||
import { apiURL, authorize, isNativeApp } from './platform.js';
|
||||
|
||||
const API = '/api/v1/';
|
||||
const blobs = new Map();
|
||||
|
||||
async function blobFor(path) {
|
||||
if (!blobs.has(path)) {
|
||||
const promise = fetch(apiURL(path.slice(API.length - 1)), authorize({ method: 'GET' })).then(
|
||||
async (res) => {
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
);
|
||||
// A failure is not cached: the next sight of the picture tries again.
|
||||
promise.catch(() => blobs.delete(path));
|
||||
blobs.set(path, promise);
|
||||
}
|
||||
return blobs.get(path);
|
||||
}
|
||||
|
||||
function adopt(img) {
|
||||
const path = img.getAttribute('src');
|
||||
// Our own blob address coming back through the observer is not an API path,
|
||||
// so it stops here; a new API path on an image already adopted is taken up.
|
||||
if (!path?.startsWith(API) || img.dataset.src === path) return;
|
||||
img.dataset.src = path;
|
||||
// Nothing until the picture is here, rather than a broken-image glyph.
|
||||
img.removeAttribute('src');
|
||||
blobFor(path)
|
||||
.then((url) => {
|
||||
if (img.dataset.src === path) img.src = url;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function scan(root) {
|
||||
if (root.nodeType !== 1) return;
|
||||
if (root.tagName === 'IMG') adopt(root);
|
||||
for (const img of root.querySelectorAll?.('img[src^="/api/v1/"]') ?? []) adopt(img);
|
||||
}
|
||||
|
||||
async function openLink(event) {
|
||||
const a = event.target.closest?.('a[href]');
|
||||
const path = a?.getAttribute('href');
|
||||
if (!path?.startsWith(API)) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
window.open(await blobFor(path), '_blank');
|
||||
} catch {
|
||||
// Nothing to open; the request's own failure is what there is to say,
|
||||
// and there is no screen here to say it on.
|
||||
}
|
||||
}
|
||||
|
||||
/** Called once by the layout. A no-op in a browser. */
|
||||
export function installNativeMedia() {
|
||||
if (!isNativeApp() || typeof document === 'undefined') return;
|
||||
scan(document.body);
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === 'attributes') adopt(record.target);
|
||||
else for (const node of record.addedNodes) scan(node);
|
||||
}
|
||||
}).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src']
|
||||
});
|
||||
document.addEventListener('click', openLink, true);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Where the interface is running, and what that changes about reaching the server.
|
||||
*
|
||||
* In a browser the interface and the API share an origin: every path is relative
|
||||
* and the session is an httpOnly cookie no script can see. In the iOS app the
|
||||
* interface is bundled into the app and served from capacitor://localhost, and
|
||||
* the server is whichever one its owner typed in. So the app needs three things
|
||||
* a browser never does: the server's address, its own session token, and that
|
||||
* token on every request instead of a cookie.
|
||||
*
|
||||
* Everything that differs between the two lives here, so `api.js` and the socket
|
||||
* ask one question — `apiURL(path)`, `authorize(init)` — and the browser's answer
|
||||
* is exactly what it was before this file existed.
|
||||
*/
|
||||
|
||||
/** True inside the iOS app. Capacitor puts its bridge on `window` before any of
|
||||
* our code runs. */
|
||||
export function isNativeApp() {
|
||||
return !!globalThis.Capacitor?.isNativePlatform?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's stored settings: which server, and the session on it.
|
||||
*
|
||||
* localStorage for now, behind this one object, so the Keychain plugin can take
|
||||
* its place without anything else changing. The token is a session — whoever
|
||||
* holds it is signed in — and the Keychain is where a phone keeps those; until it
|
||||
* is wired in, this is a WKWebView's own storage, readable only by the app.
|
||||
*/
|
||||
const SERVER_KEY = 'verdande.app.server';
|
||||
const TOKEN_KEY = 'verdande.app.session';
|
||||
|
||||
function read(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage refused (private mode, quota). The app then asks again next
|
||||
// launch, which is the right failure: a token that seemed saved and was
|
||||
// not would look like being signed out for no reason.
|
||||
}
|
||||
}
|
||||
|
||||
export const appSession = {
|
||||
server: () => read(SERVER_KEY),
|
||||
setServer: (url) => write(SERVER_KEY, url),
|
||||
token: () => read(TOKEN_KEY),
|
||||
setToken: (token) => write(TOKEN_KEY, token),
|
||||
/** Signed out: the token goes, the server stays — signing in again is to the
|
||||
* same place nearly every time. */
|
||||
signOut: () => write(TOKEN_KEY, '')
|
||||
};
|
||||
|
||||
/** The address of an API path: relative in a browser, on the chosen server in
|
||||
* the app. */
|
||||
export function apiURL(path) {
|
||||
const base = isNativeApp() ? appSession.server() : '';
|
||||
return `${base}/api/v1${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in how a request proves who is asking.
|
||||
*
|
||||
* A browser sends its cookie, same-origin only. The app sends no cookie at all —
|
||||
* `omit`, so nothing the WebView picked up from somewhere rides along — and its
|
||||
* token as a bearer.
|
||||
*/
|
||||
export function authorize(init) {
|
||||
if (!isNativeApp()) {
|
||||
init.credentials = 'same-origin';
|
||||
return init;
|
||||
}
|
||||
init.credentials = 'omit';
|
||||
const token = appSession.token();
|
||||
if (token) {
|
||||
init.headers = { ...(init.headers ?? {}), Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
/** What the app calls itself in the list of signed-in devices. */
|
||||
export function deviceName() {
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPad/.test(ua)) return 'iPad';
|
||||
if (/iPhone/.test(ua)) return 'iPhone';
|
||||
return 'Verdande-appen';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises what somebody typed as their server's address.
|
||||
*
|
||||
* "verdande.example.dk" becomes "https://verdande.example.dk"; a trailing slash
|
||||
* and a pasted "/api/v1" are dropped. Plain http is kept when typed on purpose,
|
||||
* because a self-hoster on their own network may have no certificate — iOS will
|
||||
* refuse it unless the app allows it, and that refusal should be the one they see.
|
||||
*/
|
||||
export function normaliseServer(input) {
|
||||
let s = (input ?? '').trim();
|
||||
if (!s) return '';
|
||||
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return `${u.protocol}//${u.host}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@
|
||||
|
||||
import { api } from './api.js';
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { isNativeApp } from './platform.js';
|
||||
|
||||
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
||||
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
||||
export function supported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
|
||||
// has no push service, and a subscription made there would go nowhere.
|
||||
!isNativeApp() &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
|
||||
@@ -267,7 +267,10 @@ function inlineToMarkdown(node) {
|
||||
// — og den første gemning ville tage hvert billede ud af noten uden at
|
||||
// nogen havde rørt det.
|
||||
case 'IMG':
|
||||
return ` ?? ''})`;
|
||||
// data-src first: in the iOS app the shown source is a blob made on this
|
||||
// device, and the path it was made from is what the note keeps. See
|
||||
// nativemedia.js.
|
||||
return ` || ''})`;
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return inner ? `**${inner}**` : '';
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n, t } from './i18n.svelte.js';
|
||||
import { appSession, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* Where the live-sync socket is. A browser opens it on its own origin and the
|
||||
* cookie goes with the handshake. The app cannot put its token on a handshake,
|
||||
* so it asks for a one-use ticket first and opens the socket on its server with
|
||||
* that.
|
||||
*/
|
||||
async function socketURL() {
|
||||
if (!isNativeApp()) {
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${scheme}://${location.host}/api/v1/ws`;
|
||||
}
|
||||
const { ticket } = await api.wsTicket();
|
||||
const base = appSession.server().replace(/^http/, 'ws');
|
||||
return `${base}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -229,6 +246,14 @@ class AppState {
|
||||
async load() {
|
||||
this.loading = true;
|
||||
|
||||
// The app before it knows which server it belongs to: nothing to ask, and
|
||||
// the sign-in screen is where the address is asked for.
|
||||
if (isNativeApp() && !appSession.server()) {
|
||||
this.user = null;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
||||
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
||||
// linje ville ansigtet falde af ved hvert F5.
|
||||
@@ -281,10 +306,23 @@ class AppState {
|
||||
|
||||
// --- realtime ---------------------------------------------------------------
|
||||
|
||||
connect() {
|
||||
async connect() {
|
||||
if (this.#socket) return;
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
|
||||
// Claimed before the await below, so two calls in the same moment cannot
|
||||
// both open one.
|
||||
this.#socket = 'opening';
|
||||
let url;
|
||||
try {
|
||||
url = await socketURL();
|
||||
} catch {
|
||||
// No ticket — offline, or signed out. Try again the way a dropped
|
||||
// socket does.
|
||||
this.#socket = null;
|
||||
setTimeout(() => this.connect(), this.#reconnectDelay);
|
||||
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
|
||||
return;
|
||||
}
|
||||
const socket = new WebSocket(url);
|
||||
this.#socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
||||
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
||||
import { installNativeMedia } from '$lib/nativemedia.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -58,6 +59,12 @@
|
||||
app.load();
|
||||
});
|
||||
|
||||
// Pictures and downloads from the server, in the iOS app. A no-op in a
|
||||
// browser. See nativemedia.js.
|
||||
$effect(() => {
|
||||
installNativeMedia();
|
||||
});
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue er inde ad.
|
||||
*
|
||||
|
||||
@@ -45,6 +45,17 @@ export default {
|
||||
*/
|
||||
version: {
|
||||
name: process.env.VERDANDE_VERSION ?? 'dev'
|
||||
},
|
||||
|
||||
/**
|
||||
* Not in the iOS app. WKWebView does not run service workers for an app's
|
||||
* own scheme, and the offline cache it would keep belongs to an interface
|
||||
* served by the instance — the app carries its interface inside itself.
|
||||
* `VERDANDE_TARGET=app` is set by the app's build; the browser build is
|
||||
* unchanged.
|
||||
*/
|
||||
serviceWorker: {
|
||||
register: process.env.VERDANDE_TARGET !== 'app'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user