mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
Compare commits
392 commits
0.1.0-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3032c09778 | ||
|
|
f1903bcbe8 | ||
|
|
621c108e78 | ||
|
|
131d99a2e3 | ||
|
|
7d1e74183d | ||
|
|
3f98a70ad9 | ||
|
|
0d6079db8b | ||
|
|
a86315c5c7 | ||
|
|
887c4d130b | ||
|
|
74e9e1eba3 | ||
|
|
53d4a8399f | ||
|
|
043f683db7 | ||
|
|
36e1ad8441 | ||
|
|
a06a0879ff | ||
|
|
ddce3947c6 | ||
|
|
4bc42cc1d2 | ||
|
|
94d5fca879 | ||
|
|
dc676327c2 | ||
|
|
e3bb9ad4e2 | ||
|
|
e176f2828e | ||
|
|
164a990dfe | ||
|
|
6d02415b7d | ||
|
|
3def60ae3e | ||
|
|
809965e81c | ||
|
|
b855ccffcb | ||
|
|
32dd4c3d08 | ||
|
|
a3f792944c | ||
|
|
3e670d37c0 | ||
|
|
46e792bc6d | ||
|
|
9019561bb3 | ||
|
|
838b4fd33b | ||
|
|
4451361f15 | ||
|
|
265972ac24 | ||
|
|
7f97114f6e | ||
|
|
3250bb0402 | ||
|
|
50853728bc | ||
|
|
3cdc267809 | ||
|
|
1298814521 | ||
|
|
a5cb38421e | ||
|
|
e6d66af298 | ||
|
|
d4b5cf99d5 | ||
|
|
d81c1b99f1 | ||
|
|
0b2b5bf25f | ||
|
|
f1a9103f0d | ||
|
|
d49a2d4f6c | ||
|
|
be2bb637c9 | ||
|
|
f3538048d4 | ||
|
|
a7963947f8 | ||
|
|
b1467204f8 | ||
|
|
fb9469d9cd | ||
|
|
b6faa2f232 | ||
|
|
60f2a38169 | ||
|
|
838db288e7 | ||
|
|
8776c668b4 | ||
|
|
1096118e03 | ||
|
|
b10bbca774 | ||
|
|
0322ec899e | ||
|
|
7c8e55588a | ||
|
|
dac8064938 | ||
|
|
fd41e77e7d | ||
|
|
568bd69fea | ||
|
|
61b9ff56e0 | ||
|
|
73f0fd26c2 | ||
|
|
7014250ac5 | ||
|
|
c157dcf3b6 | ||
|
|
742fd86c77 | ||
|
|
7489d85592 | ||
|
|
22a24383b2 | ||
|
|
468e8a130d | ||
|
|
346f04a7af | ||
|
|
31a73ccc6e | ||
|
|
042654993a | ||
|
|
6c438ff4d4 | ||
|
|
b7ce0b6152 | ||
|
|
cdf477e2ed | ||
|
|
e63953dc13 | ||
|
|
a8f3232ccc | ||
|
|
41baff51a6 | ||
|
|
f0f6b28107 | ||
|
|
e53c584eed | ||
|
|
1ccaa8382b | ||
|
|
36f783ac60 | ||
|
|
dd4237360c | ||
|
|
ef9cb81edb | ||
|
|
10b53c3772 | ||
|
|
52dfe92054 | ||
|
|
882cbff7fe | ||
|
|
80e2e8058a | ||
|
|
38e1b6f2a6 | ||
|
|
315fbac261 | ||
|
|
5abc452770 | ||
|
|
4de32e9d47 | ||
|
|
aa46dff00b | ||
|
|
311236a70d | ||
|
|
5e0d0ad73f | ||
|
|
f6897fdfc7 | ||
|
|
04ae695a14 | ||
|
|
a7e76f3c07 | ||
|
|
627d9ad09b | ||
|
|
583d8b2440 | ||
|
|
b18d561361 | ||
|
|
4c65c015b9 | ||
|
|
58e1fc32a4 | ||
|
|
74a65d6daf | ||
|
|
8aea83703c | ||
|
|
161200df44 | ||
|
|
23a7a217db | ||
|
|
c8f6dd3bec | ||
|
|
c1786dfb6d | ||
|
|
91e222f7a6 | ||
|
|
203584107f | ||
|
|
56548a96cb | ||
|
|
e812f56c11 | ||
|
|
50318b8b9d | ||
|
|
93dacb0baf | ||
|
|
4af4bfe14a | ||
|
|
96e0223f2f | ||
|
|
6f7efd9e36 | ||
|
|
3e5ba47a12 | ||
|
|
6ae3e023d4 | ||
|
|
e443539357 | ||
|
|
b4b6286172 | ||
|
|
c67a182cf2 | ||
|
|
a1a57014b6 | ||
|
|
42b515e322 | ||
|
|
4f251bf37e | ||
|
|
d8f47eb9c2 | ||
|
|
39ac9f0ad2 | ||
|
|
8958e65ec2 | ||
|
|
2d76afee6f | ||
|
|
00fc526371 | ||
|
|
b3b251bae2 | ||
|
|
0c5041a8ca | ||
|
|
e9037ef5d7 | ||
|
|
ee9f21a83d | ||
|
|
19a8bb18cc | ||
|
|
b7bcaf6feb | ||
|
|
85e887f8a5 | ||
|
|
8791a5154e | ||
|
|
29d1838690 | ||
|
|
97e6beaee4 | ||
|
|
cc4aa0560c | ||
|
|
93406b83a5 | ||
|
|
88e10101b8 | ||
|
|
e65473f932 | ||
|
|
12c82e02d7 | ||
|
|
f20977a822 | ||
|
|
425dbf6b3f | ||
|
|
495bbe7b7e | ||
|
|
ec704d8d83 | ||
|
|
38e77eaeb6 | ||
|
|
770b511290 | ||
|
|
4bb5723767 | ||
|
|
40ceba267a | ||
|
|
aa8d3d1428 | ||
|
|
d999b5157f | ||
|
|
d3690c9e10 | ||
|
|
6d7968a7b0 | ||
|
|
7434911275 | ||
|
|
e12f9bb91c | ||
|
|
bcbb1be1da | ||
|
|
f777e850b1 | ||
|
|
14d0fbfa7e | ||
|
|
9d1f62c6dd | ||
|
|
f38ac778f1 | ||
|
|
2eee024b36 | ||
|
|
a129c71d79 | ||
|
|
58fad59301 | ||
|
|
0ffc0acc3f | ||
|
|
fb506fa846 | ||
|
|
4c66761baa | ||
|
|
25f36eaf54 | ||
|
|
f4f8ab5181 | ||
|
|
6cd00943a1 | ||
|
|
e50cbc14ec | ||
|
|
989ad2e985 | ||
|
|
f1d5c196e8 | ||
|
|
414ca47cbf | ||
|
|
a927c0fb97 | ||
|
|
2318c8d2ae | ||
|
|
2427436b31 | ||
|
|
51af95da2c | ||
|
|
2ed3fd5ca9 | ||
|
|
4e3b47e4eb | ||
|
|
888b61a575 | ||
|
|
fdf8031d08 | ||
|
|
ba3bf31bb7 | ||
|
|
a88e72b75e | ||
|
|
0503be65c2 | ||
|
|
93dc4a1492 | ||
|
|
e49de8da5f | ||
|
|
032c82e4aa | ||
|
|
65b9162ef3 | ||
|
|
d6dd1c5f31 | ||
|
|
caaf5f8755 | ||
|
|
6bc74b2c5c | ||
|
|
b4639b3ffb | ||
|
|
7288fd7c6b | ||
|
|
55b188a7e8 | ||
|
|
9f23a7a48e | ||
|
|
393fa9598c | ||
|
|
eacb891cba | ||
|
|
4b88726c96 | ||
|
|
895d126ab7 | ||
|
|
227f0e51c2 | ||
|
|
a182b7143a | ||
|
|
0eab9ca2b2 | ||
|
|
f25d40ba46 | ||
|
|
92035ca52a | ||
|
|
a44479a48a | ||
|
|
c20e1a9a58 | ||
|
|
7e355c2d92 | ||
|
|
4fb5f54e18 | ||
|
|
4b748b102b | ||
|
|
f767d39c8f | ||
|
|
56a29e8aed | ||
|
|
2056e2a2c2 | ||
|
|
1e30ddb7b6 | ||
|
|
11ced8e03c | ||
|
|
45e40a5661 | ||
|
|
4b32f3e8b2 | ||
|
|
ba9def7d4b | ||
|
|
f5d5cb4aef | ||
|
|
a72e39e0ec | ||
|
|
72a407dd6f | ||
|
|
b438457d32 | ||
|
|
e326ded933 | ||
|
|
3673654df6 | ||
|
|
94c156d3c1 | ||
|
|
3568f60b9b | ||
|
|
779c0c3b7d | ||
|
|
02cc91a21b | ||
|
|
0c5a69e8c1 | ||
|
|
7590a9d64a | ||
|
|
dcf3bf7c67 | ||
|
|
33e1f99fad | ||
|
|
1122764333 | ||
|
|
c1a4acc40a | ||
|
|
e535496a1e | ||
|
|
16b2e8436e | ||
|
|
56c00c2489 | ||
|
|
ea6eef0db3 | ||
|
|
af934ffdc2 | ||
|
|
42c25fc403 | ||
|
|
4bc31e5c60 | ||
|
|
7cd4263a1d | ||
|
|
6ea32c4149 | ||
|
|
612e002f4e | ||
|
|
ccba0f23f1 | ||
|
|
56638b26e8 | ||
|
|
ed9d03d0b8 | ||
|
|
a7a2d9d73e | ||
|
|
c023eb591b | ||
|
|
c94fbb8589 | ||
|
|
4102d33336 | ||
|
|
78f1f74594 | ||
|
|
e0124ccb15 | ||
|
|
3141b3cc41 | ||
|
|
0ec6c27a53 | ||
|
|
f0b7180edd | ||
|
|
c045dc1e85 | ||
|
|
04d089c445 | ||
|
|
c3f451f6b6 | ||
|
|
d6533c4447 | ||
|
|
ae66d15051 | ||
|
|
23077ea445 | ||
|
|
87b9b57c3d | ||
|
|
93aac51182 | ||
|
|
eed132dad6 | ||
|
|
f187cabcd9 | ||
|
|
82ec221a87 | ||
|
|
a13d24a82d | ||
|
|
fcd95a43e0 | ||
|
|
0590fe9a4d | ||
|
|
c25432bc34 | ||
|
|
8bd5bcd76e | ||
|
|
022bbc12fb | ||
|
|
725843dec0 | ||
|
|
c069db219a | ||
|
|
fbb237f7d8 | ||
|
|
1d8371c628 | ||
|
|
a0a1082217 | ||
|
|
b18c3f6b01 | ||
|
|
9cb808dee6 | ||
|
|
d572c738cc | ||
|
|
d75d099490 | ||
|
|
27ea7d1496 | ||
|
|
ad5ff5d2c9 | ||
|
|
cc63c8cce2 | ||
|
|
580a5833a8 | ||
|
|
faaccdc6fc | ||
|
|
acb9a65215 | ||
|
|
d9ba831c20 | ||
|
|
81f721099c | ||
|
|
fd18cf5246 | ||
|
|
8d7a53b888 | ||
|
|
b4dfb1673f | ||
|
|
a80e24c8bd | ||
|
|
8481656cc6 | ||
|
|
fb7e5037e1 | ||
|
|
4de1403105 | ||
|
|
56210405ff | ||
|
|
929e7fc4c0 | ||
|
|
e7e1b10a04 | ||
|
|
b9369c224e | ||
|
|
aa0a60ac69 | ||
|
|
a0586116e0 | ||
|
|
6594545f12 | ||
|
|
c75eec059d | ||
|
|
7775ffae1e | ||
|
|
684a2901c4 | ||
|
|
e27c50ed14 | ||
|
|
159ef61e8e | ||
|
|
7a60bba99d | ||
|
|
f530875210 | ||
|
|
302730c81e | ||
|
|
b4dea89fad | ||
|
|
f1fafe7ef0 | ||
|
|
4336910fd8 | ||
|
|
7b9e0991d2 | ||
|
|
e2fa5c1340 | ||
|
|
c499f9ddb4 | ||
|
|
2a6ff6554b | ||
|
|
74845c3004 | ||
|
|
c1c31fcfb8 | ||
|
|
71eaeac949 | ||
|
|
b8a0aa47bf | ||
|
|
e13f9708fd | ||
|
|
c3a5c9110c | ||
|
|
6e5e4cd633 | ||
|
|
5367fc8a64 | ||
|
|
eec8ea7e68 | ||
|
|
58ff12485b | ||
|
|
fd5f1bc48c | ||
|
|
807d520323 | ||
|
|
b59e44a9aa | ||
|
|
aed7addfb5 | ||
|
|
9166b20d42 | ||
|
|
eee79fd11f | ||
|
|
0c0a3026ce | ||
|
|
8eb0b2ccd7 | ||
|
|
941305dff6 | ||
|
|
dfd77b392a | ||
|
|
282fdac583 | ||
|
|
a45684d940 | ||
|
|
6b40a2b836 | ||
|
|
c8b21acb36 | ||
|
|
cd50e27288 | ||
|
|
f9b88ce446 | ||
|
|
9a9b5080d6 | ||
|
|
88f90ff9ae | ||
|
|
4485e20725 | ||
|
|
a6eb2f6101 | ||
|
|
8a5a813540 | ||
|
|
4c8cdf992c | ||
|
|
3906747b1a | ||
|
|
f74d36e40d | ||
|
|
2c3747e061 | ||
|
|
8e47d102c0 | ||
|
|
b0610a62b0 | ||
|
|
09b0d8bcf8 | ||
|
|
f03fff5784 | ||
|
|
64eb1bc047 | ||
|
|
cda1223103 | ||
|
|
7d87b42817 | ||
|
|
539a98a356 | ||
|
|
86b8632bc3 | ||
|
|
2ef028527f | ||
|
|
ec862942cd | ||
|
|
11f4ccb946 | ||
|
|
61ec772d35 | ||
|
|
0ddade085a | ||
|
|
6a05544529 | ||
|
|
1f0771a19c | ||
|
|
295b2e45bd | ||
|
|
e768d82d8a | ||
|
|
4195269414 | ||
|
|
276511fc98 | ||
|
|
de097c90dc | ||
|
|
8f2cf98e4e | ||
|
|
61ad2fe4e8 | ||
|
|
3460283662 | ||
|
|
54b7d2dcf0 | ||
|
|
afab1e3e8e | ||
|
|
41fa370c2c | ||
|
|
1597b70a4b | ||
|
|
05b5b206dd | ||
|
|
501f25dcc8 | ||
|
|
578b51353f | ||
|
|
d400704d14 | ||
|
|
fa85c82010 | ||
|
|
e2a2c8a21c |
329 changed files with 34068 additions and 2934 deletions
37
.devcontainer/cli/Dockerfile
Normal file
37
.devcontainer/cli/Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# From official php image.
|
||||
FROM php:8.4-cli-alpine
|
||||
# Create a user group and account under id 1000.
|
||||
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
|
||||
# Install quality-of-life packages.
|
||||
RUN apk add --no-cache bash curl git vim
|
||||
# Install composer for php deps.
|
||||
RUN apk add --no-cache composer
|
||||
# Add Chromium and Image Magick for puppeteer.
|
||||
RUN apk add --no-cache \
|
||||
imagemagick-dev \
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
||||
RUN mkdir -p /usr/src/php/ext/imagick
|
||||
RUN chmod 777 /usr/src/php/ext/imagick
|
||||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
# Composer uses its php binary, but we want it to use the container's one
|
||||
RUN rm -f /usr/bin/php84
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||
# Install postgres pdo driver.
|
||||
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
|
||||
# Install redis driver.
|
||||
# RUN mkdir -p /usr/src/php/ext/redis; \
|
||||
# curl -fsSL --ipv4 https://github.com/phpredis/phpredis/archive/6.0.2.tar.gz | tar xvz -C "/usr/src/php/ext/redis" --strip 1; \
|
||||
# docker-php-ext-install redis
|
||||
# Install nodejs and npm for frontend.
|
||||
RUN apk add --no-cache nodejs npm
|
||||
# Prevent container from exiting early.
|
||||
CMD ["sleep", "infinity"]
|
||||
21
.devcontainer/devcontainer.json
Normal file
21
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "LaravelServer",
|
||||
"service": "cli",
|
||||
"remoteUser": "user",
|
||||
"shutdownAction": "stopCompose",
|
||||
"workspaceFolder": "/workspaces/laravel",
|
||||
"dockerComposeFile": "docker-compose.yaml",
|
||||
"forwardPorts": [ "nginx:80" ],
|
||||
"postCreateCommand": "composer install && npm install && npm run build",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"bmewburn.vscode-intelephense-client",
|
||||
"EditorConfig.EditorConfig",
|
||||
"mikestead.dotenv",
|
||||
"onecentlin.laravel-blade",
|
||||
"laravel.vscode-laravel"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
.devcontainer/docker-compose.yaml
Normal file
21
.devcontainer/docker-compose.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
version: "3.9"
|
||||
services:
|
||||
cli:
|
||||
build: cli
|
||||
volumes:
|
||||
- ..:/workspaces/laravel
|
||||
|
||||
fpm:
|
||||
build: fpm
|
||||
volumes:
|
||||
- ..:/workspaces/laravel
|
||||
user: 1000:1000
|
||||
|
||||
nginx:
|
||||
build: nginx
|
||||
volumes:
|
||||
- ..:/workspaces/laravel
|
||||
ports:
|
||||
- 8080:80
|
||||
depends_on:
|
||||
- fpm
|
||||
31
.devcontainer/fpm/Dockerfile
Normal file
31
.devcontainer/fpm/Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# From official php image.
|
||||
FROM php:8.4-fpm-alpine
|
||||
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
|
||||
# Install postgres pdo driver.
|
||||
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
|
||||
# Install redis driver.
|
||||
# RUN mkdir -p /usr/src/php/ext/redis; \
|
||||
# curl -fsSL --ipv4 https://github.com/phpredis/phpredis/archive/6.0.2.tar.gz | tar xvz -C "/usr/src/php/ext/redis" --strip 1; \
|
||||
# docker-php-ext-install redis
|
||||
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
imagemagick-dev \
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
||||
RUN mkdir -p /usr/src/php/ext/imagick
|
||||
RUN chmod 777 /usr/src/php/ext/imagick
|
||||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
RUN rm -f /usr/bin/php84
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||
21
.devcontainer/license.md
Normal file
21
.devcontainer/license.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 Theodore Messinezis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
2
.devcontainer/nginx/Dockerfile
Normal file
2
.devcontainer/nginx/Dockerfile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
FROM nginx:1.27-alpine
|
||||
COPY default.conf /etc/nginx/conf.d/default.conf
|
||||
35
.devcontainer/nginx/default.conf
Normal file
35
.devcontainer/nginx/default.conf
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /workspaces/laravel/public;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
index index.php;
|
||||
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass fpm:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param PHP_VALUE "error_log=/var/log/nginx/php_errors.log";
|
||||
fastcgi_buffers 16 16k;
|
||||
fastcgi_buffer_size 32k;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
71
.devcontainer/readme.md
Normal file
71
.devcontainer/readme.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
### Laravel Development Containers for Visual Studio Code
|
||||
|
||||
`laravel-devcontainer` is a simple configuration to support fully-dockerised development of Laravel applications using Visual Studio Code.
|
||||
Unlike Laravel Sail, `laravel-devcontainer` has been built so that the entire development experience is dockerised. The only requirements are:
|
||||
|
||||
- [Visual Studio Code](https://code.visualstudio.com/)
|
||||
- [Visual Studio Code Remote Containers Extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
Visual Studio Code will actually run inside a Docker container with php-cli as well as other development tools.
|
||||
Any Extensions will also run in the same container, meaning that intellisense will use the same php-cli configuration!
|
||||
|
||||
`laravel-devcontainer` currently ships with:
|
||||
|
||||
- `php:8.3-cli-alpine` workspace with composer, pgsql, redis, and nodejs.
|
||||
- `php:8.3-fpm-alpine` container with pgsql and redis extensions.
|
||||
- `nginx:1.27-alpine` preconfigured for your Laravel application.
|
||||
- `postgres:16.3-alpine` preconfigured with the default Laravel credentials.
|
||||
- `redis:7.2-alpine` for caching, queues, sessions, etc.
|
||||
|
||||
#### Easy Installation
|
||||
|
||||
Using this configuration is quite simple. [Download](https://github.com/theomessin/laravel-devcontainer/archive/refs/heads/master.zip) and place `laravel-devcontainer` in a `.devcontainer` folder with your Laravel Code. If starting a new project, you may create a new folder with just `laravel-devcontainer` in your `.devcontainer` folder. You may then [install Laravel using Composer](https://laravel.com/docs/11.x/installation#creating-a-laravel-project) (e.g. under `example-app`). You may then move the `.devcontainer` folder to your code folder (`mv .devcontainer example-app`) and use that!
|
||||
|
||||
#### Installing Using Git Submodules
|
||||
|
||||
Alternatively, you may use [Git Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules). Install the configuration by running
|
||||
|
||||
```sh
|
||||
git submodule add https://github.com/theomessin/laravel-devcontainer .devcontainer
|
||||
```
|
||||
|
||||
If you use this method, do not forget to install submodules when cloning:
|
||||
|
||||
```
|
||||
git clone --recurse-submodules ...
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
Start Visual Studio Code (e.g. `code example-app`) and re-open in remote containers (`Remote-Containers: Reopen in Container`). This may take some time on the first use, as Docker initially downloads and builds the images. Eventually, Visual Studio Code will run inside the workspace container. Extensions and settings specified in `devcontainer.json` will be auto-configured!
|
||||
|
||||
Be sure to correctly configure your application `.env` to use the devcontainer postgres and redis. For example:
|
||||
|
||||
```env
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=laravel
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
You may then navigate to [`localhost`](http://localhost) on your local machine. Fingers crossed, you will see your Laravel application!
|
||||
Run any artisan or composer commands using the Visual Studio Code [Integrated Terminal](https://code.visualstudio.com/docs/editor/integrated-terminal).
|
||||
As such, you do not need anything else installed on your host machine!
|
||||
|
||||
#### Extensions
|
||||
|
||||
`laravel-devcontainer` currently ships with the following extensions for Laravel development in Visual Studio Code:
|
||||
|
||||
- ["bmewburn.vscode-intelephense-client"](https://marketplace.visualstudio.com/items?itemName=bmewburn.vscode-intelephense-client)
|
||||
- ["eamodio.gitlens"](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens)
|
||||
- ["EditorConfig.EditorConfig"](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
|
||||
- ["mikestead.dotenv"](https://marketplace.visualstudio.com/items?itemName=mikestead.dotenv)
|
||||
- ["onecentlin.laravel-blade"](https://marketplace.visualstudio.com/items?itemName=onecentlin.laravel-blade)
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
/.devcontainer
|
||||
/.github
|
||||
/.phpunit.cache
|
||||
/database/*.sqlite
|
||||
/bootstrap/cache/*
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
|
|
@ -6,6 +10,7 @@
|
|||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
.editorconfig
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
|
|
@ -20,5 +25,3 @@ yarn-error.log
|
|||
/.idea
|
||||
/.vscode
|
||||
/.zed
|
||||
/bootstrap/cache/*
|
||||
/database/database.sqlite
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ PHP_CLI_SERVER_WORKERS=4
|
|||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single,stderr
|
||||
LOG_STACK=single,stderr,stdout
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
|
|
@ -68,3 +68,10 @@ VITE_APP_NAME="${APP_NAME}"
|
|||
TRMNL_PROXY_BASE_URL=https://trmnl.app
|
||||
TRMNL_PROXY_REFRESH_MINUTES=15
|
||||
REGISTRATION_ENABLED=1
|
||||
|
||||
PUPPETEER_MODE=
|
||||
SIDECAR_ACCESS_KEY_ID=
|
||||
SIDECAR_SECRET_ACCESS_KEY=
|
||||
SIDECAR_REGION=
|
||||
SIDECAR_ARTIFACT_BUCKET_NAME=
|
||||
SIDECAR_EXECUTION_ROLE=
|
||||
|
|
|
|||
70
.env.example.local
Normal file
70
.env.example.local
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
APP_NAME=TrmnlServer
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:zzPXBQPlgn0NHwVBTVG0B//8P/PVwVnBp2gk0ZWR0+k=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single,stderr,stdout
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
TRMNL_PROXY_BASE_URL=https://trmnl.app
|
||||
TRMNL_PROXY_REFRESH_MINUTES=15
|
||||
REGISTRATION_ENABLED=1
|
||||
59
.github/workflows/docker-build.yml
vendored
Normal file
59
.github/workflows/docker-build.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
target: production
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
|||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
environment: Testing
|
||||
|
||||
steps:
|
||||
|
|
@ -23,7 +23,6 @@ jobs:
|
|||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: xdebug
|
||||
|
||||
- name: Setup Node
|
||||
|
|
@ -32,9 +31,6 @@ jobs:
|
|||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
|
|
@ -45,7 +41,9 @@ jobs:
|
|||
run: php artisan key:generate
|
||||
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
run: |
|
||||
npm ci --no-audit
|
||||
npm run build
|
||||
|
||||
- name: Run Tests
|
||||
run: ./vendor/bin/pest
|
||||
run: ./vendor/bin/pest --ci --coverage
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -22,3 +22,15 @@ yarn-error.log
|
|||
/.vscode
|
||||
/.zed
|
||||
/database/seeders/PersonalDeviceSeeder.php
|
||||
/.junie/mcp/mcp.json
|
||||
/.cursor/mcp.json
|
||||
/.cursor/rules/laravel-boost.mdc
|
||||
/.github/copilot-instructions.md
|
||||
/.junie/guidelines.md
|
||||
/CLAUDE.md
|
||||
/.mcp.json
|
||||
/.ai
|
||||
.DS_Store
|
||||
/boost.json
|
||||
/.gemini
|
||||
/GEMINI.md
|
||||
|
|
|
|||
17
CONTRIBUTING.md
Normal file
17
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
### CONTRIBUTING
|
||||
Contributions are welcome! If you’d like to improve the project, follow these steps:
|
||||
|
||||
1. Open an Issue
|
||||
- Before submitting a pull request, create an issue to discuss your idea.
|
||||
- Clearly describe the feature or bug fix you want to work on.
|
||||
2. Fork the Repository & Create a Branch
|
||||
3. Make Your Changes & Add Tests
|
||||
- Ensure your code follows best practices.
|
||||
- Add Pest tests to cover your changes.
|
||||
4. Run Tests
|
||||
- `php artisan test`
|
||||
5. Submit a Pull Request (PR)
|
||||
- Push your branch and create a PR.
|
||||
- Provide a clear description of your changes.
|
||||
|
||||
Thank you for contributing!
|
||||
97
Dockerfile
97
Dockerfile
|
|
@ -1,68 +1,57 @@
|
|||
FROM php:8.3-fpm-alpine3.20
|
||||
########################
|
||||
# Base Image
|
||||
########################
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq \
|
||||
nodejs \
|
||||
npm \
|
||||
git \
|
||||
curl \
|
||||
zip \
|
||||
unzip \
|
||||
imagemagick-dev \
|
||||
chromium
|
||||
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
|
||||
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
||||
LABEL org.opencontainers.image.licenses=MIT
|
||||
|
||||
# Configure Chromium Path
|
||||
ENV PUPPETEER_EXECUTABLE_PATH /usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER 1
|
||||
ARG APP_VERSION
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
#RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS imagemagick-dev \
|
||||
#&& pecl install imagick \
|
||||
#&& docker-php-ext-enable imagick \
|
||||
#&& apk del .build-deps \
|
||||
ENV AUTORUN_ENABLED="true"
|
||||
|
||||
#RUN docker-php-ext-install imagick \
|
||||
# && docker-php-ext-enable imagick
|
||||
# Mark trmnl-liquid-cli as installed
|
||||
ENV TRMNL_LIQUID_ENABLED=1
|
||||
|
||||
RUN mkdir -p /usr/src/php/ext/imagick
|
||||
RUN chmod 777 /usr/src/php/ext/imagick
|
||||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
# Switch to the root user so we can do root things
|
||||
USER root
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install opcache imagick
|
||||
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
# Set the working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy application files
|
||||
COPY --chown=www-data:www-data . .
|
||||
COPY --chown=www-data:www-data ./.env.example ./.env
|
||||
# Copy the application files
|
||||
COPY --chown=www-data:www-data . /var/www/html
|
||||
COPY --chown=www-data:www-data .env.example .env
|
||||
|
||||
# Install application dependencies
|
||||
RUN composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
RUN npm install && npm run build
|
||||
# Install the composer dependencies
|
||||
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
# Copy configuration files
|
||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
|
||||
########################
|
||||
# Assets Image
|
||||
########################
|
||||
FROM node:22-alpine AS assets
|
||||
|
||||
# Create required directories
|
||||
RUN mkdir -p /var/log/supervisor \
|
||||
&& mkdir -p storage/logs \
|
||||
&& mkdir -p storage/framework/{cache,sessions,views} \
|
||||
&& chmod -R 775 storage \
|
||||
&& chmod -R 775 bootstrap/cache \
|
||||
&& touch database/database.sqlite \
|
||||
&& chmod -R 777 database
|
||||
# Copy the application
|
||||
COPY --from=base /var/www/html /app
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Start supervisor
|
||||
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
# Install the node dependencies and build the assets
|
||||
RUN npm ci --no-audit \
|
||||
&& npm run build
|
||||
|
||||
########################
|
||||
# Production Image
|
||||
########################
|
||||
FROM base AS production
|
||||
|
||||
# Copy the assets from the assets image
|
||||
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build
|
||||
COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules
|
||||
# Drop back to the www-data user
|
||||
USER www-data
|
||||
|
|
|
|||
299
README.md
299
README.md
|
|
@ -1,103 +1,137 @@
|
|||
## TRMNL BYOS (PHP/Laravel)
|
||||
|
||||
Laravel Trmnl Server is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||
It enables you to manage TRMNL devices, generate screens dynamically, and can act as a proxy for the TRMNL API (native plugin system).
|
||||
Inspired by [usetrmnl/byos_sinatra](https://github.com/usetrmnl/byos_sinatra).
|
||||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
||||
|
||||
If you are looking for a Laravel package designed to streamline the development of both public and private TRMNL plugins, check out [bnussbau/trmnl-laravel](https://github.com/bnussbau/laravel-trmnl).
|
||||
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.
|
||||
|
||||

|
||||

|
||||
|
||||
* 👉 [more Screenshots](screenshots/SCREENSHOTS.md)
|
||||
|
||||
### Key Features
|
||||
|
||||
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
||||
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
||||
* 🖥️ Screen Generation – Supports Markup, API, or update via Code.
|
||||
* 🔄 TRMNL API Proxy – Can act as a proxy for the TRMNL Display API (requires TRMNL Developer Edition).
|
||||
* This enables a hybrid setup – for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day.
|
||||
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
||||
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
||||
* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
|
||||
* Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
|
||||
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
|
||||
* Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes)
|
||||
* Supported Devices
|
||||
* TRMNL OG (1-bit & 2-bit)
|
||||
* SeeedStudio TRMNL 7,5" (OG) DIY Kit
|
||||
* Seeed Studio (XIAO 7.5" ePaper Panel)
|
||||
* reTerminal E1001 Monochrome ePaper Display
|
||||
* Custom ESP32 with TRMNL firmware
|
||||
* E-Reader Devices
|
||||
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
|
||||
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
|
||||
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
|
||||
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
|
||||
* Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
|
||||
* Raspberry Pi (HDMI output) [trmnl-display](https://github.com/usetrmnl/trmnl-display)
|
||||
* 🔄 TRMNL API Proxy – Can act as a proxy for the native cloud service (requires TRMNL Developer Edition).
|
||||
* This enables a hybrid setup – for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day.
|
||||
* 🌙 Dark Mode – Switch between light and dark mode.
|
||||
* 🐳 Deployment – Dockerized setup for easier hosting (Dockerfile, docker-compose).
|
||||
* 💾 Flexible Database configuration – uses SQLite by default, also compatible with MySQL or PostgreSQL
|
||||
* 🛠️ Devcontainer support for easier development.
|
||||
|
||||
### 🎯 Target Audience
|
||||
|
||||
This project is for developers who are looking for a self-hosted server for devices running the TRMNL firmware.
|
||||
It serves as a starter kit, giving you the flexibility to build and extend it however you like.
|
||||

|
||||
|
||||
### Support ❤️
|
||||
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
|
||||
|
||||
Support the development of this package by purchasing a TRMNL device through our referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
||||
Support the development of this package by purchasing a TRMNL device through the referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
||||
|
||||
### Requirements
|
||||
or
|
||||
|
||||
[](https://www.buymeacoffee.com/bnussbau)
|
||||
|
||||
[GitHub Sponsors](https://github.com/sponsors/bnussbau/)
|
||||
|
||||
### Hosting
|
||||
|
||||
Run everywhere, where Docker is supported: Raspberry Pi, VPS, NAS, Container Cloud Service (Cloud Run, ...).
|
||||
For production use, generate a new APP_KEY (`php artisan key:generate --show`) and set the environment variable `APP_KEY=`. For personal use, you can disable registration (see section Environment Variables).
|
||||
|
||||
#### Docker Compose
|
||||
Docker Compose file located at: [docker/prod/docker-compose.yml](docker/prod/docker-compose.yml).
|
||||
|
||||
##### Backup Database
|
||||
```sh
|
||||
docker ps #find container id of byos_laravel container
|
||||
docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite
|
||||
```
|
||||
|
||||
##### Updating via Docker Compose
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### VPS
|
||||
If you’re using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
|
||||
It’s a quick way to get started without having to manually manage Docker setup.
|
||||
|
||||
#### PikaPods
|
||||
You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
|
||||
|
||||
#### Umbrel
|
||||
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
|
||||
|
||||
#### Other Hosting Options
|
||||
Laravel Forge, or bare metal PHP server with Nginx or Apache is also supported.
|
||||
|
||||
#### Requirements
|
||||
|
||||
* PHP >= 8.2
|
||||
* ext-imagick
|
||||
* puppeteer [see Browsershot docs](https://spatie.be/docs/browsershot/v4/requirements)
|
||||
|
||||
### Installation
|
||||
### Local Development
|
||||
|
||||
#### Clone the repository
|
||||
see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
||||
|
||||
|
||||
### Demo Plugins
|
||||
|
||||
Run the ExampleRecipesSeeder to seed the database with example plugins:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:bnussbau/laravel-trmnl-server.git
|
||||
php artisan db:seed --class=ExampleRecipesSeeder
|
||||
```
|
||||
|
||||
#### Copy environment file
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
```bash
|
||||
composer install
|
||||
npm i
|
||||
```
|
||||
|
||||
#### Run migrations
|
||||
|
||||
```bash
|
||||
php artisan migrate --seed
|
||||
```
|
||||
|
||||
#### Run the server
|
||||
|
||||
To make your server accessible in the network, you can run the following command:
|
||||
|
||||
```bash
|
||||
php artisan serve --host=0.0.0.0 --port 4567
|
||||
```
|
||||
|
||||
### Docker
|
||||
Use the provided Dockerfile, or docker-compose file to run the server in a container.
|
||||
You can persist the database file by mounting a volume to `/var/www/html/database/database.sqlite`.
|
||||
|
||||
```Dockerfile
|
||||
# docker-compose.yaml
|
||||
volumes:
|
||||
- ./database/database.sqlite:/var/www/html/database/database.sqlite
|
||||
```
|
||||
* Zen Quotes
|
||||
* This Day in History
|
||||
* Weather
|
||||
* Train Departure Monitor
|
||||
* Home Assistant
|
||||
* Sunrise/Sunset
|
||||
|
||||
### Usage
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| environment | description | default |
|
||||
|-------------------------------|------------------------------------------------------------------|-------------------|
|
||||
| `TRMNL_PROXY_BASE_URL` | Base URL of the native TRMNL service | https://trmnl.app |
|
||||
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
|
||||
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
|
||||
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
|
||||
| Environment Variable | Description | Default |
|
||||
|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|-------------------|
|
||||
| `TRMNL_PROXY_BASE_URL` | Base URL of the native TRMNL service | https://trmnl.app |
|
||||
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
|
||||
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
|
||||
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
|
||||
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
|
||||
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
|
||||
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
|
||||
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC |
|
||||
|
||||
#### Login
|
||||
|
||||
If your environment is local, you can access the server at `http://localhost:4567` and login with user / password
|
||||
`admin@example.com` / `admin@example.com`, otherwise register. With environment variable `REGISTRATION_ENABLED` you can control, if registration is allowed.
|
||||
|
||||
#### ➕ Add Your TRMNL Device
|
||||
### ➕ Add Your TRMNL Device
|
||||
|
||||
##### Auto-Join (Local Network)
|
||||
|
||||
|
|
@ -109,11 +143,12 @@ If your environment is local, you can access the server at `http://localhost:456
|
|||
##### Manually
|
||||
|
||||
1. Open the Devices page:
|
||||
👉 http://localhost:4567/devices
|
||||
👉 http://localhost:4567/devices
|
||||
2. Click “Add New Device”.
|
||||
3. Retrieve your TRMNL MAC Address and API Key:
|
||||
- You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard
|
||||
- Alternatively, debug incoming requests to /api/setup to determine them
|
||||
- You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard
|
||||
- Alternatively, debug incoming requests to /api/setup to determine them
|
||||
|
||||
|
||||
### ⚙️ Configure Server for Device
|
||||
|
||||
|
|
@ -129,11 +164,37 @@ If your device firmware is older than 1.4.6, you need to flash a new firmware ve
|
|||
|
||||
See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://www.youtube.com/watch?v=3xehPW-PCOM)
|
||||
|
||||
### ☁️ Activate fresh TRMNL Device with Cloud Proxy
|
||||
|
||||
1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works)
|
||||
2) Setup Laravel BYOS, create a user and login
|
||||
3) In Laravel BYOS in the header bar, activate the toggle "Permit Auto-Join"
|
||||
4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash).
|
||||
5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of your Laravel BYOS
|
||||
6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again.
|
||||
7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.)
|
||||
8) As long as no Laravel BYOS plugin is scheduled, the device will show your cloud plugins.
|
||||
|
||||
###### Troubleshooting
|
||||
|
||||
Make sure that your device has a Developer license, you should be able to verify by calling the `https://trmnl.app/api/display` endpoint.
|
||||
|
||||
* [https://docs.usetrmnl.com/go/private-api/introduction](https://docs.usetrmnl.com/go/private-api/introduction)
|
||||
* [https://docs.usetrmnl.com/go/private-api/fetch-screen-content](https://docs.usetrmnl.com/go/private-api/fetch-screen-content)
|
||||
|
||||
### 🖥️ Generate Screens
|
||||
|
||||
#### Markup via Web Interface
|
||||
|
||||
1. Navigate to Plugins > Markup in the Web Interface.
|
||||
2. Enter your markup manually or select from the available templates.
|
||||
3. Save and apply the changes.
|
||||
|
||||
* Available Blade Components are listed here: [laravel-trmnl-blade | Blade Components](https://github.com/bnussbau/laravel-trmnl-blade/tree/main/resources/views/components)
|
||||
|
||||
#### 🎨 Blade View
|
||||
* Edit `resources/views/trmnl.blade.php`
|
||||
* Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components)
|
||||
* Available Blade Components are listed here: [laravel-trmnl-blade | Blade Components](https://github.com/bnussbau/laravel-trmnl-blade/tree/main/resources/views/components)
|
||||
* To generate the screen, run
|
||||
|
||||
```bash
|
||||
|
|
@ -157,109 +218,15 @@ You can dynamically update screens by sending a POST request.
|
|||
}
|
||||
```
|
||||
|
||||
Token can be retrieved under Plugins > API in the Web Interface.
|
||||
### Releated Work
|
||||
* [bnussbau/laravel-trmnl-blade](https://github.com/bnussbau/laravel-trmnl-blade) – Blade Components on top of the TRMNL Design System
|
||||
* [bnussbau/trmnl-pipeline-php](https://github.com/bnussbau/trmnl-pipeline-php) – Browser Rendering and Image Conversion Pipeline with support for TRMNL Models API
|
||||
* [bnussbau/trmnl-recipe-catalog](https://github.com/bnussbau/trmnl-recipe-catalog) – A community-driven catalog of public repositories containing trmnlp-compatible recipes.
|
||||
|
||||
#### Markup via Web Interface
|
||||
|
||||
1. Navigate to Plugins > Markup in the Web Interface.
|
||||
2. Enter your markup manually or select from the available templates.
|
||||
3. Save and apply the changes.
|
||||
|
||||
* Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components)
|
||||
|
||||
#### 🛠️ Generate Screens Programmatically
|
||||
|
||||
You can fetch external data, process it, and generate screens dynamically.
|
||||
* Fetch data from an external source.
|
||||
* Either render it in a Blade view or directly insert markup.
|
||||
* Use Laravel’s scheduler to automate updates.
|
||||
|
||||
#### 📌 Example: Fetch Train Monitor Data
|
||||
|
||||
This example retrieves data from [trmnl-train-monitor](https://github.com/bnussbau/trmnl-train-monitor) and updates the screen periodically.
|
||||
|
||||
##### Step 1: Create a new Artisan Command
|
||||
|
||||
```bash
|
||||
php artisan make:command PluginTrainMonitorFetch
|
||||
```
|
||||
|
||||
##### Step 2: Edit PluginTrainMonitorFetch.php
|
||||
|
||||
```php
|
||||
class PluginTrainMonitorFetch extends Command
|
||||
{
|
||||
protected $signature = 'plugin:train:fetch';
|
||||
|
||||
protected $description = 'Fetches train monitor data and updates the screen';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$markup = Http::get('https://oebb.trmnl.yourserver.at/markup')->json('markup');
|
||||
GenerateScreenJob::dispatchSync(1, $markup);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Step 3: Schedule the Command in console.php
|
||||
|
||||
```php
|
||||
Schedule::command('plugin:train:fetch')
|
||||
->everyFiveMinutes()
|
||||
->timezone('Europe/Vienna')
|
||||
->between('5:00', '18:00');
|
||||
```
|
||||
|
||||
This will automatically update the screen every 5 minutes between 5:00 AM and 6:00 PM local time.
|
||||
|
||||
### 🏗️ Roadmap
|
||||
|
||||
Here are some features and improvements that are open for contribution:
|
||||
|
||||
##### 🔌 Plugin System
|
||||
|
||||
- Enable configurable plugins via the Web Interface.
|
||||
- Ensure compatibility with the trmnl-laravel package.
|
||||
- Implement auto-discovery for plugins.
|
||||
|
||||
##### ⏳ Scheduling
|
||||
|
||||
- Move task scheduling from console.php to a Web Interface.
|
||||
- Allow users to configure custom schedule intervals.
|
||||
|
||||
##### 🖥️ “Native” Plugins
|
||||
- Add built-in plugins such as (as an example):
|
||||
- ☁️ Weather
|
||||
- 💬 Quotes
|
||||
- 🏡 Home Assistant integration
|
||||
- Provide Web UI controls to enable/disable plugins.
|
||||
|
||||
##### 📦 Visual Studio Code Devcontainer
|
||||
* Add a .devcontainer to this repo for easier development with Docker.
|
||||
|
||||
##### Improve Code Coverage
|
||||
|
||||
- Expand Pest tests to cover more functionality.
|
||||
- Increase code coverage (currently at 86.9%).
|
||||
|
||||
### 🤝 Contribution
|
||||
Contributions are welcome! If you’d like to improve the project, follow these steps:
|
||||
|
||||
1. Open an Issue
|
||||
- Before submitting a pull request, create an issue to discuss your idea.
|
||||
- Clearly describe the feature or bug fix you want to work on.
|
||||
2. Fork the Repository & Create a Branch
|
||||
3. Make Your Changes & Add Tests
|
||||
- Ensure your code follows best practices.
|
||||
- Add Pest tests to cover your changes.
|
||||
4. Run Tests
|
||||
- `php artisan test`
|
||||
5. Submit a Pull Request (PR)
|
||||
- Push your branch and create a PR.
|
||||
- Provide a clear description of your changes.
|
||||
|
||||
🚀 Thank you for contributing! Every contribution helps improve the project.
|
||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
### License
|
||||
MIT
|
||||
[MIT](LICENSE.md)
|
||||
|
||||
|
|
|
|||
BIN
README_byos-devices.jpeg
Normal file
BIN
README_byos-devices.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
README_byos-screenshot-dark.png
Normal file
BIN
README_byos-screenshot-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 205 KiB |
20
app/Console/Commands/ExampleRecipesSeederCommand.php
Normal file
20
app/Console/Commands/ExampleRecipesSeederCommand.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Database\Seeders\ExampleRecipesSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
||||
|
||||
class ExampleRecipesSeederCommand extends Command implements PromptsForMissingInput
|
||||
{
|
||||
protected $signature = 'recipes:seed {user_id}';
|
||||
|
||||
protected $description = 'Seed example recipes';
|
||||
|
||||
public function handle(ExampleRecipesSeeder $seeder): void
|
||||
{
|
||||
$user_id = $this->argument('user_id');
|
||||
$seeder->run($user_id);
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/FetchDeviceModelsCommand.php
Normal file
46
app/Console/Commands/FetchDeviceModelsCommand.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\FetchDeviceModelsJob;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class FetchDeviceModelsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'device-models:fetch';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fetch device models from the TRMNL API and update the database';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Dispatching FetchDeviceModelsJob...');
|
||||
|
||||
try {
|
||||
FetchDeviceModelsJob::dispatchSync();
|
||||
|
||||
$this->info('FetchDeviceModelsJob has been dispatched successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Exception $e) {
|
||||
$this->error('Failed to dispatch FetchDeviceModelsJob: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/FirmwareCheckCommand.php
Normal file
38
app/Console/Commands/FirmwareCheckCommand.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\FirmwarePollJob;
|
||||
use App\Models\Firmware;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\spin;
|
||||
use function Laravel\Prompts\table;
|
||||
|
||||
class FirmwareCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'trmnl:firmware:check {--download : Download the latest firmware if available}';
|
||||
|
||||
protected $description = 'Checks for the latest firmware and downloads it if flag --download is passed.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
spin(
|
||||
callback: fn () => FirmwarePollJob::dispatchSync(download: $this->option('download')),
|
||||
message: 'Checking for latest firmware...'
|
||||
);
|
||||
|
||||
$latestFirmware = Firmware::getLatest();
|
||||
if ($latestFirmware instanceof Firmware) {
|
||||
table(
|
||||
rows: [
|
||||
['Latest Version', $latestFirmware->version_tag],
|
||||
['Download URL', $latestFirmware->url],
|
||||
['Storage Location', $latestFirmware->storage_location],
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->error('No firmware found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Console/Commands/FirmwareUpdateCommand.php
Normal file
70
app/Console/Commands/FirmwareUpdateCommand.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Firmware;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\select;
|
||||
|
||||
class FirmwareUpdateCommand extends Command
|
||||
{
|
||||
protected $signature = 'trmnl:firmware:update';
|
||||
|
||||
protected $description = 'Command description';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
|
||||
$checkFirmware = select(
|
||||
label: 'Check for new firmware?',
|
||||
options: [
|
||||
'check' => 'Check. Devices will download binary from the original source.',
|
||||
'download' => 'Check & Download. Devices will download binary from BYOS.',
|
||||
'no' => 'Do not check.',
|
||||
],
|
||||
);
|
||||
|
||||
if ($checkFirmware !== 'no') {
|
||||
$this->call('trmnl:firmware:check', [
|
||||
'--download' => $checkFirmware === 'download',
|
||||
]);
|
||||
}
|
||||
|
||||
$firmwareVersion = select(
|
||||
label: 'Update to which version?',
|
||||
options: Firmware::pluck('version_tag', 'id')
|
||||
);
|
||||
|
||||
$devices = multiselect(
|
||||
label: 'Which devices should be updated?',
|
||||
options: [
|
||||
'all' => 'ALL Devices',
|
||||
...Device::all()->mapWithKeys(fn ($device): array =>
|
||||
// without _ returns index
|
||||
["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"])->toArray(),
|
||||
],
|
||||
scroll: 10
|
||||
);
|
||||
|
||||
if ($devices === []) {
|
||||
$this->error('No devices selected. Aborting.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array('all', $devices)) {
|
||||
$devices = Device::pluck('id')->toArray();
|
||||
} else {
|
||||
$devices = array_map(fn ($selected): int => (int) str_replace('_', '', $selected), $devices);
|
||||
}
|
||||
|
||||
foreach ($devices as $deviceId) {
|
||||
Device::find($deviceId)->update(['update_firmware_id' => $firmwareVersion]);
|
||||
|
||||
$this->info("Device with id [$deviceId] will update firmware on next request.");
|
||||
}
|
||||
}
|
||||
}
|
||||
201
app/Console/Commands/GenerateDefaultImagesCommand.php
Normal file
201
app/Console/Commands/GenerateDefaultImagesCommand.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
|
||||
use Bnussbau\TrmnlPipeline\Stages\ImageStage;
|
||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
class GenerateDefaultImagesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'images:generate-defaults {--force : Force regeneration of existing images}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate default images (setup-logo and sleep) for all device models from Blade templates';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting generation of default images for all device models...');
|
||||
|
||||
$deviceModels = DeviceModel::all();
|
||||
|
||||
if ($deviceModels->isEmpty()) {
|
||||
$this->warn('No device models found in the database.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$deviceModels->count()} device models to process.");
|
||||
|
||||
// Create the target directory
|
||||
$targetDir = 'images/default-screens';
|
||||
if (! Storage::disk('public')->exists($targetDir)) {
|
||||
Storage::disk('public')->makeDirectory($targetDir);
|
||||
$this->info("Created directory: {$targetDir}");
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
$skipCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($deviceModels as $deviceModel) {
|
||||
$this->info("Processing device model: {$deviceModel->label} (ID: {$deviceModel->id})");
|
||||
|
||||
try {
|
||||
// Process setup-logo
|
||||
$setupResult = $this->transformImage('setup-logo', $deviceModel, $targetDir);
|
||||
if ($setupResult) {
|
||||
++$successCount;
|
||||
} else {
|
||||
++$skipCount;
|
||||
}
|
||||
|
||||
// Process sleep
|
||||
$sleepResult = $this->transformImage('sleep', $deviceModel, $targetDir);
|
||||
if ($sleepResult) {
|
||||
++$successCount;
|
||||
} else {
|
||||
++$skipCount;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error("Error processing device model {$deviceModel->label}: ".$e->getMessage());
|
||||
++$errorCount;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("\nGeneration completed!");
|
||||
$this->info("Successfully processed: {$successCount} images");
|
||||
$this->info("Skipped (already exist): {$skipCount} images");
|
||||
$this->info("Errors: {$errorCount} images");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single image for a device model using Blade templates
|
||||
*/
|
||||
private function transformImage(string $imageType, DeviceModel $deviceModel, string $targetDir): bool
|
||||
{
|
||||
// Generate filename: {width}_{height}_{bit_depth}_{rotation}.{extension}
|
||||
$extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
|
||||
$filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}";
|
||||
$targetPath = "{$targetDir}/{$imageType}_{$filename}";
|
||||
|
||||
// Check if target already exists and force is not set
|
||||
if (Storage::disk('public')->exists($targetPath) && ! $this->option('force')) {
|
||||
$this->line(" Skipping {$imageType} - already exists: {$filename}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
// Generate HTML from Blade template
|
||||
$html = $this->generateHtmlFromTemplate($imageType, $deviceModel);
|
||||
// dump($html);
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($html);
|
||||
|
||||
// Set timezone from app config (no user context in this command)
|
||||
$browserStage->timezone(config('app.timezone'));
|
||||
|
||||
$browserStage
|
||||
->width($deviceModel->width)
|
||||
->height($deviceModel->height);
|
||||
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
$outputPath = Storage::disk('public')->path($targetPath);
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($extension)
|
||||
->width($deviceModel->width)
|
||||
->height($deviceModel->height)
|
||||
->colors($deviceModel->colors)
|
||||
->bitDepth($deviceModel->bit_depth)
|
||||
->rotation($deviceModel->rotation)
|
||||
// ->offsetX($deviceModel->offset_x)
|
||||
// ->offsetY($deviceModel->offset_y)
|
||||
->outputPath($outputPath);
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
$this->line(" ✓ Generated {$imageType}: {$filename}");
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error(" ✗ Failed to generate {$imageType} for {$deviceModel->label}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML from Blade template for the given image type and device model
|
||||
*/
|
||||
private function generateHtmlFromTemplate(string $imageType, DeviceModel $deviceModel): string
|
||||
{
|
||||
// Map image type to template name
|
||||
$templateName = match ($imageType) {
|
||||
'setup-logo' => 'default-screens.setup',
|
||||
'sleep' => 'default-screens.sleep',
|
||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||
};
|
||||
|
||||
// Determine device properties from DeviceModel
|
||||
$deviceVariant = $deviceModel->name ?? 'og';
|
||||
$colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
|
||||
$scaleLevel = $deviceModel->scale_level; // Use the accessor method
|
||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||
|
||||
// Render the Blade template
|
||||
return view($templateName, [
|
||||
'noBleed' => false,
|
||||
'darkMode' => $darkMode,
|
||||
'deviceVariant' => $deviceVariant,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
])->render();
|
||||
}
|
||||
}
|
||||
175
app/Console/Commands/MashupCreateCommand.php
Normal file
175
app/Console/Commands/MashupCreateCommand.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistItem;
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class MashupCreateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'mashup:create';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Create a new mashup and add it to a playlist';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// Select device
|
||||
$device = $this->selectDevice();
|
||||
if (! $device instanceof Device) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Select playlist
|
||||
$playlist = $this->selectPlaylist($device);
|
||||
if (! $playlist instanceof Playlist) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Select mashup layout
|
||||
$layout = $this->selectLayout();
|
||||
if (! $layout) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get mashup name
|
||||
$name = $this->getMashupName();
|
||||
if (! $name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Select plugins
|
||||
$plugins = $this->selectPlugins($layout);
|
||||
if ($plugins->isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$maxOrder = $playlist->items()->max('order') ?? 0;
|
||||
|
||||
// Create playlist item with mashup
|
||||
PlaylistItem::createMashup(
|
||||
playlist: $playlist,
|
||||
layout: $layout,
|
||||
pluginIds: $plugins->pluck('id')->toArray(),
|
||||
name: $name,
|
||||
order: $maxOrder + 1
|
||||
);
|
||||
|
||||
$this->info('Mashup created successfully!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function selectDevice(): ?Device
|
||||
{
|
||||
$devices = Device::all();
|
||||
if ($devices->isEmpty()) {
|
||||
$this->error('No devices found. Please create a device first.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$deviceId = $this->choice(
|
||||
'Select a device',
|
||||
$devices->mapWithKeys(fn ($device): array => [$device->id => $device->name])->toArray()
|
||||
);
|
||||
|
||||
return $devices->firstWhere('id', $deviceId);
|
||||
}
|
||||
|
||||
protected function selectPlaylist(Device $device): ?Playlist
|
||||
{
|
||||
/** @var Collection|Playlist[] $playlists */
|
||||
$playlists = $device->playlists;
|
||||
if ($playlists->isEmpty()) {
|
||||
$this->error('No playlists found for this device. Please create a playlist first.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$playlistId = $this->choice(
|
||||
'Select a playlist',
|
||||
$playlists->mapWithKeys(fn (Playlist $playlist): array => [$playlist->id => $playlist->name])->toArray()
|
||||
);
|
||||
|
||||
return $playlists->firstWhere('id', $playlistId);
|
||||
}
|
||||
|
||||
protected function selectLayout(): ?string
|
||||
{
|
||||
return $this->choice(
|
||||
'Select a layout',
|
||||
PlaylistItem::getAvailableLayouts()
|
||||
);
|
||||
}
|
||||
|
||||
protected function getMashupName(): ?string
|
||||
{
|
||||
$name = $this->ask('Enter a name for this mashup', 'Mashup');
|
||||
|
||||
if (mb_strlen((string) $name) < 2) {
|
||||
$this->error('The name must be at least 2 characters.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mb_strlen((string) $name) > 50) {
|
||||
$this->error('The name must not exceed 50 characters.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
protected function selectPlugins(string $layout): Collection
|
||||
{
|
||||
$requiredCount = PlaylistItem::getRequiredPluginCountForLayout($layout);
|
||||
|
||||
$plugins = Plugin::all();
|
||||
if ($plugins->isEmpty()) {
|
||||
$this->error('No plugins found. Please create some plugins first.');
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
$selectedPlugins = collect();
|
||||
$availablePlugins = $plugins->mapWithKeys(fn ($plugin): array => [$plugin->id => $plugin->name])->toArray();
|
||||
|
||||
for ($i = 0; $i < $requiredCount; ++$i) {
|
||||
$position = match ($i) {
|
||||
0 => 'first',
|
||||
1 => 'second',
|
||||
2 => 'third',
|
||||
3 => 'fourth',
|
||||
default => ($i + 1).'th'
|
||||
};
|
||||
|
||||
$pluginId = $this->choice(
|
||||
"Select the $position plugin",
|
||||
$availablePlugins
|
||||
);
|
||||
|
||||
$selectedPlugins->push($plugins->firstWhere('id', $pluginId));
|
||||
unset($availablePlugins[$pluginId]);
|
||||
}
|
||||
|
||||
return $selectedPlugins;
|
||||
}
|
||||
}
|
||||
104
app/Console/Commands/OidcTestCommand.php
Normal file
104
app/Console/Commands/OidcTestCommand.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use InvalidArgumentException;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class OidcTestCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'oidc:test';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Test OIDC configuration and driver registration';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Testing OIDC Configuration...');
|
||||
$this->newLine();
|
||||
|
||||
// Check if OIDC is enabled
|
||||
$enabled = config('services.oidc.enabled');
|
||||
$this->line('OIDC Enabled: '.($enabled ? '✅ Yes' : '❌ No'));
|
||||
|
||||
// Check configuration values
|
||||
$endpoint = config('services.oidc.endpoint');
|
||||
$clientId = config('services.oidc.client_id');
|
||||
$clientSecret = config('services.oidc.client_secret');
|
||||
$redirect = config('services.oidc.redirect');
|
||||
if (! $redirect) {
|
||||
$redirect = config('app.url', 'http://localhost').'/auth/oidc/callback';
|
||||
}
|
||||
$scopes = config('services.oidc.scopes', []);
|
||||
$defaultScopes = ['openid', 'profile', 'email'];
|
||||
$effectiveScopes = empty($scopes) ? $defaultScopes : $scopes;
|
||||
|
||||
$this->line('OIDC Endpoint: '.($endpoint ? "✅ {$endpoint}" : '❌ Not set'));
|
||||
$this->line('Client ID: '.($clientId ? "✅ {$clientId}" : '❌ Not set'));
|
||||
$this->line('Client Secret: '.($clientSecret ? '✅ Set' : '❌ Not set'));
|
||||
$this->line('Redirect URL: '.($redirect ? "✅ {$redirect}" : '❌ Not set'));
|
||||
$this->line('Scopes: ✅ '.implode(', ', $effectiveScopes));
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Test driver registration
|
||||
try {
|
||||
// Only test driver if we have basic configuration
|
||||
if ($endpoint && $clientId && $clientSecret) {
|
||||
$driver = Socialite::driver('oidc');
|
||||
$this->line('OIDC Driver: ✅ Successfully registered and accessible');
|
||||
|
||||
if ($enabled) {
|
||||
$this->info('✅ OIDC is fully configured and ready to use!');
|
||||
$this->line('You can test the login flow at: /auth/oidc/redirect');
|
||||
} else {
|
||||
$this->warn('⚠️ OIDC driver is working but OIDC_ENABLED is false.');
|
||||
}
|
||||
} else {
|
||||
$this->line('OIDC Driver: ✅ Registered (configuration test skipped due to missing values)');
|
||||
$this->warn('⚠️ OIDC driver is registered but missing required configuration.');
|
||||
$this->line('Please set the following environment variables:');
|
||||
if (! $enabled) {
|
||||
$this->line(' - OIDC_ENABLED=true');
|
||||
}
|
||||
if (! $endpoint) {
|
||||
$this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)');
|
||||
$this->line(' OR');
|
||||
$this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)');
|
||||
}
|
||||
if (! $clientId) {
|
||||
$this->line(' - OIDC_CLIENT_ID=your-client-id');
|
||||
}
|
||||
if (! $clientSecret) {
|
||||
$this->line(' - OIDC_CLIENT_SECRET=your-client-secret');
|
||||
}
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if (str_contains($e->getMessage(), 'Driver [oidc] not supported')) {
|
||||
$this->error('❌ OIDC Driver registration failed: Driver not supported');
|
||||
} else {
|
||||
$this->error('❌ OIDC Driver error: '.$e->getMessage());
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->warn('⚠️ OIDC Driver registered but configuration error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Jobs\GenerateScreenJob;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ScreenGeneratorCommand extends Command
|
||||
{
|
||||
|
|
@ -24,20 +25,19 @@ class ScreenGeneratorCommand extends Command
|
|||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): int
|
||||
{
|
||||
$deviceId = $this->argument('deviceId');
|
||||
$view = $this->argument('view');
|
||||
|
||||
try {
|
||||
$markup = view($view)->render();
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Failed to render view: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
GenerateScreenJob::dispatchSync($deviceId, $markup);
|
||||
GenerateScreenJob::dispatchSync($deviceId, null, $markup);
|
||||
|
||||
$this->info('Screen generation job finished.');
|
||||
|
||||
|
|
|
|||
23
app/Enums/ImageFormat.php
Normal file
23
app/Enums/ImageFormat.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ImageFormat: string
|
||||
{
|
||||
case AUTO = 'auto';
|
||||
case PNG_8BIT_GRAYSCALE = 'png_8bit_grayscale';
|
||||
case BMP3_1BIT_SRGB = 'bmp3_1bit_srgb';
|
||||
case PNG_8BIT_256C = 'png_8bit_256c';
|
||||
case PNG_2BIT_4C = 'png_2bit_4c';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AUTO => 'Auto',
|
||||
self::PNG_8BIT_GRAYSCALE => 'PNG 8-bit Grayscale Gray 2c',
|
||||
self::BMP3_1BIT_SRGB => 'BMP3 1-bit sRGB 2c',
|
||||
self::PNG_8BIT_256C => 'PNG 8-bit Grayscale Gray 256c',
|
||||
self::PNG_2BIT_4C => 'PNG 2-bit Grayscale 4c',
|
||||
};
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/Auth/OidcController.php
Normal file
123
app/Http/Controllers/Auth/OidcController.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class OidcController extends Controller
|
||||
{
|
||||
/**
|
||||
* Redirect the user to the OIDC provider authentication page.
|
||||
*/
|
||||
public function redirect()
|
||||
{
|
||||
if (! config('services.oidc.enabled')) {
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
|
||||
}
|
||||
|
||||
// Check if all required OIDC configuration is present
|
||||
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
|
||||
foreach ($requiredConfig as $key) {
|
||||
if (! config("services.oidc.{$key}")) {
|
||||
Log::error("OIDC configuration missing: {$key}");
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return Socialite::driver('oidc')->redirect();
|
||||
} catch (Exception $e) {
|
||||
Log::error('OIDC redirect error: '.$e->getMessage());
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'Failed to initiate OIDC authentication.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain the user information from the OIDC provider.
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
if (! config('services.oidc.enabled')) {
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
|
||||
}
|
||||
|
||||
// Check if all required OIDC configuration is present
|
||||
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
|
||||
foreach ($requiredConfig as $key) {
|
||||
if (! config("services.oidc.{$key}")) {
|
||||
Log::error("OIDC configuration missing: {$key}");
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$oidcUser = Socialite::driver('oidc')->user();
|
||||
|
||||
// Find or create the user
|
||||
$user = $this->findOrCreateUser($oidcUser);
|
||||
|
||||
// Log the user in
|
||||
Auth::login($user, true);
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('OIDC callback error: '.$e->getMessage());
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'Failed to authenticate with OIDC provider.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a user based on OIDC information.
|
||||
*/
|
||||
protected function findOrCreateUser($oidcUser)
|
||||
{
|
||||
// First, try to find user by OIDC subject ID
|
||||
$user = User::where('oidc_sub', $oidcUser->getId())->first();
|
||||
|
||||
if ($user) {
|
||||
// Update user information from OIDC
|
||||
$user->update([
|
||||
'name' => $oidcUser->getName() ?: $user->name,
|
||||
'email' => $oidcUser->getEmail() ?: $user->email,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// If not found by OIDC sub, try to find by email
|
||||
if ($oidcUser->getEmail()) {
|
||||
$user = User::where('email', $oidcUser->getEmail())->first();
|
||||
|
||||
if ($user) {
|
||||
// Link the existing user with OIDC
|
||||
$user->update([
|
||||
'oidc_sub' => $oidcUser->getId(),
|
||||
'name' => $oidcUser->getName() ?: $user->name,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new user
|
||||
return User::create([
|
||||
'oidc_sub' => $oidcUser->getId(),
|
||||
'name' => $oidcUser->getName() ?: 'OIDC User',
|
||||
'email' => $oidcUser->getEmail() ?: $oidcUser->getId().'@oidc.local',
|
||||
'password' => bcrypt(Str::random(32)), // Random password since we're using OIDC
|
||||
'email_verified_at' => now(), // OIDC users are considered verified
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
app/Jobs/CleanupDeviceLogsJob.php
Normal file
30
app/Jobs/CleanupDeviceLogsJob.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CleanupDeviceLogsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Device::each(function ($device): void {
|
||||
$keepIds = $device->logs()->latest('device_timestamp')->take(50)->pluck('id');
|
||||
|
||||
// Delete all other logs for this device
|
||||
$device->logs()
|
||||
->whereNotIn('id', $keepIds)
|
||||
->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
247
app/Jobs/FetchDeviceModelsJob.php
Normal file
247
app/Jobs/FetchDeviceModelsJob.php
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\DevicePalette;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class FetchDeviceModelsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const API_URL = 'https://usetrmnl.com/api/models';
|
||||
|
||||
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$this->processPalettes();
|
||||
|
||||
$response = Http::timeout(30)->get(self::API_URL);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Failed to fetch device models from API', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
if (! is_array($data)) {
|
||||
Log::error('Invalid response format from device models API', [
|
||||
'response' => $response->json(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processDeviceModels($data);
|
||||
|
||||
Log::info('Successfully fetched and updated device models', [
|
||||
'count' => count($data),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Exception occurred while fetching device models', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process palettes from API and update/create records.
|
||||
*/
|
||||
private function processPalettes(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(30)->get(self::PALETTES_API_URL);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Failed to fetch palettes from API', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
if (! is_array($data)) {
|
||||
Log::error('Invalid response format from palettes API', [
|
||||
'response' => $response->json(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data as $paletteData) {
|
||||
try {
|
||||
$this->updateOrCreatePalette($paletteData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to process palette', [
|
||||
'palette_data' => $paletteData,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('Successfully fetched and updated palettes', [
|
||||
'count' => count($data),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Exception occurred while fetching palettes', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a palette record.
|
||||
*/
|
||||
private function updateOrCreatePalette(array $paletteData): void
|
||||
{
|
||||
$name = $paletteData['id'] ?? null;
|
||||
|
||||
if (! $name) {
|
||||
Log::warning('Palette data missing id field', [
|
||||
'palette_data' => $paletteData,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'name' => $name,
|
||||
'description' => $paletteData['name'] ?? '',
|
||||
'grays' => $paletteData['grays'] ?? 2,
|
||||
'colors' => $paletteData['colors'] ?? null,
|
||||
'framework_class' => $paletteData['framework_class'] ?? '',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
DevicePalette::updateOrCreate(
|
||||
['name' => $name],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the device models data and update/create records.
|
||||
*/
|
||||
private function processDeviceModels(array $deviceModels): void
|
||||
{
|
||||
foreach ($deviceModels as $modelData) {
|
||||
try {
|
||||
$this->updateOrCreateDeviceModel($modelData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to process device model', [
|
||||
'model_data' => $modelData,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a device model record.
|
||||
*/
|
||||
private function updateOrCreateDeviceModel(array $modelData): void
|
||||
{
|
||||
$name = $modelData['name'] ?? null;
|
||||
|
||||
if (! $name) {
|
||||
Log::warning('Device model data missing name field', [
|
||||
'model_data' => $modelData,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'label' => $modelData['label'] ?? '',
|
||||
'description' => $modelData['description'] ?? '',
|
||||
'width' => $modelData['width'] ?? 0,
|
||||
'height' => $modelData['height'] ?? 0,
|
||||
'colors' => $modelData['colors'] ?? 0,
|
||||
'bit_depth' => $modelData['bit_depth'] ?? 0,
|
||||
'scale_factor' => $modelData['scale_factor'] ?? 1,
|
||||
'rotation' => $modelData['rotation'] ?? 0,
|
||||
'mime_type' => $modelData['mime_type'] ?? '',
|
||||
'offset_x' => $modelData['offset_x'] ?? 0,
|
||||
'offset_y' => $modelData['offset_y'] ?? 0,
|
||||
'published_at' => $modelData['published_at'] ?? null,
|
||||
'kind' => $modelData['kind'] ?? null,
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
// Set palette_id to the first palette from the model's palettes array
|
||||
$firstPaletteId = $this->getFirstPaletteId($modelData);
|
||||
if ($firstPaletteId) {
|
||||
$attributes['palette_id'] = $firstPaletteId;
|
||||
}
|
||||
|
||||
DeviceModel::updateOrCreate(
|
||||
['name' => $name],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first palette ID from model data.
|
||||
*/
|
||||
private function getFirstPaletteId(array $modelData): ?int
|
||||
{
|
||||
$paletteName = null;
|
||||
|
||||
// Check for palette_ids array
|
||||
if (isset($modelData['palette_ids']) && is_array($modelData['palette_ids']) && $modelData['palette_ids'] !== []) {
|
||||
$paletteName = $modelData['palette_ids'][0];
|
||||
}
|
||||
|
||||
// Check for palettes array (array of objects with id)
|
||||
if (! $paletteName && isset($modelData['palettes']) && is_array($modelData['palettes']) && $modelData['palettes'] !== []) {
|
||||
$firstPalette = $modelData['palettes'][0];
|
||||
if (is_array($firstPalette) && isset($firstPalette['id'])) {
|
||||
$paletteName = $firstPalette['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (! $paletteName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look up palette by name to get the integer ID
|
||||
$palette = DevicePalette::where('name', $paletteName)->first();
|
||||
|
||||
return $palette?->id;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
|
@ -11,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FetchProxyCloudResponses implements ShouldQueue
|
||||
{
|
||||
|
|
@ -21,53 +23,10 @@ class FetchProxyCloudResponses implements ShouldQueue
|
|||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Device::where('proxy_cloud', true)->each(function ($device) {
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rssi' => $device->last_rssi_level,
|
||||
'battery_voltage' => $device->last_battery_voltage,
|
||||
'refresh-rate' => $device->default_refresh_interval,
|
||||
'fw-version' => $device->last_firmware_version,
|
||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
||||
'user-agent' => 'ESP32HTTPClient',
|
||||
])->get(config('services.trmnl.proxy_base_url').'/api/display');
|
||||
|
||||
$device->update([
|
||||
'proxy_cloud_response' => $response->json(),
|
||||
]);
|
||||
|
||||
$imageUrl = $response->json('image_url');
|
||||
$filename = $response->json('filename');
|
||||
|
||||
\Log::info('Response data: '.$imageUrl);
|
||||
if (isset($imageUrl)) {
|
||||
try {
|
||||
$imageContents = Http::get($imageUrl)->body();
|
||||
if (! Storage::disk('public')->exists("images/generated/{$filename}.bmp")) {
|
||||
Storage::disk('public')->put(
|
||||
"images/generated/{$filename}.bmp",
|
||||
$imageContents
|
||||
);
|
||||
}
|
||||
|
||||
$device->update([
|
||||
'current_screen_image' => $filename,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to download and save image for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
|
||||
|
||||
if ($device->last_log_request) {
|
||||
Http::withHeaders([
|
||||
Device::where('proxy_cloud', true)->each(function ($device): void {
|
||||
if (! $device->getNextPlaylistItem()) {
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'width' => 800,
|
||||
|
|
@ -78,17 +37,80 @@ class FetchProxyCloudResponses implements ShouldQueue
|
|||
'fw-version' => $device->last_firmware_version,
|
||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
||||
'user-agent' => 'ESP32HTTPClient',
|
||||
])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
|
||||
])->get(config('services.trmnl.proxy_base_url').'/api/display');
|
||||
|
||||
$device->update([
|
||||
'last_log_request' => null,
|
||||
'proxy_cloud_response' => $response->json(),
|
||||
]);
|
||||
|
||||
$imageUrl = $response->json('image_url');
|
||||
$filename = $response->json('filename');
|
||||
|
||||
parse_str(parse_url($imageUrl)['query'] ?? '', $queryParams);
|
||||
$imageType = urldecode($queryParams['response-content-type'] ?? 'image/bmp');
|
||||
$imageExtension = $imageType === 'image/png' ? 'png' : 'bmp';
|
||||
|
||||
if (Str::contains($imageUrl, '.png')) {
|
||||
$imageExtension = 'png';
|
||||
}
|
||||
|
||||
\Log::info("Response data: $imageUrl. Image Extension: $imageExtension");
|
||||
if (isset($imageUrl)) {
|
||||
try {
|
||||
$imageContents = Http::get($imageUrl)->body();
|
||||
if (! Storage::disk('public')->exists("images/generated/{$filename}.{$imageExtension}")) {
|
||||
Storage::disk('public')->put(
|
||||
"images/generated/{$filename}.{$imageExtension}",
|
||||
$imageContents
|
||||
);
|
||||
}
|
||||
|
||||
$device->update([
|
||||
'current_screen_image' => $filename,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Failed to download and save image for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
|
||||
|
||||
if ($device->last_log_request) {
|
||||
try {
|
||||
Http::withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rssi' => $device->last_rssi_level,
|
||||
'battery_voltage' => $device->last_battery_voltage,
|
||||
'refresh-rate' => $device->default_refresh_interval,
|
||||
'fw-version' => $device->last_firmware_version,
|
||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
||||
'user-agent' => 'ESP32HTTPClient',
|
||||
])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
|
||||
|
||||
// Only clear the pending log request if the POST succeeded
|
||||
$device->update([
|
||||
'last_log_request' => null,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Do not fail the entire proxy fetch if the log upload fails
|
||||
Log::error("Failed to upload device log for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} else {
|
||||
Log::info("Skipping device: {$device->mac_address} as it has a pending playlist item.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
52
app/Jobs/FirmwareDownloadJob.php
Normal file
52
app/Jobs/FirmwareDownloadJob.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Firmware;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FirmwareDownloadJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(private Firmware $firmware) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! Storage::disk('public')->exists('firmwares')) {
|
||||
Storage::disk('public')->makeDirectory('firmwares');
|
||||
}
|
||||
|
||||
try {
|
||||
$filename = "FW{$this->firmware->version_tag}.bin";
|
||||
$response = Http::get($this->firmware->url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new Exception('HTTP request failed with status: '.$response->status());
|
||||
}
|
||||
|
||||
// Save the response content to file
|
||||
Storage::disk('public')->put("firmwares/$filename", $response->body());
|
||||
|
||||
// Only update storage location if download was successful
|
||||
$this->firmware->update([
|
||||
'storage_location' => "firmwares/$filename",
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::error('Firmware download failed: '.$e->getMessage());
|
||||
// Don't update storage_location on failure
|
||||
} catch (Exception $e) {
|
||||
Log::error('An unexpected error occurred: '.$e->getMessage());
|
||||
// Don't update storage_location on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Jobs/FirmwarePollJob.php
Normal file
53
app/Jobs/FirmwarePollJob.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Firmware;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Log;
|
||||
|
||||
class FirmwarePollJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(private bool $download = false) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::get('https://usetrmnl.com/api/firmware/latest')->json();
|
||||
|
||||
if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) {
|
||||
Log::error('Invalid firmware response format received');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latestFirmware = Firmware::updateOrCreate(
|
||||
['version_tag' => $response['version']],
|
||||
[
|
||||
'url' => $response['url'],
|
||||
'latest' => true,
|
||||
]
|
||||
);
|
||||
|
||||
Firmware::where('id', '!=', $latestFirmware->id)->update(['latest' => false]);
|
||||
|
||||
if ($this->download && $latestFirmware->url && $latestFirmware->storage_location === null) {
|
||||
FirmwareDownloadJob::dispatchSync($latestFirmware);
|
||||
}
|
||||
|
||||
} catch (ConnectionException $e) {
|
||||
Log::error('Firmware download failed: '.$e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
Log::error('Unexpected error in firmware polling: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,13 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\ImageGenerationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
|
||||
class GenerateScreenJob implements ShouldQueue
|
||||
{
|
||||
|
|
@ -21,6 +20,7 @@ class GenerateScreenJob implements ShouldQueue
|
|||
*/
|
||||
public function __construct(
|
||||
private readonly int $deviceId,
|
||||
private readonly ?int $pluginId,
|
||||
private readonly string $markup
|
||||
) {}
|
||||
|
||||
|
|
@ -29,61 +29,15 @@ class GenerateScreenJob implements ShouldQueue
|
|||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
|
||||
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
|
||||
$newImageUuid = ImageGenerationService::generateImage($this->markup, $this->deviceId);
|
||||
|
||||
// Generate PNG
|
||||
try {
|
||||
Browsershot::html($this->markup)
|
||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : [])
|
||||
->windowSize(800, 480)
|
||||
->save($pngPath);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
|
||||
|
||||
if ($this->pluginId) {
|
||||
// cache current image
|
||||
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->convertToBmpImageMagick($pngPath, $bmpPath);
|
||||
} catch (\ImagickException $e) {
|
||||
throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
Device::find($this->deviceId)->update(['current_screen_image' => $uuid]);
|
||||
\Log::info("Device $this->deviceId: updated with new image: $uuid");
|
||||
|
||||
$this->cleanupFolder();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ImagickException
|
||||
*/
|
||||
private function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
|
||||
{
|
||||
$imagick = new \Imagick($pngPath);
|
||||
$imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE);
|
||||
$imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth(1);
|
||||
$imagick->stripImage();
|
||||
$imagick->setFormat('BMP3');
|
||||
$imagick->writeImage($bmpPath);
|
||||
$imagick->clear();
|
||||
}
|
||||
|
||||
private function cleanupFolder(): void
|
||||
{
|
||||
$activeImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
|
||||
|
||||
$files = Storage::disk('public')->files('/images/generated/');
|
||||
foreach ($files as $file) {
|
||||
if (basename($file) === '.gitignore') {
|
||||
continue;
|
||||
}
|
||||
// Get filename without path and extension
|
||||
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
|
||||
// If the UUID is not in use by any device, move it to archive
|
||||
if (! in_array($fileUuid, $activeImageUuids)) {
|
||||
Storage::disk('public')->delete($file);
|
||||
}
|
||||
}
|
||||
ImageGenerationService::cleanupFolder();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
app/Jobs/NotifyDeviceBatteryLowJob.php
Normal file
54
app/Jobs/NotifyDeviceBatteryLowJob.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BatteryLow;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NotifyDeviceBatteryLowJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$devices = Device::all();
|
||||
$batteryThreshold = config('app.notifications.battery_low.warn_at_percent');
|
||||
|
||||
foreach ($devices as $device) {
|
||||
$batteryPercent = $device->battery_percent;
|
||||
|
||||
// If battery is above threshold, reset the notification flag
|
||||
if ($batteryPercent > $batteryThreshold && $device->battery_notification_sent) {
|
||||
$device->battery_notification_sent = false;
|
||||
$device->save();
|
||||
|
||||
continue;
|
||||
}
|
||||
// Skip if battery is not low or notification was already sent
|
||||
if ($batteryPercent > $batteryThreshold) {
|
||||
continue;
|
||||
}
|
||||
if ($device->battery_notification_sent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = $device->user;
|
||||
|
||||
if (! $user) {
|
||||
continue; // Skip if no user is associated with the device
|
||||
}
|
||||
|
||||
// Send notification and mark as sent
|
||||
$user->notify(new BatteryLow($device));
|
||||
$device->battery_notification_sent = true;
|
||||
$device->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/Liquid/FileSystems/InlineTemplatesFileSystem.php
Normal file
62
app/Liquid/FileSystems/InlineTemplatesFileSystem.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Liquid\FileSystems;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Keepsuit\Liquid\Contracts\LiquidFileSystem;
|
||||
|
||||
/**
|
||||
* A file system that allows registering inline templates defined with the template tag
|
||||
*/
|
||||
class InlineTemplatesFileSystem implements LiquidFileSystem
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $templates = [];
|
||||
|
||||
/**
|
||||
* Register a template with the given name and content
|
||||
*/
|
||||
public function register(string $name, string $content): void
|
||||
{
|
||||
$this->templates[$name] = $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template exists
|
||||
*/
|
||||
public function hasTemplate(string $templateName): bool
|
||||
{
|
||||
return isset($this->templates[$templateName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered template names
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getTemplateNames(): array
|
||||
{
|
||||
return array_keys($this->templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered templates
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->templates = [];
|
||||
}
|
||||
|
||||
public function readTemplateFile(string $templateName): string
|
||||
{
|
||||
if (! isset($this->templates[$templateName])) {
|
||||
throw new InvalidArgumentException("Template '{$templateName}' not found in inline templates");
|
||||
}
|
||||
|
||||
return $this->templates[$templateName];
|
||||
}
|
||||
}
|
||||
136
app/Liquid/Filters/Data.php
Normal file
136
app/Liquid/Filters/Data.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use App\Liquid\Utils\ExpressionUtils;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Data filters for Liquid templates
|
||||
*/
|
||||
class Data extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Convert a variable to JSON
|
||||
*
|
||||
* @param mixed $value The variable to convert
|
||||
* @return string JSON representation of the variable
|
||||
*/
|
||||
public function json(mixed $value): string
|
||||
{
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an object in a collection by a specific key-value pair
|
||||
*
|
||||
* @param array $collection The collection to search in
|
||||
* @param string $key The key to search for
|
||||
* @param mixed $value The value to match
|
||||
* @param mixed $fallback Optional fallback value if no match is found
|
||||
* @return mixed The matching object or fallback value
|
||||
*/
|
||||
public function find_by(array $collection, string $key, mixed $value, mixed $fallback = null): mixed
|
||||
{
|
||||
foreach ($collection as $item) {
|
||||
if (is_array($item) && isset($item[$key]) && $item[$key] === $value) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a collection by a specific key
|
||||
*
|
||||
* @param array $collection The collection to group
|
||||
* @param string $key The key to group by
|
||||
* @return array The grouped collection
|
||||
*/
|
||||
public function group_by(array $collection, string $key): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($collection as $item) {
|
||||
if (is_array($item) && array_key_exists($key, $item)) {
|
||||
$groupKey = $item[$key];
|
||||
if (! isset($grouped[$groupKey])) {
|
||||
$grouped[$groupKey] = [];
|
||||
}
|
||||
$grouped[$groupKey][] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a random element from an array
|
||||
*
|
||||
* @param array $array The array to sample from
|
||||
* @return mixed A random element from the array
|
||||
*/
|
||||
public function sample(array $array): mixed
|
||||
{
|
||||
if ($array === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $array[array_rand($array)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON string into a PHP value
|
||||
*
|
||||
* @param string $json The JSON string to parse
|
||||
* @return mixed The parsed JSON value
|
||||
*/
|
||||
public function parse_json(string $json): mixed
|
||||
{
|
||||
return json_decode($json, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a collection using an expression
|
||||
*
|
||||
* @param mixed $input The collection to filter
|
||||
* @param string $variable The variable name to use in the expression
|
||||
* @param string $expression The expression to evaluate
|
||||
* @return array The filtered collection
|
||||
*/
|
||||
public function where_exp(mixed $input, string $variable, string $expression): array
|
||||
{
|
||||
// Return input as-is if it's not an array or doesn't have values method
|
||||
if (! is_array($input)) {
|
||||
return is_string($input) ? [$input] : [];
|
||||
}
|
||||
|
||||
// Convert hash to array of values if needed
|
||||
if (ExpressionUtils::isAssociativeArray($input)) {
|
||||
$input = array_values($input);
|
||||
}
|
||||
|
||||
$condition = ExpressionUtils::parseCondition($expression);
|
||||
$result = [];
|
||||
|
||||
foreach ($input as $object) {
|
||||
if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) {
|
||||
$result[] = $object;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of strings to integers
|
||||
*
|
||||
* @param array $input Array of string numbers
|
||||
* @return array Array of integers
|
||||
*/
|
||||
public function map_to_i(array $input): array
|
||||
{
|
||||
return array_map(intval(...), $input);
|
||||
}
|
||||
}
|
||||
55
app/Liquid/Filters/Date.php
Normal file
55
app/Liquid/Filters/Date.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use App\Liquid\Utils\ExpressionUtils;
|
||||
use Carbon\Carbon;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Data filters for Liquid templates
|
||||
*/
|
||||
class Date extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Calculate a date that is a specified number of days in the past
|
||||
*
|
||||
* @param int|string $num The number of days to subtract
|
||||
* @return string The date in Y-m-d format
|
||||
*/
|
||||
public function days_ago(int|string $num): string
|
||||
{
|
||||
$days = (int) $num;
|
||||
|
||||
return Carbon::now()->subDays($days)->toDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string with ordinal day (1st, 2nd, 3rd, etc.)
|
||||
*
|
||||
* @param string $dateStr The date string to parse
|
||||
* @param string $strftimeExp The strftime format string with <<ordinal_day>> placeholder
|
||||
* @return string The formatted date with ordinal day
|
||||
*/
|
||||
public function ordinalize(string $dateStr, string $strftimeExp): string
|
||||
{
|
||||
$date = Carbon::parse($dateStr);
|
||||
$ordinalDay = $date->ordinal('day');
|
||||
|
||||
// Convert strftime format to PHP date format
|
||||
$phpFormat = ExpressionUtils::strftimeToPhpFormat($strftimeExp);
|
||||
|
||||
// Split the format string by the ordinal day placeholder
|
||||
$parts = explode('<<ordinal_day>>', $phpFormat);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
$before = $date->format($parts[0]);
|
||||
$after = $date->format($parts[1]);
|
||||
|
||||
return $before.$ordinalDay.$after;
|
||||
}
|
||||
|
||||
// Fallback: if no placeholder found, just format normally
|
||||
return $date->format($phpFormat);
|
||||
}
|
||||
}
|
||||
52
app/Liquid/Filters/Localization.php
Normal file
52
app/Liquid/Filters/Localization.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Localization filters for Liquid templates
|
||||
*
|
||||
* Uses Laravel's translator for word translations. Translation files are located in the
|
||||
* lang/{locale}/custom_plugins.php files.
|
||||
*/
|
||||
class Localization extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Localize a date with strftime syntax
|
||||
*
|
||||
* @param mixed $date The date to localize (string or DateTime)
|
||||
* @param string $format The strftime format string
|
||||
* @param string|null $locale The locale to use for localization
|
||||
* @return string The localized date string
|
||||
*/
|
||||
public function l_date(mixed $date, string $format = 'Y-m-d', ?string $locale = null): string
|
||||
{
|
||||
$carbon = $date instanceof DateTimeInterface ? Carbon::instance($date) : Carbon::parse($date);
|
||||
if ($locale) {
|
||||
$carbon->locale($locale);
|
||||
}
|
||||
|
||||
return $carbon->translatedFormat($format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a common word to another language
|
||||
*
|
||||
* @param string $word The word to translate
|
||||
* @param string $locale The locale to translate to
|
||||
* @return string The translated word
|
||||
*/
|
||||
public function l_word(string $word, string $locale): string
|
||||
{
|
||||
$translation = trans('custom_plugins.'.mb_strtolower($word), locale: $locale);
|
||||
|
||||
if ($translation === 'custom_plugins.'.mb_strtolower($word)) {
|
||||
return $word;
|
||||
}
|
||||
|
||||
return $translation;
|
||||
}
|
||||
}
|
||||
50
app/Liquid/Filters/Numbers.php
Normal file
50
app/Liquid/Filters/Numbers.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use Illuminate\Support\Number;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
class Numbers extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Format a number with delimiters (default: comma)
|
||||
*
|
||||
* @param mixed $value The number to format
|
||||
* @param string $delimiter The delimiter to use (default: comma)
|
||||
* @param string $separator The separator for decimal part (default: period)
|
||||
*/
|
||||
public function number_with_delimiter(mixed $value, string $delimiter = ',', string $separator = '.'): string
|
||||
{
|
||||
// 2 decimal places for floats, 0 for integers
|
||||
$decimal = is_float($value + 0) ? 2 : 0;
|
||||
|
||||
return number_format($value, decimals: $decimal, decimal_separator: $separator, thousands_separator: $delimiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as currency
|
||||
*
|
||||
* @param mixed $value The number to format
|
||||
* @param string $currency Currency symbol or locale code
|
||||
* @param string $delimiter The delimiter to use (default: comma)
|
||||
* @param string $separator The separator for decimal part (default: period)
|
||||
*/
|
||||
public function number_to_currency(mixed $value, string $currency = 'USD', string $delimiter = ',', string $separator = '.'): string
|
||||
{
|
||||
if ($currency === '$') {
|
||||
$currency = 'USD';
|
||||
} elseif ($currency === '€') {
|
||||
$currency = 'EUR';
|
||||
} elseif ($currency === '£') {
|
||||
$currency = 'GBP';
|
||||
}
|
||||
|
||||
$locale = $delimiter === '.' && $separator === ',' ? 'de' : 'en';
|
||||
|
||||
// 2 decimal places for floats, 0 for integers
|
||||
$decimal = is_float($value + 0) ? 2 : 0;
|
||||
|
||||
return Number::currency($value, in: $currency, locale: $locale, precision: $decimal);
|
||||
}
|
||||
}
|
||||
20
app/Liquid/Filters/StandardFilters.php
Normal file
20
app/Liquid/Filters/StandardFilters.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
class StandardFilters extends \Keepsuit\Liquid\Filters\StandardFilters
|
||||
{
|
||||
/**
|
||||
* Converts any URL-unsafe characters in a string to the
|
||||
* [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) equivalent.
|
||||
*/
|
||||
public function urlEncode(string|int|float|array|null $input): string
|
||||
{
|
||||
|
||||
if (is_array($input)) {
|
||||
$input = json_encode($input);
|
||||
}
|
||||
|
||||
return parent::urlEncode($input);
|
||||
}
|
||||
}
|
||||
61
app/Liquid/Filters/StringMarkup.php
Normal file
61
app/Liquid/Filters/StringMarkup.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Exception\CommonMarkException;
|
||||
|
||||
/**
|
||||
* String, Markup, and HTML filters for Liquid templates
|
||||
*/
|
||||
class StringMarkup extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Pluralize a word based on count
|
||||
*
|
||||
* @param string $word The word to pluralize
|
||||
* @param int $count The count to determine pluralization
|
||||
* @return string The pluralized word with count
|
||||
*/
|
||||
public function pluralize(string $word, int $count = 2): string
|
||||
{
|
||||
if ($count === 1) {
|
||||
return "{$count} {$word}";
|
||||
}
|
||||
|
||||
return "{$count} ".Str::plural($word, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown to HTML
|
||||
*
|
||||
* @param string $markdown The markdown text to convert
|
||||
* @return string The HTML representation of the markdown
|
||||
*/
|
||||
public function markdown_to_html(string $markdown): ?string
|
||||
{
|
||||
$converter = new CommonMarkConverter();
|
||||
|
||||
try {
|
||||
return $converter->convert($markdown);
|
||||
} catch (CommonMarkException $e) {
|
||||
Log::error('Markdown conversion error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from a string
|
||||
*
|
||||
* @param string $html The HTML string to strip
|
||||
* @return string The string without HTML tags
|
||||
*/
|
||||
public function strip_html(string $html): string
|
||||
{
|
||||
return strip_tags($html);
|
||||
}
|
||||
}
|
||||
43
app/Liquid/Filters/Uniqueness.php
Normal file
43
app/Liquid/Filters/Uniqueness.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use Keepsuit\Liquid\Concerns\ContextAware;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Uniqueness filters for Liquid templates
|
||||
*/
|
||||
class Uniqueness extends FiltersProvider
|
||||
{
|
||||
use ContextAware;
|
||||
|
||||
/**
|
||||
* Append a random string to ensure uniqueness within a template
|
||||
*
|
||||
* @param string $prefix The prefix to append the random string to
|
||||
* @return string The prefix with a random string appended
|
||||
*/
|
||||
public function append_random(string $prefix): string
|
||||
{
|
||||
return $prefix.$this->generateRandomString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string
|
||||
*
|
||||
* @param int $length The length of the random string
|
||||
* @return string A random string
|
||||
*/
|
||||
private function generateRandomString(int $length = 4): string
|
||||
{
|
||||
$characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
$randomString = '';
|
||||
|
||||
for ($i = 0; $i < $length; ++$i) {
|
||||
$randomString .= $characters[random_int(0, mb_strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
return $randomString;
|
||||
}
|
||||
}
|
||||
100
app/Liquid/Tags/TemplateTag.php
Normal file
100
app/Liquid/Tags/TemplateTag.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Liquid\Tags;
|
||||
|
||||
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
||||
use Keepsuit\Liquid\Exceptions\SyntaxException;
|
||||
use Keepsuit\Liquid\Nodes\BodyNode;
|
||||
use Keepsuit\Liquid\Nodes\Raw;
|
||||
use Keepsuit\Liquid\Nodes\VariableLookup;
|
||||
use Keepsuit\Liquid\Parse\TagParseContext;
|
||||
use Keepsuit\Liquid\Render\RenderContext;
|
||||
use Keepsuit\Liquid\TagBlock;
|
||||
|
||||
/**
|
||||
* The {% template [name] %} tag block is used to define custom templates within the context of the current Liquid template.
|
||||
* These templates are registered with the InlineTemplatesFileSystem and can be rendered using the render tag.
|
||||
*/
|
||||
class TemplateTag extends TagBlock
|
||||
{
|
||||
protected string $templateName;
|
||||
|
||||
protected Raw $body;
|
||||
|
||||
public static function tagName(): string
|
||||
{
|
||||
return 'template';
|
||||
}
|
||||
|
||||
public static function hasRawBody(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function parse(TagParseContext $context): static
|
||||
{
|
||||
// Get the template name from the tag parameters
|
||||
$templateNameExpression = $context->params->expression();
|
||||
|
||||
$this->templateName = match (true) {
|
||||
is_string($templateNameExpression) => mb_trim($templateNameExpression),
|
||||
is_numeric($templateNameExpression) => (string) $templateNameExpression,
|
||||
$templateNameExpression instanceof VariableLookup => (string) $templateNameExpression,
|
||||
default => throw new SyntaxException('Template name must be a string, number, or variable'),
|
||||
};
|
||||
|
||||
// Validate template name (letters, numbers, underscores, and slashes only)
|
||||
if (! preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) {
|
||||
throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes");
|
||||
}
|
||||
|
||||
$context->params->assertEnd();
|
||||
|
||||
assert($context->body instanceof BodyNode);
|
||||
|
||||
$body = $context->body->children()[0] ?? null;
|
||||
$this->body = match (true) {
|
||||
$body instanceof Raw => $body,
|
||||
default => throw new SyntaxException('template tag must have a single raw body'),
|
||||
};
|
||||
|
||||
// Register the template with the file system during parsing
|
||||
$fileSystem = $context->getParseContext()->environment->fileSystem;
|
||||
if ($fileSystem instanceof InlineTemplatesFileSystem) {
|
||||
// Store the raw content for later rendering
|
||||
$fileSystem->register($this->templateName, $this->body->value);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function render(RenderContext $context): string
|
||||
{
|
||||
// Get the file system from the environment
|
||||
$fileSystem = $context->environment->fileSystem;
|
||||
|
||||
if (! $fileSystem instanceof InlineTemplatesFileSystem) {
|
||||
// If no inline file system is available, just return empty string
|
||||
// This allows the template to be used in contexts where inline templates aren't supported
|
||||
return '';
|
||||
}
|
||||
|
||||
// Register the template with the file system
|
||||
$fileSystem->register($this->templateName, $this->body->render($context));
|
||||
|
||||
// Return empty string as template tags don't output anything
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return $this->templateName;
|
||||
}
|
||||
|
||||
public function getBody(): Raw
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
}
|
||||
210
app/Liquid/Utils/ExpressionUtils.php
Normal file
210
app/Liquid/Utils/ExpressionUtils.php
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Utils;
|
||||
|
||||
/**
|
||||
* Utility class for parsing and evaluating expressions in Liquid filters
|
||||
*/
|
||||
class ExpressionUtils
|
||||
{
|
||||
/**
|
||||
* Check if an array is associative
|
||||
*/
|
||||
public static function isAssociativeArray(array $array): bool
|
||||
{
|
||||
if ($array === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_keys($array) !== range(0, count($array) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a condition expression into a structured format
|
||||
*/
|
||||
public static function parseCondition(string $expression): array
|
||||
{
|
||||
$expression = mb_trim($expression);
|
||||
|
||||
// Handle logical operators (and, or)
|
||||
if (str_contains($expression, ' and ')) {
|
||||
$parts = explode(' and ', $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'and',
|
||||
'left' => self::parseCondition(mb_trim($parts[0])),
|
||||
'right' => self::parseCondition(mb_trim($parts[1])),
|
||||
];
|
||||
}
|
||||
|
||||
if (str_contains($expression, ' or ')) {
|
||||
$parts = explode(' or ', $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'or',
|
||||
'left' => self::parseCondition(mb_trim($parts[0])),
|
||||
'right' => self::parseCondition(mb_trim($parts[1])),
|
||||
];
|
||||
}
|
||||
|
||||
// Handle comparison operators
|
||||
$operators = ['>=', '<=', '!=', '==', '>', '<', '='];
|
||||
|
||||
foreach ($operators as $operator) {
|
||||
if (str_contains($expression, $operator)) {
|
||||
$parts = explode($operator, $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'comparison',
|
||||
'left' => mb_trim($parts[0]),
|
||||
'operator' => $operator === '=' ? '==' : $operator,
|
||||
'right' => mb_trim($parts[1]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// If no operator found, treat as a simple expression
|
||||
return [
|
||||
'type' => 'simple',
|
||||
'expression' => $expression,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition against an object
|
||||
*/
|
||||
public static function evaluateCondition(array $condition, string $variable, mixed $object): bool
|
||||
{
|
||||
switch ($condition['type']) {
|
||||
case 'and':
|
||||
return self::evaluateCondition($condition['left'], $variable, $object) &&
|
||||
self::evaluateCondition($condition['right'], $variable, $object);
|
||||
|
||||
case 'or':
|
||||
if (self::evaluateCondition($condition['left'], $variable, $object)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::evaluateCondition($condition['right'], $variable, $object);
|
||||
|
||||
case 'comparison':
|
||||
$leftValue = self::resolveValue($condition['left'], $variable, $object);
|
||||
$rightValue = self::resolveValue($condition['right'], $variable, $object);
|
||||
|
||||
return match ($condition['operator']) {
|
||||
'==' => $leftValue === $rightValue,
|
||||
'!=' => $leftValue !== $rightValue,
|
||||
'>' => $leftValue > $rightValue,
|
||||
'<' => $leftValue < $rightValue,
|
||||
'>=' => $leftValue >= $rightValue,
|
||||
'<=' => $leftValue <= $rightValue,
|
||||
default => false,
|
||||
};
|
||||
|
||||
case 'simple':
|
||||
$value = self::resolveValue($condition['expression'], $variable, $object);
|
||||
|
||||
return (bool) $value;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a value from an expression, variable, or literal
|
||||
*/
|
||||
public static function resolveValue(string $expression, string $variable, mixed $object): mixed
|
||||
{
|
||||
$expression = mb_trim($expression);
|
||||
|
||||
// If it's the variable name, return the object
|
||||
if ($expression === $variable) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
// If it's a property access (e.g., "n.age"), resolve it
|
||||
if (str_starts_with($expression, $variable.'.')) {
|
||||
$property = mb_substr($expression, mb_strlen($variable) + 1);
|
||||
if (is_array($object) && array_key_exists($property, $object)) {
|
||||
return $object[$property];
|
||||
}
|
||||
if (is_object($object) && property_exists($object, $property)) {
|
||||
return $object->$property;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse as a number
|
||||
if (is_numeric($expression)) {
|
||||
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
|
||||
}
|
||||
|
||||
// Try to parse as boolean
|
||||
if (in_array(mb_strtolower($expression), ['true', 'false'])) {
|
||||
return mb_strtolower($expression) === 'true';
|
||||
}
|
||||
|
||||
// Try to parse as null
|
||||
if (mb_strtolower($expression) === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return as string (remove quotes if present)
|
||||
if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) ||
|
||||
(str_starts_with($expression, "'") && str_ends_with($expression, "'"))) {
|
||||
return mb_substr($expression, 1, -1);
|
||||
}
|
||||
|
||||
return $expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert strftime format string to PHP date format string
|
||||
*
|
||||
* @param string $strftimeFormat The strftime format string
|
||||
* @return string The PHP date format string
|
||||
*/
|
||||
public static function strftimeToPhpFormat(string $strftimeFormat): string
|
||||
{
|
||||
$conversions = [
|
||||
// Special Ruby format cases
|
||||
'%N' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
|
||||
'%u' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
|
||||
'%-m' => 'n', // Month without leading zero (Ruby) -> month without leading zero (PHP)
|
||||
'%-d' => 'j', // Day without leading zero (Ruby) -> day without leading zero (PHP)
|
||||
'%-H' => 'G', // Hour without leading zero (Ruby) -> hour without leading zero (PHP)
|
||||
'%-I' => 'g', // Hour 12h without leading zero (Ruby) -> hour 12h without leading zero (PHP)
|
||||
'%-M' => 'i', // Minute without leading zero (Ruby) -> minute without leading zero (PHP)
|
||||
'%-S' => 's', // Second without leading zero (Ruby) -> second without leading zero (PHP)
|
||||
'%z' => 'O', // Timezone offset (Ruby) -> timezone offset (PHP)
|
||||
'%Z' => 'T', // Timezone name (Ruby) -> timezone name (PHP)
|
||||
|
||||
// Standard strftime conversions
|
||||
'%A' => 'l', // Full weekday name
|
||||
'%a' => 'D', // Abbreviated weekday name
|
||||
'%B' => 'F', // Full month name
|
||||
'%b' => 'M', // Abbreviated month name
|
||||
'%Y' => 'Y', // Full year (4 digits)
|
||||
'%y' => 'y', // Year without century (2 digits)
|
||||
'%m' => 'm', // Month as decimal number (01-12)
|
||||
'%d' => 'd', // Day of month as decimal number (01-31)
|
||||
'%H' => 'H', // Hour in 24-hour format (00-23)
|
||||
'%I' => 'h', // Hour in 12-hour format (01-12)
|
||||
'%M' => 'i', // Minute as decimal number (00-59)
|
||||
'%S' => 's', // Second as decimal number (00-59)
|
||||
'%p' => 'A', // AM/PM
|
||||
'%P' => 'a', // am/pm
|
||||
'%j' => 'z', // Day of year as decimal number (001-366)
|
||||
'%w' => 'w', // Weekday as decimal number (0-6, Sunday is 0)
|
||||
'%U' => 'W', // Week number of year (00-53, Sunday is first day)
|
||||
'%W' => 'W', // Week number of year (00-53, Monday is first day)
|
||||
'%c' => 'D M j H:i:s Y', // Date and time representation
|
||||
'%x' => 'm/d/Y', // Date representation
|
||||
'%X' => 'H:i:s', // Time representation
|
||||
];
|
||||
|
||||
return str_replace(array_keys($conversions), array_values($conversions), $strftimeFormat);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,14 +10,14 @@ class DeviceAutoJoin extends Component
|
|||
|
||||
public bool $isFirstUser = false;
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->deviceAutojoin = auth()->user()->assign_new_devices;
|
||||
$this->isFirstUser = auth()->user()->id === 1;
|
||||
|
||||
}
|
||||
|
||||
public function updating($name, $value)
|
||||
public function updating($name, $value): void
|
||||
{
|
||||
$this->validate([
|
||||
'deviceAutojoin' => 'boolean',
|
||||
|
|
@ -30,7 +30,7 @@ class DeviceAutoJoin extends Component
|
|||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
|
||||
{
|
||||
return view('livewire.actions.device-auto-join');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class Logout
|
|||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke()
|
||||
public function __invoke(): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use Livewire\Component;
|
|||
|
||||
class DeviceDashboard extends Component
|
||||
{
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
|
||||
{
|
||||
return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,49 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* @property-read DeviceModel|null $deviceModel
|
||||
* @property-read DevicePalette|null $palette
|
||||
*/
|
||||
class Device extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Set the MAC address attribute, normalizing to uppercase.
|
||||
*/
|
||||
public function setMacAddressAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null;
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'battery_notification_sent' => 'boolean',
|
||||
'proxy_cloud' => 'boolean',
|
||||
'last_log_request' => 'json',
|
||||
'proxy_cloud_response' => 'json',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'rotate' => 'integer',
|
||||
'last_refreshed_at' => 'datetime',
|
||||
'sleep_mode_enabled' => 'boolean',
|
||||
'sleep_mode_from' => 'datetime:H:i',
|
||||
'sleep_mode_to' => 'datetime:H:i',
|
||||
'special_function' => 'string',
|
||||
'pause_until' => 'datetime',
|
||||
];
|
||||
|
||||
public function getBatteryPercentAttribute()
|
||||
public function getBatteryPercentAttribute(): int|float
|
||||
{
|
||||
$volts = $this->last_battery_voltage;
|
||||
|
||||
|
|
@ -27,7 +55,8 @@ class Device extends Model
|
|||
// Ensure the voltage is within range
|
||||
if ($volts <= $min_volt) {
|
||||
return 0;
|
||||
} elseif ($volts >= $max_volt) {
|
||||
}
|
||||
if ($volts >= $max_volt) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
|
|
@ -37,17 +66,221 @@ class Device extends Model
|
|||
return round($percent);
|
||||
}
|
||||
|
||||
public function getWifiStrenghAttribute()
|
||||
/**
|
||||
* Calculate battery voltage from percentage
|
||||
*
|
||||
* @param int $percent Battery percentage (0-100)
|
||||
* @return float Calculated voltage
|
||||
*/
|
||||
public function calculateVoltageFromPercent(int $percent): float
|
||||
{
|
||||
// Define min and max voltage for Li-ion battery (3.0V empty, 4.2V full)
|
||||
$min_volt = 3.0;
|
||||
$max_volt = 4.2;
|
||||
|
||||
// Ensure the percentage is within range
|
||||
if ($percent <= 0) {
|
||||
return $min_volt;
|
||||
}
|
||||
if ($percent >= 100) {
|
||||
return $max_volt;
|
||||
}
|
||||
|
||||
// Calculate voltage
|
||||
$voltage = $min_volt + (($percent / 100) * ($max_volt - $min_volt));
|
||||
|
||||
return round($voltage, 2);
|
||||
}
|
||||
|
||||
public function getWifiStrengthAttribute(): int
|
||||
{
|
||||
$rssi = $this->last_rssi_level;
|
||||
if ($rssi >= 0) {
|
||||
return 0; // No signal (0 bars)
|
||||
} elseif ($rssi <= -80) {
|
||||
}
|
||||
if ($rssi <= -80) {
|
||||
return 1; // Weak signal (1 bar)
|
||||
} elseif ($rssi <= -60) {
|
||||
}
|
||||
if ($rssi <= -60) {
|
||||
return 2; // Moderate signal (2 bars)
|
||||
} else {
|
||||
return 3; // Strong signal (3 bars)
|
||||
}
|
||||
|
||||
return 3; // Strong signal (3 bars)
|
||||
|
||||
}
|
||||
|
||||
public function getUpdateFirmwareAttribute(): bool
|
||||
{
|
||||
if ($this->update_firmware_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware'];
|
||||
}
|
||||
|
||||
public function getFirmwareUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->update_firmware_id) {
|
||||
$firmware = Firmware::find($this->update_firmware_id);
|
||||
if ($firmware) {
|
||||
if ($firmware->storage_location) {
|
||||
return Storage::disk('public')->url($firmware->storage_location);
|
||||
}
|
||||
|
||||
return $firmware->url;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->proxy_cloud_response && $this->proxy_cloud_response['firmware_url']) {
|
||||
return $this->proxy_cloud_response['firmware_url'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function resetUpdateFirmwareFlag(): void
|
||||
{
|
||||
if ($this->proxy_cloud_response) {
|
||||
$this->proxy_cloud_response = array_merge($this->proxy_cloud_response, ['update_firmware' => false]);
|
||||
$this->save();
|
||||
}
|
||||
if ($this->update_firmware_id) {
|
||||
$this->update_firmware_id = null;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function playlists(): HasMany
|
||||
{
|
||||
return $this->hasMany(Playlist::class);
|
||||
}
|
||||
|
||||
public function getNextPlaylistItem(): ?PlaylistItem
|
||||
{
|
||||
// Get all active playlists
|
||||
/** @var \Illuminate\Support\Collection|Playlist[] $playlists */
|
||||
$playlists = $this->playlists()
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Find the first active playlist with an available item
|
||||
foreach ($playlists as $playlist) {
|
||||
if ($playlist->isActiveNow()) {
|
||||
$nextItem = $playlist->getNextPlaylistItem();
|
||||
if ($nextItem) {
|
||||
return $nextItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function playlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Playlist::class);
|
||||
}
|
||||
|
||||
public function mirrorDevice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'mirror_device_id');
|
||||
}
|
||||
|
||||
public function updateFirmware(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Firmware::class, 'update_firmware_id');
|
||||
}
|
||||
|
||||
public function deviceModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeviceModel::class);
|
||||
}
|
||||
|
||||
public function palette(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color depth string (e.g., "4bit") for the associated device model.
|
||||
*/
|
||||
public function colorDepth(): ?string
|
||||
{
|
||||
return $this->deviceModel?->color_depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scale level (e.g., large/xlarge/xxlarge) for the associated device model.
|
||||
*/
|
||||
public function scaleLevel(): ?string
|
||||
{
|
||||
return $this->deviceModel?->scale_level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device variant name, defaulting to 'og' if not available.
|
||||
*/
|
||||
public function deviceVariant(): string
|
||||
{
|
||||
return $this->deviceModel->name ?? 'og';
|
||||
}
|
||||
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceLog::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isSleepModeActive(?DateTimeInterface $now = null): bool
|
||||
{
|
||||
if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
|
||||
|
||||
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
||||
return $this->sleep_mode_from < $this->sleep_mode_to
|
||||
? $now->between($this->sleep_mode_from, $this->sleep_mode_to)
|
||||
: ($now->gte($this->sleep_mode_from) || $now->lte($this->sleep_mode_to));
|
||||
}
|
||||
|
||||
public function getSleepModeEndsInSeconds(?DateTimeInterface $now = null): ?int
|
||||
{
|
||||
if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
|
||||
$from = $this->sleep_mode_from;
|
||||
$to = $this->sleep_mode_to;
|
||||
|
||||
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
||||
if ($from < $to) {
|
||||
// Normal range, same day
|
||||
return $now->between($from, $to) ? (int) $now->diffInSeconds($to, false) : null;
|
||||
}
|
||||
// Overnight range
|
||||
if ($now->gte($from)) {
|
||||
// After 'from', before midnight
|
||||
return (int) $now->diffInSeconds($to->copy()->addDay(), false);
|
||||
}
|
||||
if ($now->lt($to)) {
|
||||
// After midnight, before 'to'
|
||||
return (int) $now->diffInSeconds($to, false);
|
||||
}
|
||||
|
||||
// Not in sleep window
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
public function isPauseActive(): bool
|
||||
{
|
||||
return $this->pause_until && $this->pause_until->isFuture();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
app/Models/DeviceLog.php
Normal file
27
app/Models/DeviceLog.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DeviceLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'log_entry' => 'array',
|
||||
'device_timestamp' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Models/DeviceModel.php
Normal file
78
app/Models/DeviceModel.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property-read DevicePalette|null $palette
|
||||
*/
|
||||
final class DeviceModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'colors' => 'integer',
|
||||
'bit_depth' => 'integer',
|
||||
'scale_factor' => 'float',
|
||||
'rotation' => 'integer',
|
||||
'offset_x' => 'integer',
|
||||
'offset_y' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getColorDepthAttribute(): ?string
|
||||
{
|
||||
if (! $this->bit_depth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->bit_depth === 3) {
|
||||
return '2bit';
|
||||
}
|
||||
|
||||
// if higher than 4 return 4bit
|
||||
if ($this->bit_depth > 4) {
|
||||
return '4bit';
|
||||
}
|
||||
|
||||
return $this->bit_depth.'bit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scale level based on the device width.
|
||||
*/
|
||||
public function getScaleLevelAttribute(): ?string
|
||||
{
|
||||
if (! $this->width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->width > 800 && $this->width <= 1000) {
|
||||
return 'large';
|
||||
}
|
||||
|
||||
if ($this->width > 1000 && $this->width <= 1400) {
|
||||
return 'xlarge';
|
||||
}
|
||||
|
||||
if ($this->width > 1400) {
|
||||
return 'xxlarge';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function palette(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
||||
}
|
||||
}
|
||||
23
app/Models/DevicePalette.php
Normal file
23
app/Models/DevicePalette.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property array|null $colors
|
||||
*/
|
||||
final class DevicePalette extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'grays' => 'integer',
|
||||
'colors' => 'array',
|
||||
];
|
||||
}
|
||||
25
app/Models/Firmware.php
Normal file
25
app/Models/Firmware.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Firmware extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latest' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getLatest(): ?self
|
||||
{
|
||||
return self::where('latest', true)->first();
|
||||
}
|
||||
}
|
||||
115
app/Models/Playlist.php
Normal file
115
app/Models/Playlist.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Playlist extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'weekdays' => 'array',
|
||||
'active_from' => 'datetime:H:i',
|
||||
'active_until' => 'datetime:H:i',
|
||||
'refresh_time' => 'integer',
|
||||
];
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PlaylistItem::class);
|
||||
}
|
||||
|
||||
public function isActiveNow(): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get user's timezone or fall back to app timezone
|
||||
$timezone = $this->device->user->timezone ?? config('app.timezone');
|
||||
$now = now($timezone);
|
||||
|
||||
// Check weekday (using timezone-aware time)
|
||||
if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->active_from !== null && $this->active_until !== null) {
|
||||
// Create timezone-aware datetime objects for active_from and active_until
|
||||
$activeFrom = $now->copy()
|
||||
->setTimeFrom($this->active_from)
|
||||
->timezone($timezone);
|
||||
|
||||
$activeUntil = $now->copy()
|
||||
->setTimeFrom($this->active_until)
|
||||
->timezone($timezone);
|
||||
|
||||
// Handle time ranges that span across midnight
|
||||
if ($activeFrom > $activeUntil) {
|
||||
// Time range spans midnight (e.g., 09:01 to 03:58)
|
||||
if ($now >= $activeFrom || $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
} elseif ($now >= $activeFrom && $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getNextPlaylistItem(): ?PlaylistItem
|
||||
{
|
||||
if (! $this->isActiveNow()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get active playlist items ordered by display order
|
||||
/** @var \Illuminate\Support\Collection|PlaylistItem[] $playlistItems */
|
||||
$playlistItems = $this->items()
|
||||
->where('is_active', true)
|
||||
->orderBy('order')
|
||||
->get();
|
||||
|
||||
if ($playlistItems->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the last displayed item
|
||||
$lastDisplayed = $playlistItems
|
||||
->sortByDesc('last_displayed_at')
|
||||
->first();
|
||||
|
||||
if (! $lastDisplayed || ! $lastDisplayed->last_displayed_at) {
|
||||
// If no item has been displayed yet, return the first one
|
||||
return $playlistItems->first();
|
||||
}
|
||||
|
||||
// Find the next item in sequence
|
||||
$currentOrder = $lastDisplayed->order;
|
||||
$nextItem = $playlistItems
|
||||
->where('order', '>', $currentOrder)
|
||||
->first();
|
||||
|
||||
// If there's no next item, loop back to the first one
|
||||
if (! $nextItem) {
|
||||
return $playlistItems->first();
|
||||
}
|
||||
|
||||
return $nextItem;
|
||||
}
|
||||
}
|
||||
218
app/Models/PlaylistItem.php
Normal file
218
app/Models/PlaylistItem.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PlaylistItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'last_displayed_at' => 'datetime',
|
||||
'mashup' => 'json',
|
||||
];
|
||||
|
||||
public function playlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Playlist::class);
|
||||
}
|
||||
|
||||
public function plugin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Plugin::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this playlist item is a mashup
|
||||
*/
|
||||
public function isMashup(): bool
|
||||
{
|
||||
return ! is_null($this->mashup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mashup name if this is a mashup
|
||||
*/
|
||||
public function getMashupName(): ?string
|
||||
{
|
||||
return $this->mashup['mashup_name'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mashup layout type if this is a mashup
|
||||
*/
|
||||
public function getMashupLayoutType(): ?string
|
||||
{
|
||||
return $this->mashup['mashup_layout'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all plugin IDs for this mashup
|
||||
*/
|
||||
public function getMashupPluginIds(): array
|
||||
{
|
||||
return $this->mashup['plugin_ids'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of plugins required for the current layout
|
||||
*/
|
||||
public function getRequiredPluginCount(): int
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return match ($this->getMashupLayoutType()) {
|
||||
'1Lx1R', '1Tx1B' => 2, // Left-Right or Top-Bottom split
|
||||
'1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3, // Two on one side, one on other
|
||||
'2x2' => 4, // Quadrant
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the layout type (horizontal, vertical, or grid)
|
||||
*/
|
||||
public function getLayoutType(): string
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return 'single';
|
||||
}
|
||||
|
||||
return match ($this->getMashupLayoutType()) {
|
||||
'1Lx1R', '1Lx2R', '2Lx1R' => 'vertical',
|
||||
'1Tx1B', '2Tx1B', '1Tx2B' => 'horizontal',
|
||||
'2x2' => 'grid',
|
||||
default => 'single',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the layout size for a plugin based on its position
|
||||
*/
|
||||
public function getLayoutSize(int $position = 0): string
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
return match ($this->getMashupLayoutType()) {
|
||||
'1Lx1R' => 'half_vertical', // Both sides are single plugins
|
||||
'1Tx1B' => 'half_horizontal', // Both sides are single plugins
|
||||
'2Lx1R' => match ($position) {
|
||||
0, 1 => 'quadrant', // Left side has 2 plugins
|
||||
2 => 'half_vertical', // Right side has 1 plugin
|
||||
default => 'full'
|
||||
},
|
||||
'1Lx2R' => match ($position) {
|
||||
0 => 'half_vertical', // Left side has 1 plugin
|
||||
1, 2 => 'quadrant', // Right side has 2 plugins
|
||||
default => 'full'
|
||||
},
|
||||
'2Tx1B' => match ($position) {
|
||||
0, 1 => 'quadrant', // Top side has 2 plugins
|
||||
2 => 'half_horizontal', // Bottom side has 1 plugin
|
||||
default => 'full'
|
||||
},
|
||||
'1Tx2B' => match ($position) {
|
||||
0 => 'half_horizontal', // Top side has 1 plugin
|
||||
1, 2 => 'quadrant', // Bottom side has 2 plugins
|
||||
default => 'full'
|
||||
},
|
||||
'2x2' => 'quadrant', // All positions are quadrants
|
||||
default => 'full'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all plugins with appropriate layout
|
||||
*/
|
||||
public function render(?Device $device = null): string
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $this->plugin instanceof Plugin
|
||||
? $this->plugin->render('full', false)
|
||||
: throw new Exception('Invalid plugin instance'),
|
||||
])->render();
|
||||
}
|
||||
|
||||
$pluginMarkups = [];
|
||||
$pluginIds = $this->getMashupPluginIds();
|
||||
$plugins = Plugin::whereIn('id', $pluginIds)->get();
|
||||
|
||||
// Sort the collection to match plugin_ids order
|
||||
$plugins = $plugins->sortBy(fn ($plugin): int|string|false => array_search($plugin->id, $pluginIds))->values();
|
||||
|
||||
foreach ($plugins as $index => $plugin) {
|
||||
$size = $this->getLayoutSize($index);
|
||||
$pluginMarkups[] = $plugin->render($size, false);
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'mashupLayout' => $this->getMashupLayoutType(),
|
||||
'slot' => implode('', $pluginMarkups),
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Available mashup layouts with their descriptions
|
||||
*/
|
||||
public static function getAvailableLayouts(): array
|
||||
{
|
||||
return [
|
||||
'1Lx1R' => '1 Left - 1 Right (2 plugins)',
|
||||
'1Lx2R' => '1 Left - 2 Right (3 plugins)',
|
||||
'2Lx1R' => '2 Left - 1 Right (3 plugins)',
|
||||
'1Tx1B' => '1 Top - 1 Bottom (2 plugins)',
|
||||
'2Tx1B' => '2 Top - 1 Bottom (3 plugins)',
|
||||
'1Tx2B' => '1 Top - 2 Bottom (3 plugins)',
|
||||
'2x2' => 'Quadrant (4 plugins)',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required number of plugins for a given layout
|
||||
*/
|
||||
public static function getRequiredPluginCountForLayout(string $layout): int
|
||||
{
|
||||
return match ($layout) {
|
||||
'1Lx1R', '1Tx1B' => 2,
|
||||
'1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3,
|
||||
'2x2' => 4,
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new mashup with the given layout and plugins
|
||||
*/
|
||||
public static function createMashup(Playlist $playlist, string $layout, array $pluginIds, string $name, $order): self
|
||||
{
|
||||
return static::create([
|
||||
'playlist_id' => $playlist->id,
|
||||
'plugin_id' => $pluginIds[0], // First plugin is the main plugin
|
||||
'mashup' => [
|
||||
'mashup_layout' => $layout,
|
||||
'mashup_name' => $name,
|
||||
'plugin_ids' => $pluginIds,
|
||||
],
|
||||
'is_active' => true,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
642
app/Models/Plugin.php
Normal file
642
app/Models/Plugin.php
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
||||
use App\Liquid\Filters\Data;
|
||||
use App\Liquid\Filters\Date;
|
||||
use App\Liquid\Filters\Localization;
|
||||
use App\Liquid\Filters\Numbers;
|
||||
use App\Liquid\Filters\StandardFilters;
|
||||
use App\Liquid\Filters\StringMarkup;
|
||||
use App\Liquid\Filters\Uniqueness;
|
||||
use App\Liquid\Tags\TemplateTag;
|
||||
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||
use App\Services\PluginImportService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||
|
||||
class Plugin extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'data_payload' => 'json',
|
||||
'data_payload_updated_at' => 'datetime',
|
||||
'is_native' => 'boolean',
|
||||
'markup_language' => 'string',
|
||||
'configuration' => 'json',
|
||||
'configuration_template' => 'json',
|
||||
'no_bleed' => 'boolean',
|
||||
'dark_mode' => 'boolean',
|
||||
'preferred_renderer' => 'string',
|
||||
'plugin_type' => 'string',
|
||||
'alias' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model): void {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = Str::uuid();
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($model): void {
|
||||
// Reset image cache when markup changes
|
||||
if ($model->isDirty('render_markup')) {
|
||||
$model->current_image = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize configuration template on save
|
||||
static::saving(function ($model): void {
|
||||
$model->sanitizeTemplate();
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
|
||||
protected function sanitizeTemplate(): void
|
||||
{
|
||||
$template = $this->configuration_template;
|
||||
|
||||
if (isset($template['custom_fields']) && is_array($template['custom_fields'])) {
|
||||
foreach ($template['custom_fields'] as &$field) {
|
||||
if (isset($field['description'])) {
|
||||
$field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']);
|
||||
}
|
||||
if (isset($field['help_text'])) {
|
||||
$field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->configuration_template = $template;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasMissingRequiredConfigurationFields(): bool
|
||||
{
|
||||
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->configuration_template['custom_fields'] as $field) {
|
||||
// Skip fields as they are informational only
|
||||
if ($field['field_type'] === 'author_bio') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field['field_type'] === 'copyable') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field['field_type'] === 'copyable_webhook_url') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
|
||||
|
||||
// Check if field is required (not marked as optional)
|
||||
$isRequired = ! isset($field['optional']) || $field['optional'] !== true;
|
||||
|
||||
if ($isRequired) {
|
||||
$currentValue = $this->configuration[$fieldKey] ?? null;
|
||||
|
||||
// If the field has a default value and no current value is set, it's not missing
|
||||
if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) {
|
||||
return true; // Found a required field that is not set and has no default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // All required fields are set
|
||||
}
|
||||
|
||||
public function isDataStale(): bool
|
||||
{
|
||||
// Image webhook plugins don't use data staleness - images are pushed directly
|
||||
if ($this->plugin_type === 'image_webhook') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->data_strategy === 'webhook') {
|
||||
// Treat as stale if any webhook event has occurred in the past hour
|
||||
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
||||
}
|
||||
if (! $this->data_payload_updated_at || ! $this->data_stale_minutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->data_payload_updated_at->addMinutes($this->data_stale_minutes)->isPast();
|
||||
}
|
||||
|
||||
public function updateDataPayload(): void
|
||||
{
|
||||
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
||||
return;
|
||||
}
|
||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||
|
||||
// resolve headers
|
||||
if ($this->polling_header) {
|
||||
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
||||
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
||||
foreach ($headerLines as $line) {
|
||||
$parts = explode(':', $line, 2);
|
||||
if (count($parts) === 2) {
|
||||
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolve and clean URLs
|
||||
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
||||
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
|
||||
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
||||
fn ($url): bool => filled($url)
|
||||
));
|
||||
|
||||
$combinedResponse = [];
|
||||
|
||||
// Loop through all URLs (Handles 1 or many)
|
||||
foreach ($urls as $index => $url) {
|
||||
$httpRequest = Http::withHeaders($headers);
|
||||
|
||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||
}
|
||||
|
||||
try {
|
||||
$httpResponse = ($this->polling_verb === 'post')
|
||||
? $httpRequest->post($url)
|
||||
: $httpRequest->get($url);
|
||||
|
||||
$response = $this->parseResponse($httpResponse);
|
||||
|
||||
// Nest if it's a sequential array
|
||||
if (array_keys($response) === range(0, count($response) - 1)) {
|
||||
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
||||
} else {
|
||||
$combinedResponse["IDX_{$index}"] = $response;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
|
||||
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
||||
}
|
||||
}
|
||||
|
||||
// unwrap IDX_0 if only one URL
|
||||
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
|
||||
|
||||
$this->update([
|
||||
'data_payload' => $finalPayload,
|
||||
'data_payload_updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseResponse(Response $httpResponse): array
|
||||
{
|
||||
$parsers = app(ResponseParserRegistry::class)->getParsers();
|
||||
|
||||
foreach ($parsers as $parser) {
|
||||
$parserName = class_basename($parser);
|
||||
|
||||
try {
|
||||
$result = $parser->parse($httpResponse);
|
||||
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ['error' => 'Failed to parse response'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Liquid template replacements (converts 'with' syntax to comma syntax)
|
||||
*/
|
||||
private function applyLiquidReplacements(string $template): string
|
||||
{
|
||||
|
||||
$replacements = [];
|
||||
|
||||
// Apply basic replacements
|
||||
$template = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
|
||||
// Convert Ruby/strftime date formats to PHP date formats
|
||||
$template = $this->convertDateFormats($template);
|
||||
|
||||
// Convert {% render "template" with %} syntax to {% render "template", %} syntax
|
||||
$template = preg_replace(
|
||||
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
|
||||
'{% render $1, ',
|
||||
$template
|
||||
);
|
||||
|
||||
// Convert for loops with filters to use temporary variables
|
||||
// This handles: {% for item in collection | filter: "key", "value" %}
|
||||
// Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %}
|
||||
$template = preg_replace_callback(
|
||||
'/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/',
|
||||
function (array $matches): string {
|
||||
$variableName = mb_trim($matches[1]);
|
||||
$collection = mb_trim($matches[2]);
|
||||
$filter = mb_trim($matches[3]);
|
||||
$tempVarName = '_temp_'.uniqid();
|
||||
|
||||
return "{% assign {$tempVarName} = {$collection} | {$filter} %}{% for {$variableName} in {$tempVarName} %}";
|
||||
},
|
||||
(string) $template
|
||||
);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Ruby/strftime date formats to PHP date formats in Liquid templates
|
||||
*/
|
||||
private function convertDateFormats(string $template): string
|
||||
{
|
||||
// Handle date filter formats: date: "format" or date: 'format'
|
||||
$template = preg_replace_callback(
|
||||
'/date:\s*(["\'])([^"\']+)\1/',
|
||||
function (array $matches): string {
|
||||
$quote = $matches[1];
|
||||
$format = $matches[2];
|
||||
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
||||
|
||||
return 'date: '.$quote.$convertedFormat.$quote;
|
||||
},
|
||||
$template
|
||||
);
|
||||
|
||||
// Handle l_date filter formats: l_date: "format" or l_date: 'format'
|
||||
$template = preg_replace_callback(
|
||||
'/l_date:\s*(["\'])([^"\']+)\1/',
|
||||
function (array $matches): string {
|
||||
$quote = $matches[1];
|
||||
$format = $matches[2];
|
||||
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
||||
|
||||
return 'l_date: '.$quote.$convertedFormat.$quote;
|
||||
},
|
||||
(string) $template
|
||||
);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template contains a Liquid for loop pattern
|
||||
*
|
||||
* @param string $template The template string to check
|
||||
* @return bool True if the template contains a for loop pattern
|
||||
*/
|
||||
private function containsLiquidForLoop(string $template): bool
|
||||
{
|
||||
return preg_match('/{%-?\s*for\s+/i', $template) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Liquid variables in a template string using the Liquid template engine
|
||||
*
|
||||
* Uses the external trmnl-liquid renderer when:
|
||||
* - preferred_renderer is 'trmnl-liquid'
|
||||
* - External renderer is enabled in config
|
||||
* - Template contains a Liquid for loop pattern
|
||||
*
|
||||
* Otherwise uses the internal PHP-based Liquid renderer.
|
||||
*
|
||||
* @param string $template The template string containing Liquid variables
|
||||
* @return string The resolved template with variables replaced with their values
|
||||
*
|
||||
* @throws LiquidException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resolveLiquidVariables(string $template): string
|
||||
{
|
||||
// Get configuration variables - make them available at root level
|
||||
$variables = $this->configuration ?? [];
|
||||
|
||||
// Check if external renderer should be used
|
||||
$useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid'
|
||||
&& config('services.trmnl.liquid_enabled')
|
||||
&& $this->containsLiquidForLoop($template);
|
||||
|
||||
if ($useExternalRenderer) {
|
||||
// Use external Ruby liquid renderer
|
||||
return $this->renderWithExternalLiquidRenderer($template, $variables);
|
||||
}
|
||||
|
||||
// Use the Liquid template engine to resolve variables
|
||||
$environment = App::make('liquid.environment');
|
||||
$environment->filterRegistry->register(StandardFilters::class);
|
||||
$liquidTemplate = $environment->parseString($template);
|
||||
$context = $environment->newRenderContext(data: $variables);
|
||||
|
||||
return $liquidTemplate->render($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render template using external Ruby liquid renderer
|
||||
*
|
||||
* @param string $template The liquid template string
|
||||
* @param array $context The render context data
|
||||
* @return string The rendered HTML
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function renderWithExternalLiquidRenderer(string $template, array $context): string
|
||||
{
|
||||
$liquidPath = config('services.trmnl.liquid_path');
|
||||
|
||||
if (empty($liquidPath)) {
|
||||
throw new Exception('External liquid renderer path is not configured');
|
||||
}
|
||||
|
||||
// HTML encode the template
|
||||
$encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Encode context as JSON
|
||||
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($jsonContext === false) {
|
||||
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg());
|
||||
}
|
||||
|
||||
// Validate argument sizes
|
||||
app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath);
|
||||
|
||||
// Execute the external renderer
|
||||
$process = Process::run([
|
||||
$liquidPath,
|
||||
'--template',
|
||||
$encodedTemplate,
|
||||
'--context',
|
||||
$jsonContext,
|
||||
]);
|
||||
|
||||
if (! $process->successful()) {
|
||||
$errorOutput = $process->errorOutput() ?: $process->output();
|
||||
throw new Exception('External liquid renderer failed: '.$errorOutput);
|
||||
}
|
||||
|
||||
return $process->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the plugin's markup
|
||||
*
|
||||
* @throws LiquidException
|
||||
*/
|
||||
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
|
||||
{
|
||||
if ($this->plugin_type !== 'recipe') {
|
||||
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
|
||||
}
|
||||
|
||||
if ($this->render_markup) {
|
||||
$renderedContent = '';
|
||||
|
||||
if ($this->markup_language === 'liquid') {
|
||||
// Get timezone from user or fall back to app timezone
|
||||
$timezone = $this->user->timezone ?? config('app.timezone');
|
||||
|
||||
// Calculate UTC offset in seconds
|
||||
$utcOffset = (string) Carbon::now($timezone)->getOffset();
|
||||
|
||||
// Build render context
|
||||
$context = [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
...(is_array($this->data_payload) ? $this->data_payload : []),
|
||||
'trmnl' => [
|
||||
'system' => [
|
||||
'timestamp_utc' => now()->utc()->timestamp,
|
||||
],
|
||||
'user' => [
|
||||
'utc_offset' => $utcOffset,
|
||||
'name' => $this->user->name ?? 'Unknown User',
|
||||
'locale' => 'en',
|
||||
'time_zone_iana' => $timezone,
|
||||
],
|
||||
'plugin_settings' => [
|
||||
'instance_name' => $this->name,
|
||||
'strategy' => $this->data_strategy,
|
||||
'dark_mode' => $this->dark_mode ? 'yes' : 'no',
|
||||
'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
|
||||
'polling_headers' => $this->polling_header,
|
||||
'polling_url' => $this->polling_url,
|
||||
'custom_fields_values' => [
|
||||
...(is_array($this->configuration) ? $this->configuration : []),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Check if external renderer should be used
|
||||
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
||||
// Use external Ruby renderer - pass raw template without preprocessing
|
||||
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
|
||||
} else {
|
||||
// Use PHP keepsuit/liquid renderer
|
||||
// Create a custom environment with inline templates support
|
||||
$inlineFileSystem = new InlineTemplatesFileSystem();
|
||||
$environment = new \Keepsuit\Liquid\Environment(
|
||||
fileSystem: $inlineFileSystem,
|
||||
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
|
||||
);
|
||||
|
||||
// Register all custom filters
|
||||
$environment->filterRegistry->register(Data::class);
|
||||
$environment->filterRegistry->register(Date::class);
|
||||
$environment->filterRegistry->register(Localization::class);
|
||||
$environment->filterRegistry->register(Numbers::class);
|
||||
$environment->filterRegistry->register(StringMarkup::class);
|
||||
$environment->filterRegistry->register(Uniqueness::class);
|
||||
|
||||
// Register the template tag for inline templates
|
||||
$environment->tagRegistry->register(TemplateTag::class);
|
||||
|
||||
// Apply Liquid replacements (including 'with' syntax conversion)
|
||||
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
||||
|
||||
$template = $environment->parseString($processedMarkup);
|
||||
$liquidContext = $environment->newRenderContext(data: $context);
|
||||
$renderedContent = $template->render($liquidContext);
|
||||
}
|
||||
} else {
|
||||
$renderedContent = Blade::render($this->render_markup, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($standalone) {
|
||||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
|
||||
}
|
||||
|
||||
return $renderedContent;
|
||||
}
|
||||
|
||||
if ($this->render_markup_view) {
|
||||
if ($standalone) {
|
||||
$renderedView = view($this->render_markup_view, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
])->render();
|
||||
|
||||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view($this->render_markup_view, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
])->render();
|
||||
|
||||
}
|
||||
|
||||
return '<p>No render markup yet defined for this plugin.</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value by key
|
||||
*/
|
||||
public function getConfiguration(string $key, $default = null)
|
||||
{
|
||||
return $this->configuration[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getPreviewMashupLayoutForSize(string $size): string
|
||||
{
|
||||
return match ($size) {
|
||||
'half_vertical' => '1Lx1R',
|
||||
'quadrant' => '2x2',
|
||||
default => '1Tx1B',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the plugin, copying all attributes and handling render_markup_view
|
||||
*
|
||||
* @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id.
|
||||
* @return Plugin The newly created duplicate plugin
|
||||
*/
|
||||
public function duplicate(?int $userId = null): self
|
||||
{
|
||||
// Get all attributes except id and uuid
|
||||
// Use toArray() to get cast values (respects JSON casts)
|
||||
$attributes = $this->toArray();
|
||||
unset($attributes['id'], $attributes['uuid']);
|
||||
|
||||
// Handle render_markup_view - copy file content to render_markup
|
||||
if ($this->render_markup_view) {
|
||||
try {
|
||||
$basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view));
|
||||
$paths = [
|
||||
$basePath.'.blade.php',
|
||||
$basePath.'.liquid',
|
||||
];
|
||||
|
||||
$fileContent = null;
|
||||
$markupLanguage = null;
|
||||
foreach ($paths as $path) {
|
||||
if (file_exists($path)) {
|
||||
$fileContent = file_get_contents($path);
|
||||
// Determine markup language based on file extension
|
||||
$markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileContent !== null) {
|
||||
$attributes['render_markup'] = $fileContent;
|
||||
$attributes['markup_language'] = $markupLanguage;
|
||||
$attributes['render_markup_view'] = null;
|
||||
} else {
|
||||
// File doesn't exist, remove the view reference
|
||||
$attributes['render_markup_view'] = null;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// If file reading fails, remove the view reference
|
||||
$attributes['render_markup_view'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Append " (Copy)" to the name
|
||||
$attributes['name'] = $this->name.' (Copy)';
|
||||
|
||||
// Set user_id - use provided userId or fall back to original plugin's user_id
|
||||
$attributes['user_id'] = $userId ?? $this->user_id;
|
||||
|
||||
// Create and return the new plugin
|
||||
return self::create($attributes);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,9 @@ class User extends Authenticatable // implements MustVerifyEmail
|
|||
'email',
|
||||
'password',
|
||||
'assign_new_devices',
|
||||
'assign_new_device_id',
|
||||
'oidc_sub',
|
||||
'timezone',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -66,4 +69,14 @@ class User extends Authenticatable // implements MustVerifyEmail
|
|||
{
|
||||
return $this->hasMany(Device::class);
|
||||
}
|
||||
|
||||
public function plugins(): HasMany
|
||||
{
|
||||
return $this->hasMany(Plugin::class);
|
||||
}
|
||||
|
||||
public function routeNotificationForWebhook(): ?string
|
||||
{
|
||||
return config('services.webhook.notifications.url');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
app/Notifications/BatteryLow.php
Normal file
66
app/Notifications/BatteryLow.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Notifications\Channels\WebhookChannel;
|
||||
use App\Notifications\Messages\WebhookMessage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BatteryLow extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(private Device $device) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail', WebhookChannel::class];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]);
|
||||
}
|
||||
|
||||
public function toWebhook(object $notifiable): WebhookMessage
|
||||
{
|
||||
return WebhookMessage::create()
|
||||
->data([
|
||||
'topic' => config('services.webhook.notifications.topic', 'battery.low'),
|
||||
'message' => "Battery below {$this->device->battery_percent}% on device: {$this->device->name}",
|
||||
'device_id' => $this->device->id,
|
||||
'device_name' => $this->device->name,
|
||||
'battery_percent' => $this->device->battery_percent,
|
||||
|
||||
])
|
||||
->userAgent(config('app.name'))
|
||||
->header('X-TrmnlByos-Event', 'battery.low');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'device_name' => $this->device->name,
|
||||
'battery_percent' => $this->device->battery_percent,
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Notifications/Channels/WebhookChannel.php
Normal file
54
app/Notifications/Channels/WebhookChannel.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class WebhookChannel extends Notification
|
||||
{
|
||||
public function __construct(protected Client $client) {}
|
||||
|
||||
/**
|
||||
* Send the given notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function send($notifiable, Notification $notification): ?Response
|
||||
{
|
||||
$url = $notifiable->routeNotificationFor('webhook', $notification);
|
||||
|
||||
if (! $url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! method_exists($notification, 'toWebhook')) {
|
||||
throw new Exception('Notification does not implement toWebhook method.');
|
||||
}
|
||||
|
||||
$webhookData = $notification->toWebhook($notifiable)->toArray();
|
||||
$response = $this->client->post($url, [
|
||||
'query' => Arr::get($webhookData, 'query'),
|
||||
'body' => json_encode(Arr::get($webhookData, 'data')),
|
||||
'verify' => Arr::get($webhookData, 'verify'),
|
||||
'headers' => Arr::get($webhookData, 'headers'),
|
||||
]);
|
||||
|
||||
if (! $response instanceof Response) {
|
||||
throw new Exception('Webhook request did not return a valid GuzzleHttp\Psr7\Response.');
|
||||
}
|
||||
|
||||
if ($response->getStatusCode() >= 300 || $response->getStatusCode() < 200) {
|
||||
throw new Exception('Webhook request failed with status code: '.$response->getStatusCode());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
122
app/Notifications/Messages/WebhookMessage.php
Normal file
122
app/Notifications/Messages/WebhookMessage.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Messages;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
final class WebhookMessage extends Notification
|
||||
{
|
||||
/**
|
||||
* The GET parameters of the request.
|
||||
*
|
||||
* @var array|string|null
|
||||
*/
|
||||
private $query;
|
||||
|
||||
/**
|
||||
* The headers to send with the request.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private $headers;
|
||||
|
||||
/**
|
||||
* The Guzzle verify option.
|
||||
*
|
||||
* @var bool|string
|
||||
*/
|
||||
private $verify = false;
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
public static function create($data = ''): self
|
||||
{
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
public function __construct(
|
||||
/**
|
||||
* The POST data of the Webhook request.
|
||||
*/
|
||||
private $data = ''
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set the Webhook parameters to be URL encoded.
|
||||
*
|
||||
* @param mixed $query
|
||||
* @return $this
|
||||
*/
|
||||
public function query($query): self
|
||||
{
|
||||
$this->query = $query;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Webhook data to be JSON encoded.
|
||||
*
|
||||
* @param mixed $data
|
||||
* @return $this
|
||||
*/
|
||||
public function data($data): self
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Webhook request custom header.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string $value
|
||||
* @return $this
|
||||
*/
|
||||
public function header($name, $value): self
|
||||
{
|
||||
$this->headers[$name] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Webhook request UserAgent.
|
||||
*
|
||||
* @param string $userAgent
|
||||
* @return $this
|
||||
*/
|
||||
public function userAgent($userAgent): self
|
||||
{
|
||||
$this->headers['User-Agent'] = $userAgent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the request should be verified.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function verify($value = true): self
|
||||
{
|
||||
$this->verify = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'query' => $this->query,
|
||||
'data' => $this->data,
|
||||
'headers' => $this->headers,
|
||||
'verify' => $this->verify,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\OidcProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -20,7 +24,33 @@ class AppServiceProvider extends ServiceProvider
|
|||
public function boot(): void
|
||||
{
|
||||
if (app()->isProduction() && config('app.force_https')) {
|
||||
\URL::forceScheme('https');
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) {
|
||||
$https = clone $this;
|
||||
$https->server->set('HTTPS', 'on');
|
||||
|
||||
$http = clone $this;
|
||||
$http->server->set('HTTPS', 'off');
|
||||
if (URL::hasValidSignature($https, $absolute, $ignoreQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return URL::hasValidSignature($http, $absolute, $ignoreQuery);
|
||||
});
|
||||
|
||||
// Register OIDC provider with Socialite
|
||||
Socialite::extend('oidc', function (\Illuminate\Contracts\Foundation\Application $app): OidcProvider {
|
||||
$config = $app->make('config')->get('services.oidc', []);
|
||||
|
||||
return new OidcProvider(
|
||||
$app->make(Request::class),
|
||||
$config['client_id'] ?? null,
|
||||
$config['client_secret'] ?? null,
|
||||
$config['redirect'] ?? null,
|
||||
$config['scopes'] ?? ['openid', 'profile', 'email']
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
541
app/Services/ImageGenerationService.php
Normal file
541
app/Services/ImageGenerationService.php
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ImageFormat;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\Plugin;
|
||||
use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
|
||||
use Bnussbau\TrmnlPipeline\Stages\ImageStage;
|
||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RuntimeException;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
use function config;
|
||||
use function file_exists;
|
||||
use function filesize;
|
||||
|
||||
class ImageGenerationService
|
||||
{
|
||||
public static function generateImage(string $markup, $deviceId): string
|
||||
{
|
||||
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
|
||||
$uuid = self::generateImageFromModel(
|
||||
markup: $markup,
|
||||
deviceModel: $device->deviceModel,
|
||||
user: $device->user,
|
||||
palette: $device->palette ?? $device->deviceModel?->palette,
|
||||
device: $device
|
||||
);
|
||||
|
||||
$device->update(['current_screen_image' => $uuid]);
|
||||
Log::info("Device $device->id: updated with new image: $uuid");
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image from markup using a DeviceModel
|
||||
*
|
||||
* @param string $markup The HTML markup to render
|
||||
* @param DeviceModel|null $deviceModel The device model to use for image generation
|
||||
* @param \App\Models\User|null $user Optional user for timezone settings
|
||||
* @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
|
||||
* @param Device|null $device Optional device for legacy devices without DeviceModel
|
||||
* @return string The UUID of the generated image
|
||||
*/
|
||||
public static function generateImageFromModel(
|
||||
string $markup,
|
||||
?DeviceModel $deviceModel = null,
|
||||
?\App\Models\User $user = null,
|
||||
?\App\Models\DevicePalette $palette = null,
|
||||
?Device $device = null
|
||||
): string {
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
|
||||
try {
|
||||
// Get image generation settings from DeviceModel or Device (for legacy devices)
|
||||
$imageSettings = $deviceModel
|
||||
? self::getImageSettingsFromModel($deviceModel)
|
||||
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
|
||||
|
||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($markup);
|
||||
|
||||
// Set timezone from user or fall back to app timezone
|
||||
$timezone = $user?->timezone ?? config('app.timezone');
|
||||
$browserStage->timezone($timezone);
|
||||
|
||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||
$browserStage
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height']);
|
||||
} else {
|
||||
// default behaviour for Framework v1
|
||||
$browserStage->useDefaultDimensions();
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
// Get palette from parameter or fallback to device model's default palette
|
||||
$colorPalette = null;
|
||||
if ($palette && $palette->colors) {
|
||||
$colorPalette = $palette->colors;
|
||||
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
|
||||
$colorPalette = $deviceModel->palette->colors;
|
||||
}
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($fileExtension)
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height'])
|
||||
->colors($imageSettings['colors'])
|
||||
->bitDepth($imageSettings['bit_depth'])
|
||||
->rotation($imageSettings['rotation'])
|
||||
->offsetX($imageSettings['offset_x'])
|
||||
->offsetY($imageSettings['offset_y'])
|
||||
->outputPath($outputPath);
|
||||
|
||||
// Apply color palette if available
|
||||
if ($colorPalette) {
|
||||
$imageStage->colormap($colorPalette);
|
||||
}
|
||||
|
||||
// Apply dithering if requested by markup
|
||||
$shouldDither = self::markupContainsDitherImage($markup);
|
||||
if ($shouldDither) {
|
||||
$imageStage->dither();
|
||||
}
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
Log::info("Generated image: $uuid");
|
||||
|
||||
return $uuid;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate image: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate image: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
*/
|
||||
private static function getImageSettings(Device $device): array
|
||||
{
|
||||
// If device has a DeviceModel, use its settings
|
||||
if ($device->deviceModel) {
|
||||
return self::getImageSettingsFromModel($device->deviceModel);
|
||||
}
|
||||
|
||||
// Fallback to device settings
|
||||
$imageFormat = $device->image_format ?? ImageFormat::AUTO->value;
|
||||
$mimeType = self::getMimeTypeFromImageFormat($imageFormat);
|
||||
$colors = self::getColorsFromImageFormat($imageFormat);
|
||||
$bitDepth = self::getBitDepthFromImageFormat($imageFormat);
|
||||
|
||||
return [
|
||||
'width' => $device->width ?? 800,
|
||||
'height' => $device->height ?? 480,
|
||||
'colors' => $colors,
|
||||
'bit_depth' => $bitDepth,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => $device->rotate ?? 0,
|
||||
'mime_type' => $mimeType,
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'image_format' => $imageFormat,
|
||||
'use_model_settings' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image generation settings from a DeviceModel
|
||||
*/
|
||||
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
|
||||
{
|
||||
if ($deviceModel) {
|
||||
return [
|
||||
'width' => $deviceModel->width,
|
||||
'height' => $deviceModel->height,
|
||||
'colors' => $deviceModel->colors,
|
||||
'bit_depth' => $deviceModel->bit_depth,
|
||||
'scale_factor' => $deviceModel->scale_factor,
|
||||
'rotation' => $deviceModel->rotation,
|
||||
'mime_type' => $deviceModel->mime_type,
|
||||
'offset_x' => $deviceModel->offset_x,
|
||||
'offset_y' => $deviceModel->offset_y,
|
||||
'image_format' => self::determineImageFormatFromModel($deviceModel),
|
||||
'use_model_settings' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Default settings if no device model provided
|
||||
return [
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'image_format' => ImageFormat::AUTO->value,
|
||||
'use_model_settings' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate ImageFormat based on DeviceModel settings
|
||||
*/
|
||||
private static function determineImageFormatFromModel(DeviceModel $model): string
|
||||
{
|
||||
// Map DeviceModel settings to ImageFormat
|
||||
if ($model->mime_type === 'image/bmp' && $model->bit_depth === 1) {
|
||||
return ImageFormat::BMP3_1BIT_SRGB->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 2) {
|
||||
return ImageFormat::PNG_8BIT_GRAYSCALE->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 256) {
|
||||
return ImageFormat::PNG_8BIT_256C->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 2 && $model->colors === 4) {
|
||||
return ImageFormat::PNG_2BIT_4C->value;
|
||||
}
|
||||
|
||||
// Default to AUTO for unknown combinations
|
||||
return ImageFormat::AUTO->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from ImageFormat
|
||||
*/
|
||||
private static function getMimeTypeFromImageFormat(string $imageFormat): string
|
||||
{
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value => 'image/bmp',
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value,
|
||||
ImageFormat::PNG_8BIT_256C->value,
|
||||
ImageFormat::PNG_2BIT_4C->value => 'image/png',
|
||||
ImageFormat::AUTO->value => 'image/png', // Default for AUTO
|
||||
default => 'image/png',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get colors from ImageFormat
|
||||
*/
|
||||
private static function getColorsFromImageFormat(string $imageFormat): int
|
||||
{
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value => 2,
|
||||
ImageFormat::PNG_8BIT_256C->value => 256,
|
||||
ImageFormat::PNG_2BIT_4C->value => 4,
|
||||
ImageFormat::AUTO->value => 2, // Default for AUTO
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bit depth from ImageFormat
|
||||
*/
|
||||
private static function getBitDepthFromImageFormat(string $imageFormat): int
|
||||
{
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value => 1,
|
||||
ImageFormat::PNG_8BIT_256C->value => 8,
|
||||
ImageFormat::PNG_2BIT_4C->value => 2,
|
||||
ImageFormat::AUTO->value => 1, // Default for AUTO
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the provided HTML markup contains an <img> tag with class "image-dither".
|
||||
*/
|
||||
private static function markupContainsDitherImage(string $markup): bool
|
||||
{
|
||||
if (mb_trim($markup) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find <img ... class="..."> (or with single quotes) and inspect class tokens
|
||||
$imgWithClassPattern = '/<img\b[^>]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i';
|
||||
if (! preg_match_all($imgWithClassPattern, $markup, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($matches[2] as $classValue) {
|
||||
// Look for class token 'image-dither' or 'image--dither'
|
||||
if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function cleanupFolder(): void
|
||||
{
|
||||
$activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
|
||||
$activePluginImageUuids = Plugin::pluck('current_image')->filter()->toArray();
|
||||
$activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids);
|
||||
|
||||
$files = Storage::disk('public')->files('/images/generated/');
|
||||
foreach ($files as $file) {
|
||||
if (basename($file) === '.gitignore') {
|
||||
continue;
|
||||
}
|
||||
// Get filename without path and extension
|
||||
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
|
||||
// If the UUID is not in use by any device, move it to archive
|
||||
if (! in_array($fileUuid, $activeImageUuids)) {
|
||||
Storage::disk('public')->delete($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||
{
|
||||
if ($plugin?->id) {
|
||||
// Image webhook plugins have finalized images that shouldn't be reset
|
||||
if ($plugin->plugin_type === 'image_webhook') {
|
||||
return;
|
||||
}
|
||||
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
||||
$hasCustomDimensions = Device::query()
|
||||
->where(function ($query): void {
|
||||
$query->where('width', '!=', 800)
|
||||
->orWhere('height', '!=', 480)
|
||||
->orWhere('rotate', '!=', 0);
|
||||
})
|
||||
->orWhereHas('deviceModel', function ($query): void {
|
||||
// Only allow caching if all device models have standard dimensions (800x480, rotation=0)
|
||||
$query->where(function ($subQuery): void {
|
||||
$subQuery->where('width', '!=', 800)
|
||||
->orWhere('height', '!=', 480)
|
||||
->orWhere('rotation', '!=', 0);
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($hasCustomDimensions) {
|
||||
// TODO cache image per device
|
||||
$plugin->update(['current_image' => null]);
|
||||
Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device-specific default image path for setup or sleep mode
|
||||
*/
|
||||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If device has a DeviceModel, try to find device-specific image
|
||||
if ($device->deviceModel) {
|
||||
$model = $device->deviceModel;
|
||||
$extension = $model->mime_type === 'image/bmp' ? 'bmp' : 'png';
|
||||
$filename = "{$model->width}_{$model->height}_{$model->bit_depth}_{$model->rotation}.{$extension}";
|
||||
$deviceSpecificPath = "images/default-screens/{$imageType}_{$filename}";
|
||||
|
||||
if (Storage::disk('public')->exists($deviceSpecificPath)) {
|
||||
return $deviceSpecificPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original hardcoded images
|
||||
$fallbackPath = "images/{$imageType}.bmp";
|
||||
if (Storage::disk('public')->exists($fallbackPath)) {
|
||||
return $fallbackPath;
|
||||
}
|
||||
|
||||
// Try PNG fallback
|
||||
$fallbackPathPng = "images/{$imageType}.png";
|
||||
if (Storage::disk('public')->exists($fallbackPathPng)) {
|
||||
return $fallbackPathPng;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a default screen image from Blade template
|
||||
*/
|
||||
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||
}
|
||||
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
|
||||
try {
|
||||
// Load device with relationships
|
||||
$device->load(['palette', 'deviceModel.palette', 'user']);
|
||||
|
||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
$imageSettings = self::getImageSettings($device);
|
||||
|
||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||
|
||||
// Generate HTML from Blade template
|
||||
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
||||
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($html);
|
||||
|
||||
// Set timezone from user or fall back to app timezone
|
||||
$timezone = $device->user->timezone ?? config('app.timezone');
|
||||
$browserStage->timezone($timezone);
|
||||
|
||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||
$browserStage
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height']);
|
||||
} else {
|
||||
$browserStage->useDefaultDimensions();
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
// Get palette from device or fallback to device model's default palette
|
||||
$palette = $device->palette ?? $device->deviceModel?->palette;
|
||||
$colorPalette = null;
|
||||
|
||||
if ($palette && $palette->colors) {
|
||||
$colorPalette = $palette->colors;
|
||||
}
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($fileExtension)
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height'])
|
||||
->colors($imageSettings['colors'])
|
||||
->bitDepth($imageSettings['bit_depth'])
|
||||
->rotation($imageSettings['rotation'])
|
||||
->offsetX($imageSettings['offset_x'])
|
||||
->offsetY($imageSettings['offset_y'])
|
||||
->outputPath($outputPath);
|
||||
|
||||
// Apply color palette if available
|
||||
if ($colorPalette) {
|
||||
$imageStage->colormap($colorPalette);
|
||||
}
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
Log::info("Device $device->id: generated default screen image: $uuid for type: $imageType");
|
||||
|
||||
return $uuid;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate default screen image: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate default screen image: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML from Blade template for default screens
|
||||
*/
|
||||
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Map image type to template name
|
||||
$templateName = match ($imageType) {
|
||||
'setup-logo' => 'default-screens.setup',
|
||||
'sleep' => 'default-screens.sleep',
|
||||
'error' => 'default-screens.error',
|
||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||
};
|
||||
|
||||
// Determine device properties from DeviceModel or device settings
|
||||
$deviceVariant = $device->deviceVariant();
|
||||
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
|
||||
$colorDepth = $device->colorDepth() ?? '1bit';
|
||||
$scaleLevel = $device->scaleLevel();
|
||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||
|
||||
// Build view data
|
||||
$viewData = [
|
||||
'noBleed' => false,
|
||||
'darkMode' => $darkMode,
|
||||
'deviceVariant' => $deviceVariant,
|
||||
'deviceOrientation' => $deviceOrientation,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
];
|
||||
|
||||
// Add plugin name for error screens
|
||||
if ($imageType === 'error' && $pluginName !== null) {
|
||||
$viewData['pluginName'] = $pluginName;
|
||||
}
|
||||
|
||||
// Render the Blade template
|
||||
return view($templateName, $viewData)->render();
|
||||
}
|
||||
}
|
||||
158
app/Services/OidcProvider.php
Normal file
158
app/Services/OidcProvider.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Laravel\Socialite\Two\ProviderInterface;
|
||||
use Laravel\Socialite\Two\User;
|
||||
|
||||
class OidcProvider extends AbstractProvider implements ProviderInterface
|
||||
{
|
||||
/**
|
||||
* The scopes being requested.
|
||||
*/
|
||||
protected $scopes = [];
|
||||
|
||||
/**
|
||||
* The separating character for the requested scopes.
|
||||
*/
|
||||
protected $scopeSeparator = ' ';
|
||||
|
||||
/**
|
||||
* The OIDC configuration.
|
||||
*/
|
||||
protected $oidcConfig;
|
||||
|
||||
/**
|
||||
* The base URL for OIDC endpoints.
|
||||
*/
|
||||
protected $baseUrl;
|
||||
|
||||
/**
|
||||
* Create a new provider instance.
|
||||
*/
|
||||
public function __construct(\Illuminate\Http\Request $request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = [])
|
||||
{
|
||||
parent::__construct($request, $clientId, $clientSecret, $redirectUrl, $guzzle);
|
||||
|
||||
$endpoint = config('services.oidc.endpoint');
|
||||
if (! $endpoint) {
|
||||
throw new Exception('OIDC endpoint is not configured. Please set OIDC_ENDPOINT environment variable.');
|
||||
}
|
||||
|
||||
// Handle both full well-known URL and base URL
|
||||
if (str_ends_with((string) $endpoint, '/.well-known/openid-configuration')) {
|
||||
$this->baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint);
|
||||
} else {
|
||||
$this->baseUrl = mb_rtrim($endpoint, '/');
|
||||
}
|
||||
|
||||
$this->scopes = $scopes ?: ['openid', 'profile', 'email'];
|
||||
$this->loadOidcConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OIDC configuration from the well-known endpoint.
|
||||
*/
|
||||
protected function loadOidcConfiguration()
|
||||
{
|
||||
try {
|
||||
$url = $this->baseUrl.'/.well-known/openid-configuration';
|
||||
$client = app(Client::class);
|
||||
$response = $client->get($url);
|
||||
$this->oidcConfig = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (! $this->oidcConfig) {
|
||||
throw new Exception('OIDC configuration is empty or invalid JSON');
|
||||
}
|
||||
|
||||
if (! isset($this->oidcConfig['authorization_endpoint'])) {
|
||||
throw new Exception('authorization_endpoint not found in OIDC configuration');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to load OIDC configuration: '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication URL for the provider.
|
||||
*/
|
||||
protected function getAuthUrl($state)
|
||||
{
|
||||
if (! $this->oidcConfig || ! isset($this->oidcConfig['authorization_endpoint'])) {
|
||||
throw new Exception('OIDC configuration not loaded or authorization_endpoint not found.');
|
||||
}
|
||||
|
||||
return $this->buildAuthUrlFromBase($this->oidcConfig['authorization_endpoint'], $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token URL for the provider.
|
||||
*/
|
||||
protected function getTokenUrl()
|
||||
{
|
||||
if (! $this->oidcConfig || ! isset($this->oidcConfig['token_endpoint'])) {
|
||||
throw new Exception('OIDC configuration not loaded or token_endpoint not found.');
|
||||
}
|
||||
|
||||
return $this->oidcConfig['token_endpoint'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw user for the given access token.
|
||||
*/
|
||||
protected function getUserByToken($token)
|
||||
{
|
||||
if (! $this->oidcConfig || ! isset($this->oidcConfig['userinfo_endpoint'])) {
|
||||
throw new Exception('OIDC configuration not loaded or userinfo_endpoint not found.');
|
||||
}
|
||||
|
||||
$response = $this->getHttpClient()->get($this->oidcConfig['userinfo_endpoint'], [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
],
|
||||
]);
|
||||
|
||||
return json_decode($response->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the raw user array to a Socialite User instance.
|
||||
*/
|
||||
public function mapUserToObject(array $user)
|
||||
{
|
||||
return (new User)->setRaw($user)->map([
|
||||
'id' => $user['sub'],
|
||||
'nickname' => $user['preferred_username'] ?? null,
|
||||
'name' => $user['name'] ?? null,
|
||||
'email' => $user['email'] ?? null,
|
||||
'avatar' => $user['picture'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token response for the given code.
|
||||
*/
|
||||
public function getAccessTokenResponse($code)
|
||||
{
|
||||
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
'form_params' => $this->getTokenFields($code),
|
||||
]);
|
||||
|
||||
return json_decode($response->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the POST fields for the token request.
|
||||
*/
|
||||
protected function getTokenFields($code)
|
||||
{
|
||||
return array_merge(parent::getTokenFields($code), [
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
}
|
||||
}
|
||||
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal file
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use om\IcalParser;
|
||||
|
||||
class IcalResponseParser implements ResponseParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IcalParser $parser = new IcalParser(),
|
||||
) {}
|
||||
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
$body = $response->body();
|
||||
|
||||
if (! $this->isIcalResponse($contentType, $body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->parser->parseString($body);
|
||||
|
||||
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
|
||||
$windowStart = now()->subDays(7);
|
||||
$windowEnd = now()->addDays(30);
|
||||
|
||||
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
|
||||
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
|
||||
|
||||
if (! $startDate instanceof Carbon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $startDate->between($windowStart, $windowEnd, true);
|
||||
}));
|
||||
|
||||
$normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents);
|
||||
|
||||
return ['ical' => $normalizedEvents];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse iCal response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse iCal response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function isIcalResponse(?string $contentType, string $body): bool
|
||||
{
|
||||
$normalizedContentType = $contentType ? mb_strtolower($contentType) : '';
|
||||
|
||||
if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($body, 'BEGIN:VCALENDAR');
|
||||
}
|
||||
|
||||
private function asCarbon(DateTimeInterface|string|null $value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse date value: '.$exception->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeIcalEvent(array $event): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($event as $key => $value) {
|
||||
$normalized[$key] = $this->normalizeIcalValue($value);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeIcalValue(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value)->toAtomString();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map($this->normalizeIcalValue(...), $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal file
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JsonOrTextResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): array
|
||||
{
|
||||
try {
|
||||
$json = $response->json();
|
||||
if ($json !== null) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
return ['data' => $response->body()];
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to parse JSON response: '.$e->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse JSON response'];
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal file
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
interface ResponseParser
|
||||
{
|
||||
/**
|
||||
* Attempt to parse the given response.
|
||||
*
|
||||
* Return null when the parser is not applicable so other parsers can run.
|
||||
*/
|
||||
public function parse(Response $response): ?array;
|
||||
}
|
||||
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal file
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
class ResponseParserRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<int, ResponseParser>
|
||||
*/
|
||||
private readonly array $parsers;
|
||||
|
||||
/**
|
||||
* @param array<int, ResponseParser> $parsers
|
||||
*/
|
||||
public function __construct(array $parsers = [])
|
||||
{
|
||||
$this->parsers = $parsers ?: [
|
||||
new XmlResponseParser(),
|
||||
new IcalResponseParser(),
|
||||
new JsonOrTextResponseParser(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ResponseParser>
|
||||
*/
|
||||
public function getParsers(): array
|
||||
{
|
||||
return $this->parsers;
|
||||
}
|
||||
}
|
||||
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal file
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class XmlResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
|
||||
if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($response->body());
|
||||
if ($xml === false) {
|
||||
throw new Exception('Invalid XML content');
|
||||
}
|
||||
|
||||
return ['rss' => $this->xmlToArray($xml)];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse XML response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function xmlToArray(SimpleXMLElement $xml): array
|
||||
{
|
||||
$array = (array) $xml;
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if ($value instanceof SimpleXMLElement) {
|
||||
$array[$key] = $this->xmlToArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
172
app/Services/PluginExportService.php
Normal file
172
app/Services/PluginExportService.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* PluginExportService
|
||||
*
|
||||
* Exports plugins to ZIP files in the same format that can be imported by PluginImportService.
|
||||
*
|
||||
* The exported ZIP file contains:
|
||||
* - settings.yml: Plugin configuration including custom fields, polling settings, etc.
|
||||
* - full.liquid or full.blade.php: The main template file
|
||||
* - shared.liquid: Optional shared template (for liquid templates)
|
||||
*
|
||||
* This format is compatible with the PluginImportService and can be used to:
|
||||
* - Backup plugins
|
||||
* - Share plugins between users
|
||||
* - Migrate plugins between environments
|
||||
* - Create plugin templates
|
||||
*/
|
||||
class PluginExportService
|
||||
{
|
||||
/**
|
||||
* Export a plugin to a ZIP file in the same format that can be imported
|
||||
*
|
||||
* @param Plugin $plugin The plugin to export
|
||||
* @param User $user The user exporting the plugin
|
||||
* @return BinaryFileResponse The ZIP file response
|
||||
*
|
||||
* @throws Exception If the ZIP file cannot be created
|
||||
*/
|
||||
public function exportToZip(Plugin $plugin, User $user): BinaryFileResponse
|
||||
{
|
||||
// Create a temporary directory
|
||||
$tempDirName = 'temp/'.uniqid('plugin_export_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
// Generate settings.yml content
|
||||
$settings = $this->generateSettingsYaml($plugin);
|
||||
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
File::put($tempDir.'/settings.yml', $settingsYaml);
|
||||
// Generate full template content
|
||||
$fullTemplate = $this->generateFullTemplate($plugin);
|
||||
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
||||
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
||||
// Generate shared.liquid if needed (for liquid templates)
|
||||
if ($plugin->markup_language === 'liquid') {
|
||||
$sharedTemplate = $this->generateSharedTemplate();
|
||||
/** @phpstan-ignore-next-line */
|
||||
if ($sharedTemplate) {
|
||||
File::put($tempDir.'/shared.liquid', $sharedTemplate);
|
||||
}
|
||||
}
|
||||
// Create ZIP file
|
||||
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Could not create ZIP file.');
|
||||
}
|
||||
// Add files directly to ZIP root
|
||||
$this->addDirectoryToZip($zip, $tempDir, '');
|
||||
$zip->close();
|
||||
|
||||
// Return the ZIP file as a download response
|
||||
return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the settings.yml content for the plugin
|
||||
*/
|
||||
private function generateSettingsYaml(Plugin $plugin): array
|
||||
{
|
||||
$settings = [];
|
||||
|
||||
// Add fields in the specific order requested
|
||||
$settings['name'] = $plugin->name;
|
||||
$settings['no_screen_padding'] = 'no'; // Default value
|
||||
$settings['dark_mode'] = 'no'; // Default value
|
||||
$settings['strategy'] = $plugin->data_strategy;
|
||||
|
||||
// Add static data if available
|
||||
if ($plugin->data_payload) {
|
||||
$settings['static_data'] = json_encode($plugin->data_payload, JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
// Add polling configuration if applicable
|
||||
if ($plugin->data_strategy === 'polling') {
|
||||
if ($plugin->polling_verb) {
|
||||
$settings['polling_verb'] = $plugin->polling_verb;
|
||||
}
|
||||
if ($plugin->polling_url) {
|
||||
$settings['polling_url'] = $plugin->polling_url;
|
||||
}
|
||||
if ($plugin->polling_header) {
|
||||
// Convert header format from "key: value" to "key=value"
|
||||
$settings['polling_headers'] = str_replace(':', '=', $plugin->polling_header);
|
||||
}
|
||||
if ($plugin->polling_body) {
|
||||
$settings['polling_body'] = $plugin->polling_body;
|
||||
}
|
||||
}
|
||||
|
||||
$settings['refresh_interval'] = $plugin->data_stale_minutes;
|
||||
$settings['id'] = $plugin->trmnlp_id;
|
||||
|
||||
// Add custom fields from configuration template
|
||||
if (isset($plugin->configuration_template['custom_fields'])) {
|
||||
$settings['custom_fields'] = $plugin->configuration_template['custom_fields'];
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full template content
|
||||
*/
|
||||
private function generateFullTemplate(Plugin $plugin): string
|
||||
{
|
||||
$markup = $plugin->render_markup;
|
||||
|
||||
// Remove the wrapper div if it exists (it will be added during import)
|
||||
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
|
||||
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
|
||||
|
||||
return mb_trim($markup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the shared template content (for liquid templates)
|
||||
*/
|
||||
private function generateSharedTemplate(): null
|
||||
{
|
||||
// For now, we don't have a way to store shared templates separately
|
||||
// TODO - add support for shared templates
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a directory and its contents to a ZIP file
|
||||
*/
|
||||
private function addDirectoryToZip(ZipArchive $zip, string $dirPath, string $zipPath): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dirPath),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! $file->isDir()) {
|
||||
$filePath = $file->getRealPath();
|
||||
$fileName = basename((string) $filePath);
|
||||
|
||||
// For root directory, just use the filename
|
||||
$relativePath = $zipPath === '' ? $fileName : $zipPath.'/'.mb_substr((string) $filePath, mb_strlen($dirPath) + 1);
|
||||
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
598
app/Services/PluginImportService.php
Normal file
598
app/Services/PluginImportService.php
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use ZipArchive;
|
||||
|
||||
class PluginImportService
|
||||
{
|
||||
/**
|
||||
* Validate YAML settings
|
||||
*
|
||||
* @param array $settings The parsed YAML settings
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function validateYAML(array $settings): void
|
||||
{
|
||||
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['field_type']) && $field['field_type'] === 'multi_string') {
|
||||
|
||||
if (isset($field['default']) && str_contains($field['default'], ',')) {
|
||||
throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas.");
|
||||
}
|
||||
|
||||
if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) {
|
||||
throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas.");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a plugin from a ZIP file
|
||||
*
|
||||
* @param UploadedFile $zipFile The uploaded ZIP file
|
||||
* @param User $user The user importing the plugin
|
||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
||||
* @return Plugin The created plugin instance
|
||||
*
|
||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||
*/
|
||||
public function importFromZip(UploadedFile $zipFile, User $user, ?string $zipEntryPath = null): Plugin
|
||||
{
|
||||
// Create a temporary directory using Laravel's temporary directory helper
|
||||
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
|
||||
try {
|
||||
// Get the real path of the temporary file
|
||||
$zipFullPath = $zipFile->getRealPath();
|
||||
|
||||
// Extract the ZIP file using ZipArchive
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFullPath) !== true) {
|
||||
throw new Exception('Could not open the ZIP file.');
|
||||
}
|
||||
|
||||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||
|
||||
// Validate that we found the required files
|
||||
if (! $filePaths['settingsYamlPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||
}
|
||||
|
||||
// Validate that we have at least one template file
|
||||
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||
}
|
||||
|
||||
// Parse settings.yml
|
||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||
$settings = Yaml::parse($settingsYaml);
|
||||
$this->validateYAML($settings);
|
||||
|
||||
// Determine which template file to use and read its content
|
||||
$templatePath = null;
|
||||
$markupLanguage = 'blade';
|
||||
|
||||
if ($filePaths['fullLiquidPath']) {
|
||||
$templatePath = $filePaths['fullLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
|
||||
// Prepend shared.liquid or shared.blade.php content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
}
|
||||
} elseif ($filePaths['sharedLiquidPath']) {
|
||||
$templatePath = $filePaths['sharedLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
} elseif ($filePaths['sharedBladePath']) {
|
||||
$templatePath = $filePaths['sharedBladePath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'blade';
|
||||
}
|
||||
|
||||
// Ensure custom_fields is properly formatted
|
||||
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||
$settings['custom_fields'] = [];
|
||||
}
|
||||
|
||||
// Normalize options in custom_fields (convert non-named values to named values)
|
||||
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
|
||||
|
||||
// Create configuration template with the custom fields
|
||||
$configurationTemplate = [
|
||||
'custom_fields' => $settings['custom_fields'],
|
||||
];
|
||||
|
||||
$plugin_updated = isset($settings['id'])
|
||||
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||
// Create a new plugin
|
||||
$plugin = Plugin::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||
'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||
'polling_url' => $settings['polling_url'] ?? null,
|
||||
'polling_verb' => $settings['polling_verb'] ?? 'get',
|
||||
'polling_header' => isset($settings['polling_headers'])
|
||||
? str_replace('=', ':', $settings['polling_headers'])
|
||||
: null,
|
||||
'polling_body' => $settings['polling_body'] ?? null,
|
||||
'markup_language' => $markupLanguage,
|
||||
'render_markup' => $fullLiquid,
|
||||
'configuration_template' => $configurationTemplate,
|
||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||
]);
|
||||
|
||||
if (! $plugin_updated) {
|
||||
// Extract default values from custom_fields and populate configuration
|
||||
$configuration = [];
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['keyname']) && isset($field['default'])) {
|
||||
$configuration[$field['keyname']] = $field['default'];
|
||||
}
|
||||
}
|
||||
// set only if plugin is new
|
||||
$plugin->update([
|
||||
'configuration' => $configuration,
|
||||
]);
|
||||
}
|
||||
$plugin['trmnlp_yaml'] = $settingsYaml;
|
||||
|
||||
return $plugin;
|
||||
|
||||
} finally {
|
||||
// Clean up temporary directory
|
||||
Storage::deleteDirectory($tempDirName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a plugin from a ZIP URL
|
||||
*
|
||||
* @param string $zipUrl The URL to the ZIP file
|
||||
* @param User $user The user importing the plugin
|
||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
||||
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
|
||||
* @param string|null $iconUrl Optional icon URL to set on the plugin
|
||||
* @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists
|
||||
* @return Plugin The created plugin instance
|
||||
*
|
||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||
*/
|
||||
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
|
||||
{
|
||||
// Download the ZIP file
|
||||
$response = Http::timeout(60)->get($zipUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new Exception('Could not download the ZIP file from the provided URL.');
|
||||
}
|
||||
|
||||
// Create a temporary file
|
||||
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
$zipPath = $tempDir.'/plugin.zip';
|
||||
|
||||
// Save the downloaded content to a temporary file
|
||||
File::put($zipPath, $response->body());
|
||||
|
||||
try {
|
||||
// Extract the ZIP file using ZipArchive
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new Exception('Could not open the downloaded ZIP file.');
|
||||
}
|
||||
|
||||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||
|
||||
// Validate that we found the required files
|
||||
if (! $filePaths['settingsYamlPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||
}
|
||||
|
||||
// Validate that we have at least one template file
|
||||
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||
}
|
||||
|
||||
// Parse settings.yml
|
||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||
$settings = Yaml::parse($settingsYaml);
|
||||
$this->validateYAML($settings);
|
||||
|
||||
// Determine which template file to use and read its content
|
||||
$templatePath = null;
|
||||
$markupLanguage = 'blade';
|
||||
|
||||
if ($filePaths['fullLiquidPath']) {
|
||||
$templatePath = $filePaths['fullLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
|
||||
// Prepend shared.liquid or shared.blade.php content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
}
|
||||
} elseif ($filePaths['sharedLiquidPath']) {
|
||||
$templatePath = $filePaths['sharedLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
} elseif ($filePaths['sharedBladePath']) {
|
||||
$templatePath = $filePaths['sharedBladePath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'blade';
|
||||
}
|
||||
|
||||
// Ensure custom_fields is properly formatted
|
||||
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||
$settings['custom_fields'] = [];
|
||||
}
|
||||
|
||||
// Normalize options in custom_fields (convert non-named values to named values)
|
||||
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
|
||||
|
||||
// Create configuration template with the custom fields
|
||||
$configurationTemplate = [
|
||||
'custom_fields' => $settings['custom_fields'],
|
||||
];
|
||||
|
||||
// Determine the trmnlp_id to use
|
||||
$trmnlpId = $settings['id'] ?? Uuid::v7();
|
||||
|
||||
// If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID
|
||||
if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) {
|
||||
$trmnlpId = Uuid::v7();
|
||||
}
|
||||
|
||||
$plugin_updated = ! $allowDuplicate && isset($settings['id'])
|
||||
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||
|
||||
// Create a new plugin
|
||||
$plugin = Plugin::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||
'trmnlp_id' => $trmnlpId,
|
||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||
'polling_url' => $settings['polling_url'] ?? null,
|
||||
'polling_verb' => $settings['polling_verb'] ?? 'get',
|
||||
'polling_header' => isset($settings['polling_headers'])
|
||||
? str_replace('=', ':', $settings['polling_headers'])
|
||||
: null,
|
||||
'polling_body' => $settings['polling_body'] ?? null,
|
||||
'markup_language' => $markupLanguage,
|
||||
'render_markup' => $fullLiquid,
|
||||
'configuration_template' => $configurationTemplate,
|
||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||
'preferred_renderer' => $preferredRenderer,
|
||||
'icon_url' => $iconUrl,
|
||||
]);
|
||||
|
||||
if (! $plugin_updated) {
|
||||
// Extract default values from custom_fields and populate configuration
|
||||
$configuration = [];
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['keyname']) && isset($field['default'])) {
|
||||
$configuration[$field['keyname']] = $field['default'];
|
||||
}
|
||||
}
|
||||
// set only if plugin is new
|
||||
$plugin->update([
|
||||
'configuration' => $configuration,
|
||||
]);
|
||||
}
|
||||
$plugin['trmnlp_yaml'] = $settingsYaml;
|
||||
|
||||
return $plugin;
|
||||
|
||||
} finally {
|
||||
// Clean up temporary directory
|
||||
Storage::deleteDirectory($tempDirName);
|
||||
}
|
||||
}
|
||||
|
||||
private function findRequiredFiles(string $tempDir, ?string $zipEntryPath = null): array
|
||||
{
|
||||
$settingsYamlPath = null;
|
||||
$fullLiquidPath = null;
|
||||
$sharedLiquidPath = null;
|
||||
$sharedBladePath = null;
|
||||
|
||||
// If zipEntryPath is specified, look for files in that specific directory first
|
||||
if ($zipEntryPath) {
|
||||
$targetDir = $tempDir.'/'.$zipEntryPath;
|
||||
if (File::exists($targetDir)) {
|
||||
// Check if files are directly in the target directory
|
||||
if (File::exists($targetDir.'/settings.yml')) {
|
||||
$settingsYamlPath = $targetDir.'/settings.yml';
|
||||
|
||||
if (File::exists($targetDir.'/full.liquid')) {
|
||||
$fullLiquidPath = $targetDir.'/full.liquid';
|
||||
} elseif (File::exists($targetDir.'/full.blade.php')) {
|
||||
$fullLiquidPath = $targetDir.'/full.blade.php';
|
||||
}
|
||||
|
||||
if (File::exists($targetDir.'/shared.liquid')) {
|
||||
$sharedLiquidPath = $targetDir.'/shared.liquid';
|
||||
} elseif (File::exists($targetDir.'/shared.blade.php')) {
|
||||
$sharedBladePath = $targetDir.'/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if files are in src subdirectory of target directory
|
||||
if (! $settingsYamlPath && File::exists($targetDir.'/src/settings.yml')) {
|
||||
$settingsYamlPath = $targetDir.'/src/settings.yml';
|
||||
|
||||
if (File::exists($targetDir.'/src/full.liquid')) {
|
||||
$fullLiquidPath = $targetDir.'/src/full.liquid';
|
||||
} elseif (File::exists($targetDir.'/src/full.blade.php')) {
|
||||
$fullLiquidPath = $targetDir.'/src/full.blade.php';
|
||||
}
|
||||
|
||||
if (File::exists($targetDir.'/src/shared.liquid')) {
|
||||
$sharedLiquidPath = $targetDir.'/src/shared.liquid';
|
||||
} elseif (File::exists($targetDir.'/src/shared.blade.php')) {
|
||||
$sharedBladePath = $targetDir.'/src/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the required files in the target directory, return them
|
||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||
return [
|
||||
'settingsYamlPath' => $settingsYamlPath,
|
||||
'fullLiquidPath' => $fullLiquidPath,
|
||||
'sharedLiquidPath' => $sharedLiquidPath,
|
||||
'sharedBladePath' => $sharedBladePath,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First, check if files are directly in the src folder
|
||||
if (File::exists($tempDir.'/src/settings.yml')) {
|
||||
$settingsYamlPath = $tempDir.'/src/settings.yml';
|
||||
|
||||
// Check for full.liquid or full.blade.php
|
||||
if (File::exists($tempDir.'/src/full.liquid')) {
|
||||
$fullLiquidPath = $tempDir.'/src/full.liquid';
|
||||
} elseif (File::exists($tempDir.'/src/full.blade.php')) {
|
||||
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
||||
}
|
||||
|
||||
// Check for shared.liquid or shared.blade.php in the same directory
|
||||
if (File::exists($tempDir.'/src/shared.liquid')) {
|
||||
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
||||
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
||||
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
||||
}
|
||||
} else {
|
||||
// Search for the files in the extracted directory structure
|
||||
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($directories);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = $file->getFilename();
|
||||
$filepath = $file->getPathname();
|
||||
|
||||
if ($filename === 'settings.yml') {
|
||||
$settingsYamlPath = $filepath;
|
||||
} elseif ($filename === 'full.liquid' || $filename === 'full.blade.php') {
|
||||
$fullLiquidPath = $filepath;
|
||||
} elseif ($filename === 'shared.liquid') {
|
||||
$sharedLiquidPath = $filepath;
|
||||
} elseif ($filename === 'shared.blade.php') {
|
||||
$sharedBladePath = $filepath;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
|
||||
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
|
||||
$fullLiquidDir = dirname((string) $fullLiquidPath);
|
||||
if (File::exists($fullLiquidDir.'/shared.liquid')) {
|
||||
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
|
||||
} elseif (File::exists($fullLiquidDir.'/shared.blade.php')) {
|
||||
$sharedBladePath = $fullLiquidDir.'/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the files but they're not in the src folder,
|
||||
// check if they're in the root of the ZIP or in a subfolder
|
||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||
// If the files are in the root of the ZIP, create a src folder and move them there
|
||||
$srcDir = dirname((string) $settingsYamlPath);
|
||||
|
||||
// If the parent directory is not named 'src', create a src directory
|
||||
if (basename($srcDir) !== 'src') {
|
||||
$newSrcDir = $tempDir.'/src';
|
||||
File::makeDirectory($newSrcDir, 0755, true);
|
||||
|
||||
// Copy the files to the src directory
|
||||
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
||||
|
||||
// Copy full.liquid or full.blade.php if it exists
|
||||
if ($fullLiquidPath) {
|
||||
$extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION);
|
||||
File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension);
|
||||
$fullLiquidPath = $newSrcDir.'/full.'.$extension;
|
||||
}
|
||||
|
||||
// Copy shared.liquid or shared.blade.php if it exists
|
||||
if ($sharedLiquidPath) {
|
||||
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
||||
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
|
||||
} elseif ($sharedBladePath) {
|
||||
File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php');
|
||||
$sharedBladePath = $newSrcDir.'/shared.blade.php';
|
||||
}
|
||||
|
||||
// Update the paths
|
||||
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'settingsYamlPath' => $settingsYamlPath,
|
||||
'fullLiquidPath' => $fullLiquidPath,
|
||||
'sharedLiquidPath' => $sharedLiquidPath,
|
||||
'sharedBladePath' => $sharedBladePath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize options in custom_fields by converting non-named values to named values
|
||||
* This ensures that options like ["true", "false"] become [["true" => "true"], ["false" => "false"]]
|
||||
*
|
||||
* @param array $customFields The custom_fields array from settings
|
||||
* @return array The normalized custom_fields array
|
||||
*/
|
||||
private function normalizeCustomFieldsOptions(array $customFields): array
|
||||
{
|
||||
foreach ($customFields as &$field) {
|
||||
// Only process select fields with options
|
||||
if (isset($field['field_type']) && $field['field_type'] === 'select' && isset($field['options']) && is_array($field['options'])) {
|
||||
$normalizedOptions = [];
|
||||
foreach ($field['options'] as $option) {
|
||||
// If option is already a named value (array with key-value pair), keep it as is
|
||||
if (is_array($option)) {
|
||||
$normalizedOptions[] = $option;
|
||||
} else {
|
||||
// Convert non-named value to named value
|
||||
// Convert boolean to string, use lowercase for label
|
||||
$value = is_bool($option) ? ($option ? 'true' : 'false') : (string) $option;
|
||||
$normalizedOptions[] = [$value => $value];
|
||||
}
|
||||
}
|
||||
$field['options'] = $normalizedOptions;
|
||||
|
||||
// Normalize default value to match normalized option values
|
||||
if (isset($field['default'])) {
|
||||
$default = $field['default'];
|
||||
// If default is boolean, convert to string to match normalized options
|
||||
if (is_bool($default)) {
|
||||
$field['default'] = $default ? 'true' : 'false';
|
||||
} else {
|
||||
// Convert to string to ensure consistency
|
||||
$field['default'] = (string) $default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $customFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that template and context are within command-line argument limits
|
||||
*
|
||||
* @param string $template The liquid template string
|
||||
* @param string $jsonContext The JSON-encoded context
|
||||
* @param string $liquidPath The path to the liquid renderer executable
|
||||
*
|
||||
* @throws Exception If the template or context exceeds argument limits
|
||||
*/
|
||||
public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void
|
||||
{
|
||||
// MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments
|
||||
// ARG_MAX is the total size of all arguments (typically 2MB on modern systems)
|
||||
$maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit
|
||||
$maxTotalArgLength = $this->getMaxArgumentLength();
|
||||
|
||||
// Check individual argument sizes (template and context are the largest)
|
||||
if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) {
|
||||
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
|
||||
}
|
||||
|
||||
// Calculate total size of all arguments (path + flags + template + context)
|
||||
// Add overhead for path, flags, and separators (conservative estimate: ~200 bytes)
|
||||
$totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template)
|
||||
+ mb_strlen('--context') + mb_strlen($jsonContext) + 200;
|
||||
|
||||
if ($totalArgSize > $maxTotalArgLength) {
|
||||
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum argument length for command-line arguments
|
||||
*
|
||||
* @return int Maximum argument length in bytes
|
||||
*/
|
||||
private function getMaxArgumentLength(): int
|
||||
{
|
||||
// Try to get ARG_MAX from system using getconf
|
||||
$argMax = null;
|
||||
if (function_exists('shell_exec')) {
|
||||
$result = @shell_exec('getconf ARG_MAX 2>/dev/null');
|
||||
if ($result !== null && is_numeric(mb_trim($result))) {
|
||||
$argMax = (int) mb_trim($result);
|
||||
}
|
||||
}
|
||||
|
||||
// Use conservative fallback if ARG_MAX cannot be determined
|
||||
// ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB)
|
||||
// We use 200KB as a conservative limit that works on both systems
|
||||
// Note: ARG_MAX includes environment variables, so we leave headroom
|
||||
return $argMax !== null ? min($argMax, 204800) : 204800;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,34 +4,45 @@
|
|||
"type": "project",
|
||||
"description": "TRMNL Server Implementation (BYOS) for Laravel",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework",
|
||||
"trmnl"
|
||||
"trmnl",
|
||||
"trmnl-server",
|
||||
"trmnl-byos",
|
||||
"laravel"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-imagick": "*",
|
||||
"bnussbau/laravel-trmnl": "^0.1.4",
|
||||
"intervention/image": "^3.11",
|
||||
"ext-simplexml": "*",
|
||||
"ext-zip": "*",
|
||||
"bnussbau/laravel-trmnl-blade": "2.1.*",
|
||||
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
||||
"keepsuit/laravel-liquid": "^0.5.2",
|
||||
"laravel/framework": "^12.1",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/socialite": "^5.23",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/volt": "^1.7",
|
||||
"om/icalparser": "^3.2",
|
||||
"spatie/browsershot": "^5.0",
|
||||
"spatie/pest-expectations": "^1.3"
|
||||
"stevebauman/purify": "^6.3",
|
||||
"symfony/yaml": "^7.3",
|
||||
"wnx/sidecar-browsershot": "^2.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"larastan/larastan": "^3.0",
|
||||
"laravel/boost": "^1.0",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.18",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^3.7",
|
||||
"pestphp/pest-plugin-drift": "^3.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.1"
|
||||
"pestphp/pest": "^4.0",
|
||||
"pestphp/pest-plugin-drift": "^4.0",
|
||||
"pestphp/pest-plugin-laravel": "^4.0",
|
||||
"rector/rector": "^2.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
@ -64,7 +75,13 @@
|
|||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
|
||||
]
|
||||
],
|
||||
"test": "vendor/bin/pest",
|
||||
"test-coverage": "vendor/bin/pest --coverage",
|
||||
"format": "vendor/bin/pint",
|
||||
"analyse": "vendor/bin/phpstan analyse",
|
||||
"analyze": "vendor/bin/phpstan analyse",
|
||||
"rector": "vendor/bin/rector process"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
|
|
|
|||
5034
composer.lock
generated
5034
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -129,5 +129,28 @@ return [
|
|||
|
||||
'force_https' => env('FORCE_HTTPS', false),
|
||||
'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
|
||||
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
|
||||
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true),
|
||||
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
|
||||
|
||||
'notifications' => [
|
||||
'battery_low' => [
|
||||
'warn_at_percent' => env('NOTIFICATION_BATTERYLOW_WARNATPERCENT', 20),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Version
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the version of your application, which will be used when
|
||||
| displaying the version number in the UI. This is set during the Docker
|
||||
| build process from the release tag.
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', null),
|
||||
|
||||
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -38,7 +38,30 @@ return [
|
|||
'trmnl' => [
|
||||
'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'),
|
||||
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
|
||||
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
|
||||
'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false),
|
||||
'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices
|
||||
'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false),
|
||||
'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'),
|
||||
],
|
||||
|
||||
'webhook' => [
|
||||
'notifications' => [
|
||||
'url' => env('WEBHOOK_NOTIFICATION_URL', null),
|
||||
'topic' => env('WEBHOOK_NOTIFICATION_TOPIC', 'null'),
|
||||
],
|
||||
],
|
||||
|
||||
'oidc' => [
|
||||
'enabled' => env('OIDC_ENABLED', false),
|
||||
// OIDC_ENDPOINT can be either:
|
||||
// - Base URL: https://your-provider.com (will append /.well-known/openid-configuration)
|
||||
// - Full well-known URL: https://your-provider.com/.well-known/openid-configuration
|
||||
'endpoint' => env('OIDC_ENDPOINT'),
|
||||
'client_id' => env('OIDC_CLIENT_ID'),
|
||||
'client_secret' => env('OIDC_CLIENT_SECRET'),
|
||||
'redirect' => env('APP_URL', 'http://localhost:8000').'/auth/oidc/callback',
|
||||
'scopes' => explode(',', env('OIDC_SCOPES', 'openid,profile,email')),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
56
config/sidecar-browsershot.php
Normal file
56
config/sidecar-browsershot.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/**
|
||||
* Define the allocated memory available to SidecarBrowsershot in megabytes. (Defaults to 2GB)
|
||||
* We suggest to allocate at least 513 MB of memory to push Chrome/Puppeteer out of "low-spec" mode.
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/customization#memory
|
||||
* @see https://github.blog/2021-06-22-framework-building-open-graph-images/
|
||||
*/
|
||||
'memory' => env('SIDECAR_BROWSERSHOT_MEMORY', 2048),
|
||||
|
||||
/**
|
||||
* The default ephemeral storage available to SidecarBrowsershot, in megabytes. (Defaults to 512MB)
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/customization#storage
|
||||
*/
|
||||
'storage' => env('SIDECAR_BROWSERSHOT_STORAGE', 512),
|
||||
|
||||
/**
|
||||
* The default timeout to use for SidecarBrowsershot, in seconds. (Defaults to 300)
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/customization#timeout
|
||||
*/
|
||||
'timeout' => env('SIDECAR_BROWSERSHOT_TIMEOUT', 300),
|
||||
|
||||
/**
|
||||
* Define the number of warming instances to boot.
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/warming
|
||||
*/
|
||||
'warming' => env('SIDECAR_BROWSERSHOT_WARMING_INSTANCES', 0),
|
||||
|
||||
/**
|
||||
* AWS Layers to use by the Lambda function.
|
||||
* Defaults to "shelfio/chrome-aws-lambda-layer" and "sidecar-browsershot-layer" in your respective AWS region.
|
||||
*
|
||||
* If you customize this, you must include both "sidecar-browsershot-layer" and "shelfio/chrome-aws-lambda-layer"
|
||||
* in your list, as the config overrides the default values.
|
||||
* (See BrowsershotFunction@layers for more details)
|
||||
*
|
||||
* @see https://github.com/shelfio/chrome-aws-lambda-layer
|
||||
* @see https://github.com/stefanzweifel/sidecar-browsershot-layer
|
||||
*/
|
||||
'layers' => [
|
||||
// "arn:aws:lambda:us-east-1:821527532446:layer:sidecar-browsershot-layer:2",
|
||||
// "arn:aws:lambda:us-east-1:764866452798:layer:chrome-aws-lambda:42",
|
||||
],
|
||||
|
||||
/**
|
||||
* Path to local directory containing fonts to be installed in the Lambda function.
|
||||
* During deployment, BorwsershotLambda will scan this directory for
|
||||
* any files and will bundle them into the Lambda function.
|
||||
*/
|
||||
'fonts' => resource_path('sidecar-browsershot/fonts'),
|
||||
];
|
||||
10
config/sidecar.php
Normal file
10
config/sidecar.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
* All of your function classes that you'd like to deploy go here.
|
||||
*/
|
||||
'functions' => [
|
||||
Wnx\SidecarBrowsershot\Functions\BrowsershotFunction::class,
|
||||
],
|
||||
];
|
||||
6
config/trustedproxy.php
Normal file
6
config/trustedproxy.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Comma‑separated list from .env, e.g. "10.0.0.0/8,172.16.0.0/12" or '*'
|
||||
'proxies' => ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)),
|
||||
];
|
||||
24
database/factories/DeviceLogFactory.php
Normal file
24
database/factories/DeviceLogFactory.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceLog;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class DeviceLogFactory extends Factory
|
||||
{
|
||||
protected $model = DeviceLog::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'log_entry' => ['creation_timestamp' => fake()->dateTimeBetween('-1 month', 'now')->getTimestamp(), 'device_status_stamp' => ['wifi_rssi_level' => -65, 'wifi_status' => 'connected', 'refresh_rate' => 900, 'time_since_last_sleep_start' => 901, 'current_fw_version' => '1.5.5', 'special_function' => 'none', 'battery_voltage' => 4.052, 'wakeup_reason' => 'timer', 'free_heap_size' => 215128, 'max_alloc_size' => 192500], 'log_id' => 17, 'log_message' => 'Error fetching API display: 7, detail: HTTP Client failed with error: connection refused(-1)', 'log_codeline' => 586, 'log_sourcefile' => "src\/bl.cpp", 'additional_info' => ['filename_current' => 'UUID.png', 'filename_new' => null, 'retry_attempt' => 5]],
|
||||
'device_timestamp' => fake()->dateTimeBetween('-1 month', 'now'),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
'device_id' => Device::first(),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
database/factories/DeviceModelFactory.php
Normal file
38
database/factories/DeviceModelFactory.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DeviceModel>
|
||||
*/
|
||||
class DeviceModelFactory extends Factory
|
||||
{
|
||||
protected $model = DeviceModel::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->slug(),
|
||||
'label' => $this->faker->words(2, true),
|
||||
'description' => $this->faker->sentence(),
|
||||
'width' => $this->faker->randomElement([800, 1024, 1280, 1920]),
|
||||
'height' => $this->faker->randomElement([480, 600, 720, 1080]),
|
||||
'colors' => $this->faker->randomElement([2, 16, 256, 65536]),
|
||||
'bit_depth' => $this->faker->randomElement([1, 4, 8, 16]),
|
||||
'scale_factor' => $this->faker->randomElement([1, 2, 4]),
|
||||
'rotation' => $this->faker->randomElement([0, 90, 180, 270]),
|
||||
'mime_type' => $this->faker->randomElement(['image/png', 'image/jpeg', 'image/gif']),
|
||||
'offset_x' => $this->faker->numberBetween(-100, 100),
|
||||
'offset_y' => $this->faker->numberBetween(-100, 100),
|
||||
'published_at' => $this->faker->optional()->dateTimeBetween('-1 year', 'now'),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
database/factories/DevicePaletteFactory.php
Normal file
38
database/factories/DevicePaletteFactory.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DevicePalette;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DevicePalette>
|
||||
*/
|
||||
class DevicePaletteFactory extends Factory
|
||||
{
|
||||
protected $model = DevicePalette::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'test-'.$this->faker->unique()->slug(),
|
||||
'name' => $this->faker->words(3, true),
|
||||
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
||||
'colors' => $this->faker->optional()->passthrough([
|
||||
'#FF0000',
|
||||
'#00FF00',
|
||||
'#0000FF',
|
||||
'#FFFF00',
|
||||
'#000000',
|
||||
'#FFFFFF',
|
||||
]),
|
||||
'framework_class' => null,
|
||||
'source' => 'api',
|
||||
];
|
||||
}
|
||||
}
|
||||
24
database/factories/FirmwareFactory.php
Normal file
24
database/factories/FirmwareFactory.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Firmware;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class FirmwareFactory extends Factory
|
||||
{
|
||||
protected $model = Firmware::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'version_tag' => $this->faker->word(),
|
||||
'url' => $this->faker->url(),
|
||||
'latest' => $this->faker->boolean(),
|
||||
'storage_location' => $this->faker->word(),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
database/factories/PlaylistFactory.php
Normal file
27
database/factories/PlaylistFactory.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Playlist;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PlaylistFactory extends Factory
|
||||
{
|
||||
protected $model = Playlist::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->words(3, true),
|
||||
'is_active' => $this->faker->boolean(80), // 80% chance of being active
|
||||
'weekdays' => $this->faker->randomElements(range(0, 6), $this->faker->numberBetween(1, 7)),
|
||||
'active_from' => $this->faker->time('H:i:s'),
|
||||
'active_until' => $this->faker->time('H:i:s'),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
'device_id' => Device::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
database/factories/PlaylistItemFactory.php
Normal file
28
database/factories/PlaylistItemFactory.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistItem;
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PlaylistItemFactory extends Factory
|
||||
{
|
||||
protected $model = PlaylistItem::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'playlist_id' => Playlist::factory(),
|
||||
'plugin_id' => Plugin::factory(),
|
||||
'mashup' => null,
|
||||
'order' => $this->faker->numberBetween(0, 100),
|
||||
'is_active' => $this->faker->boolean(80), // 80% chance of being active
|
||||
'last_displayed_at' => null,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
database/factories/PluginFactory.php
Normal file
52
database/factories/PluginFactory.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PluginFactory extends Factory
|
||||
{
|
||||
protected $model = Plugin::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->faker->uuid(),
|
||||
'user_id' => '1',
|
||||
'name' => $this->faker->randomElement(['Weather', 'Clock', 'News', 'Stocks', 'Calendar']),
|
||||
'data_payload' => null,
|
||||
'data_stale_minutes' => $this->faker->numberBetween(5, 300),
|
||||
'data_strategy' => $this->faker->randomElement(['polling', 'webhook']),
|
||||
'polling_url' => $this->faker->url(),
|
||||
'polling_verb' => $this->faker->randomElement(['get', 'post']),
|
||||
'polling_header' => null,
|
||||
'polling_body' => null,
|
||||
'render_markup' => null,
|
||||
'render_markup_view' => null,
|
||||
'detail_view_route' => null,
|
||||
'icon_url' => null,
|
||||
'flux_icon_name' => null,
|
||||
'author_name' => $this->faker->name(),
|
||||
'plugin_type' => 'recipe',
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the plugin is an image webhook plugin.
|
||||
*/
|
||||
public function imageWebhook(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'plugin_type' => 'image_webhook',
|
||||
'data_strategy' => 'static',
|
||||
'data_stale_minutes' => 60,
|
||||
'polling_url' => null,
|
||||
'polling_verb' => 'get',
|
||||
'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('plugins', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->nullable();
|
||||
$table->foreignIdFor(User::class)->nullable();
|
||||
$table->string('name')->nullable();
|
||||
$table->text('data_payload')->nullable();
|
||||
$table->integer('data_stale_minutes')->nullable();
|
||||
$table->string('data_strategy')->nullable();
|
||||
$table->string('polling_url')->nullable();
|
||||
$table->string('polling_verb')->nullable();
|
||||
$table->string('polling_header')->nullable();
|
||||
$table->text('render_markup')->nullable();
|
||||
$table->string('render_markup_view')->nullable();
|
||||
$table->string('detail_view_route')->nullable();
|
||||
$table->string('icon_url')->nullable();
|
||||
$table->string('flux_icon_name')->nullable();
|
||||
$table->string('author_name')->nullable();
|
||||
$table->boolean('is_native')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plugins');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('playlists', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->constrained()->onDelete('cascade');
|
||||
$table->string('name');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->json('weekdays')->nullable(); // Array of weekday numbers (0-6)
|
||||
$table->time('active_from')->nullable();
|
||||
$table->time('active_until')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('playlists');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('playlist_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('playlist_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('plugin_id')->constrained()->onDelete('cascade');
|
||||
$table->integer('order')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamp('last_displayed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('playlist_items');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->timestamp('data_payload_updated_at')->nullable()->after('data_payload');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('data_payload_updated_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('playlists', function (Blueprint $table) {
|
||||
$table->integer('refresh_time')->nullable()->after('active_until');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('playlists', function (Blueprint $table) {
|
||||
$table->dropColumn('refresh_time');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Correct the typo in render_markup_view for all plugin UUIDs
|
||||
$pluginUuids = [
|
||||
'9e46c6cf-358c-4bfe-8998-436b3a207fec', // ÖBB Departures
|
||||
'3b046eda-34e9-4232-b935-c33b989a284b', // Weather
|
||||
'21464b16-5f5a-4099-a967-f5c915e3da54', // Zen Quotes
|
||||
'8d472959-400f-46ee-afb2-4a9f1cfd521f', // This Day in History
|
||||
'4349fdad-a273-450b-aa00-3d32f2de788d', // Home Assistant
|
||||
];
|
||||
|
||||
foreach ($pluginUuids as $uuid) {
|
||||
DB::table('plugins')
|
||||
->where('uuid', $uuid)
|
||||
->update([
|
||||
'render_markup_view' => DB::raw("REPLACE(render_markup_view, 'receipts.', 'recipes.')"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Revert the typo correction if needed
|
||||
$pluginUuids = [
|
||||
'9e46c6cf-358c-4bfe-8998-436b3a207fec', // ÖBB Departures
|
||||
'3b046eda-34e9-4232-b935-c33b989a284b', // Weather
|
||||
'21464b16-5f5a-4099-a967-f5c915e3da54', // Zen Quotes
|
||||
'8d472959-400f-46ee-afb2-4a9f1cfd521f', // This Day in History
|
||||
'4349fdad-a273-450b-aa00-3d32f2de788d', // Home Assistant
|
||||
];
|
||||
|
||||
foreach ($pluginUuids as $uuid) {
|
||||
DB::table('plugins')
|
||||
->where('uuid', $uuid)
|
||||
->update([
|
||||
'render_markup_view' => DB::raw("REPLACE(render_markup_view, 'recipes.', 'receipts.')"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->integer('width')->nullable()->default(800)->after('api_key');
|
||||
$table->integer('height')->nullable()->default(480)->after('width');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('width');
|
||||
$table->dropColumn('height');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->foreignId('mirror_device_id')->nullable()->constrained('devices')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropForeign(['mirror_device_id']);
|
||||
$table->dropColumn('mirror_device_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignId('assign_new_device_id')->nullable()->constrained('devices')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['assign_new_device_id']);
|
||||
$table->dropColumn('assign_new_device_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
28
database/migrations/2025_05_10_182724_add_plugin_cache.php
Normal file
28
database/migrations/2025_05_10_182724_add_plugin_cache.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->string('current_image')->nullable()->after('data_payload_updated_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('current_image');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->integer('rotate')->nullable()->default(0)->after('width');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('rotate');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->string('image_format')->default('auto')->after('rotate');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('image_format');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('firmware', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('version_tag');
|
||||
$table->string('url')->nullable();
|
||||
$table->boolean('latest')->default(false);
|
||||
$table->string('storage_location')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('firmware');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->foreignId('update_firmware_id')->nullable()->constrained('firmware')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropForeign(['update_firmware_id']);
|
||||
$table->dropColumn('update_firmware_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue