Compare commits
No commits in common. "master" and "v1.3.0" have entirely different histories.
25 changed files with 80 additions and 8741 deletions
|
|
@ -1,87 +0,0 @@
|
|||
# Setup Runner Forgejo — passo-a-passo (5 min)
|
||||
|
||||
Token **já foi gerado** no admin do Forgejo: `t6yTf9Fu7ana95Y7CynCi4edGCWj6fT8wJQkdnDP`
|
||||
|
||||
(Se precisar de novo, ir em https://git.pontualtech.work/admin/actions/runners → "Criar novo Runner")
|
||||
|
||||
---
|
||||
|
||||
## 1. Subir runner no Coolify
|
||||
|
||||
1. Abra https://painel.pontualtech.work
|
||||
2. **Pontualtech** → `+ Add Resource` → **Docker Compose Empty**
|
||||
3. Selecione **localhost** (NÃO Servidor Imprimitech)
|
||||
4. No campo "Docker Compose" cole:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
forgejo-runner:
|
||||
image: code.forgejo.org/forgejo/runner:6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
FORGEJO_INSTANCE_URL: https://git.pontualtech.work
|
||||
FORGEJO_RUNNER_REGISTRATION_TOKEN: t6yTf9Fu7ana95Y7CynCi4edGCWj6fT8wJQkdnDP
|
||||
FORGEJO_RUNNER_NAME: shivao-runner
|
||||
FORGEJO_RUNNER_LABELS: ubuntu-latest,docker
|
||||
volumes:
|
||||
- runner_data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks: [coolify]
|
||||
|
||||
volumes:
|
||||
runner_data:
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
external: true
|
||||
```
|
||||
|
||||
5. **Save** + **Deploy**
|
||||
6. Aguarde ~30s. Confira em https://git.pontualtech.work/admin/actions/runners — deve aparecer "shivao-runner" online
|
||||
|
||||
---
|
||||
|
||||
## 2. Configurar secrets no repo Shivão
|
||||
|
||||
Em https://git.pontualtech.work/karlao/shivao-projeto → **Settings → Actions → Secrets**
|
||||
|
||||
Adicione 3 secrets:
|
||||
|
||||
### `SHIVAO_KEYSTORE_BASE64`
|
||||
|
||||
```bash
|
||||
# Rode no Git Bash:
|
||||
base64 -w0 ~/Downloads/Shivao-keystore-backup/shivao-release-CAPACITOR.keystore | clip
|
||||
# Cola o conteúdo (já vai pro clipboard)
|
||||
```
|
||||
|
||||
### `SHIVAO_KEYSTORE_PWD`
|
||||
|
||||
Valor: `ShivaoKeystore2026!`
|
||||
|
||||
### `FORGEJO_TOKEN`
|
||||
|
||||
Em https://git.pontualtech.work/user/settings/applications → **Generate New Token**
|
||||
- Token name: `actions-shivao`
|
||||
- Scopes: marque **write:repository**
|
||||
- Generate → cole o token gerado neste secret
|
||||
|
||||
---
|
||||
|
||||
## 3. Testar workflow
|
||||
|
||||
Depois dos secrets configurados:
|
||||
|
||||
1. Em **Actions** do repo (https://git.pontualtech.work/karlao/shivao-projeto/actions), o workflow `Build Android (APK + AAB)` deve aparecer
|
||||
2. Botão **Run workflow** → branch master → **Run**
|
||||
3. Build automático deve iniciar (download SDK + JDK + npm install + gradle bundleRelease + assembleRelease + upload artifacts)
|
||||
|
||||
A partir daí, qualquer push em `app/`, `mobile/` ou `scripts/sync-html.mjs` dispara build automático no servidor — você não precisa mais buildar local.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Runner não aparece online:** verificar logs do container no Coolify (Logs tab). Token expirado = gerar novo no admin.
|
||||
- **`/var/run/docker.sock not found`:** o Coolify tem o socket mountado por padrão, mas se der erro adicione `privileged: true` ao service.
|
||||
- **Build falha em `cap sync`:** runner não tem suficiente RAM. Ajustar limit no Coolify (Resource Limits → 2GB RAM mínimo).
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,7 +15,6 @@ server/data/
|
|||
!.env.example
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,8 +7,8 @@ android {
|
|||
applicationId "br.com.pontualtech.shivao"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 33
|
||||
versionName "1.12.0"
|
||||
versionCode 2
|
||||
versionName "1.2.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ android {
|
|||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-bluetooth-le')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-geolocation')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
|
|
|
|||
|
|
@ -51,11 +51,4 @@
|
|||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
|
||||
|
||||
<!-- Bluetooth LE (BMS de bateria, fones, smart shunts) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-bluetooth-le'
|
||||
project(':capacitor-community-bluetooth-le').projectDir = new File('../node_modules/@capacitor-community/bluetooth-le/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
|
|
|
|||
23
mobile/package-lock.json
generated
23
mobile/package-lock.json
generated
|
|
@ -1,14 +1,13 @@
|
|||
{
|
||||
"name": "shivao-mobile",
|
||||
"version": "1.9.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shivao-mobile",
|
||||
"version": "1.9.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@capacitor-community/bluetooth-le": "^6.1.0",
|
||||
"@capacitor/android": "^6.1.2",
|
||||
"@capacitor/app": "^6.0.1",
|
||||
"@capacitor/core": "^6.1.2",
|
||||
|
|
@ -22,18 +21,6 @@
|
|||
"@capacitor/cli": "^6.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor-community/bluetooth-le": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-6.1.0.tgz",
|
||||
"integrity": "sha512-hnNChEwV+xNOVqDYI4bfkQtFtvEyzBMlgYs+6xsLYTJVl0v8h6Hn3nCwjW9l6LH0tMzYaRYlFLCiGHKPHt1N0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz",
|
||||
|
|
@ -356,12 +343,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "shivao-mobile",
|
||||
"version": "1.12.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
"ios:open": "npx cap open ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/bluetooth-le": "^6.1.0",
|
||||
"@capacitor/android": "^6.1.2",
|
||||
"@capacitor/app": "^6.0.1",
|
||||
"@capacitor/core": "^6.1.2",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 276 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 195 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB |
|
|
@ -1,81 +0,0 @@
|
|||
# ======================================================
|
||||
# SHIVAO CLOUD - Configuração
|
||||
# Copie este arquivo para .env e preencha os valores
|
||||
# ======================================================
|
||||
|
||||
# --- Autenticação ---
|
||||
# Token único do barco. GERE UMA STRING ALEATÓRIA LONGA!
|
||||
# Sugestão: openssl rand -hex 32
|
||||
BOAT_TOKEN=troque-este-valor-por-uma-string-aleatoria-longa-e-secreta
|
||||
|
||||
# --- Dead-man switch ---
|
||||
# Se o app não enviar heartbeat por X segundos enquanto fundeado,
|
||||
# o servidor dispara o alarme automaticamente. Padrão: 300 (5 min)
|
||||
HEARTBEAT_TIMEOUT_SEC=300
|
||||
|
||||
# ======================================================
|
||||
# CANAIS DE NOTIFICAÇÃO (configure os que quiser usar)
|
||||
# ======================================================
|
||||
|
||||
# --- Telegram (RECOMENDADO - grátis, instantâneo) ---
|
||||
# 1. No Telegram, fale com @BotFather → /newbot → anote o token
|
||||
# 2. Inicie conversa com seu novo bot
|
||||
# 3. Acesse https://api.telegram.org/bot<TOKEN>/getUpdates → anote o chat.id
|
||||
# Você pode enviar para múltiplos chats separando por vírgula
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_IDS=
|
||||
|
||||
# --- ntfy.sh (push notifications grátis sem cadastro) ---
|
||||
# Instale o app ntfy no celular, escolha um tópico secreto único
|
||||
# Ex: shivao-alertas-x7k9p2 — qualquer pessoa com o nome ouve, então use algo aleatório
|
||||
NTFY_TOPIC=
|
||||
NTFY_SERVER=https://ntfy.sh
|
||||
|
||||
# --- E-mail (SMTP) ---
|
||||
# Para Gmail: ative 2FA, crie "App password" em
|
||||
# https://myaccount.google.com/apppasswords
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=Shivao Alertas <alerts@example.com>
|
||||
# Múltiplos destinatários separados por vírgula
|
||||
SMTP_TO=
|
||||
|
||||
# --- Twilio SMS / WhatsApp (PAGO) ---
|
||||
# Crie conta em twilio.com
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_FROM_NUMBER=
|
||||
TWILIO_WHATSAPP_FROM=
|
||||
# Múltiplos números (com DDI, ex: +5521999998888) separados por vírgula
|
||||
TWILIO_SMS_TO=
|
||||
TWILIO_WHATSAPP_TO=
|
||||
|
||||
# --- Webhook genérico ---
|
||||
# Para Discord, Slack, n8n, ou seu próprio endpoint
|
||||
# Recebe POST com JSON {boat, message, lat, lng, distance, ...}
|
||||
WEBHOOK_URL=
|
||||
|
||||
# ======================================================
|
||||
# IOT (Smart Life / Tuya) — controlar dispositivos do barco
|
||||
# ======================================================
|
||||
# Tuya é o fabricante por trás do app Smart Life. Lâmpadas/tomadas
|
||||
# brand X (Positivo, Multilaser, Intelbras, RWS) são todas Tuya.
|
||||
#
|
||||
# Setup (5 min, gratuito):
|
||||
# 1. Crie conta em https://iot.tuya.com (use mesmo email do Smart Life)
|
||||
# 2. Cloud → Development → Create Cloud Project
|
||||
# - Industry: Smart Home
|
||||
# - Method: Custom Development
|
||||
# - Data Center: escolha o mesmo da app Smart Life
|
||||
# (Eu → Account & Security → Region)
|
||||
# 3. Aba Service API → autorize: IoT Core, Authorization, Smart Home Basic
|
||||
# 4. Aba Devices → Link Tuya App Account → escaneia QR Code com Smart Life
|
||||
# 5. Copie da aba Overview: Access ID + Access Secret
|
||||
TUYA_ACCESS_ID=
|
||||
TUYA_ACCESS_SECRET=
|
||||
# Data center: tuyaus (US, default Brasil), tuyaeu (Europa), tuyacn (China),
|
||||
# tuyain (Índia). Mude se sua conta estiver em outra região.
|
||||
TUYA_BASE_URL=https://openapi.tuyaus.com
|
||||
22
server/package-lock.json
generated
22
server/package-lock.json
generated
|
|
@ -16,7 +16,6 @@
|
|||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1575,27 +1574,6 @@
|
|||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"license": "MIT"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
// Shivao Service Worker — offline real
|
||||
// Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto.
|
||||
// Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys.
|
||||
const VERSION = 'shivao-v1.12.0';
|
||||
const VERSION = 'shivao-v1';
|
||||
const SHELL_CACHE = `shivao-shell-${VERSION}`;
|
||||
const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell
|
||||
const WINDY_CACHE = `shivao-windy-${VERSION}`;
|
||||
|
|
|
|||
|
|
@ -141,18 +141,6 @@ db.exec(`
|
|||
ip TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS google_connections (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
expires_at INTEGER,
|
||||
email TEXT,
|
||||
sync_token TEXT,
|
||||
last_sync_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// migration: add zones column if missing
|
||||
|
|
@ -437,41 +425,5 @@ export function recentAudit(userId, limit = 100) {
|
|||
return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
|
||||
}
|
||||
|
||||
// ===== Google Calendar connections =====
|
||||
export function getGoogleConnection(userId) {
|
||||
return db.prepare('SELECT * FROM google_connections WHERE user_id = ?').get(userId);
|
||||
}
|
||||
export function saveGoogleConnection(userId, data) {
|
||||
const now = Date.now();
|
||||
const existing = getGoogleConnection(userId);
|
||||
if (existing) {
|
||||
db.prepare(`UPDATE google_connections SET
|
||||
access_token=?, refresh_token=?, expires_at=?, email=?, sync_token=?, last_sync_at=?
|
||||
WHERE user_id=?`).run(
|
||||
data.access_token,
|
||||
data.refresh_token || existing.refresh_token, // refresh_token may not come on every refresh
|
||||
data.expires_at || null,
|
||||
data.email || existing.email || null,
|
||||
data.sync_token || existing.sync_token || null,
|
||||
data.last_sync_at || existing.last_sync_at || now,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
db.prepare(`INSERT INTO google_connections
|
||||
(user_id, access_token, refresh_token, expires_at, email, sync_token, last_sync_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
userId, data.access_token, data.refresh_token, data.expires_at || null,
|
||||
data.email || null, data.sync_token || null, data.last_sync_at || now, now,
|
||||
);
|
||||
}
|
||||
}
|
||||
export function deleteGoogleConnection(userId) {
|
||||
db.prepare('DELETE FROM google_connections WHERE user_id = ?').run(userId);
|
||||
}
|
||||
export function setGoogleSyncToken(userId, syncToken, lastSyncAt) {
|
||||
db.prepare('UPDATE google_connections SET sync_token=?, last_sync_at=? WHERE user_id=?')
|
||||
.run(syncToken, lastSyncAt || Date.now(), userId);
|
||||
}
|
||||
|
||||
export const dataDir = DATA_DIR;
|
||||
export default db;
|
||||
|
|
|
|||
|
|
@ -1,236 +0,0 @@
|
|||
// Google Calendar bidirectional sync — graceful-disabled if env vars empty.
|
||||
// OAuth 2.0 authorization-code flow (with PKCE recommended pra mobile mas web client tá ok).
|
||||
//
|
||||
// Pra ativar em produção:
|
||||
// GOOGLE_CLIENT_ID=...apps.googleusercontent.com
|
||||
// GOOGLE_CLIENT_SECRET=...
|
||||
// GOOGLE_REDIRECT_URI=https://shivao.pontualtech.work/api/google/callback
|
||||
//
|
||||
// Sem essas env vars, todos os endpoints retornam 503 com mensagem clara.
|
||||
|
||||
import * as db from './db.js';
|
||||
|
||||
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
|
||||
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
|
||||
const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || '';
|
||||
|
||||
const SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
];
|
||||
|
||||
export function isEnabled() {
|
||||
return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
|
||||
}
|
||||
|
||||
export function disabledResponse(res) {
|
||||
return res.status(503).json({
|
||||
error: 'google_calendar_disabled',
|
||||
detail: 'Backend não configurado. Falta GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI no servidor.',
|
||||
});
|
||||
}
|
||||
|
||||
// Inicia o OAuth: gera URL de autorização do Google e devolve pra cliente
|
||||
export function buildAuthUrl(userId, returnTo = '/') {
|
||||
const state = Buffer.from(JSON.stringify({ uid: userId, rt: returnTo, n: Math.random().toString(36).slice(2) })).toString('base64url');
|
||||
const u = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
u.searchParams.set('client_id', CLIENT_ID);
|
||||
u.searchParams.set('redirect_uri', REDIRECT_URI);
|
||||
u.searchParams.set('response_type', 'code');
|
||||
u.searchParams.set('scope', SCOPES.join(' '));
|
||||
u.searchParams.set('access_type', 'offline');
|
||||
u.searchParams.set('prompt', 'consent');
|
||||
u.searchParams.set('state', state);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
// Troca authorization code por tokens
|
||||
export async function exchangeCodeForTokens(code) {
|
||||
const body = new URLSearchParams({
|
||||
code,
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
throw new Error(`Google token exchange failed (${r.status}): ${t.slice(0, 200)}`);
|
||||
}
|
||||
return r.json(); // { access_token, refresh_token, expires_in, scope, token_type, id_token }
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken) {
|
||||
const body = new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
throw new Error(`Google token refresh failed (${r.status}): ${t.slice(0, 200)}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getUserInfo(accessToken) {
|
||||
const r = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!r.ok) throw new Error(`userinfo failed (${r.status})`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Wrapper que renova token automaticamente se expirado (401)
|
||||
export async function withFreshToken(userId, fn) {
|
||||
const conn = db.getGoogleConnection(userId);
|
||||
if (!conn) throw new Error('not_connected');
|
||||
let accessToken = conn.access_token;
|
||||
// Se expirou (com 60s de margem), renova proativamente
|
||||
if (conn.expires_at && conn.expires_at < Date.now() + 60000) {
|
||||
const fresh = await refreshAccessToken(conn.refresh_token);
|
||||
accessToken = fresh.access_token;
|
||||
db.saveGoogleConnection(userId, {
|
||||
...conn,
|
||||
access_token: accessToken,
|
||||
expires_at: Date.now() + (fresh.expires_in * 1000),
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn(accessToken);
|
||||
} catch (e) {
|
||||
if (String(e.message).includes('401')) {
|
||||
const fresh = await refreshAccessToken(conn.refresh_token);
|
||||
db.saveGoogleConnection(userId, {
|
||||
...conn,
|
||||
access_token: fresh.access_token,
|
||||
expires_at: Date.now() + (fresh.expires_in * 1000),
|
||||
});
|
||||
return fn(fresh.access_token);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Cria/atualiza um evento no Google Calendar a partir de uma pendência
|
||||
// pending: { id, title, notes?, dueDate?, completed?, googleEventId? }
|
||||
export async function upsertEventForPending(userId, pending, calendarId = 'primary') {
|
||||
return withFreshToken(userId, async (accessToken) => {
|
||||
const event = pendingToEvent(pending);
|
||||
let url, method;
|
||||
if (pending.googleEventId) {
|
||||
url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(pending.googleEventId)}`;
|
||||
method = 'PATCH';
|
||||
} else {
|
||||
url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`;
|
||||
method = 'POST';
|
||||
}
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
throw new Error(`Google event ${method} failed (${r.status}): ${t.slice(0, 200)}`);
|
||||
}
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEvent(userId, eventId, calendarId = 'primary') {
|
||||
return withFreshToken(userId, async (accessToken) => {
|
||||
const r = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (r.status !== 204 && r.status !== 410 && r.status !== 404) {
|
||||
const t = await r.text();
|
||||
throw new Error(`Google delete failed (${r.status}): ${t.slice(0, 200)}`);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
// Lista eventos modificados desde um momento (pra pull periódico)
|
||||
export async function listChangedEvents(userId, syncToken, calendarId = 'primary') {
|
||||
return withFreshToken(userId, async (accessToken) => {
|
||||
const u = new URL(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`);
|
||||
if (syncToken) u.searchParams.set('syncToken', syncToken);
|
||||
else {
|
||||
// Primeira sync: pega eventos do "Shivao" criados nos últimos 90 dias
|
||||
u.searchParams.set('q', 'Shivão');
|
||||
u.searchParams.set('timeMin', new Date(Date.now() - 90 * 86400 * 1000).toISOString());
|
||||
}
|
||||
const r = await fetch(u.toString(), { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
throw new Error(`Google list failed (${r.status}): ${t.slice(0, 200)}`);
|
||||
}
|
||||
return r.json(); // { items: [...], nextSyncToken }
|
||||
});
|
||||
}
|
||||
|
||||
function pendingToEvent(p) {
|
||||
const ev = {
|
||||
summary: `⚓ ${p.title || 'Pendência'}`,
|
||||
description: (p.notes || '') + '\n\n— do Diário do Shivão',
|
||||
extendedProperties: {
|
||||
private: {
|
||||
shivaoPendingId: String(p.id),
|
||||
shivaoCompleted: p.completed ? '1' : '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
if (p.completed) {
|
||||
ev.summary = '✅ ' + ev.summary;
|
||||
}
|
||||
if (p.dueDate) {
|
||||
// dueDate formato YYYY-MM-DD vira evento all-day
|
||||
ev.start = { date: p.dueDate };
|
||||
const d = new Date(p.dueDate + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
ev.end = { date: d.toISOString().slice(0, 10) };
|
||||
} else {
|
||||
// Sem data: defaulta pra hoje all-day
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
ev.start = { date: today };
|
||||
const d = new Date(today + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
ev.end = { date: d.toISOString().slice(0, 10) };
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
export function eventToPending(ev) {
|
||||
// Reconstrói pending parcial a partir de evento Google. Cliente faz merge.
|
||||
const p = {
|
||||
googleEventId: ev.id,
|
||||
googleUpdated: ev.updated,
|
||||
};
|
||||
// Tira emoji do começo do summary se houver
|
||||
let title = ev.summary || '';
|
||||
title = title.replace(/^(✅\s*)?⚓\s*/, '').trim();
|
||||
p.title = title;
|
||||
if (ev.description) {
|
||||
p.notes = ev.description.replace(/\n\n— do Diário do Shivão$/, '');
|
||||
}
|
||||
if (ev.start?.date) p.dueDate = ev.start.date;
|
||||
if (ev.extendedProperties?.private?.shivaoPendingId) {
|
||||
p.id = ev.extendedProperties.private.shivaoPendingId;
|
||||
}
|
||||
if (ev.extendedProperties?.private?.shivaoCompleted === '1') p.completed = true;
|
||||
if (ev.status === 'cancelled') p.deleted = true;
|
||||
return p;
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import express from 'express';
|
||||
import http from 'node:http';
|
||||
import multer from 'multer';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'node:path';
|
||||
|
|
@ -10,9 +9,6 @@ import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notificat
|
|||
import { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema, checkoutSchema } from './schemas/index.js';
|
||||
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js';
|
||||
import * as billing from './billing.js';
|
||||
import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js';
|
||||
import * as gcal from './google-calendar.js';
|
||||
import * as tuya from './tuya.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = parseInt(process.env.PORT || '3000');
|
||||
|
|
@ -127,205 +123,6 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
|
|||
res.json(req.user);
|
||||
});
|
||||
|
||||
// Diagnostic log endpoint — recebe log do BLE pra debugar
|
||||
app.post('/api/bms/diag-log', requireAuth, (req, res) => {
|
||||
const { log } = req.body || {};
|
||||
if (!log || typeof log !== 'string') return res.status(400).json({ error: 'log string required' });
|
||||
const dir = path.join(db.dataDir, 'diag-logs');
|
||||
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const file = path.join(dir, `${req.user.id}-${ts}.txt`);
|
||||
try {
|
||||
fs.writeFileSync(file, log.slice(0, 50000));
|
||||
db.audit(req.user.id, 'bms_diag_log', 'bluetooth', null, { bytes: log.length, file: path.basename(file) }, req.ip);
|
||||
res.json({ ok: true, file: path.basename(file) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ADMIN: lista TODOS os logs (BOAT_TOKEN apenas)
|
||||
app.get('/api/bms/diag-log/_all', requireAuth, (req, res) => {
|
||||
if (!req.user.viaBoatToken) return res.status(403).json({ error: 'admin only' });
|
||||
const dir = path.join(db.dataDir, 'diag-logs');
|
||||
try {
|
||||
if (!fs.existsSync(dir)) return res.json({ files: [] });
|
||||
const files = fs.readdirSync(dir).map(f => {
|
||||
const stat = fs.statSync(path.join(dir, f));
|
||||
return { name: f, size: stat.size, mtime: stat.mtime };
|
||||
}).sort((a, b) => b.mtime - a.mtime);
|
||||
res.json({ files });
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
});
|
||||
|
||||
// Lista logs disponíveis (debug)
|
||||
app.get('/api/bms/diag-log', requireAuth, (req, res) => {
|
||||
const dir = path.join(db.dataDir, 'diag-logs');
|
||||
try {
|
||||
if (!fs.existsSync(dir)) return res.json({ files: [] });
|
||||
const files = fs.readdirSync(dir)
|
||||
.filter(f => f.startsWith(`${req.user.id}-`))
|
||||
.map(f => {
|
||||
const stat = fs.statSync(path.join(dir, f));
|
||||
return { name: f, size: stat.size, mtime: stat.mtime };
|
||||
})
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
res.json({ files });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Lê conteúdo de um log específico
|
||||
app.get('/api/bms/diag-log/:file', requireAuth, (req, res) => {
|
||||
const file = req.params.file.replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
// Admin (BOAT_TOKEN) lê qualquer; user normal só os próprios
|
||||
if (!req.user.viaBoatToken && !file.startsWith(`${req.user.id}-`)) {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
const fullPath = path.join(db.dataDir, 'diag-logs', file);
|
||||
try {
|
||||
if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'not found' });
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
res.type('text/plain').send(content);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== IoT (Smart Life / Tuya) =====
|
||||
// Proxy assinado pra Tuya Cloud API. Access Secret nunca vai pro client.
|
||||
|
||||
app.get('/api/iot/devices', requireAuth, async (req, res) => {
|
||||
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
|
||||
try {
|
||||
const r = await tuya.listDevices();
|
||||
if (r.error) return res.status(502).json({ error: r.error, code: r.code });
|
||||
// Enriquece com label humano
|
||||
const devices = r.devices.map(d => ({ ...d, category_label: tuya.categoryLabel(d.category) }));
|
||||
res.json({ devices });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/iot/status/:deviceId', requireAuth, async (req, res) => {
|
||||
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
|
||||
const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
|
||||
try {
|
||||
const r = await tuya.getDeviceStatus(deviceId);
|
||||
if (r.error) return res.status(502).json({ error: r.error, code: r.code });
|
||||
res.json({ status: r.status });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/iot/command/:deviceId', requireAuth, async (req, res) => {
|
||||
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
|
||||
const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, '');
|
||||
const { commands } = req.body || {};
|
||||
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
|
||||
if (!Array.isArray(commands) || commands.length === 0) {
|
||||
return res.status(400).json({ error: 'commands array required' });
|
||||
}
|
||||
// Validação básica: cada item precisa ter code:string + value
|
||||
for (const c of commands) {
|
||||
if (!c || typeof c.code !== 'string') {
|
||||
return res.status(400).json({ error: 'each command needs {code:string, value:any}' });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const r = await tuya.sendCommand(deviceId, commands);
|
||||
if (!r.ok) return res.status(502).json({ error: r.error, code: r.code });
|
||||
db.audit(req.user.id, 'iot_command', 'tuya', deviceId, { commands }, req.ip);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Google Login via OAuth redirect (pra apps Capacitor onde GSI popup não funciona) =====
|
||||
// In-memory store: session_id → { tokens, createdAt }. Auto-expira em 10min.
|
||||
const pendingGoogleSessions = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of pendingGoogleSessions.entries()) {
|
||||
if (now - v.createdAt > 10 * 60 * 1000) pendingGoogleSessions.delete(k);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// App chama isso pra obter URL pro browser externo
|
||||
app.get('/api/auth/google/start', (req, res) => {
|
||||
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
|
||||
const sessionId = req.query.session || ('s_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
|
||||
// Reusa buildAuthUrl mas com flow=login e session encoded em state
|
||||
// Truque: passa userId=0 (não-autenticado) + flow no returnTo
|
||||
const stateRaw = Buffer.from(JSON.stringify({ uid: 0, flow: 'login', session: sessionId, n: Math.random().toString(36).slice(2) })).toString('base64url');
|
||||
const u = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
u.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
|
||||
u.searchParams.set('redirect_uri', process.env.GOOGLE_REDIRECT_URI);
|
||||
u.searchParams.set('response_type', 'code');
|
||||
u.searchParams.set('scope', 'openid email profile');
|
||||
u.searchParams.set('access_type', 'online');
|
||||
u.searchParams.set('prompt', 'select_account');
|
||||
u.searchParams.set('state', stateRaw);
|
||||
res.json({ url: u.toString(), session: sessionId });
|
||||
});
|
||||
|
||||
// App polling: retorna 204 se ainda esperando, 200 com tokens se Google completou
|
||||
app.get('/api/auth/google/poll', (req, res) => {
|
||||
const session = req.query.session;
|
||||
if (!session) return res.status(400).json({ error: 'session required' });
|
||||
const data = pendingGoogleSessions.get(session);
|
||||
if (!data) return res.status(204).send();
|
||||
pendingGoogleSessions.delete(session); // one-shot
|
||||
res.json(data.tokens);
|
||||
});
|
||||
|
||||
// Login com Google (Sign-In via popup do GSI no web) — recebe ID token, valida no Google, cria/loga user
|
||||
app.post('/api/auth/google', async (req, res) => {
|
||||
const { credential } = req.body || {};
|
||||
if (!credential) return res.status(400).json({ error: 'credential (Google ID token) required' });
|
||||
try {
|
||||
// Valida o ID token via tokeninfo endpoint do Google (sem dependência adicional)
|
||||
const r = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
||||
if (!r.ok) return res.status(401).json({ error: 'invalid_google_token' });
|
||||
const info = await r.json();
|
||||
// Confere que aud = nosso CLIENT_ID
|
||||
const expectedAud = process.env.GOOGLE_CLIENT_ID || '';
|
||||
if (expectedAud && info.aud !== expectedAud) {
|
||||
return res.status(401).json({ error: 'token_audience_mismatch' });
|
||||
}
|
||||
if (!info.email_verified || info.email_verified === 'false') {
|
||||
return res.status(403).json({ error: 'email_not_verified' });
|
||||
}
|
||||
const email = info.email;
|
||||
const name = info.name || email.split('@')[0];
|
||||
let user = db.findUserByEmail(email);
|
||||
if (!user) {
|
||||
// Auto-cria com senha aleatória inutilizável (login só via Google daqui pra frente)
|
||||
const randomPwd = 'google-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
||||
const hash = await hashPassword(randomPwd);
|
||||
const id = db.createUser(email, hash, name);
|
||||
db.audit(id, 'user_signup_google', 'user', String(id), { email }, req.ip);
|
||||
user = db.findUserById(id);
|
||||
}
|
||||
db.updateLastLogin(user.id);
|
||||
db.audit(user.id, 'user_login_google', 'user', String(user.id), {}, req.ip);
|
||||
const safe = db.findUserById(user.id);
|
||||
res.json({
|
||||
user: safe,
|
||||
accessToken: signAccessToken(safe),
|
||||
refreshToken: signRefreshToken(safe),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[auth/google]', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Plans + license info
|
||||
app.get('/api/license', requireAuth, (req, res) => {
|
||||
const lic = db.getActiveLicense(req.user.id) || { plan: 'free', status: 'active', expires_at: null };
|
||||
|
|
@ -466,89 +263,6 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
|
|||
}]);
|
||||
});
|
||||
|
||||
// Atalho: /apk redireciona pra última APK release no Forgejo
|
||||
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.12.0/Shivao-v1.12.0.apk';
|
||||
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
|
||||
|
||||
// Página A4 imprimível com QR Code + instruções (cola no barco/marina)
|
||||
app.get('/imprimir', (req, res) => {
|
||||
const url = `https://${req.headers.host || 'shivao.pontualtech.work'}/apk`;
|
||||
const qrApi = `https://api.qrserver.com/v1/create-qr-code/?size=480x480&data=${encodeURIComponent(url)}&color=0e2a3d&bgcolor=ffffff&qzone=2&format=png`;
|
||||
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><title>Imprimir · QR Code Shivao</title><style>
|
||||
@page{size:A4;margin:0}
|
||||
*{box-sizing:border-box;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
body{margin:0;padding:0;background:#efe5cd;font-family:Georgia,serif;color:#0e2a3d}
|
||||
.page{width:210mm;min-height:297mm;padding:25mm 20mm;margin:0 auto;background:linear-gradient(180deg,#fbf5e2 0%,#efe5cd 100%);position:relative;overflow:hidden}
|
||||
.page::before{content:'';position:absolute;top:0;left:0;right:0;height:8mm;background:linear-gradient(90deg,#0e2a3d,#a07832,#0e2a3d)}
|
||||
.page::after{content:'';position:absolute;bottom:0;left:0;right:0;height:8mm;background:linear-gradient(90deg,#0e2a3d,#a07832,#0e2a3d)}
|
||||
.head{text-align:center;margin-bottom:8mm}
|
||||
.brand{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:6mm}
|
||||
.compass{width:42px;height:42px;border:2.5px solid #a07832;border-radius:50%;position:relative;flex-shrink:0}
|
||||
.compass::before,.compass::after{content:'';position:absolute;background:#a07832}
|
||||
.compass::before{top:50%;left:-3px;right:-3px;height:2px;transform:translateY(-50%)}
|
||||
.compass::after{top:-3px;bottom:-3px;left:50%;width:2px;transform:translateX(-50%)}
|
||||
h1{font-style:italic;font-size:34pt;color:#a07832;margin:0;letter-spacing:-0.5px}
|
||||
.subtitle{font-style:italic;font-size:13pt;color:#5d7186;margin-top:1mm}
|
||||
.tagline{font-family:'Courier New',monospace;font-size:9pt;letter-spacing:3px;text-transform:uppercase;color:#7d6943;margin-top:2mm}
|
||||
.qr-section{background:#fff;padding:10mm;border-radius:6mm;box-shadow:0 4px 16px rgba(14,42,61,.12);margin:8mm auto;width:fit-content;text-align:center;border:1px solid rgba(184,156,108,.3)}
|
||||
.qr-section img{display:block;margin:0 auto;width:140mm;height:140mm;max-width:100%}
|
||||
.qr-label{font-family:'Courier New',monospace;font-size:11pt;color:#0e2a3d;margin-top:5mm;letter-spacing:1px}
|
||||
.steps{margin:8mm 0;display:grid;grid-template-columns:repeat(2,1fr);gap:5mm}
|
||||
.step{background:rgba(255,255,255,.55);padding:5mm 6mm;border-radius:4mm;border-left:3px solid #a07832}
|
||||
.step-num{font-family:'Courier New',monospace;font-size:9pt;color:#a07832;letter-spacing:2px;font-weight:600}
|
||||
.step-text{font-size:11pt;line-height:1.5;margin-top:2mm;color:#0e2a3d}
|
||||
.step-text strong{color:#0e2a3d}
|
||||
.url-box{background:#0e2a3d;color:#efe5cd;padding:5mm 8mm;text-align:center;border-radius:4mm;margin-top:4mm}
|
||||
.url-box .label{font-family:'Courier New',monospace;font-size:8pt;letter-spacing:3px;text-transform:uppercase;opacity:.7;margin-bottom:2mm}
|
||||
.url-box .url{font-family:'Courier New',monospace;font-size:14pt;letter-spacing:1px;color:#c89f54;font-weight:600}
|
||||
.foot{position:absolute;bottom:14mm;left:20mm;right:20mm;text-align:center;font-size:8pt;color:#5d7186;font-style:italic;line-height:1.6}
|
||||
.foot a{color:#a07832;text-decoration:none}
|
||||
.print-btn{position:fixed;bottom:20px;right:20px;background:#a07832;color:#fff;padding:14px 24px;border-radius:8px;border:none;font-family:Georgia,serif;font-size:14pt;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:1000}
|
||||
@media print{.print-btn{display:none}}
|
||||
</style></head><body>
|
||||
<div class="page">
|
||||
<div class="head">
|
||||
<div class="brand"><div class="compass"></div><h1>Shivao</h1></div>
|
||||
<div class="subtitle">Diário de Bordo · Aplicativo Náutico</div>
|
||||
<div class="tagline">GPS · Vigia de Fundeio · Meteorologia</div>
|
||||
</div>
|
||||
<div class="qr-section">
|
||||
<img src="${qrApi}" alt="QR Code">
|
||||
<div class="qr-label">aponte a câmera do celular Android aqui ↑</div>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step"><div class="step-num">PASSO 1</div><div class="step-text">Abra a <strong>câmera</strong> do seu Android e aponte pro QR Code acima</div></div>
|
||||
<div class="step"><div class="step-num">PASSO 2</div><div class="step-text">Toque na notificação que aparece pra abrir o link no <strong>Chrome</strong></div></div>
|
||||
<div class="step"><div class="step-num">PASSO 3</div><div class="step-text">O Chrome baixa o <strong>APK</strong> (3,4 MB). Toque no download → <strong>Instalar</strong></div></div>
|
||||
<div class="step"><div class="step-num">PASSO 4</div><div class="step-text">Aceite "fontes desconhecidas" se pedir. <strong>Pronto</strong> — abra o ícone Shivao</div></div>
|
||||
</div>
|
||||
<div class="url-box">
|
||||
<div class="label">Não tem leitor de QR? Digite no Chrome:</div>
|
||||
<div class="url">shivao.pontualtech.work/apk</div>
|
||||
</div>
|
||||
<div class="foot">
|
||||
Shivao · PontualTech · CNPJ 32.772.178/0001-47<br>
|
||||
<a href="https://shivao.pontualtech.work/politica">shivao.pontualtech.work/politica</a> · <a href="https://shivao.pontualtech.work/termos">/termos</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="print-btn" onclick="window.print()">🖨️ Imprimir / Salvar PDF</button>
|
||||
</body></html>`);
|
||||
});
|
||||
|
||||
// QR Code da URL /apk pra facilitar instalação no celular
|
||||
app.get('/qr', (req, res) => {
|
||||
const url = `https://${req.headers.host || 'shivao.pontualtech.work'}/apk`;
|
||||
const qrApi = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(url)}&color=0e2a3d&bgcolor=efe5cd&qzone=2`;
|
||||
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Baixar app · Shivao</title><style>body{font-family:Georgia,serif;background:#efe5cd;color:#0e2a3d;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:20px}h1{font-style:italic;color:#a07832;margin:0 0 8px}p{margin:0 0 24px;color:#5d7186}.qr{background:#fff;padding:24px;border-radius:16px;box-shadow:0 8px 32px rgba(14,42,61,.15);margin:8px 0}img{display:block}.url{font-family:'Courier New',monospace;font-size:13px;background:#fff;padding:10px 14px;border-radius:8px;margin-top:14px;border:1px solid rgba(184,156,108,.3)}.btn{display:inline-block;background:linear-gradient(180deg,#143a52,#0e2a3d);color:#efe5cd;padding:14px 24px;border-radius:8px;text-decoration:none;margin-top:20px;font-family:Georgia,serif;font-style:italic;letter-spacing:.5px;box-shadow:0 4px 12px rgba(14,42,61,.2)}.btn:hover{transform:translateY(-1px)}small{opacity:.6;margin-top:24px;text-align:center;max-width:340px;line-height:1.5}</style></head><body>
|
||||
<h1>Shivao · Diário de Bordo</h1>
|
||||
<p>Aponte a câmera do celular pro QR Code:</p>
|
||||
<div class="qr"><img src="${qrApi}" alt="QR Code APK Shivao" width="400" height="400"></div>
|
||||
<div class="url">${url}</div>
|
||||
<a class="btn" href="/apk">Baixar APK direto</a>
|
||||
<small>Android: o Chrome vai baixar o APK. Toque na notificação de download e siga as instruções pra instalar.</small>
|
||||
</body></html>`);
|
||||
});
|
||||
|
||||
// Termos de Uso
|
||||
app.get('/termos', (req, res) => {
|
||||
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Termos de Uso · Shivao</title><style>body{font-family:Georgia,serif;max-width:780px;margin:40px auto;padding:0 20px;color:#0e2a3d;background:#efe5cd;line-height:1.7}h1,h2{font-style:italic;color:#a07832}h1{border-bottom:2px solid #a07832;padding-bottom:8px}h2{margin-top:32px}.warn{background:#8c343422;border-left:4px solid #8c3434;padding:12px 16px;margin:20px 0}a{color:#3f7768}small{opacity:.7}</style></head><body>
|
||||
|
|
@ -754,143 +468,10 @@ app.get('/api/info', requireAuth, (req, res) => {
|
|||
res.json({
|
||||
channels: listConfiguredChannels(),
|
||||
heartbeatTimeoutSec: HEARTBEAT_TIMEOUT / 1000,
|
||||
googleCalendar: gcal.isEnabled(),
|
||||
version: '1.0'
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Google Calendar OAuth + sync =====
|
||||
app.get('/api/google/status', requireAuth, (req, res) => {
|
||||
if (!gcal.isEnabled()) return res.json({ enabled: false });
|
||||
const conn = db.getGoogleConnection(req.user.id);
|
||||
res.json({
|
||||
enabled: true,
|
||||
connected: !!conn,
|
||||
email: conn?.email || null,
|
||||
last_sync_at: conn?.last_sync_at || null,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/google/auth-url', requireAuth, (req, res) => {
|
||||
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
|
||||
const returnTo = (req.query.return_to || '/').toString().slice(0, 200);
|
||||
const url = gcal.buildAuthUrl(req.user.id, returnTo);
|
||||
res.json({ url });
|
||||
});
|
||||
|
||||
app.get('/api/google/callback', async (req, res) => {
|
||||
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
|
||||
const { code, state, error } = req.query;
|
||||
if (error) return res.status(400).send(`Erro do Google: ${error}`);
|
||||
if (!code || !state) return res.status(400).send('Faltam parâmetros code/state.');
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); }
|
||||
catch (e) { return res.status(400).send('state inválido'); }
|
||||
try {
|
||||
const tokens = await gcal.exchangeCodeForTokens(code);
|
||||
const userInfo = await gcal.getUserInfo(tokens.access_token);
|
||||
|
||||
// === Flow LOGIN (do app via /api/auth/google/start) ===
|
||||
if (parsed.flow === 'login' && parsed.session) {
|
||||
let user = db.findUserByEmail(userInfo.email);
|
||||
if (!user) {
|
||||
const randomPwd = 'google-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
||||
const hash = await hashPassword(randomPwd);
|
||||
const id = db.createUser(userInfo.email, hash, userInfo.name || userInfo.email.split('@')[0]);
|
||||
db.audit(id, 'user_signup_google', 'user', String(id), { email: userInfo.email }, req.ip);
|
||||
user = db.findUserById(id);
|
||||
}
|
||||
db.updateLastLogin(user.id);
|
||||
db.audit(user.id, 'user_login_google', 'user', String(user.id), {}, req.ip);
|
||||
const safe = db.findUserById(user.id);
|
||||
// Salva tokens em memória pra app coletar via /poll
|
||||
pendingGoogleSessions.set(parsed.session, {
|
||||
tokens: {
|
||||
user: safe,
|
||||
accessToken: signAccessToken(safe),
|
||||
refreshToken: signRefreshToken(safe),
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Login OK</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>body{font-family:system-ui;background:#0e2a3d;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;padding:20px}h1{color:#d4a04a;margin:0 0 16px}p{margin:8px 0;line-height:1.5}.big{font-size:64px;margin-bottom:16px}</style></head>
|
||||
<body><div><div class="big">⚓</div><h1>Logado com sucesso</h1>
|
||||
<p>Você está conectado como <strong>${userInfo.email}</strong></p>
|
||||
<p style="opacity:.7">Volte pro app — ele vai detectar o login automaticamente em alguns segundos.</p>
|
||||
<script>setTimeout(()=>{try{window.close()}catch(e){}},3000)</script>
|
||||
</div></body></html>`);
|
||||
}
|
||||
|
||||
// === Flow CALENDAR (conectar Google Calendar pra user já logado) ===
|
||||
const userId = parsed.uid;
|
||||
if (!userId) return res.status(400).send('state sem uid');
|
||||
db.saveGoogleConnection(userId, {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: Date.now() + (tokens.expires_in * 1000),
|
||||
email: userInfo.email,
|
||||
});
|
||||
db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip);
|
||||
const returnTo = parsed.rt || '/';
|
||||
res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>body{font-family:system-ui;background:#0e2a3d;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;padding:20px}h1{color:#d4a04a}</style></head>
|
||||
<body><div><h1>✓ Google Agenda conectado</h1>
|
||||
<p>Conectado como <strong>${userInfo.email}</strong></p>
|
||||
<p>Pode fechar esta janela e voltar pro app.</p>
|
||||
<script>setTimeout(()=>{try{window.close()}catch(e){};location.href=${JSON.stringify(returnTo)}},2000)</script>
|
||||
</div></body></html>`);
|
||||
} catch (e) {
|
||||
console.warn('[google] callback failed', e.message);
|
||||
res.status(500).send('Erro: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/google/disconnect', requireAuth, (req, res) => {
|
||||
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
|
||||
db.deleteGoogleConnection(req.user.id);
|
||||
db.audit(req.user.id, 'google_disconnected', 'google_calendar', null, null, req.ip);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Push: envia/atualiza um evento no Google a partir de uma pendência local
|
||||
app.post('/api/google/sync-pending', requireAuth, async (req, res) => {
|
||||
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
|
||||
const { pending } = req.body || {};
|
||||
if (!pending || !pending.id) return res.status(400).json({ error: 'pending.id required' });
|
||||
try {
|
||||
if (pending.deleted) {
|
||||
if (pending.googleEventId) await gcal.deleteEvent(req.user.id, pending.googleEventId);
|
||||
return res.json({ ok: true, deleted: true });
|
||||
}
|
||||
const ev = await gcal.upsertEventForPending(req.user.id, pending);
|
||||
res.json({ ok: true, event: { id: ev.id, htmlLink: ev.htmlLink, updated: ev.updated } });
|
||||
} catch (e) {
|
||||
if (String(e.message).includes('not_connected')) {
|
||||
return res.status(409).json({ error: 'not_connected' });
|
||||
}
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Pull: lista eventos modificados no Google (cliente faz merge)
|
||||
app.get('/api/google/pull', requireAuth, async (req, res) => {
|
||||
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
|
||||
try {
|
||||
const conn = db.getGoogleConnection(req.user.id);
|
||||
if (!conn) return res.status(409).json({ error: 'not_connected' });
|
||||
const result = await gcal.listChangedEvents(req.user.id, conn.sync_token);
|
||||
if (result.nextSyncToken) {
|
||||
db.setGoogleSyncToken(req.user.id, result.nextSyncToken, Date.now());
|
||||
}
|
||||
const items = (result.items || []).map(gcal.eventToPending);
|
||||
res.json({ items });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- State sync (whole JSON blob, per-user) ---
|
||||
app.get('/api/data', requireAuth, (req, res) => {
|
||||
const s = db.getState(req.user.id);
|
||||
|
|
@ -901,10 +482,7 @@ app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => {
|
|||
const { data } = req.body;
|
||||
const ts = db.setState(req.user.id, data);
|
||||
db.audit(req.user.id, 'state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip);
|
||||
// Notifica outros devices do mesmo user em tempo real (não bloqueia resposta)
|
||||
const originDeviceId = req.headers['x-device-id'] || req.query.device || null;
|
||||
broadcastStateChange(req.user.id, { kind: 'state', updated_at: ts, originDeviceId });
|
||||
res.json({ ok: true, updated_at: ts, online_devices: getOnlineCount(req.user.id) });
|
||||
res.json({ ok: true, updated_at: ts });
|
||||
});
|
||||
|
||||
// --- Media ---
|
||||
|
|
@ -1259,10 +837,7 @@ async function checkDeadman() {
|
|||
setInterval(checkDeadman, 30000); // check every 30s
|
||||
|
||||
// ==== Start ====
|
||||
const httpServer = http.createServer(app);
|
||||
initRealtime(httpServer);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Shivao Cloud rodando em :${PORT}`);
|
||||
console.log(`Canais configurados: ${listConfiguredChannels().join(', ') || '(nenhum!)'}`);
|
||||
console.log(`Dead-man switch: ${HEARTBEAT_TIMEOUT/1000}s`);
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
// Realtime sync via WebSocket — broadcast state-change notifications between devices of the same user.
|
||||
// Cliente reage à notificação fazendo pull do estado via REST. Sem entity-level diffing.
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { verifyToken } from './auth.js';
|
||||
|
||||
const TOKEN = process.env.BOAT_TOKEN;
|
||||
const HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
// Map<userId, Set<WebSocket>>
|
||||
const clientsByUser = new Map();
|
||||
|
||||
function addClient(userId, ws) {
|
||||
if (!clientsByUser.has(userId)) clientsByUser.set(userId, new Set());
|
||||
clientsByUser.get(userId).add(ws);
|
||||
}
|
||||
|
||||
function removeClient(userId, ws) {
|
||||
const set = clientsByUser.get(userId);
|
||||
if (!set) return;
|
||||
set.delete(ws);
|
||||
if (set.size === 0) clientsByUser.delete(userId);
|
||||
}
|
||||
|
||||
// Resolve um token (JWT ou BOAT_TOKEN) → userId. Retorna null se inválido.
|
||||
function authenticateToken(token) {
|
||||
if (!token) return null;
|
||||
if (token === TOKEN) return 1; // legacy single-tenant
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || payload.type !== 'access' || !payload.uid) return null;
|
||||
return payload.uid;
|
||||
}
|
||||
|
||||
export function initRealtime(httpServer) {
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token') || '';
|
||||
const deviceId = url.searchParams.get('device') || ('anon-' + Math.random().toString(36).slice(2, 8));
|
||||
const userId = authenticateToken(token);
|
||||
|
||||
if (!userId) {
|
||||
ws.send(JSON.stringify({ type: 'error', code: 'auth_failed' }));
|
||||
ws.close(1008, 'auth failed');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.userId = userId;
|
||||
ws.deviceId = deviceId;
|
||||
ws.isAlive = true;
|
||||
addClient(userId, ws);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'hello', userId, deviceId, ts: Date.now() }));
|
||||
|
||||
// Quantos devices estão online pro mesmo user
|
||||
broadcastPresence(userId);
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
// Cliente pode mandar pings explícitos ou notificações. Ignoramos qualquer outra coisa.
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
removeClient(userId, ws);
|
||||
broadcastPresence(userId);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.warn('[ws] error', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Heartbeat: drop dead connections
|
||||
const heartbeat = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
try { ws.ping(); } catch (e) {}
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
|
||||
wss.on('close', () => clearInterval(heartbeat));
|
||||
|
||||
console.log('[ws] WebSocket server attached at /ws');
|
||||
return wss;
|
||||
}
|
||||
|
||||
// Notifica todos os devices do user (exceto o que originou) que o estado mudou.
|
||||
// payload: { kind: 'state'|'pending'|'trip'|..., ts, originDeviceId? }
|
||||
export function broadcastStateChange(userId, payload = {}) {
|
||||
const set = clientsByUser.get(userId);
|
||||
if (!set) return;
|
||||
const msg = JSON.stringify({
|
||||
type: 'state:changed',
|
||||
ts: Date.now(),
|
||||
...payload,
|
||||
});
|
||||
for (const ws of set) {
|
||||
if (ws.readyState !== ws.OPEN) continue;
|
||||
if (payload.originDeviceId && ws.deviceId === payload.originDeviceId) continue;
|
||||
try { ws.send(msg); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastPresence(userId) {
|
||||
const set = clientsByUser.get(userId);
|
||||
if (!set) return;
|
||||
const msg = JSON.stringify({ type: 'presence', count: set.size, ts: Date.now() });
|
||||
for (const ws of set) {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
try { ws.send(msg); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getOnlineCount(userId) {
|
||||
return clientsByUser.get(userId)?.size || 0;
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
// Tuya OpenAPI client — Smart Life devices via HMAC-SHA256 signing
|
||||
// Docs: https://developer.tuya.com/en/docs/cloud/cloud-api-best-practice
|
||||
//
|
||||
// Why server-side: Access Secret never goes to client (PWA), pra evitar token
|
||||
// leak via DevTools. Client só conhece deviceId; server assina e proxia.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const ACCESS_ID = process.env.TUYA_ACCESS_ID || '';
|
||||
const ACCESS_SECRET = process.env.TUYA_ACCESS_SECRET || '';
|
||||
// Tuya tem 5 data centers. Escolha o mesmo da conta Smart Life (Eu → Account → Region):
|
||||
// us = openapi.tuyaus.com (default North America)
|
||||
// eu = openapi.tuyaeu.com (Europe)
|
||||
// cn = openapi.tuyacn.com (China)
|
||||
// in = openapi.tuyain.com (India)
|
||||
// sg = openapi-sg.iotbing.com (South Asia)
|
||||
// Brasil normalmente cai no US.
|
||||
const BASE_URL = process.env.TUYA_BASE_URL || 'https://openapi.tuyaus.com';
|
||||
|
||||
let cachedToken = null; // {access_token, expires_at_ms}
|
||||
|
||||
export function isEnabled() {
|
||||
return !!(ACCESS_ID && ACCESS_SECRET);
|
||||
}
|
||||
|
||||
export function disabledResponse(res) {
|
||||
return res.status(503).json({
|
||||
error: 'tuya_not_configured',
|
||||
message: 'Configure TUYA_ACCESS_ID + TUYA_ACCESS_SECRET no env do servidor.',
|
||||
setup_url: 'https://iot.tuya.com',
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(str) {
|
||||
return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function hmacSha256(key, str) {
|
||||
return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
// stringToSign = HTTPMethod + "\n" + Content-SHA256 + "\n" + Headers + "\n" + Url
|
||||
// Headers fica vazio porque não usamos signedHeaders custom.
|
||||
function buildStringToSign(method, urlPath, body) {
|
||||
const contentSha = sha256(body || '');
|
||||
return `${method.toUpperCase()}\n${contentSha}\n\n${urlPath}`;
|
||||
}
|
||||
|
||||
// Para token endpoint: sign = client_id + t + nonce + stringToSign
|
||||
// Para business endpoints: sign = client_id + access_token + t + nonce + stringToSign
|
||||
function buildSignature(method, urlPath, body, withToken) {
|
||||
const t = String(Date.now());
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
const stringToSign = buildStringToSign(method, urlPath, body);
|
||||
const tokenPart = withToken && cachedToken ? cachedToken.access_token : '';
|
||||
const str = ACCESS_ID + tokenPart + t + nonce + stringToSign;
|
||||
const sign = hmacSha256(ACCESS_SECRET, str);
|
||||
return { sign, t, nonce };
|
||||
}
|
||||
|
||||
async function fetchToken() {
|
||||
const urlPath = '/v1.0/token?grant_type=1';
|
||||
const { sign, t, nonce } = buildSignature('GET', urlPath, '', false);
|
||||
const r = await fetch(BASE_URL + urlPath, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'client_id': ACCESS_ID,
|
||||
'sign': sign,
|
||||
'sign_method': 'HMAC-SHA256',
|
||||
't': t,
|
||||
'nonce': nonce,
|
||||
},
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.success) throw new Error(`tuya_token_failed: ${j.code} ${j.msg}`);
|
||||
cachedToken = {
|
||||
access_token: j.result.access_token,
|
||||
refresh_token: j.result.refresh_token,
|
||||
expires_at_ms: Date.now() + (j.result.expire_time * 1000) - 60000, // refresh 1min antes
|
||||
};
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
async function ensureToken() {
|
||||
if (cachedToken && Date.now() < cachedToken.expires_at_ms) return cachedToken;
|
||||
return await fetchToken();
|
||||
}
|
||||
|
||||
// Request genérico assinado a um endpoint Tuya OpenAPI
|
||||
async function tuyaRequest(method, urlPath, body) {
|
||||
await ensureToken();
|
||||
const bodyStr = body ? JSON.stringify(body) : '';
|
||||
const { sign, t, nonce } = buildSignature(method, urlPath, bodyStr, true);
|
||||
const r = await fetch(BASE_URL + urlPath, {
|
||||
method,
|
||||
headers: {
|
||||
'client_id': ACCESS_ID,
|
||||
'access_token': cachedToken.access_token,
|
||||
'sign': sign,
|
||||
'sign_method': 'HMAC-SHA256',
|
||||
't': t,
|
||||
'nonce': nonce,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: bodyStr || undefined,
|
||||
});
|
||||
const j = await r.json();
|
||||
// Token expirado mid-flight: invalida + retry 1x
|
||||
if (j.code === 1010 || j.code === 1011 || j.code === 1004) {
|
||||
cachedToken = null;
|
||||
return tuyaRequest(method, urlPath, body);
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
// ===== APIs públicas =====
|
||||
|
||||
// Lista todos os devices vinculados ao app Smart Life autorizado
|
||||
// (vinculado em iot.tuya.com → Cloud → Devices → Link Tuya App Account)
|
||||
export async function listDevices(uid) {
|
||||
// uid é opcional; sem uid retorna devices da org. Pra Karlão (1 conta) ok sem.
|
||||
const res = await tuyaRequest('GET', '/v1.3/iot-03/devices?source_type=tuyaUser&source_id=' + (uid || ''), null);
|
||||
if (!res.success) return { error: res.msg, code: res.code, devices: [] };
|
||||
return {
|
||||
devices: (res.result?.list || []).map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
online: d.online,
|
||||
product_id: d.product_id,
|
||||
product_name: d.product_name,
|
||||
category: d.category, // 'cz' = socket, 'dj' = light, 'kg' = switch, 'fs' = fan, etc.
|
||||
icon: d.icon,
|
||||
ip: d.ip,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Status atual do device (lista de DPs / data points)
|
||||
export async function getDeviceStatus(deviceId) {
|
||||
const res = await tuyaRequest('GET', `/v1.0/iot-03/devices/${deviceId}/status`, null);
|
||||
if (!res.success) return { error: res.msg, code: res.code };
|
||||
// Result é array tipo [{code:'switch_1', value:true}, {code:'bright_value', value:600}]
|
||||
return { status: res.result || [] };
|
||||
}
|
||||
|
||||
// Dispara comando: array de {code, value}
|
||||
// Ex pra ligar: [{code:'switch_1', value:true}]
|
||||
// Ex pra dimmer: [{code:'switch_led', value:true}, {code:'bright_value_v2', value:800}]
|
||||
export async function sendCommand(deviceId, commands) {
|
||||
const res = await tuyaRequest('POST', `/v1.0/iot-03/devices/${deviceId}/commands`, { commands });
|
||||
if (!res.success) return { ok: false, error: res.msg, code: res.code };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Categoria → função humanizada (ajuda UI a renderizar ícone certo)
|
||||
export function categoryLabel(cat) {
|
||||
const map = {
|
||||
cz: 'Tomada', dj: 'Lâmpada', kg: 'Interruptor', fs: 'Ventilador',
|
||||
dd: 'Fita LED', xdd: 'Luminária', dc: 'Cordão LED', tdq: 'Disjuntor',
|
||||
cwwsq: 'Alimentador', kt: 'Ar-condicionado', wsdcg: 'Sensor temp/umid',
|
||||
mcs: 'Sensor porta', co2bj: 'Sensor CO2', sd: 'Robô aspirador',
|
||||
cl: 'Cortina', clkg: 'Switch cortina', wnykq: 'Termostato',
|
||||
};
|
||||
return map[cat] || cat || 'Dispositivo';
|
||||
}
|
||||
Loading…
Reference in a new issue