Compare commits

...
12 Commits
Author SHA1 Message Date
Kristian Wind ec5cf8f9f9 Merge pull request #108 from kristianwind/app-shell
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 16m4s
CI / Docker image builds (push) Successful in 3m1s
CI / Go (push) Failing after 26m23s
feat(app): iOS-skallen — Capacitor, Xcode-projektet og et CI-job, der bygger den
2026-10-23 14:33:23 +02:00
Kristian Wind 4ddc41632b Merge pull request #107 from kristianwind/app-base
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 16m41s
CI / Docker image builds (push) Successful in 2m50s
CI / Go (push) Failing after 26m54s
feat(app): fladen kan tale med en valgfri server, med sessionen som bearer
2026-10-23 14:28:20 +02:00
Kristian Wind 2e9a92996e Merge pull request #106 from kristianwind/app-cors
CI / OpenAPI is valid (push) Successful in 13s
CI / Go (push) Failing after 26m32s
CI / End-to-end (push) Successful in 15m7s
CI / Docker image builds (push) Successful in 2m30s
feat(app): CORS for appen, en billet til live-synk, og /ping siger hvad den er
2026-10-23 14:23:48 +02:00
kristianwindandClaude Opus 5.5 ce1e12417b feat(app): iOS-skallen — Capacitor, Xcode-projektet og et CI-job, der bygger den
Femte trin mod iOS-appen. Fladen fra trin 3 pakket ind i en rigtig app.

- `app/`: Capacitor 8 med SPM (ingen CocoaPods). `npm run sync` bygger web
  med VERDANDE_TARGET=app og kopierer det ind i Xcode-projektet; `npm run
  open` åbner det på en Mac. Bundle-id `dk.nolimit.verdande`.
- `app/ios/` er committet. Webbygget og den genererede konfiguration i det er
  ikke — sync lægger dem der.
- `NSAllowsLocalNetworking`: en hjemmeserver uden certifikat kan nås med http
  på det lokale net; alt på internettet skal stadig være https.
- Ikon og opstartsbillede tegnes fra web/static/icon.svg
  (scripts/generate-ios-assets.js). Kvadratisk og uden gennemsigtighed, fordi
  iOS runder hjørnerne selv.
- CI-jobbet "iOS app shell" bygger og synker på Linux og tjekker, at
  app-bygget ikke registrerer en service worker. Ingen Xcode i CI; det dækker
  alt op til den.
- docs/ios.md: hvordan appen adskiller sig fra en browser, hvordan den bygges,
  hvad der er ejerens skridt (Apple-konto, signering, TestFlight), og hvad der
  mangler — notifikationer via APNs venter på en beslutning om en relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:21:44 +02:00
kristianwindandClaude Opus 5.5 b9028e8f7a feat(app): fladen kan tale med en valgfri server, med sessionen som bearer
Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver
sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i
appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind.
Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er
præcis, hvad det var før.

- `apiURL()` og `authorize()`: relative stier og cookie i en browser; den
  valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`.
- Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra
  telefonen, også når serveren ikke svarede.
- Live-synk i appen beder om en billet og åbner socket'en på serveren med den.
- Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen
  prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router
  eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af
  første konto vises ikke i appen; begge hører til serverens eget domæne.
- Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob,
  i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og
  `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse.
- Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem.

Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes
for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme
server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger
server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en,
viser et billede fra en blob og logger ud.

Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer,
socket uden billet, billeder ikke hentet, token efterladt ved log ud.

Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:16:33 +02:00
Kristian Wind 3867467f24 Merge pull request #105 from kristianwind/app-login
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 3m32s
CI / Go (push) Failing after 26m58s
feat(login): appen kan logge ind og bære sin session som bearer
2026-10-23 14:08:41 +02:00
kristianwindandClaude Opus 5.5 a71af41d9e feat(app): CORS for appen, en billet til live-synk, og /ping siger hvad den er
Andet trin mod iOS-appen. Appen henter sin flade fra capacitor://localhost og
taler med den server, ejeren har skrevet ind, så hver anmodning er cross-origin.

- CORS på /api/v1 for appens origin og ingen andres. Ingen
  Allow-Credentials: appen bærer sin session som bearer, aldrig som cookie.
  Alle andre origins får ingen headere, præcis som før.
- POST /ws-ticket giver en engangsbillet, der lever 30 sekunder, og
  /ws?ticket=… åbner socket'en med den. En browser kan ikke sætte en header
  på et WebSocket-håndtryk, og tokenet selv i URL'en ville ligge i hver
  proxy-log i månedsvis. En socket med billet springer origin-tjekket over,
  fordi den ikke bærer nogen cookie, som en fremmed side kunne låne.
- Et håndtryk fra appens origin uden billet afvises. Prøven fandt hvorfor:
  biblioteket sammenligner kun værtsnavne, og capacitor://localhost har samme
  værtsnavn som en instans på http://localhost.
- /ping svarer med product, edition og api, så appen kan kende en Verdande
  fra en routers login-side, før nogen er logget ind. Ingen version: /version
  er bag et login med vilje.

Fem plantede fejl, hver fanget af sin egen prøve: CORS til enhver origin,
genbrugelig billet, billet uden udløb, app-origin på en cookie, og
Allow-Credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:03:01 +02:00
kristianwindandClaude Opus 5.5 a8db761a4b fix(docs): et kolon i en beskrivelse gjorde specifikationen ulæselig
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:59:55 +02:00
kristianwindandClaude Opus 5.5 4ef050ae04 feat(login): appen kan logge ind og bære sin session som bearer
Første trin mod en iOS-app. Appen pakker den eksisterende flade ind og henter
den fra sig selv (capacitor://localhost), så den deler aldrig origin med
serveren og får aldrig cookien tilbage. Den logger derfor ind som en browser,
med adgangskode og evt. kode, men får sin session i svaret i stedet for i en
cookie og sender den som `Authorization: Bearer`.

Det er med vilje en SESSION og ikke et API-token: den står i listen over
enheder under det navn, appen giver sig selv, kan logges ud derfra, og den når
2FA og nøgler, som et token med vilje ikke gør.

- `POST /auth/login` tager `client: "app"` og `device_name`. Svaret får
  `session_token` og ingen cookie, også mens `totp_required` er sand.
- En app-session glider: hver brug flytter udløbet frem, op til
  `VERDANDE_APP_SESSION_TTL` (180 dage) ubrugt. En browser beholder sin faste
  levetid. En halvfærdig login glider ikke; den beholder sine ti minutter.
- En bearer, der ikke er et API-token, slås op som session, men kun hvis den
  blev udstedt til appen. En browsers cookie-værdi som header må ikke blive en
  anden vej forbi SameSite og CSRF-låsen.
- CSRF-låsen genkender appens origin. Det var prøven, der fandt det: selve
  login-kaldet har intet token endnu og blev afvist som cross-site. En
  hjemmeside kan ikke skrive `Origin`-headeren, og en app-login sætter ingen
  cookie, så der er ingen session for en forfalsket anmodning at ride på.

Passkeys er udenfor i første omgang: RP-ID er bundet til domænet og kræver
apple-app-site-association på hver instans.

Migration 0037: sessions.client, device_name, idle_ttl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:46 +02:00
Kristian Wind 95e02b567b Merge pull request #104 from kristianwind/billedvisning-stoerre
CI / OpenAPI is valid (push) Successful in 18s
CI / End-to-end (push) Failing after 4m58s
CI / Go (push) Failing after 5m30s
CI / Docker image builds (push) Successful in 2m39s
fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
2026-10-18 23:46:13 +02:00
kristianwindandClaude Opus 5.5 93470259d2 fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
Billedvisningen havde kun max-width/max-height, så den kunne gøre et billede
mindre og aldrig større. Målt i den rigtige app: et 600×400-billede stod 600 px
i noten og 600 i visningen, og et højt telefon-skærmbillede stod 701 px bredt i
noten og 357 i visningen. På en telefon var der ingen forskel overhovedet.

Nu fylder billedet skærmen (også når det skal forstørres), og et klik på
billedet — eller knappen "Fuld størrelse" — viser det i egne pixels, aldrig
smallere end skærmen, med rulning hen til det sted, man klikkede. Knapperne
ligger fast nederst, så de kan nås, uanset hvor man har rullet hen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:37:10 +02:00
Kristian Wind 6a5228aed8 Merge pull request #103 from kristianwind/alarm-i-saetningen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m53s
CI / Docker image builds (push) Successful in 3m38s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 27m13s
feat(opgaver): alarmen kan tales ind, og kan være standard
2026-10-08 20:40:44 +02:00
57 changed files with 4013 additions and 102 deletions
+34
View File
@@ -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
+1
View File
@@ -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. |
+9
View File
@@ -0,0 +1,9 @@
{
"appId": "dk.nolimit.verdande",
"appName": "Verdande",
"webDir": "../web/build",
"ios": {
"contentInset": "never",
"scheme": "Verdande"
}
}
+13
View File
@@ -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
+380
View File
@@ -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>
+44
View File
@@ -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>
+79
View File
@@ -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>
+24
View File
@@ -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)
}
}
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+25
View File
@@ -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")
]
)
]
)
+5
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
CAPACITOR_DEBUG = true
+1239
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+3
View File
@@ -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
View File
@@ -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).
+70
View File
@@ -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 }
+20 -2
View File
@@ -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
}
+224
View File
@@ -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)
}
}
+169
View File
@@ -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,
})
}
+269
View File
@@ -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
}
+71 -8
View File
@@ -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
}
+7 -6
View File
@@ -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)
+50 -2
View File
@@ -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
+4
View File
@@ -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)
+13 -4
View File
@@ -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.
+12 -3
View File
@@ -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
View File
@@ -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()
}
+1
View File
@@ -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
+219
View File
@@ -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![](${saved.url})\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:');
});
+78
View File
@@ -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.
*
+19 -2
View File
@@ -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}`
}
},
+45
View File
@@ -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
View File
@@ -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'),
+131 -29
View File
@@ -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);
+114 -5
View File
@@ -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">&nbsp;</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);
+11
View File
@@ -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?',
+11
View File
@@ -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?',
+92
View File
@@ -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);
}
+115
View File
@@ -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 '';
}
}
+4
View File
@@ -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
+4 -1
View File
@@ -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 `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`;
// 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 `![${node.getAttribute('alt') ?? ''}](${node.dataset?.src || node.getAttribute('src') || ''})`;
case 'STRONG':
case 'B':
return inner ? `**${inner}**` : '';
+41 -3
View File
@@ -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 = () => {
+7
View File
@@ -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.
*
+11
View File
@@ -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'
}
}
};