feat: use "drizzle-orm/expo-sqlite" as DB (#21)

This commit is contained in:
IZUMI-Zu 2024-08-22 08:42:53 +08:00 committed by GitHub
parent e245cfa66d
commit 7f87a3bf34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1704 additions and 396 deletions

61
App.js
View File

@ -13,28 +13,59 @@
// limitations under the License.
import * as React from "react";
import {PaperProvider} from "react-native-paper";
import {NavigationContainer} from "@react-navigation/native";
import {BulletList} from "react-content-loader/native";
import {SQLiteProvider} from "expo-sqlite";
import {PaperProvider} from "react-native-paper";
import {SafeAreaView, Text} from "react-native";
import ContentLoader, {Circle, Rect} from "react-content-loader/native";
import Toast from "react-native-toast-message";
import {useMigrations} from "drizzle-orm/expo-sqlite/migrator";
import Header from "./Header";
import NavigationBar from "./NavigationBar";
import {migrateDb} from "./TotpDatabase";
import {db} from "./db/client";
import migrations from "./drizzle/migrations";
const App = () => {
const {success, error} = useMigrations(db, migrations);
if (error) {
return (
<SafeAreaView style={{flex: 1}}>
<Text>Migration error: {error.message}</Text>
</SafeAreaView>
);
}
if (!success) {
return (
<ContentLoader
speed={2}
width={400}
height={150}
viewBox="0 0 400 150"
backgroundColor="#f3f3f3"
foregroundColor="#ecebeb"
>
<Circle cx="10" cy="20" r="8" />
<Rect x="25" y="15" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="50" r="8" />
<Rect x="25" y="45" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="80" r="8" />
<Rect x="25" y="75" rx="5" ry="5" width="220" height="10" />
<Circle cx="10" cy="110" r="8" />
<Rect x="25" y="105" rx="5" ry="5" width="220" height="10" />
</ContentLoader>
);
}
return (
<React.Suspense fallback={<BulletList />}>
<SQLiteProvider databaseName="totp.db" onInit={migrateDb} options={{enableChangeListener: true}}>
<NavigationContainer>
<PaperProvider>
<Header />
<NavigationBar />
</PaperProvider>
</NavigationContainer>
<Toast />
</SQLiteProvider>
</React.Suspense>
<NavigationContainer>
<PaperProvider>
<Header />
<NavigationBar />
</PaperProvider>
<Toast />
</NavigationContainer>
);
};
export default App;

View File

@ -18,13 +18,13 @@ import {Appbar, Avatar, Menu, Text, TouchableRipple} from "react-native-paper";
import Toast from "react-native-toast-message";
import CasdoorLoginPage, {CasdoorLogout} from "./CasdoorLoginPage";
import useStore from "./useStorage";
import useSyncStore from "./useSyncStore";
import {useAccountSync} from "./useAccountStore";
const {width} = Dimensions.get("window");
const Header = () => {
const {userInfo, clearAll} = useStore();
const syncError = useSyncStore(state => state.syncError);
const {syncError, clearSyncError} = useAccountSync();
const [showLoginPage, setShowLoginPage] = React.useState(false);
const [menuVisible, setMenuVisible] = React.useState(false);
@ -42,6 +42,7 @@ const Header = () => {
const handleCasdoorLogout = () => {
CasdoorLogout();
clearAll();
clearSyncError();
};
const handleSyncErrorPress = () => {

View File

@ -20,16 +20,19 @@ import {CountdownCircleTimer} from "react-native-countdown-circle-timer";
import {useNetInfo} from "@react-native-community/netinfo";
import {FlashList} from "@shopify/flash-list";
import Toast from "react-native-toast-message";
import * as SQLite from "expo-sqlite/next";
import {useLiveQuery} from "drizzle-orm/expo-sqlite";
import {isNull} from "drizzle-orm";
import SearchBar from "./SearchBar";
import EnterAccountDetails from "./EnterAccountDetails";
import ScanQRCode from "./ScanQRCode";
import EditAccountDetails from "./EditAccountDetails";
import AvatarWithFallback from "./AvatarWithFallback";
import * as TotpDatabase from "./TotpDatabase";
import useStore from "./useStorage";
import useSyncStore from "./useSyncStore";
import * as schema from "./db/schema";
import {db} from "./db/client";
import {calculateCountdown, validateSecret} from "./totpUtil";
import {useAccountSync, useEditAccount, useUpdateAccountToken} from "./useAccountStore";
const {width, height} = Dimensions.get("window");
const REFRESH_INTERVAL = 10000;
@ -41,7 +44,7 @@ export default function HomePage() {
const [showOptions, setShowOptions] = useState(false);
const [showEnterAccountModal, setShowEnterAccountModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [accounts, setAccounts] = useState([]);
const {data: accounts} = useLiveQuery(db.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)));
const [filteredData, setFilteredData] = useState(accounts);
const [showScanner, setShowScanner] = useState(false);
const [showEditAccountModal, setShowEditAccountModal] = useState(false);
@ -53,17 +56,10 @@ export default function HomePage() {
const [key, setKey] = useState(0);
const swipeableRef = useRef(null);
const db = SQLite.useSQLiteContext();
const {userInfo, serverUrl, token} = useStore();
const {startSync} = useSyncStore();
const syncError = useSyncStore(state => state.syncError);
useEffect(() => {
if (db) {
const subscription = SQLite.addDatabaseChangeListener((event) => {loadAccounts();});
return () => {if (subscription) {subscription.remove();}};
}
}, [db]);
const {startSync} = useAccountSync();
const {updateToken} = useUpdateAccountToken();
const {setAccount, updateAccount, insertAccount, deleteAccount} = useEditAccount();
useEffect(() => {
setCanSync(Boolean(isConnected && userInfo && serverUrl));
@ -73,27 +69,17 @@ export default function HomePage() {
setFilteredData(accounts);
}, [accounts]);
useEffect(() => {
loadAccounts();
}, []);
useEffect(() => {
const timer = setInterval(() => {
if (canSync) {startSync(db, userInfo, serverUrl, token);}
if (canSync) {startSync(userInfo, serverUrl, token);}
}, REFRESH_INTERVAL);
return () => clearInterval(timer);
}, [startSync]);
const loadAccounts = async() => {
const loadedAccounts = await TotpDatabase.getAllAccounts(db);
setAccounts(loadedAccounts);
setFilteredData(loadedAccounts);
};
const onRefresh = async() => {
setRefreshing(true);
if (canSync) {
await startSync(db, userInfo, serverUrl, token);
const syncError = await startSync(userInfo, serverUrl, token);
if (syncError) {
Toast.show({
type: "error",
@ -110,19 +96,17 @@ export default function HomePage() {
});
}
}
setKey(prevKey => prevKey + 1);
setRefreshing(false);
};
const handleAddAccount = async(accountData) => {
setKey(prevKey => prevKey + 1);
await TotpDatabase.insertAccount(db, accountData);
setAccount(accountData);
insertAccount();
closeEnterAccountModal();
};
const handleDeleteAccount = async(id) => {
await TotpDatabase.deleteAccount(db, id);
};
const handleEditAccount = (account) => {
closeSwipeableMenu();
setEditingAccount(account);
@ -132,7 +116,8 @@ export default function HomePage() {
const onAccountEdit = async(newAccountName) => {
if (editingAccount) {
await TotpDatabase.updateAccountName(db, editingAccount.id, newAccountName);
setAccount({...editingAccount, accountName: newAccountName, oldAccountName: editingAccount.accountName});
updateAccount();
setPlaceholder("");
setEditingAccount(null);
closeEditAccountModal();
@ -176,7 +161,7 @@ export default function HomePage() {
const handleSearch = (query) => {
setSearchQuery(query);
setFilteredData(query.trim() !== ""
? accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
? accounts && accounts.filter(item => item.accountName.toLowerCase().includes(query.toLowerCase()))
: accounts
);
};
@ -205,7 +190,7 @@ export default function HomePage() {
</TouchableOpacity>
<TouchableOpacity
style={{height: 70, width: 80, backgroundColor: "#FFC0CB", alignItems: "center", justifyContent: "center"}}
onPress={() => handleDeleteAccount(item.id)}
onPress={() => deleteAccount(item.id)}
>
<Text>Delete</Text>
</TouchableOpacity>
@ -242,16 +227,16 @@ export default function HomePage() {
key={key}
isPlaying={true}
duration={30}
initialRemainingTime={TotpDatabase.calculateCountdown()}
initialRemainingTime={calculateCountdown()}
colors={["#004777", "#0072A0", "#0099CC", "#FF6600", "#CC3300", "#A30000"]}
colorsTime={[30, 24, 18, 12, 6, 0]}
size={60}
onComplete={() => {
TotpDatabase.updateToken(db, item.id);
updateToken(item.id);
return {
shouldRepeat: true,
delay: 0,
newInitialRemainingTime: TotpDatabase.calculateCountdown(),
newInitialRemainingTime: calculateCountdown(),
};
}}
strokeWidth={5}
@ -318,7 +303,7 @@ export default function HomePage() {
transform: [{translateX: -OFFSET_X}, {translateY: -OFFSET_Y}],
}}
>
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} validateSecret={TotpDatabase.validateSecret} />
<EnterAccountDetails onClose={closeEnterAccountModal} onAdd={handleAddAccount} validateSecret={validateSecret} />
</Modal>
</Portal>

View File

@ -1,316 +0,0 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import totp from "totp-generator";
import * as api from "./api";
export async function migrateDb(db) {
const DATABASE_VERSION = 1;
const result = await db.getFirstAsync("PRAGMA user_version");
let currentVersion = result?.user_version ?? 0;
if (currentVersion === DATABASE_VERSION) {
return;
}
if (currentVersion === 0) {
await db.execAsync(`
PRAGMA journal_mode = 'wal';
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issuer TEXT,
account_name TEXT NOT NULL,
old_account_name TEXT DEFAULT NULL,
secret TEXT NOT NULL,
token TEXT,
is_deleted INTEGER DEFAULT 0,
last_change_time INTEGER DEFAULT (strftime('%s', 'now')),
last_sync_time INTEGER DEFAULT NULL
);
`);
await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);
currentVersion = 1;
}
await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);
}
export async function clearDatabase(db) {
try {
await db.execAsync("DELETE FROM accounts");
await db.execAsync("DELETE FROM sqlite_sequence WHERE name='accounts'");
await db.execAsync("PRAGMA user_version = 0");
return true;
} catch (error) {
return false;
}
}
const generateToken = (secretKey) => {
if (secretKey !== null && secretKey !== undefined && secretKey !== "") {
try {
const token = totp(secretKey);
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
return tokenWithSpace;
} catch (error) {
return "Secret Invalid";
}
} else {
return "Secret Empty";
}
};
export async function insertAccount(db, account) {
const token = generateToken(account.secretKey);
const currentTime = Math.floor(Date.now() / 1000);
return await db.runAsync(
"INSERT INTO accounts (issuer, account_name, secret, token, last_change_time) VALUES (?, ?, ?, ?, ?)",
account.issuer ?? "",
account.accountName,
account.secretKey,
token ?? "",
currentTime
);
}
export async function updateAccountName(db, id, newAccountName) {
const account = await db.getFirstAsync("SELECT * FROM accounts WHERE id = ?", id);
const currentTime = Math.floor(Date.now() / 1000);
// Only update old_account_name if it's null or if last_sync_time is more recent than last_change_time
if (account.old_account_name === null || (account.last_sync_time && account.last_sync_time > account.last_change_time)) {
await db.runAsync(`
UPDATE accounts
SET account_name = ?,
old_account_name = ?,
last_change_time = ?
WHERE id = ?
`, newAccountName, account.account_name, currentTime, id);
} else {
await db.runAsync(`
UPDATE accounts
SET account_name = ?,
last_change_time = ?
WHERE id = ?
`, newAccountName, currentTime, id);
}
}
export async function updateAccount(db, account, id) {
const token = generateToken(account.secretKey);
const currentTime = Math.floor(Date.now() / 1000);
const result = await db.runAsync(
"UPDATE accounts SET issuer = ?, account_name = ?, old_account_name = ?, secret = ?, token = ?, last_change_time = ? WHERE id = ?",
account.issuer,
account.accountName,
account.oldAccountName ?? null,
account.secretKey,
token ?? "",
currentTime,
id
);
if (result.changes === 0) {
throw new Error(`No account updated for id: ${id}`);
}
return result;
}
export async function deleteAccount(db, id) {
const currentTime = Math.floor(Date.now() / 1000);
await db.runAsync("UPDATE accounts SET is_deleted = 1, last_change_time = ? WHERE id = ?", currentTime, id);
}
export async function trueDeleteAccount(db, id) {
return await db.runAsync("DELETE FROM accounts WHERE id = ?", id);
}
export function updateToken(db, id) {
const result = db.getFirstSync("SELECT secret FROM accounts WHERE id = ?", id);
if (result.secret === null) {
return;
}
const token = generateToken(result.secret);
return db.runSync("UPDATE accounts SET token = ? WHERE id = ?", token, id);
}
export async function updateTokenForAll(db) {
const accounts = await db.getAllAsync("SELECT * FROM accounts WHERE is_deleted = 0");
for (const account of accounts) {
const token = generateToken(account.secret);
await db.runAsync("UPDATE accounts SET token = ? WHERE id = ?", token, account.id);
}
}
export async function getAllAccounts(db) {
const accounts = await db.getAllAsync("SELECT * FROM accounts WHERE is_deleted = 0");
return accounts.map(account => {
const mappedAccount = {
...account,
accountName: account.account_name,
};
return mappedAccount;
});
}
async function getLocalAccounts(db) {
const accounts = await db.getAllAsync("SELECT * FROM accounts");
return accounts.map(account => ({
id: account.id,
issuer: account.issuer,
accountName: account.account_name,
oldAccountName: account.old_account_name,
secretKey: account.secret,
isDeleted: account.is_deleted === 1,
lastChangeTime: account.last_change_time,
lastSyncTime: account.last_sync_time,
}));
}
async function updateSyncTimeForAll(db) {
const currentTime = Math.floor(Date.now() / 1000);
await db.runAsync("UPDATE accounts SET last_sync_time = ?", currentTime);
}
export function calculateCountdown() {
const now = Math.round(new Date().getTime() / 1000.0);
return 30 - (now % 30);
}
export function validateSecret(secret) {
const base32Regex = /^[A-Z2-7]+=*$/i;
if (!secret || secret.length % 8 !== 0) {
return false;
}
return base32Regex.test(secret);
}
async function updateLocalDatabase(db, mergedAccounts) {
for (const account of mergedAccounts) {
if (account.id) {
if (account.isDeleted) {
await db.runAsync("DELETE FROM accounts WHERE id = ?", account.id);
} else {
await updateAccount(db, account, account.id);
}
} else {
await insertAccount(db, account);
}
}
}
function getAccountKey(account) {
return `${account.issuer}:${account.accountName}`;
}
function mergeAccounts(localAccounts, serverAccounts, serverTimestamp) {
const mergedAccounts = new Map();
const localAccountKeys = new Map();
// Process local accounts
for (const local of localAccounts) {
const key = getAccountKey(local);
mergedAccounts.set(key, {
...local,
synced: false,
});
// Store both current and old account keys for local accounts
localAccountKeys.set(key, local);
if (local.oldAccountName) {
const oldKey = getAccountKey({...local, accountName: local.oldAccountName});
localAccountKeys.set(oldKey, local);
}
}
const processedLocalKeys = new Set();
// Merge with server accounts
for (const server of serverAccounts) {
const serverKey = getAccountKey(server);
const localAccount = localAccountKeys.get(serverKey);
if (!localAccount) {
// New account from server
mergedAccounts.set(serverKey, {...server, synced: true});
} else {
const localKey = getAccountKey(localAccount);
const local = mergedAccounts.get(localKey);
if (serverTimestamp > local.lastChangeTime) {
// Server has newer changes
mergedAccounts.set(localKey, {
...server,
id: local.id,
oldAccountName: local.accountName !== server.accountName ? local.accountName : local.oldAccountName,
synced: true,
});
} else if (local.accountName !== server.accountName) {
// Local name change is newer, update the server account name
mergedAccounts.set(localKey, {
...local,
oldAccountName: server.accountName,
synced: false,
});
}
// If local is newer or deleted, keep the local version (already in mergedAccounts)
processedLocalKeys.add(localKey);
}
}
// Handle server-side deletions
for (const [key, local] of mergedAccounts) {
if (!processedLocalKeys.has(key) && local.lastSyncTime && local.lastSyncTime < serverTimestamp) {
// This account was not found on the server and was previously synced
// Mark it as deleted
mergedAccounts.set(key, {...local, isDeleted: true, synced: true});
}
}
return Array.from(mergedAccounts.values());
}
export async function syncWithCloud(db, userInfo, serverUrl, token) {
const localAccounts = await getLocalAccounts(db);
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
token
);
const serverTimestamp = Math.floor(new Date(updatedTime).getTime() / 1000);
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, serverTimestamp);
await updateLocalDatabase(db, mergedAccounts);
const accountsToSync = mergedAccounts.filter(account => !account.isDeleted).map(account => ({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
}));
const {status} = await api.updateMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
accountsToSync,
token
);
if (status !== "ok") {
throw new Error("Sync failed");
}
await updateSyncTimeForAll(db);
await updateTokenForAll(db);
}

View File

@ -2,5 +2,6 @@ module.exports = function(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [["inline-import", {"extensions": [".sql"]}]],
};
};

View File

@ -12,27 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import {create} from "zustand";
import * as TotpDatabase from "./TotpDatabase";
import {drizzle} from "drizzle-orm/expo-sqlite";
import {openDatabaseSync} from "expo-sqlite/next";
const useSyncStore = create((set, get) => ({
isSyncing: false,
syncError: null,
const expoDb = openDatabaseSync("account.db", {enableChangeListener: true});
startSync: async(db, userInfo, casdoorServer, token) => {
if (!get().isSyncing) {
set({isSyncing: true, syncError: null});
try {
await TotpDatabase.syncWithCloud(db, userInfo, casdoorServer, token);
} catch (error) {
set({syncError: error.message});
} finally {
set({isSyncing: false});
}
}
},
clearSyncError: () => set({syncError: null}),
}));
export default useSyncStore;
export const db = drizzle(expoDb);

31
db/schema.js Normal file
View File

@ -0,0 +1,31 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {integer, sqliteTable, text, unique} from "drizzle-orm/sqlite-core";
import {sql} from "drizzle-orm";
export const accounts = sqliteTable("accounts", {
id: integer("id", {mode: "number"}).primaryKey({autoIncrement: true}),
accountName: text("account_name").notNull(),
oldAccountName: text("old_account_name").default(null),
secretKey: text("secret").notNull(),
issuer: text("issuer").default(null),
token: text("token"),
deletedAt: integer("deleted_at", {mode: "timestamp_ms"}).default(null),
changedAt: integer("changed_at", {mode: "timestamp_ms"}).default(sql`(CURRENT_TIMESTAMP)`),
syncAt: integer("sync_at", {mode: "timestamp_ms"}).default(null),
}, (accounts) => ({
unq: unique().on(accounts.accountName, accounts.issuer),
})
);

6
drizzle.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
schema: "./db/schema.js",
out: "./drizzle",
dialect: "sqlite",
driver: "expo",
};

View File

@ -0,0 +1,13 @@
CREATE TABLE `accounts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`account_name` text NOT NULL,
`old_account_name` text DEFAULT 'null',
`secret` text NOT NULL,
`issuer` text DEFAULT 'null',
`token` text,
`deleted_at` integer DEFAULT 'null',
`changed_at` integer DEFAULT (CURRENT_TIMESTAMP),
`sync_at` integer DEFAULT 'null'
);
--> statement-breakpoint
CREATE UNIQUE INDEX `accounts_account_name_issuer_unique` ON `accounts` (`account_name`,`issuer`);

View File

@ -0,0 +1,103 @@
{
"version": "6",
"dialect": "sqlite",
"id": "aaa7b5e3-521e-4c3a-970c-35372e7f05a3",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"account_name": {
"name": "account_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"old_account_name": {
"name": "old_account_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
},
"changed_at": {
"name": "changed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"sync_at": {
"name": "sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'null'"
}
},
"indexes": {
"accounts_account_name_issuer_unique": {
"name": "accounts_account_name_issuer_unique",
"columns": [
"account_name",
"issuer"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1724248639995,
"tag": "0000_smooth_owl",
"breakpoints": true
}
]
}

11
drizzle/migrations.js Normal file
View File

@ -0,0 +1,11 @@
// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo
import journal from "./meta/_journal.json";
import m0000 from "./0000_smooth_owl.sql";
export default {
journal,
migrations: {
m0000,
},
};

8
metro.config.js Normal file
View File

@ -0,0 +1,8 @@
const {getDefaultConfig} = require("expo/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts.push("sql");
module.exports = config;

1099
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start --tunnel",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
@ -17,12 +17,14 @@
"@react-navigation/native": "^6.1.7",
"@shopify/flash-list": "1.6.4",
"casdoor-react-native-sdk": "1.1.0",
"drizzle-orm": "^0.33.0",
"eslint-plugin-import": "^2.28.1",
"expo": "~51.0.26",
"expo-camera": "~15.0.14",
"expo-dev-client": "~4.0.22",
"expo-drizzle-studio-plugin": "^0.0.2",
"expo-image": "^1.12.13",
"expo-sqlite": "~14.0.6",
"expo-sqlite": "^14.0.6",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
@ -97,6 +99,8 @@
"@babel/preset-react": "^7.18.6",
"@types/react": "~18.2.79",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"babel-plugin-inline-import": "^3.0.0",
"drizzle-kit": "^0.24.0",
"eslint": "8.22.0",
"eslint-import-resolver-babel-module": "^5.3.2",
"eslint-plugin-react": "^7.31.1",

181
syncLogic.js Normal file
View File

@ -0,0 +1,181 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {eq} from "drizzle-orm";
import * as schema from "./db/schema";
import * as api from "./api";
import {generateToken} from "./totpUtil";
function getLocalAccounts(db) {
return db.select().from(schema.accounts).all();
}
function getAccountKey(account) {
return `${account.accountName}:${account.issuer ?? ""}`;
}
async function updateLocalDatabase(db, accounts) {
return db.transaction(async(tx) => {
// remove all accounts
// await tx.delete(schema.accounts).run();
for (const account of accounts) {
if (account.id) {
if (account.deletedAt === null || account.deletedAt === undefined) {
// compare all fields
const acc = await tx.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
if (acc.issuer === account.issuer &&
acc.accountName === account.accountName &&
acc.secretKey === account.secretKey &&
acc.deletedAt === account.deletedAt
) {
continue;
}
await tx.update(schema.accounts).set({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
deletedAt: null,
token: generateToken(account.secretKey),
changedAt: new Date(),
}).where(eq(schema.accounts.id, account.id));
} else {
await tx.delete(schema.accounts).where(eq(schema.accounts.id, account.id));
}
} else {
await tx.insert(schema.accounts).values({
issuer: account.issuer || null,
accountName: account.accountName,
secretKey: account.secretKey,
token: generateToken(account.secretKey),
});
}
}
});
}
function mergeAccounts(localAccounts, serverAccounts, serverTimestamp) {
const isNewer = (a, b) => new Date(a) > new Date(b);
const mergedAccounts = new Map();
const localAccountKeys = new Map();
// Process local accounts
for (const local of localAccounts) {
const key = getAccountKey(local);
mergedAccounts.set(key, {
...local,
synced: false,
});
// Store both current and old account keys for local accounts
localAccountKeys.set(key, local);
if (local.oldAccountName) {
const oldKey = getAccountKey({...local, accountName: local.oldAccountName});
localAccountKeys.set(oldKey, local);
}
}
const processedLocalKeys = new Set();
// Merge with server accounts
for (const server of serverAccounts) {
const serverKey = getAccountKey(server);
const localAccount = localAccountKeys.get(serverKey);
if (!localAccount) {
// New account from server
mergedAccounts.set(serverKey, {...server, synced: true});
} else {
const localKey = getAccountKey(localAccount);
const local = mergedAccounts.get(localKey);
if (isNewer(serverTimestamp, local.changedAt)) {
// Server has newer changes
mergedAccounts.set(localKey, {
...server,
id: local.id,
oldAccountName: local.accountName !== server.accountName ? local.accountName : local.oldAccountName,
synced: true,
});
} else if (local.accountName !== server.accountName) {
mergedAccounts.set(localKey, {
...local,
oldAccountName: server.accountName,
synced: false,
});
}
// If local is newer or deleted, keep the local version (already in mergedAccounts)
processedLocalKeys.add(localKey);
}
}
// Handle server-side deletions
for (const [key, local] of mergedAccounts) {
if (!processedLocalKeys.has(key) && local.syncAt && isNewer(serverTimestamp, local.syncAt)) {
// This account was not found on the server and was previously synced
// Mark it as deleted
mergedAccounts.set(key, {...local, deletedAt: new Date(), synced: true});
}
}
return Array.from(mergedAccounts.values());
}
export async function syncWithCloud(db, userInfo, serverUrl, token) {
// db.delete(schema.accounts).run();
const localAccounts = await getLocalAccounts(db);
const {updatedTime, mfaAccounts: serverAccounts} = await api.getMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
token
);
const mergedAccounts = mergeAccounts(localAccounts, serverAccounts, updatedTime);
await updateLocalDatabase(db, mergedAccounts);
const accountsToSync = mergedAccounts.filter(account => account.deletedAt === null || account.deletedAt === undefined)
.map(account => ({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
}));
const serverAccountsStringified = serverAccounts.map(account => JSON.stringify({
issuer: account.issuer,
accountName: account.accountName,
secretKey: account.secretKey,
}));
const accountsToSyncStringified = accountsToSync.map(account => JSON.stringify(account));
if (JSON.stringify(accountsToSyncStringified.sort()) !== JSON.stringify(serverAccountsStringified.sort())) {
const {status} = await api.updateMfaAccounts(
serverUrl,
userInfo.owner,
userInfo.name,
accountsToSync,
token
);
if (status !== "ok") {
throw new Error("Sync failed");
}
}
await db.update(schema.accounts).set({syncAt: new Date()}).run();
}

42
totpUtil.js Normal file
View File

@ -0,0 +1,42 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import totp from "totp-generator";
export function calculateCountdown(period = 30) {
const now = Math.round(new Date().getTime() / 1000.0);
return period - (now % period);
}
export function validateSecret(secret) {
const base32Regex = /^[A-Z2-7]+=*$/i;
if (!secret || secret.length % 8 !== 0) {
return false;
}
return base32Regex.test(secret);
}
export function generateToken(secret) {
if (secret !== null && secret !== undefined && secret !== "") {
try {
const token = totp(secret);
const tokenWithSpace = token.slice(0, 3) + " " + token.slice(3);
return tokenWithSpace;
} catch (error) {
return "Secret Invalid";
}
} else {
return "Secret Empty";
}
}

115
useAccountStore.js Normal file
View File

@ -0,0 +1,115 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {db} from "./db/client";
import * as schema from "./db/schema";
import {eq} from "drizzle-orm";
import {create} from "zustand";
import {generateToken} from "./totpUtil";
import {syncWithCloud} from "./syncLogic";
const useEditAccountStore = create((set, get) => ({
account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined, oldAccountName: undefined},
setAccount: (account) => set({account}),
updateAccount: () => {
const {id, accountName, issuer, secretKey, oldAccountName} = get().account;
if (!id) {return;}
const updateData = {};
if (accountName) {updateData.accountName = accountName;}
if (issuer) {updateData.issuer = issuer;}
if (secretKey) {updateData.secretKey = secretKey;}
if (Object.keys(updateData).length > 0) {
const currentAccount = db.select().from(schema.accounts)
.where(eq(schema.accounts.id, Number(id))).limit(1)
.get();
if (currentAccount) {
if (currentAccount.oldAccountName === null && oldAccountName) {
updateData.oldAccountName = oldAccountName;
}
db.update(schema.accounts).set({...updateData, changedAt: new Date()}).where(eq(schema.accounts.id, id)).run();
}
}
set({
account: {
id: undefined,
issuer: undefined,
accountName: undefined,
oldAccountName: undefined,
secretKey: undefined,
},
});
},
insertAccount: () => {
const {accountName, issuer, secretKey} = get().account;
if (!accountName || !secretKey) {return;}
db.insert(schema.accounts)
.values({accountName, issuer: issuer ? issuer : null, secretKey, token: generateToken(secretKey)})
.run();
set({account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined}});
},
deleteAccount: async(id) => {
db.update(schema.accounts).set({deletedAt: new Date()}).where(eq(schema.accounts.id, id)).run();
},
}));
export const useEditAccount = () => useEditAccountStore(state => ({
account: state.account,
setAccount: state.setAccount,
updateAccount: state.updateAccount,
insertAccount: state.insertAccount,
deleteAccount: state.deleteAccount,
}));
const useAccountSyncStore = create((set, get) => ({
isSyncing: false,
syncError: null,
startSync: async(userInfo, serverUrl, token) => {
if (get().isSyncing) {return;}
set({isSyncing: true, syncError: null});
try {
await syncWithCloud(db, userInfo, serverUrl, token);
} catch (error) {
set({syncError: error.message});
} finally {
set({isSyncing: false});
}
return get().syncError;
},
clearSyncError: () => set({syncError: null}),
}));
export const useAccountSync = () => useAccountSyncStore(state => ({
isSyncing: state.isSyncing,
syncError: state.syncError,
startSync: state.startSync,
clearSyncError: state.clearSyncError,
}));
const useUpdateAccountTokenStore = create(() => ({
updateToken: async(id) => {
const account = db.select().from(schema.accounts)
.where(eq(schema.accounts.id, Number(id))).limit(1).get();
if (account) {
db.update(schema.accounts).set({token: generateToken(account.secretKey)}).where(eq(schema.accounts.id, id)).run();
}
},
}));
export const useUpdateAccountToken = () => useUpdateAccountTokenStore(state => ({
updateToken: state.updateToken,
}));