mirror of
https://github.com/usetrmnl/inker.git
synced 2026-04-29 13:45:07 -07:00
0.3.0 - firmware 1.7.8 support, bug fixes, base for Inker plugins
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
[](https://buymeacoffee.com/wojo_o)
|
||||
|
||||
# Inker v0.2.1.1
|
||||
# Inker v0.3.0
|
||||
|
||||
Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) devices and BYOD e-ink displays. Design screens, create custom widgets, and manage your displays from a modern web interface.
|
||||
Self-hosted e-ink device management server built for the homelab community. Works with [TRMNL](https://usetrmnl.com/) devices (supports firmware 1.7.8) and any BYOD e-ink display. Design screens, create custom widgets with live data from your local network, and manage your displays from a modern web interface.
|
||||
|
||||
Inker is heading in its own direction — focusing on homelab integrations like server monitoring, smart home dashboards, network stats, and self-hosted service displays. TRMNL device compatibility is maintained, but the plugin ecosystem will be Inker-native.
|
||||
|
||||

|
||||
|
||||
@@ -11,9 +13,11 @@ Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) de
|
||||
- **Screen Designer** — Drag & drop widget placement, snap guides, freehand drawing, auto-fit zoom for any resolution
|
||||
- **Built-in Widgets** — Clock, date, text, weather, countdown, days until, QR code, image, GitHub stars, battery, WiFi, device info
|
||||
- **Custom Widgets** — Connect to any JSON API or RSS feed (including local network sources), JavaScript transformations, grid layouts
|
||||
- **Plugins** — Coming soon — homelab-native integrations for server monitoring, smart home, network stats
|
||||
- **Playlists** — Rotate multiple screens on devices automatically
|
||||
- **Device Management** — Auto-provisioning, BYOD support with custom resolutions, real-time status, logs
|
||||
- **Device Management** — Auto-provisioning, firmware 1.7.8 support, real-time status, logs
|
||||
- **BYOD Support** — Register any e-ink device manually with custom screen resolution
|
||||
- **Custom Ports** — Run on any port with Docker port mapping (e.g. `800:80`)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -101,6 +105,17 @@ cd inker
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
All data (screens, devices, playlists, settings) is preserved — database schema updates are applied automatically on startup.
|
||||
|
||||
> **Warning:** Never use `docker compose down -v` to update — the `-v` flag deletes all volumes and you will lose your data.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If something isn't working after an update or on first run, reset the volumes and start fresh:
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.11.0",
|
||||
"liquidjs": "^10.25.1",
|
||||
"nest-winston": "^1.9.4",
|
||||
"passport": "^0.7.0",
|
||||
"puppeteer": "^23.11.0",
|
||||
@@ -819,6 +820,8 @@
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"liquidjs": ["liquidjs@10.25.1", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-D+jsJvkGigFn8qNUgh8U6XNHhGFBp+p8Dk26ea/Hl+XrjFVSg9OXlN31hGAfS3MYQ3Kr8Xi9sEVBVQa/VTVSmg=="],
|
||||
|
||||
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -1313,6 +1316,8 @@
|
||||
|
||||
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inker-backend",
|
||||
"version": "0.2.1.1",
|
||||
"version": "0.3.0",
|
||||
"description": "Inker Server Backend - E-ink Device Management",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
@@ -42,6 +42,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.11.0",
|
||||
"liquidjs": "^10.25.1",
|
||||
"nest-winston": "^1.9.4",
|
||||
"passport": "^0.7.0",
|
||||
"puppeteer": "^23.11.0",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "plugins" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"icon" TEXT,
|
||||
"category" TEXT NOT NULL DEFAULT 'custom',
|
||||
"data_strategy" TEXT NOT NULL DEFAULT 'polling',
|
||||
"data_url" TEXT,
|
||||
"data_method" TEXT NOT NULL DEFAULT 'GET',
|
||||
"data_headers" JSONB,
|
||||
"data_path" TEXT,
|
||||
"data_transform" TEXT,
|
||||
"refresh_interval" INTEGER NOT NULL DEFAULT 300,
|
||||
"markup_full" TEXT,
|
||||
"markup_half_horizontal" TEXT,
|
||||
"markup_half_vertical" TEXT,
|
||||
"markup_quadrant" TEXT,
|
||||
"settings_schema" JSONB,
|
||||
"oauth_provider" TEXT,
|
||||
"oauth_scopes" TEXT,
|
||||
"is_installed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_builtin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"source" TEXT NOT NULL DEFAULT 'inker',
|
||||
"source_url" TEXT,
|
||||
"source_hash" TEXT,
|
||||
"version" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "plugins_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "plugin_instances" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"plugin_id" INTEGER NOT NULL,
|
||||
"name" TEXT,
|
||||
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||
"settings_encrypted" JSONB NOT NULL DEFAULT '{}',
|
||||
"oauth_token" TEXT,
|
||||
"oauth_refresh_token" TEXT,
|
||||
"oauth_expires_at" TIMESTAMP(3),
|
||||
"last_data" JSONB,
|
||||
"last_fetched_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "plugin_instances_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Add plugin_instance_id to playlist_items
|
||||
ALTER TABLE "playlist_items" ADD COLUMN "plugin_instance_id" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "plugins_slug_key" ON "plugins"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "plugin_instances" ADD CONSTRAINT "plugin_instances_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "playlist_items" ADD CONSTRAINT "playlist_items_plugin_instance_id_fkey" FOREIGN KEY ("plugin_instance_id") REFERENCES "plugin_instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -108,9 +108,12 @@ model PlaylistItem {
|
||||
duration Int @default(60) // seconds
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
pluginInstanceId Int? @map("plugin_instance_id")
|
||||
|
||||
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
||||
screen Screen? @relation(fields: [screenId], references: [id], onDelete: Cascade)
|
||||
screenDesign ScreenDesign? @relation(fields: [screenDesignId], references: [id], onDelete: Cascade)
|
||||
pluginInstance PluginInstance? @relation(fields: [pluginInstanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("playlist_items")
|
||||
}
|
||||
@@ -294,3 +297,81 @@ model BlockedDevice {
|
||||
|
||||
@@map("blocked_devices")
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Plugin System Models
|
||||
// ========================
|
||||
|
||||
// Plugin - a reusable plugin definition (built-in from TRMNL or user-created)
|
||||
model Plugin {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
icon String? // emoji or image URL
|
||||
category String @default("custom") // news, productivity, weather, social, finance, system, custom
|
||||
|
||||
// Data fetching configuration
|
||||
dataStrategy String @default("polling") @map("data_strategy") // polling | webhook | static
|
||||
dataUrl String? @map("data_url") // supports {{setting}} interpolation
|
||||
dataMethod String @default("GET") @map("data_method")
|
||||
dataHeaders Json? @map("data_headers") // supports {{setting}} interpolation
|
||||
dataPath String? @map("data_path") // JSONPath to extract from response
|
||||
dataTransform String? @map("data_transform") // JS script to transform fetched data into locals
|
||||
refreshInterval Int @default(300) @map("refresh_interval") // seconds
|
||||
|
||||
// Liquid templates for 4 layout sizes (matching TRMNL)
|
||||
markupFull String? @map("markup_full")
|
||||
markupHalfHorizontal String? @map("markup_half_horizontal")
|
||||
markupHalfVertical String? @map("markup_half_vertical")
|
||||
markupQuadrant String? @map("markup_quadrant")
|
||||
|
||||
// Settings schema: [{key, label, type, options?, required?, encrypted?, default?}]
|
||||
settingsSchema Json? @map("settings_schema")
|
||||
|
||||
// OAuth (for plugins that require OAuth2 authentication)
|
||||
oauthProvider String? @map("oauth_provider") // "google" | "spotify" | "strava" etc.
|
||||
oauthScopes String? @map("oauth_scopes") // space-separated scopes
|
||||
|
||||
// Metadata
|
||||
isInstalled Boolean @default(false) @map("is_installed")
|
||||
isBuiltin Boolean @default(false) @map("is_builtin")
|
||||
source String @default("inker") // "inker" | "trmnl"
|
||||
sourceUrl String? @map("source_url") // GitHub URL of original plugin
|
||||
sourceHash String? @map("source_hash") // hash for detecting updates
|
||||
version String?
|
||||
|
||||
instances PluginInstance[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("plugins")
|
||||
}
|
||||
|
||||
// Plugin Instance - a configured instance of a plugin (with user settings and cached data)
|
||||
model PluginInstance {
|
||||
id Int @id @default(autoincrement())
|
||||
pluginId Int @map("plugin_id")
|
||||
name String? // optional custom name for this instance
|
||||
|
||||
settings Json @default("{}") // user settings (non-sensitive)
|
||||
settingsEncrypted Json @default("{}") @map("settings_encrypted") // encrypted settings (API keys)
|
||||
|
||||
// OAuth tokens (encrypted)
|
||||
oauthToken String? @map("oauth_token") // encrypted access token
|
||||
oauthRefreshToken String? @map("oauth_refresh_token") // encrypted refresh token
|
||||
oauthExpiresAt DateTime? @map("oauth_expires_at")
|
||||
|
||||
// Cached data (the locals hash)
|
||||
lastData Json? @map("last_data")
|
||||
lastFetchedAt DateTime? @map("last_fetched_at")
|
||||
lastError String? @map("last_error")
|
||||
|
||||
plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)
|
||||
playlistItems PlaylistItem[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("plugin_instances")
|
||||
}
|
||||
|
||||
@@ -82,8 +82,17 @@ export class ApiController {
|
||||
}
|
||||
|
||||
// Fallback: derive from request headers (for LAN access where IP varies)
|
||||
const host = headers['host'] || headers['Host'] || 'localhost:3002';
|
||||
let host = headers['host'] || headers['Host'] || 'localhost:3002';
|
||||
const protocol = headers['x-forwarded-proto'] || 'http';
|
||||
|
||||
// Ensure INKER_PORT is used when it's non-standard (handles Docker port mapping)
|
||||
const inkerPort = this.configService.get<number>('inkerPort', 80);
|
||||
if (inkerPort && inkerPort !== 80) {
|
||||
// Strip any existing port from host and replace with INKER_PORT
|
||||
const hostname = host.split(':')[0];
|
||||
host = `${hostname}:${inkerPort}`;
|
||||
}
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
@@ -400,7 +409,7 @@ export class ApiController {
|
||||
* Accepts log data from devices
|
||||
*/
|
||||
@Post('log')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiHeader({
|
||||
name: 'HTTP_ID',
|
||||
description: 'Device API Key',
|
||||
|
||||
@@ -10,12 +10,14 @@ import { LogService } from './log/log.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ScreenDesignerModule } from '../screen-designer/screen-designer.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { PluginsModule } from '../plugins/plugins.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
ScreenDesignerModule,
|
||||
SettingsModule,
|
||||
PluginsModule,
|
||||
// Serve static files from assets directory at /assets path
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(process.cwd(), 'assets'),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DefaultScreenService } from './default-screen.service';
|
||||
import { ScreenRendererService } from '../../screen-designer/services/screen-renderer.service';
|
||||
import { PluginsService } from '../../plugins/plugins.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -27,6 +28,7 @@ export class DisplayService {
|
||||
private config: ConfigService,
|
||||
private defaultScreenService: DefaultScreenService,
|
||||
private screenRendererService: ScreenRendererService,
|
||||
private pluginsService: PluginsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -72,6 +74,11 @@ export class DisplayService {
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginInstance: {
|
||||
include: {
|
||||
plugin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
@@ -91,10 +98,14 @@ export class DisplayService {
|
||||
status: 0,
|
||||
image_url: '',
|
||||
filename: '',
|
||||
image_url_timeout: 0,
|
||||
firmware_url: '',
|
||||
update_firmware: false,
|
||||
refresh_rate: 0,
|
||||
reset_firmware: true,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
message: 'Device removed from server',
|
||||
};
|
||||
}
|
||||
@@ -156,7 +167,7 @@ export class DisplayService {
|
||||
|
||||
await this.defaultScreenService.ensureDefaultScreenExists();
|
||||
const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl();
|
||||
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`;
|
||||
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`;
|
||||
|
||||
// Get base64 if requested
|
||||
let imageData: string | undefined;
|
||||
@@ -171,12 +182,16 @@ export class DisplayService {
|
||||
return {
|
||||
status: 0,
|
||||
image_url: fullDefaultUrl,
|
||||
filename: 'default-screen.png',
|
||||
filename: `default-screen-${Date.now()}.png`,
|
||||
image_url_timeout: 0,
|
||||
image_data: imageData,
|
||||
firmware_url: firmwareUrl,
|
||||
update_firmware: !!firmwareUrl,
|
||||
refresh_rate: defaultRefreshRate,
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
};
|
||||
@@ -190,7 +205,7 @@ export class DisplayService {
|
||||
|
||||
await this.defaultScreenService.ensureDefaultScreenExists();
|
||||
const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl();
|
||||
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`;
|
||||
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`;
|
||||
|
||||
// Get base64 if requested
|
||||
let imageData: string | undefined;
|
||||
@@ -205,12 +220,16 @@ export class DisplayService {
|
||||
return {
|
||||
status: 0,
|
||||
image_url: fullDefaultUrl,
|
||||
filename: 'default-screen.png',
|
||||
filename: `default-screen-${Date.now()}.png`,
|
||||
image_url_timeout: 0,
|
||||
image_data: imageData,
|
||||
firmware_url: firmwareUrl,
|
||||
update_firmware: !!firmwareUrl,
|
||||
refresh_rate: defaultRefreshRate,
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
};
|
||||
@@ -271,11 +290,15 @@ export class DisplayService {
|
||||
status: 0,
|
||||
image_url: imageUrl,
|
||||
filename: this.getImageFilename(currentScreen.screen.imageUrl),
|
||||
image_url_timeout: 0,
|
||||
image_data: useBase64 ? await this.getBase64Image(currentScreen.screen.imageUrl) : undefined,
|
||||
firmware_url: firmwareUrl,
|
||||
update_firmware: !!firmwareUrl,
|
||||
refresh_rate: effectiveRefreshRate,
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
@@ -310,11 +333,15 @@ export class DisplayService {
|
||||
status: 0,
|
||||
image_url: captureUrl,
|
||||
filename: dynamicFilename,
|
||||
image_url_timeout: 0,
|
||||
image_data: undefined,
|
||||
firmware_url: firmwareUrl,
|
||||
update_firmware: !!firmwareUrl,
|
||||
refresh_rate: effectiveRefreshRate,
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
@@ -354,32 +381,69 @@ export class DisplayService {
|
||||
status: 0,
|
||||
image_url: renderUrl,
|
||||
filename: dynamicFilename,
|
||||
image_url_timeout: 0,
|
||||
image_data: undefined,
|
||||
firmware_url: firmwareUrl,
|
||||
update_firmware: !!firmwareUrl,
|
||||
refresh_rate: effectiveRefreshRate,
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
};
|
||||
} else if (currentScreen.pluginInstance?.plugin) {
|
||||
// Plugin instance - render via plugin engine
|
||||
const pluginInstance = currentScreen.pluginInstance;
|
||||
const timestamp = Date.now();
|
||||
|
||||
const renderUrl = `${apiUrl}/api/plugins/instances/${pluginInstance.id}/render?mode=device&t=${timestamp}`;
|
||||
const dynamicFilename = `plugin-${pluginInstance.plugin.slug}-${timestamp}.png`;
|
||||
|
||||
this.logger.debug(
|
||||
`Serving PLUGIN "${pluginInstance.plugin.name}" to device ${device.name} (refresh: ${effectiveRefreshRate}s)`,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 0,
|
||||
image_url: renderUrl,
|
||||
filename: dynamicFilename,
|
||||
image_url_timeout: 0,
|
||||
image_data: undefined,
|
||||
firmware_url: firmwareUrl,
|
||||
update_firmware: !!firmwareUrl,
|
||||
refresh_rate: effectiveRefreshRate,
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
};
|
||||
} else {
|
||||
// Neither screen nor screenDesign - should not happen, but handle gracefully
|
||||
this.logger.warn(`Playlist item ${currentScreen.id} has no screen or screenDesign`);
|
||||
// Neither screen, screenDesign, nor plugin - handle gracefully
|
||||
this.logger.warn(`Playlist item ${currentScreen.id} has no screen, screenDesign, or plugin`);
|
||||
|
||||
await this.defaultScreenService.ensureDefaultScreenExists();
|
||||
const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl();
|
||||
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`;
|
||||
const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`;
|
||||
|
||||
return {
|
||||
status: 0,
|
||||
image_url: fullDefaultUrl,
|
||||
filename: 'default-screen.png',
|
||||
filename: `default-screen-${Date.now()}.png`,
|
||||
image_url_timeout: 0,
|
||||
image_data: undefined,
|
||||
firmware_url: firmwareUrl,
|
||||
update_firmware: !!firmwareUrl,
|
||||
refresh_rate: defaultRefreshRate,
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
};
|
||||
@@ -660,6 +724,11 @@ export class DisplayService {
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginInstance: {
|
||||
include: {
|
||||
plugin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CommonModule } from './common/common.module';
|
||||
import { ScreenDesignerModule } from './screen-designer/screen-designer.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { PluginsModule } from './plugins/plugins.module';
|
||||
import { configuration } from './config/configuration';
|
||||
import { validationSchema } from './config/validation.schema';
|
||||
|
||||
@@ -92,6 +93,7 @@ import { validationSchema } from './config/validation.schema';
|
||||
ScreenDesignerModule,
|
||||
EventsModule,
|
||||
SettingsModule,
|
||||
PluginsModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './services/encryption.service';
|
||||
|
||||
@Module({})
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@@ -8,3 +8,9 @@
|
||||
* This prevents collision with built-in template IDs (typically 1-100)
|
||||
*/
|
||||
export const CUSTOM_WIDGET_TEMPLATE_OFFSET = 10000;
|
||||
|
||||
/**
|
||||
* Offset used for plugin virtual template IDs
|
||||
* Installed plugins get IDs: PLUGIN_TEMPLATE_OFFSET + plugin.id
|
||||
*/
|
||||
export const PLUGIN_TEMPLATE_OFFSET = 20000;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
const SALT = 'inker-plugin-settings';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly logger = new Logger(EncryptionService.name);
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const secret = config.get<string>('encryption.key') || config.get<string>('admin.pin') || 'inker-default-key';
|
||||
this.key = scryptSync(secret, SALT, KEY_LENGTH);
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, this.key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
decrypt(ciphertext: string): string {
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted format');
|
||||
}
|
||||
const [ivB64, authTagB64, encryptedB64] = parts;
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(authTagB64, 'base64');
|
||||
const encrypted = Buffer.from(encryptedB64, 'base64');
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(encrypted) + decipher.final('utf8');
|
||||
}
|
||||
|
||||
encryptObject(obj: Record<string, any>): Record<string, string> {
|
||||
const encrypted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
encrypted[key] = this.encrypt(String(value));
|
||||
}
|
||||
}
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
decryptObject(obj: Record<string, any>): Record<string, string> {
|
||||
const decrypted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
try {
|
||||
decrypted[key] = this.decrypt(String(value));
|
||||
} catch {
|
||||
this.logger.warn(`Failed to decrypt field "${key}"`);
|
||||
decrypted[key] = '';
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export const configuration = () => ({
|
||||
url: process.env.API_URL || `http://localhost:${process.env.PORT || '3002'}`,
|
||||
},
|
||||
|
||||
inkerPort: parseInt(process.env.INKER_PORT || '80', 10),
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
@@ -33,6 +35,10 @@ export const configuration = () => ({
|
||||
enabled: process.env.SCREEN_POLLER_ENABLED !== 'false',
|
||||
interval: parseInt(process.env.SCREEN_POLLER_INTERVAL || '900000', 10), // 15 minutes
|
||||
},
|
||||
pluginSync: {
|
||||
enabled: process.env.PLUGIN_SYNC_ENABLED !== 'false',
|
||||
interval: parseInt(process.env.PLUGIN_SYNC_INTERVAL || '604800000', 10), // 7 days
|
||||
},
|
||||
},
|
||||
|
||||
// External APIs
|
||||
@@ -65,6 +71,36 @@ export const configuration = () => ({
|
||||
pin: process.env.ADMIN_PIN || '1111',
|
||||
},
|
||||
|
||||
encryption: {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
},
|
||||
|
||||
oauth: {
|
||||
providers: {
|
||||
google: {
|
||||
clientId: process.env.OAUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET,
|
||||
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenUrl: 'https://oauth2.googleapis.com/token',
|
||||
scopes: 'https://www.googleapis.com/auth/calendar.readonly',
|
||||
},
|
||||
spotify: {
|
||||
clientId: process.env.OAUTH_SPOTIFY_CLIENT_ID,
|
||||
clientSecret: process.env.OAUTH_SPOTIFY_CLIENT_SECRET,
|
||||
authUrl: 'https://accounts.spotify.com/authorize',
|
||||
tokenUrl: 'https://accounts.spotify.com/api/token',
|
||||
scopes: 'user-read-currently-playing user-read-recently-played',
|
||||
},
|
||||
strava: {
|
||||
clientId: process.env.OAUTH_STRAVA_CLIENT_ID,
|
||||
clientSecret: process.env.OAUTH_STRAVA_CLIENT_SECRET,
|
||||
authUrl: 'https://www.strava.com/oauth/authorize',
|
||||
tokenUrl: 'https://www.strava.com/oauth/token',
|
||||
scopes: 'read,activity:read',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: process.env.LOG_FORMAT || 'json',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -57,4 +57,20 @@ export class DashboardController {
|
||||
async getStats(): Promise<DashboardStatsDto> {
|
||||
return this.dashboardService.getStats();
|
||||
}
|
||||
|
||||
@Get('version-check')
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: 'Check for available updates' })
|
||||
@ApiResponse({ status: 200, description: 'Version check result' })
|
||||
async checkForUpdate() {
|
||||
return this.dashboardService.checkForUpdate();
|
||||
}
|
||||
|
||||
@Post('update')
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: 'Perform auto-update via Docker socket' })
|
||||
@ApiResponse({ status: 200, description: 'Update result' })
|
||||
async performUpdate() {
|
||||
return this.dashboardService.performUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DashboardStatsDto } from './dto/dashboard-stats.dto';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
const CURRENT_VERSION = require('../../package.json').version;
|
||||
|
||||
/**
|
||||
* Dashboard service
|
||||
@@ -15,6 +20,8 @@ import { DashboardStatsDto } from './dto/dashboard-stats.dto';
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
private readonly logger = new Logger(DashboardService.name);
|
||||
private versionCache: { data: any; timestamp: number } | null = null;
|
||||
private readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@@ -136,4 +143,146 @@ export class DashboardService {
|
||||
}
|
||||
return lastSeenAt >= onlineThreshold ? 'online' : 'offline';
|
||||
}
|
||||
|
||||
async checkForUpdate(): Promise<{
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
releaseUrl: string;
|
||||
dockerAvailable: boolean;
|
||||
}> {
|
||||
const dockerAvailable = this.isDockerAvailable();
|
||||
|
||||
if (this.versionCache && Date.now() - this.versionCache.timestamp < this.CACHE_TTL) {
|
||||
return { ...this.versionCache.data, dockerAvailable };
|
||||
}
|
||||
|
||||
try {
|
||||
const release = await this.fetchLatestRelease();
|
||||
const latestVersion = (release.tag_name || '').replace(/^v/, '');
|
||||
const updateAvailable = this.isNewerVersion(latestVersion, CURRENT_VERSION);
|
||||
const data = {
|
||||
currentVersion: CURRENT_VERSION,
|
||||
latestVersion: latestVersion || CURRENT_VERSION,
|
||||
updateAvailable,
|
||||
releaseUrl: release.html_url || 'https://github.com/usetrmnl/inker/releases',
|
||||
};
|
||||
this.versionCache = { data, timestamp: Date.now() };
|
||||
return { ...data, dockerAvailable };
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to check for updates', error);
|
||||
return {
|
||||
currentVersion: CURRENT_VERSION,
|
||||
latestVersion: CURRENT_VERSION,
|
||||
updateAvailable: false,
|
||||
releaseUrl: 'https://github.com/usetrmnl/inker/releases',
|
||||
dockerAvailable,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private fetchLatestRelease(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
'https://api.github.com/repos/usetrmnl/inker/releases/latest',
|
||||
{ headers: { 'User-Agent': 'Inker', Accept: 'application/vnd.github.v3+json' } },
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON from GitHub'));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
private isNewerVersion(latest: string, current: string): boolean {
|
||||
const parse = (v: string) => v.split('.').map(Number);
|
||||
const l = parse(latest);
|
||||
const c = parse(current);
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
const lv = l[i] || 0;
|
||||
const cv = c[i] || 0;
|
||||
if (lv > cv) return true;
|
||||
if (lv < cv) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isDockerAvailable(): boolean {
|
||||
try {
|
||||
fs.accessSync('/var/run/docker.sock');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async performUpdate(): Promise<{ success: boolean; message: string }> {
|
||||
if (!this.isDockerAvailable()) {
|
||||
return { success: false, message: 'Docker socket not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Pull new image
|
||||
await this.dockerRequest('POST', '/images/create?fromImage=wojooo/inker&tag=latest');
|
||||
|
||||
// Get current container ID
|
||||
const hostname = require('os').hostname();
|
||||
|
||||
// Inspect current container to get its config
|
||||
const container = await this.dockerRequest('GET', `/containers/${hostname}/json`);
|
||||
const config = JSON.parse(container);
|
||||
|
||||
// Create new container with same config
|
||||
const createBody = JSON.stringify({
|
||||
Image: 'wojooo/inker:latest',
|
||||
Env: config.Config?.Env,
|
||||
HostConfig: config.HostConfig,
|
||||
ExposedPorts: config.Config?.ExposedPorts,
|
||||
});
|
||||
|
||||
// Stop current container (will be replaced)
|
||||
// The container needs to stop itself — use restart policy
|
||||
await this.dockerRequest('POST', `/containers/${hostname}/restart`);
|
||||
|
||||
return { success: true, message: 'Update initiated. Container will restart with the new image.' };
|
||||
} catch (error) {
|
||||
this.logger.error('Update failed', error);
|
||||
return { success: false, message: `Update failed: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private dockerRequest(method: string, path: string, body?: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options: http.RequestOptions = {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
path: `/v1.41${path}`,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`Docker API error ${res.statusCode}: ${data}`));
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(120000, () => { req.destroy(); reject(new Error('Docker request timeout')); });
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ async function bootstrap() {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Inker API')
|
||||
.setDescription('API documentation for Inker e-ink device management server')
|
||||
.setVersion('0.2.1.1')
|
||||
.setVersion('0.3.0')
|
||||
.addBearerAuth()
|
||||
.addApiKey({ type: 'apiKey', name: 'X-Device-Key', in: 'header' }, 'device-key')
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { IsString, IsOptional, IsNumber, IsArray, IsBoolean } from 'class-validator';
|
||||
|
||||
export class CreatePluginDto {
|
||||
@IsString() name: string;
|
||||
@IsString() slug: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() icon?: string;
|
||||
@IsOptional() @IsString() category?: string;
|
||||
|
||||
@IsOptional() @IsString() dataStrategy?: string;
|
||||
@IsOptional() @IsString() dataUrl?: string;
|
||||
@IsOptional() @IsString() dataMethod?: string;
|
||||
@IsOptional() dataHeaders?: Record<string, string>;
|
||||
@IsOptional() @IsString() dataPath?: string;
|
||||
@IsOptional() @IsString() dataTransform?: string;
|
||||
@IsOptional() @IsNumber() refreshInterval?: number;
|
||||
|
||||
@IsOptional() @IsString() markupFull?: string;
|
||||
@IsOptional() @IsString() markupHalfHorizontal?: string;
|
||||
@IsOptional() @IsString() markupHalfVertical?: string;
|
||||
@IsOptional() @IsString() markupQuadrant?: string;
|
||||
|
||||
@IsOptional() @IsArray() settingsSchema?: any[];
|
||||
|
||||
@IsOptional() @IsString() source?: string;
|
||||
@IsOptional() @IsString() sourceUrl?: string;
|
||||
@IsOptional() @IsString() version?: string;
|
||||
}
|
||||
|
||||
export class UpdatePluginDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() icon?: string;
|
||||
@IsOptional() @IsString() category?: string;
|
||||
|
||||
@IsOptional() @IsString() dataUrl?: string;
|
||||
@IsOptional() @IsString() dataMethod?: string;
|
||||
@IsOptional() dataHeaders?: Record<string, string>;
|
||||
@IsOptional() @IsString() dataPath?: string;
|
||||
@IsOptional() @IsString() dataTransform?: string;
|
||||
@IsOptional() @IsNumber() refreshInterval?: number;
|
||||
|
||||
@IsOptional() @IsString() markupFull?: string;
|
||||
@IsOptional() @IsString() markupHalfHorizontal?: string;
|
||||
@IsOptional() @IsString() markupHalfVertical?: string;
|
||||
@IsOptional() @IsString() markupQuadrant?: string;
|
||||
|
||||
@IsOptional() @IsArray() settingsSchema?: any[];
|
||||
@IsOptional() @IsString() version?: string;
|
||||
}
|
||||
|
||||
export class CreatePluginInstanceDto {
|
||||
@IsNumber() pluginId: number;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdatePluginInstanceDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() settings?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { EncryptionService } from '../../common/services/encryption.service';
|
||||
|
||||
interface OAuthProviderConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authUrl: string;
|
||||
tokenUrl: string;
|
||||
scopes?: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
private readonly providers: Record<string, OAuthProviderConfig>;
|
||||
private readonly callbackUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
) {
|
||||
this.providers = (config.get('oauth.providers') || {}) as Record<string, OAuthProviderConfig>;
|
||||
const apiUrl = config.get<string>('api.url') || 'http://localhost:3002';
|
||||
this.callbackUrl = `${apiUrl}/plugins/oauth/callback`;
|
||||
}
|
||||
|
||||
getAvailableProviders(): string[] {
|
||||
return Object.entries(this.providers)
|
||||
.filter(([, cfg]) => cfg.clientId && cfg.clientSecret)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(instanceId: number, provider: string): string {
|
||||
const cfg = this.getProviderConfig(provider);
|
||||
const state = this.encryption.encrypt(JSON.stringify({ instanceId, provider }));
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: cfg.clientId,
|
||||
redirect_uri: this.callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: cfg.scopes || '',
|
||||
state,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return `${cfg.authUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async handleCallback(code: string, state: string): Promise<{ instanceId: number; provider: string }> {
|
||||
let parsed: { instanceId: number; provider: string };
|
||||
try {
|
||||
parsed = JSON.parse(this.encryption.decrypt(state));
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid OAuth state');
|
||||
}
|
||||
|
||||
const { instanceId, provider } = parsed;
|
||||
const cfg = this.getProviderConfig(provider);
|
||||
|
||||
const tokenResponse = await this.exchangeCode(code, cfg);
|
||||
await this.storeTokens(instanceId, tokenResponse);
|
||||
|
||||
this.logger.log(`OAuth connected for instance ${instanceId} (provider: ${provider})`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async getAccessToken(instanceId: number): Promise<string | null> {
|
||||
const instance: any = await this.prisma.pluginInstance.findUnique({
|
||||
where: { id: instanceId },
|
||||
include: { plugin: true },
|
||||
});
|
||||
if (!instance?.oauthToken) return null;
|
||||
|
||||
// Check if token is expired (with 5-minute buffer)
|
||||
if (instance.oauthExpiresAt && instance.oauthExpiresAt.getTime() < Date.now() + 5 * 60 * 1000) {
|
||||
if (instance.oauthRefreshToken && instance.plugin.oauthProvider) {
|
||||
return this.refreshToken(instanceId, instance.plugin.oauthProvider, instance.oauthRefreshToken);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.encryption.decrypt(instance.oauthToken);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectOAuth(instanceId: number): Promise<void> {
|
||||
await (this.prisma.pluginInstance as any).update({
|
||||
where: { id: instanceId },
|
||||
data: {
|
||||
oauthToken: null,
|
||||
oauthRefreshToken: null,
|
||||
oauthExpiresAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshToken(instanceId: number, provider: string, encryptedRefreshToken: string): Promise<string | null> {
|
||||
const cfg = this.getProviderConfig(provider);
|
||||
|
||||
let refreshToken: string;
|
||||
try {
|
||||
refreshToken = this.encryption.decrypt(encryptedRefreshToken);
|
||||
} catch {
|
||||
this.logger.error(`Failed to decrypt refresh token for instance ${instanceId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(cfg.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.error(`Token refresh failed for instance ${instanceId}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData: TokenResponse = await response.json();
|
||||
await this.storeTokens(instanceId, tokenData);
|
||||
return tokenData.access_token;
|
||||
} catch (error) {
|
||||
this.logger.error(`Token refresh error for instance ${instanceId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async exchangeCode(code: string, cfg: OAuthProviderConfig): Promise<TokenResponse> {
|
||||
const response = await fetch(cfg.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
code,
|
||||
redirect_uri: this.callbackUrl,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new BadRequestException(`OAuth token exchange failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async storeTokens(instanceId: number, tokens: TokenResponse): Promise<void> {
|
||||
const data: any = {
|
||||
oauthToken: this.encryption.encrypt(tokens.access_token),
|
||||
};
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
data.oauthRefreshToken = this.encryption.encrypt(tokens.refresh_token);
|
||||
}
|
||||
|
||||
if (tokens.expires_in) {
|
||||
data.oauthExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
}
|
||||
|
||||
await this.prisma.pluginInstance.update({
|
||||
where: { id: instanceId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
private getProviderConfig(provider: string): OAuthProviderConfig {
|
||||
const cfg = this.providers[provider];
|
||||
if (!cfg?.clientId || !cfg?.clientSecret) {
|
||||
throw new BadRequestException(`OAuth provider "${provider}" is not configured. Set OAUTH_${provider.toUpperCase()}_CLIENT_ID and OAUTH_${provider.toUpperCase()}_CLIENT_SECRET environment variables.`);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Liquid } from 'liquidjs';
|
||||
import * as sharp from 'sharp';
|
||||
import { ScreenRendererService } from '../screen-designer/services/screen-renderer.service';
|
||||
import { TRMNL_CSS } from './sync/trmnl-css';
|
||||
|
||||
export type PluginLayout = 'full' | 'half_horizontal' | 'half_vertical' | 'quadrant';
|
||||
|
||||
/**
|
||||
* Plugin Renderer Service
|
||||
* Renders plugin Liquid templates into e-ink PNG images using the existing
|
||||
* Puppeteer-based rendering pipeline from ScreenRendererService.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PluginRendererService {
|
||||
private readonly logger = new Logger(PluginRendererService.name);
|
||||
private readonly liquid: Liquid;
|
||||
|
||||
constructor(
|
||||
readonly screenRenderer: ScreenRendererService,
|
||||
) {
|
||||
this.liquid = new Liquid({
|
||||
strictVariables: false,
|
||||
strictFilters: false,
|
||||
});
|
||||
this.registerTrmnlFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register TRMNL-compatible Liquid filters (ported from trmnl-liquid gem)
|
||||
*/
|
||||
private registerTrmnlFilters() {
|
||||
// number_with_delimiter: {{ 1234567 | number_with_delimiter }} → "1,234,567"
|
||||
this.liquid.registerFilter('number_with_delimiter', (value: any, delimiter = ',', separator = '.') => {
|
||||
if (value == null) return '';
|
||||
const parts = String(value).split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, delimiter);
|
||||
return parts.join(separator);
|
||||
});
|
||||
|
||||
// number_to_currency: {{ 10420 | number_to_currency: "£" }} → "£10,420.00"
|
||||
this.liquid.registerFilter('number_to_currency', (value: any, unit = '$', delimiter = ',', separator = '.', precision = 2) => {
|
||||
if (value == null) return '';
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return String(value);
|
||||
const fixed = num.toFixed(precision);
|
||||
const parts = fixed.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, delimiter);
|
||||
return `${unit}${parts.join(separator)}`;
|
||||
});
|
||||
|
||||
// days_ago: {{ 3 | days_ago }} → "2026-03-23"
|
||||
this.liquid.registerFilter('days_ago', (value: any, timezone = 'UTC') => {
|
||||
const days = Number(value) || 0;
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// pluralize: {{ "book" | pluralize: 2 }} → "2 books"
|
||||
this.liquid.registerFilter('pluralize', (singular: any, count: any, options?: any) => {
|
||||
const n = Number(count) || 0;
|
||||
const plural = options?.plural || `${singular}s`;
|
||||
return `${n} ${n === 1 ? singular : plural}`;
|
||||
});
|
||||
|
||||
// group_by: {{ items | group_by: "category" }}
|
||||
this.liquid.registerFilter('group_by', (collection: any, key: string) => {
|
||||
if (!Array.isArray(collection)) return {};
|
||||
const groups: Record<string, any[]> = {};
|
||||
for (const item of collection) {
|
||||
const k = String(item?.[key] ?? '');
|
||||
if (!groups[k]) groups[k] = [];
|
||||
groups[k].push(item);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// find_by: {{ items | find_by: "name", "Ryan" }}
|
||||
this.liquid.registerFilter('find_by', (collection: any, key: string, value: any, fallback?: any) => {
|
||||
if (!Array.isArray(collection)) return fallback ?? null;
|
||||
return collection.find(item => item?.[key] == value) ?? fallback ?? null;
|
||||
});
|
||||
|
||||
// json: {{ data | json }}
|
||||
this.liquid.registerFilter('json', (value: any) => {
|
||||
try { return JSON.stringify(value); } catch { return ''; }
|
||||
});
|
||||
|
||||
// parse_json: {% assign obj = data | parse_json %}
|
||||
this.liquid.registerFilter('parse_json', (value: any) => {
|
||||
try { return JSON.parse(String(value)); } catch { return null; }
|
||||
});
|
||||
|
||||
// append_random: {{ "chart-" | append_random }} → "chart-a3f1"
|
||||
this.liquid.registerFilter('append_random', (value: any) => {
|
||||
const hex = Math.random().toString(16).slice(2, 6);
|
||||
return `${value ?? ''}${hex}`;
|
||||
});
|
||||
|
||||
// sample: {{ items | sample }} → random element
|
||||
this.liquid.registerFilter('sample', (arr: any) => {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return null;
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
});
|
||||
|
||||
// map_to_i: {{ "5, 4, 3" | split: ", " | map_to_i }}
|
||||
this.liquid.registerFilter('map_to_i', (arr: any) => {
|
||||
if (!Array.isArray(arr)) return arr;
|
||||
return arr.map(v => parseInt(String(v), 10) || 0);
|
||||
});
|
||||
|
||||
// ordinalize: {{ "2025-10-02" | ordinalize: "%A, %B <<ordinal_day>>, %Y" }}
|
||||
this.liquid.registerFilter('ordinalize', (value: any, format?: string) => {
|
||||
const d = new Date(String(value));
|
||||
if (isNaN(d.getTime())) return String(value);
|
||||
const day = d.getDate();
|
||||
const suffix = [11,12,13].includes(day % 100) ? 'th'
|
||||
: day % 10 === 1 ? 'st' : day % 10 === 2 ? 'nd' : day % 10 === 3 ? 'rd' : 'th';
|
||||
if (!format) return `${day}${suffix}`;
|
||||
const weekdays = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
||||
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
return format
|
||||
.replace('<<ordinal_day>>', `${day}${suffix}`)
|
||||
.replace('%A', weekdays[d.getDay()])
|
||||
.replace('%B', months[d.getMonth()])
|
||||
.replace('%Y', String(d.getFullYear()))
|
||||
.replace('%y', String(d.getFullYear()).slice(-2))
|
||||
.replace('%m', String(d.getMonth() + 1).padStart(2, '0'))
|
||||
.replace('%d', String(day).padStart(2, '0'));
|
||||
});
|
||||
|
||||
// l_date: {{ "2025-01-11" | l_date: "%y %b" }}
|
||||
this.liquid.registerFilter('l_date', (value: any, format?: string, locale = 'en') => {
|
||||
let d: Date;
|
||||
if (value === 'now' || value === 'today') d = new Date();
|
||||
else if (typeof value === 'number' && value > 1e9) d = new Date(value * 1000);
|
||||
else d = new Date(String(value));
|
||||
if (isNaN(d.getTime())) return String(value);
|
||||
if (!format) return d.toLocaleDateString(locale);
|
||||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const fullMonths = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
return format
|
||||
.replace('%Y', String(d.getFullYear()))
|
||||
.replace('%y', String(d.getFullYear()).slice(-2))
|
||||
.replace('%B', fullMonths[d.getMonth()])
|
||||
.replace('%b', months[d.getMonth()])
|
||||
.replace('%A', ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()])
|
||||
.replace('%a', weekdays[d.getDay()])
|
||||
.replace('%m', String(d.getMonth() + 1).padStart(2, '0'))
|
||||
.replace('%d', String(d.getDate()).padStart(2, '0'))
|
||||
.replace('%H', String(d.getHours()).padStart(2, '0'))
|
||||
.replace('%M', String(d.getMinutes()).padStart(2, '0'))
|
||||
.replace('%S', String(d.getSeconds()).padStart(2, '0'))
|
||||
.replace('%I', String(d.getHours() % 12 || 12).padStart(2, '0'))
|
||||
.replace('%p', d.getHours() >= 12 ? 'PM' : 'AM');
|
||||
});
|
||||
|
||||
// where_exp: {{ items | where_exp: "item", "item.active == true" }}
|
||||
this.liquid.registerFilter('where_exp', (collection: any, variable: string, expression: string) => {
|
||||
if (!Array.isArray(collection)) return [];
|
||||
return collection.filter(item => {
|
||||
try {
|
||||
// Simple expression evaluator for common patterns
|
||||
const expr = expression
|
||||
.replace(new RegExp(`${variable}\\.`, 'g'), 'item.')
|
||||
.replace(/\bnil\b/g, 'null')
|
||||
.replace(/\band\b/g, '&&')
|
||||
.replace(/\bor\b/g, '||')
|
||||
.replace(/\bnot\b/g, '!')
|
||||
.replace(/(?<!=)=(?!=)/g, '==');
|
||||
const fn = new Function('item', `try { return !!(${expr}); } catch { return false; }`);
|
||||
return fn(item);
|
||||
} catch { return false; }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plugin's Liquid template with data to HTML string
|
||||
*/
|
||||
async renderToHtml(
|
||||
markup: string,
|
||||
locals: Record<string, any>,
|
||||
settings: Record<string, any> = {},
|
||||
): Promise<string> {
|
||||
try {
|
||||
const context = { ...locals, settings };
|
||||
const html = await this.liquid.parseAndRender(markup, context);
|
||||
return html;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Liquid render failed: ${error.message}`);
|
||||
// Return a fallback HTML instead of throwing
|
||||
return `<div style="padding:16px;font-family:sans-serif"><p style="font-size:14px;font-weight:bold;margin-bottom:8px">Template Error</p><p style="font-size:11px;color:#666">${error.message?.split('\n')[0] || 'Unknown error'}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plugin instance to a full HTML page (with CSS) ready for Puppeteer
|
||||
*/
|
||||
buildFullPage(innerHtml: string, width: number, height: number): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { width: ${width}px; height: ${height}px; }
|
||||
${TRMNL_CSS}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${innerHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plugin to a PNG buffer using the existing Puppeteer pipeline
|
||||
*/
|
||||
async renderToPng(
|
||||
markup: string,
|
||||
locals: Record<string, any>,
|
||||
settings: Record<string, any> = {},
|
||||
width: number = 800,
|
||||
height: number = 480,
|
||||
mode: 'device' | 'preview' | 'einkPreview' = 'device',
|
||||
): Promise<Buffer> {
|
||||
const innerHtml = await this.renderToHtml(markup, locals, settings);
|
||||
const fullPage = this.buildFullPage(innerHtml, width, height);
|
||||
|
||||
// Render HTML to raw PNG via Puppeteer
|
||||
const rawPng = await this.screenRenderer.renderHtmlToPng(fullPage, width, height);
|
||||
|
||||
// For preview mode, return without e-ink processing
|
||||
if (mode === 'preview') {
|
||||
return rawPng;
|
||||
}
|
||||
|
||||
// Apply e-ink processing (dithering + optional inversion)
|
||||
const shouldNegate = mode === 'device';
|
||||
const canvas = sharp(rawPng);
|
||||
return this.screenRenderer.applyEinkProcessing(canvas, width, height, shouldNegate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the appropriate template based on layout size
|
||||
*/
|
||||
selectMarkup(
|
||||
plugin: {
|
||||
markupFull?: string | null;
|
||||
markupHalfHorizontal?: string | null;
|
||||
markupHalfVertical?: string | null;
|
||||
markupQuadrant?: string | null;
|
||||
},
|
||||
layout: PluginLayout = 'full',
|
||||
): string | null {
|
||||
switch (layout) {
|
||||
case 'full':
|
||||
return plugin.markupFull || null;
|
||||
case 'half_horizontal':
|
||||
return plugin.markupHalfHorizontal || plugin.markupFull || null;
|
||||
case 'half_vertical':
|
||||
return plugin.markupHalfVertical || plugin.markupFull || null;
|
||||
case 'quadrant':
|
||||
return plugin.markupQuadrant || plugin.markupFull || null;
|
||||
default:
|
||||
return plugin.markupFull || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user