Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[QR-code] - Widget redirects to Mobile app #10591

Merged
merged 25 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d0ff4a2
mobile app QR code initial implementation
dashevchenko Apr 18, 2024
67ba644
moved all apis to one controller
dashevchenko Apr 24, 2024
b216898
updated default mobile app settings, fixed permission
dashevchenko Apr 24, 2024
dc5f592
added mobile app settings cleanup for tenant deletion
dashevchenko Apr 25, 2024
0003caf
added id, createdTime to mobile app setting entity, added MobileAppSe…
dashevchenko Apr 30, 2024
3e0fad4
merged with master
dashevchenko Apr 30, 2024
f934863
fixed api test
dashevchenko May 2, 2024
949eb67
merged with master
dashevchenko May 2, 2024
0bf5290
code formatting
dashevchenko May 2, 2024
a057f42
UI: mobile app QR code UI initial implementation
rusikv May 2, 2024
4ee969f
fixed mobile application validator
dashevchenko May 5, 2024
953c215
UI: added separate mobile qrcode widget to cards bundle
rusikv May 7, 2024
012457e
Add mobileQrEnabled to system params
ViacheslavKlimov May 7, 2024
0d7d9b4
Refactoring for mobile settings api
ViacheslavKlimov May 7, 2024
d163b56
UI: mobile qrcode improvements
rusikv May 7, 2024
034ed62
Merge branch 'qrCode' of github.com:dashevchenko/thingsboard into qrCode
vvlladd28 May 7, 2024
2bbeb6a
UI: Added support mobileQrEnabled settings in tenant admin home page …
vvlladd28 May 7, 2024
0bc0422
UI: added mobile app qr code to sys admin home page
rusikv May 9, 2024
14ebfe7
UI: mobile app qrcode widgets update
rusikv May 9, 2024
2187246
UI: added preview images for mobile app qr code widgets
rusikv May 10, 2024
bb8be27
Merge remote-tracking branch 'upstream/master' into qrCode
rusikv May 10, 2024
ffbb2c7
UI: refactor mobile app qr code widgets validationm, cleanup widgets …
rusikv May 10, 2024
9d71bb7
UI: clean up sysadmin, tenant home pages jsons
rusikv May 10, 2024
45ed484
UI: mobile app qr code improvements, cleanup
rusikv May 15, 2024
a4e5b94
Update home-links-routing.module.ts
ikulikov May 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"home_page_widgets.quick_links",
"home_page_widgets.documentation_links",
"home_page_widgets.dashboards",
"home_page_widgets.usage_info"
"home_page_widgets.usage_info",
"home_page_widgets.mobile_app_qr_code"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"fqn": "home_page_widgets.mobile_app_qr_code",
"name": "Mobile app QR code",
"deprecated": false,
"image": null,
"description": null,
"descriptor": {
"type": "static",
"sizeX": 6,
"sizeY": 3,
"resources": [],
"templateHtml": "<tb-mobile-app-qrcode-widget\n [ctx]=\"ctx\">\n</tb-mobile-app-qrcode-widget>",
"templateCss": "",
"controllerScript": "self.onInit = function() {\n self.ctx.$scope.mobileAppQrcodeWidget.ngOnInit();\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.mobileAppQrcodeWidget.ngOnDestroy();\n}",
"settingsSchema": "",
"dataKeySettingsSchema": "",
"settingsDirective": "",
"defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"cardHtml\":\"<div class='card'>HTML code here</div>\",\"cardCss\":\".card {\\n font-weight: bold;\\n font-size: 32px;\\n color: #999;\\n width: 100%;\\n height: 100%;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n}\"},\"title\":\"HTML Card\",\"dropShadow\":true}"
},
"tags": null
}
14 changes: 14 additions & 0 deletions application/src/main/data/upgrade/3.6.4/schema_update.sql
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,17 @@ DELETE FROM asset WHERE type='TbServiceQueue';
DELETE FROM asset_profile WHERE name ='TbServiceQueue';

-- QUEUE STATS UPDATE END

-- MOBILE APP SETTINGS TABLE CREATE START

CREATE TABLE IF NOT EXISTS mobile_app_settings (
id uuid NOT NULL CONSTRAINT mobile_app_settings_pkey PRIMARY KEY,
created_time bigint NOT NULL,
tenant_id uuid UNIQUE NOT NULL,
use_default_app boolean,
android_config VARCHAR(1000),
ios_config VARCHAR(1000),
qr_code_config VARCHAR(100000)
);

-- MOBILE APP SETTINGS TABLE CREATE END
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public class ThingsboardSecurityConfiguration {
public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public";
public static final String TOKEN_REFRESH_ENTRY_POINT = "/api/auth/token";
protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[]{"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**"};
protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[]{"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**", "/.well-known/**"};
public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
public static final String WS_ENTRY_POINT = "/api/ws/**";
public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.controller;

import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.mobile.AndroidConfig;
import org.thingsboard.server.common.data.mobile.IosConfig;
import org.thingsboard.server.common.data.mobile.MobileAppSettings;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.mobile.MobileAppSettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.qr.MobileAppSecretService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import org.thingsboard.server.service.security.system.SystemSecurityService;

import java.net.URI;
import java.net.URISyntaxException;

import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH;

@RequiredArgsConstructor
@RestController
@TbCoreComponent
public class MobileApplicationController extends BaseController {

@Value("${cache.specs.mobileSecretKey.timeToLiveInMinutes:2}")
private int mobileSecretKeyTtl;

public static final String ASSET_LINKS_PATTERN = "[{\n" +
" \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n" +
" \"target\": {\n" +
" \"namespace\": \"android_app\",\n" +
" \"package_name\": \"%s\",\n" +
" \"sha256_cert_fingerprints\":\n" +
" [\"%s\"]\n" +
" }\n" +
"}]";

public static final String APPLE_APP_SITE_ASSOCIATION_PATTERN = "{\n" +
" \"applinks\": {\n" +
" \"apps\": [],\n" +
" \"details\": [\n" +
" {\n" +
" \"appID\": \"%s\",\n" +
" \"paths\": [ \"/api/noauth/qr\" ]\n" +
" }\n" +
" ]\n" +
" }\n" +
"}";

public static final String ANDROID_APPLICATION_STORE_LINK = "https://play.google.com/store/apps/details?id=org.thingsboard.demo.app";
public static final String APPLE_APPLICATION_STORE_LINK = "https://apps.apple.com/us/app/thingsboard-live/id1594355695";
public static final String SECRET = "secret";
public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-live secret key";
ViacheslavKlimov marked this conversation as resolved.
Show resolved Hide resolved
public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io";
public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s&ttl=%s";

private final SystemSecurityService systemSecurityService;
private final MobileAppSecretService mobileAppSecretService;
private final MobileAppSettingsService mobileAppSettingsService;

@ApiOperation(value = "Get associated android applications (getAssetLinks)")
@GetMapping(value = "/.well-known/assetlinks.json")
public ResponseEntity<JsonNode> getAssetLinks() {
MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
AndroidConfig androidConfig = mobileAppSettings.getAndroidConfig();
if (androidConfig != null && androidConfig.isEnabled() && !androidConfig.getAppPackage().isBlank() && !androidConfig.getSha256CertFingerprints().isBlank()) {
return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(ASSET_LINKS_PATTERN, androidConfig.getAppPackage(), androidConfig.getSha256CertFingerprints())));
} else {
return ResponseEntity.notFound().build();
}
}

@ApiOperation(value = "Get associated ios applications (getAppleAppSiteAssociation)")
@GetMapping(value = "/.well-known/apple-app-site-association")
public ResponseEntity<JsonNode> getAppleAppSiteAssociation() {
MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
IosConfig iosConfig = mobileAppSettings.getIosConfig();
if (iosConfig != null && iosConfig.isEnabled() && !iosConfig.getAppId().isBlank()) {
return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(APPLE_APP_SITE_ASSOCIATION_PATTERN, iosConfig.getAppId())));
} else {
return ResponseEntity.notFound().build();
}
}

@ApiOperation(value = "Create Or Update the Mobile application settings (saveMobileAppSettings)",
notes = "The request payload contains configuration for android/iOS applications and platform qr code widget settings." + SYSTEM_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
@PostMapping(value = "/api/mobile/app/settings")
public MobileAppSettings saveMobileAppSettings(@Parameter(description = "A JSON value representing the mobile apps configuration")
@RequestBody MobileAppSettings mobileAppSettings) throws ThingsboardException {
SecurityUser currentUser = getCurrentUser();
accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.WRITE);
mobileAppSettings.setTenantId(getTenantId());
return mobileAppSettingsService.saveMobileAppSettings(currentUser.getTenantId(), mobileAppSettings);
}

@ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)",
notes = "The response payload contains configuration for android/iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/api/mobile/app/settings")
public MobileAppSettings getMobileAppSettings() throws ThingsboardException {
SecurityUser currentUser = getCurrentUser();
accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ);
return mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
}

@ApiOperation(value = "Get the deep link to the associated mobile application (getMobileAppDeepLink)",
notes = "Fetch the url that takes user to linked mobile application " + AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/api/mobile/deepLink", produces = "text/plain")
public String getMobileAppDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException {
String secret = mobileAppSecretService.generateMobileAppSecret(getCurrentUser());
String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, null, request);
String platformDomain = new URI(baseUrl).getHost();
MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID);
String appDomain;
if (!mobileAppSettings.isUseDefaultApp()) {
appDomain = platformDomain;
} else {
appDomain = DEFAULT_APP_DOMAIN;
}
String deepLink = String.format(DEEP_LINK_PATTERN, appDomain, secret, mobileSecretKeyTtl);
if (!appDomain.equals(platformDomain)) {
deepLink = deepLink + "&host=" + baseUrl;
}
return "\"" + deepLink + "\"";
}

@ApiOperation(value = "Get User Token (getUserTokenByMobileSecret)",
notes = "Returns the token of the User based on the provided secret key.")
@GetMapping(value = "/api/noauth/qr/{secret}")
public JwtPair getUserTokenByMobileSecret(@Parameter(description = SECRET_PARAM_DESCRIPTION)
@PathVariable(SECRET) String secret) throws ThingsboardException {
ViacheslavKlimov marked this conversation as resolved.
Show resolved Hide resolved
checkParameter(SECRET, secret);
return mobileAppSecretService.getJwtPair(secret);
}

@GetMapping(value = "/api/noauth/qr")
public ResponseEntity<?> getApplicationRedirect(@RequestHeader(value = "User-Agent") String userAgent) {
if (userAgent.contains("Android")) {
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", ANDROID_APPLICATION_STORE_LINK)
.build();
} else if (userAgent.contains("iPhone") || userAgent.contains("iPad")) {
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", APPLE_APPLICATION_STORE_LINK)
.build();
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.qr;

import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.service.security.model.SecurityUser;

public interface MobileAppSecretService {

String generateMobileAppSecret(SecurityUser securityUser);

JwtPair getJwtPair(String secret) throws ThingsboardException;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.qr;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.server.cache.TbCacheValueWrapper;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.dao.entity.AbstractCachedService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.system.SystemSecurityService;

import static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_MOBILE_SECRET_KEY_LENGTH;

@Service
@Slf4j
@RequiredArgsConstructor
public class MobileAppSecretServiceImpl extends AbstractCachedService<String, JwtPair, MobileSecretEvictEvent> implements MobileAppSecretService {

private final JwtTokenFactory tokenFactory;
private final SystemSecurityService systemSecurityService;

@Override
public String generateMobileAppSecret(SecurityUser securityUser) {
log.trace("Executing generateSecret for user [{}]", securityUser.getId());
Integer mobileSecretKeyLength = systemSecurityService.getSecuritySettings().getMobileSecretKeyLength();
String secret = StringUtils.generateSafeToken(mobileSecretKeyLength == null ? DEFAULT_MOBILE_SECRET_KEY_LENGTH : mobileSecretKeyLength);
cache.put(secret, tokenFactory.createTokenPair(securityUser));
return secret;
}

@Override
public JwtPair getJwtPair(String secret) throws ThingsboardException {
TbCacheValueWrapper<JwtPair> jwtPair = cache.get(secret);
if (jwtPair != null) {
return jwtPair.get();
} else {
throw new ThingsboardException("Jwt token not found or expired!", ThingsboardErrorCode.JWT_TOKEN_EXPIRED);
}
}

@TransactionalEventListener(classes = MobileSecretEvictEvent.class)
@Override
public void handleEvictEvent(MobileSecretEvictEvent event) {
cache.evict(event.getSecret());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.qr;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.thingsboard.server.cache.CaffeineTbTransactionalCache;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.security.model.JwtPair;

@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true)
@Service("MobileSecretCache")
public class MobileSecretCaffeineCache extends CaffeineTbTransactionalCache<String, JwtPair> {

public MobileSecretCaffeineCache(CacheManager cacheManager) {
super(cacheManager, CacheConstants.MOBILE_SECRET_KEY_CACHE);
}

}