186 Commits

Author SHA1 Message Date
1bb37eec30 TODO's 2023-06-18 14:28:58 +02:00
59511b2345 Fix bug in migration script 2023-06-18 14:16:37 +02:00
5b7bc02c61 Fix validation in web form 2023-06-18 13:25:00 +02:00
b329f537e7 Fix message_sent html 2023-06-18 13:19:51 +02:00
5879e81759 Enable RequestLog on dev/stag/prod 2023-06-18 13:11:48 +02:00
f4e88bef77 Fix NPE in compat-ack 2023-06-18 13:09:36 +02:00
b3ec45309c Insert exclam on compat clients if message uses old channel syntax 2023-06-18 11:59:26 +02:00
2fbc892898 various fixes in scn_send script 2023-06-18 04:45:28 +02:00
c46190c3fc Support x-www-form-urlencoded form-data 2023-06-18 03:46:01 +02:00
860e540de1 Better CreateKey API (make all_channels and channels optional) 2023-06-18 02:54:41 +02:00
8cde286cac Fix failing tests 2023-06-18 02:36:44 +02:00
90830fe384 Fix empty string in channels field in GetKeys route 2023-06-18 02:34:04 +02:00
686f89f75d change URL to simplecloudnotifier.de 2023-06-18 02:22:29 +02:00
4210af5680 Properly implement compat unack_count 2023-06-18 02:09:05 +02:00
aefc368cfd Send compat-msgid to compat clients (BF old android client) 2023-06-18 01:55:58 +02:00
67218d8045 Added a few logs 2023-06-18 01:36:34 +02:00
c05deb3a41 allow \n in private-key envs 2023-06-18 01:29:13 +02:00
43d0107fb5 Update goext (fix bool env parsing) 2023-06-18 01:18:33 +02:00
ece7612f9d more migration fixes 2023-06-18 00:49:29 +02:00
a9809d90cb fix exception in js-send (logic.js) 2023-06-18 00:25:10 +02:00
bbc9a79996 Fix bug in migration script 2023-06-18 00:24:53 +02:00
b71f1885ec Fix wrongly named env variables 2023-06-17 23:40:46 +02:00
885aad2047 Update migrationscript 2023-06-17 23:23:54 +02:00
7121afab08 Fix [TestQuotaExceededNoPro, TestQuotaExceededPro] 2023-06-17 20:16:02 +02:00
d9a4c4ffd6 Fix [TestChannelMessageCounter] 2023-06-17 20:08:39 +02:00
fb826919a6 added linter 2023-06-14 15:03:37 +02:00
22720169a2 Tests[TestUserMessageCounter, TestTokenKeysMessageCounter, TestChannelMessageCounter] 2023-06-10 03:41:54 +02:00
7fefd251db Update TODO.md 2023-06-10 00:15:42 +02:00
5de4f67344 use nil-safe json renderer in ginresp 2023-06-09 21:37:30 +02:00
d396a12d68 fix missing error on missing json header 2023-06-09 20:14:30 +02:00
3888c91a6b Switch to multi-stage Dockerbuild 2023-06-06 18:52:03 +02:00
562bac6987 Move enum-generate to goext 2023-06-05 13:37:49 +02:00
e825b4dd85 Update PrimaryHash3 2023-05-30 14:25:55 +02:00
08587b7a7a Tests[**Subscription**] 2023-05-29 01:51:51 +02:00
0daca2cf8f added UpdateClient route 2023-05-28 23:25:18 +02:00
3a9b15c2be Use sq.InsertSingle to insert entities 2023-05-28 22:27:38 +02:00
e9b4db0f1c Validate db schema before startup 2023-05-28 19:59:57 +02:00
312a31ce9e Fix missing certificates in docker 2023-05-28 17:54:53 +02:00
d4a8a2e720 Tests[SendWithAdminKey, SendWithSendKey, SendWithReadKey, SendWithPermissionSendKey] 2023-05-28 17:38:19 +02:00
dcb4f253d8 fix swagger errors 2023-05-28 17:04:44 +02:00
d0a04bae84 Move to multistage Dockerfile 2023-05-28 16:03:14 +02:00
34ac96edd7 Tests[...]:
- SearchMessageFTSMulti
 - ListMessagesFilteredChannels
 - ListMessagesFilteredChannelIDs
 - ListMessagesFilteredSenders
 - ListMessagesFilteredTime
 - ListMessagesFilteredPriority
 - ListMessagesFilteredKeyTokens
2023-05-28 15:46:46 +02:00
b42ce84c3e Added [Channels, ChannelIDs, Senders, TimeBefore, TimeAfter, Priority, KeyTokens] filter to ListMessages 2023-05-28 14:05:53 +02:00
2db779b44f split api.go into multiple files 2023-05-28 13:39:20 +02:00
397bfe78aa Remove Channel/Username normalization (except TrimSpace) 2023-05-28 13:14:05 +02:00
efaad3f97c Fix RequestLogCollectorJob sometimes not properly shutting down 2023-05-28 12:31:14 +02:00
624c613bd1 Tests[ListChannelMessagesOfUnsubscribed, ListChannelMessagesOfUnconfirmed1, ListChannelMessagesOfUnconfirmed2, ListChannelMessagesOfRevokedConfirmation] 2023-05-28 03:38:33 +02:00
07b0632c95 Tests[ListChannelSubscriptions] 2023-05-28 03:16:29 +02:00
3d1e6cfa17 Tests[ListSubscribedChannelMessages] 2023-05-28 02:50:55 +02:00
3db636d41a Tests[ListChannelMessages] 2023-05-28 02:40:24 +02:00
2053b8f07f Tests[ListMessages, ListMessagesPaginated, ListMessagesPaginatedInvalid] 2023-05-28 02:27:15 +02:00
b1681b53e4 Tests[TokenKeys, TokenKeysInitial, TokenKeysCreate, TokenKeysUpdate, TokenKeysDelete, TokenKeysDeleteSelf, TokenKeysDowngradeSelf, TokenKeysPermissions] 2023-05-28 01:39:55 +02:00
03f60ff316 Tests[RequestLogSimple] 2023-05-27 23:54:14 +02:00
b2df0a5a02 Send channel as prefix for compat clients 2023-05-27 20:02:16 +02:00
8826cb0312 Save used keytoken in messages 2023-05-27 18:51:20 +02:00
a0c72f5b94 Add keytoken explanation to api_more.html 2023-05-27 18:16:32 +02:00
7d9a58ae54 Fix swagger 2023-05-27 17:51:56 +02:00
fd72b512f8 Directly use pygmentize in Makefile (for scn script in Website) 2023-05-27 17:42:06 +02:00
28c2721036 Use gotestsum for make test 2023-05-27 15:39:07 +02:00
a1788bf75a use swaggo 1.8.12 2023-04-25 20:08:30 +02:00
b1bd278f9b Add KeyToken authorization 2023-04-21 21:45:16 +02:00
16f6ab4861 re-implement ack behaviour from version 1.0 for compat 2023-02-03 22:51:03 +01:00
01934e29b1 todos 2023-02-03 20:12:41 +01:00
d1cefb0150 API versioning ( basePath == /api/v2/* ) 2023-01-27 10:04:06 +01:00
27b189d33a BF 2023-01-24 13:52:11 +01:00
e05d88682a Tests[ListClients] 2023-01-18 21:56:37 +01:00
2a5f1f5f7e Tests[CompatUpdate, CompatUpdateFCM] 2023-01-17 23:10:38 +01:00
e7a45d9a05 Tests[UgradeUserToPro, DowngradeUserToNonPro, FailedUgradeUserToPro, FailToCreateProUser, ReuseProToken] 2023-01-17 22:56:04 +01:00
ec9a326002 Tests[CompatUpgrade] 2023-01-17 22:32:13 +01:00
23c7729fcf Tests[CompatRegisterPro] 2023-01-17 22:03:27 +01:00
7fcd324299 Tests[CompatRequery] 2023-01-17 21:47:53 +01:00
1633449638 Tests[CompatExpand] 2023-01-17 21:30:53 +01:00
57231a1406 Tests[CompatAck] 2023-01-17 21:14:42 +01:00
2eb6292733 improve AssertEqual to handle annoying json.Unmarshal type conversions... 2023-01-17 20:41:45 +01:00
ff24493ff3 Tests[CompatRegister, CompatInfo] 2023-01-17 20:27:20 +01:00
3d602af135 Tests[TestSendCompatMessageByQuery, TestSendCompatMessageByFormData] 2023-01-16 20:29:49 +01:00
590665a5e9 create compat-id for messages && TestCreateCompatUser 2023-01-16 18:53:22 +01:00
89fd0dfed7 create migration script for old data 2023-01-15 06:30:30 +01:00
82bc887767 Move to string-ids for all entities (compat translation for existing data) 2023-01-14 00:48:51 +01:00
acd7de0dee cherry-pick caller logprint fix from psycho-backend 2023-01-13 17:51:55 +01:00
e737cd9d5c requests-log db 2023-01-13 17:17:17 +01:00
0ec7a9d274 add methods for (some) missing test cases 2023-01-13 12:54:19 +01:00
e49d9159e4 Added freely editable description_name field to channel 2023-01-13 12:43:20 +01:00
3343285761 goext 55 2023-01-06 02:03:10 +01:00
14bba38324 migrate to multiple sqlite db files ( primary + requests + logs ) 2023-01-06 00:39:21 +01:00
679277d59e catch panic in gin 2022-12-28 00:32:15 +01:00
cebb2ae2b6 small cleanups 2022-12-23 20:27:21 +01:00
56d9f977ae Tests[ListChannelsDefault, ListChannelsOwned, ListChannelsSubscribedAny, ListChannelsAllAny, ListChannelsSubscribed, ListChannelsAll] 2022-12-22 17:29:59 +01:00
984470b47d Fix sql-preprocessor leading to deadlocks in parallel requests 2022-12-22 16:51:04 +01:00
0112d681ac Fix SQL unmarshalling of optional nested structs (LEFT JOIN) 2022-12-22 12:43:40 +01:00
0cb2a977a0 Save internal_name and display_name in channel 2022-12-22 11:22:36 +01:00
f65c231ba0 Properly shutdown database on SIGTERM 2022-12-22 10:21:10 +01:00
dbc014f819 Added a SQL-Preprocessor - this way we can unmarshal recursive structures (LEFT JOIN etc) 2022-12-21 18:14:13 +01:00
bbf7962e29 move server/* to scnserver/* 2022-12-21 12:35:56 +01:00
2b4d77bab4 Cleaner swagger routes 2022-12-21 11:03:31 +01:00
8582674b44 Refactoring 2022-12-20 13:55:09 +01:00
f7675be834 Use multiple DB connections but retry failed requests 2022-12-20 09:52:33 +01:00
00d77e508d Fix TestSendParallel by using only a single DB connection
see https://github.com/mattn/go-sqlite3/issues/274
see https://github.com/mattn/go-sqlite3/issues/209
see https://stackoverflow.com/questions/32479071/sqlite3-error-database-is-locked-in-golang
2022-12-20 09:22:18 +01:00
e90cfe34e9 switch to new registry image-name 2022-12-16 14:57:17 +01:00
54dfd535a4 Move parseConfOverride() to goext 2022-12-16 01:07:48 +01:00
5a02eb6d18 Prefix all config key with SCN_* 2022-12-14 18:46:26 +01:00
97fc9319d1 Fix wrong env keys in config.go 2022-12-14 18:43:32 +01:00
03b4acd13e Tests[CreateProUser] 2022-12-14 18:38:30 +01:00
86f06a3c6a Tests[CreateChannel, CreateChannelNameTooLong, ChannelNameNormalization] 2022-12-14 18:27:22 +01:00
06e8d2a6e2 Tests[SendLongContentPro] 2022-12-14 18:18:02 +01:00
99f248a8ce Tests[SendParallel] (skipped for now) 2022-12-14 18:08:03 +01:00
c7aaa6ad98 Tests[QuotaExeededPro] 2022-12-14 17:56:14 +01:00
cb5ce66c1a Tests[QuotaExeededNoPro] 2022-12-14 17:56:03 +01:00
0750bf1d8a cleanup test local-url 2022-12-14 17:02:18 +01:00
203360e8b5 Tests[SendToTooLongChannel] 2022-12-14 16:57:08 +01:00
ef1844109f Tests[SendToManualChannel] 2022-12-14 16:38:01 +01:00
de6ad35f60 new Endpoint: CreateChannel(*) 2022-12-14 14:29:59 +01:00
fbb289dedf Added error descriptions to swagger 2022-12-14 14:27:41 +01:00
f1e87170f0 Tests[SendToNewChannel] 2022-12-14 14:30:34 +01:00
66ecad27a7 Only soft-delete messages 2022-12-14 12:29:55 +01:00
98b1e8bd80 move ScanAll/ScanSingle in sq package 2022-12-11 03:14:42 +01:00
26cd1533b4 Tests[SearchMessageFTSSimple] 2022-12-11 02:47:23 +01:00
3692b915f3 Messagefilter (+FTS) [WIP] 2022-12-10 03:38:48 +01:00
06788c3e12 TestData-Factory [WIP] 2022-12-09 00:40:50 +01:00
edfcdd1135 TestData-Factory [WIP] 2022-12-09 00:13:10 +01:00
dd2f3baa0c Properly close db cursors after use 2022-12-08 11:31:52 +01:00
7db70e392b Simplify fts table schema 2022-12-07 23:43:52 +01:00
0cae24a612 Move sq + ParseDurShortString() to goext and change conf values by env 2022-12-07 23:32:58 +01:00
8db0fa37db Move to own sql abstraction on top of jmoiron/sqlx 2022-12-07 22:11:44 +01:00
d27e3d9a91 Made sqlite tables strict (type checked) 2022-12-07 22:11:07 +01:00
fa5a4107a6 Added FTS5 table to schema (full-text-search) 2022-12-07 22:10:46 +01:00
234188c4d4 Tests[SendCompat] 2022-12-01 14:45:31 +01:00
9b700581f3 Tests[SendSimpleMessageAlt1] 2022-12-01 14:30:46 +01:00
12db23d076 Improve test performance (better waiting logic until http server is up) 2022-11-30 23:46:28 +01:00
fd182f0abb Fix timeout in ReadSchema/GetMeta etc method (fixes /health call taking 2 seconds) 2022-11-30 22:59:33 +01:00
7eab74e65c Tests[SendWithTimestamp, SendInvalidTimestamp] 2022-11-30 22:29:12 +01:00
e0ecd4d9ff Tests[SendInvalidPriority] 2022-11-30 21:51:48 +01:00
1ca09c16d3 Tests[SendWithPriority] 2022-11-30 21:39:14 +01:00
a7df476e79 Tests[SendIdempotent] 2022-11-30 21:17:29 +01:00
4e5eac6178 Tests[SendLongContent, LongContent, LongTitle] 2022-11-30 20:59:01 +01:00
91a6808ad2 Tests[SendWithSendername] 2022-11-30 20:47:43 +01:00
11a6517156 Tests[SendContentMessage] 2022-11-30 20:39:04 +01:00
7aa7eb234d Tests[SendSimpleMessageQuery, SendSimpleMessageForm, SendSimpleMessageFormAndQuery, SendSimpleMessageJSONAndQuery] 2022-11-30 20:23:31 +01:00
62d7df9710 Tests[TestSendSimpleMessageJSON] 2022-11-30 17:58:04 +01:00
0ff1188c3d Added swagger themes 2022-11-30 16:46:55 +01:00
b6e8d037a0 Add json tags to query structs (otherwise swag does not get the correct names) 2022-11-30 16:46:14 +01:00
7a11b2c76f Tests[UpdateUsername, RecreateKeys, DeleteUser] 2022-11-30 13:57:55 +01:00
7f56dbdbfa Tests[GetClient, CreateClient, DeleteClient, ReuseFCM] 2022-11-30 12:40:03 +01:00
df4eb15df8 Tests[CreateUser] 2022-11-30 10:35:05 +01:00
ac9ae06cc8 Save SenderName || SenderIP per message 2022-11-29 11:07:15 +01:00
464cf3ec7e Better error message on missing envs 2022-11-26 17:03:26 +01:00
bf0ce5c963 dark-mode 2022-11-26 16:30:30 +01:00
3a0c65a849 Added google androidpublisher/v3 api to verify google purchase tokens 2022-11-25 22:42:21 +01:00
6d80638cf8 CreateUser test 2022-11-24 12:53:27 +01:00
37e09d6532 cleanup swagger 2022-11-23 22:12:47 +01:00
8ea3fdcfef tests (boilerplate) 2022-11-23 20:21:49 +01:00
1bc847cdc9 tags/grouping for API 2022-11-23 19:32:23 +01:00
03c35d6446 update HTML with new methods 2023-06-18 04:07:13 +02:00
d5aea1a828 README 2022-11-21 18:46:55 +01:00
f17ddb4ace switch to debian base-image (no more static linking) 2022-11-20 22:18:48 +01:00
0cc6e27267 Use ID types 2022-11-20 22:18:24 +01:00
ca58aa782d Routes to refresh user and channel keys 2022-11-20 21:35:08 +01:00
e8671e8650 Selector param for ListChannels() 2022-11-20 21:15:06 +01:00
d46601be5c CreateMessage() 2022-11-20 20:34:18 +01:00
d30e2cefc0 firebase via REST (less dependencies) 2023-06-18 04:06:52 +02:00
08a93551e7 DeliveryRetryJob 2022-11-20 15:40:22 +01:00
c2899fd727 swagger doku for compat methods 2022-11-20 13:18:09 +01:00
5ec66e1777 cleanup 2022-11-20 12:59:43 +01:00
516809cd02 Dockerfile, CONF_NS and fix sqlite3 under alpine 2022-11-20 03:41:38 +01:00
0d3526221d replace PHP in html with js & bugfixes 2022-11-20 03:18:23 +01:00
728b12107f compat methods 2022-11-20 01:28:32 +01:00
b56c021356 ListChannelMessages() 2022-11-20 00:30:30 +01:00
80f3b982d2 ListMessages() 2022-11-20 00:21:59 +01:00
0d641b727f CreateSubscription(), UpdateSubscription(), GetMessage(), DeleteMessage() 2022-11-19 23:16:54 +01:00
8278c059ad fix context in methods.go 2022-11-19 17:15:46 +01:00
7af0ff5413 TODO's 2022-11-19 17:09:23 +01:00
5c2877bdb8 ListChannels(), GetChannel(), ListUserSubscriptions(), ListChannelSubscriptions(), GetSubscription(), CancelSubscription() 2022-11-19 17:07:30 +01:00
85bfe79115 SendMessage() 2022-11-19 16:29:14 +01:00
fb37f94c0a firebase implementation 2022-11-19 15:11:36 +01:00
e53f40866e DeleteClient() 2022-11-19 12:59:25 +01:00
650ba20e5d AddClient() 2022-11-19 12:56:44 +01:00
6e01c41c22 GetClient() 2022-11-19 12:50:41 +01:00
f555f0f1cf ListClients() 2022-11-19 12:47:23 +01:00
35ef2175bc UpdateUser() works 2022-11-18 23:33:07 +01:00
55f53deadf GetUser() works 2022-11-18 23:12:37 +01:00
5991631bfa POST:/users works 2022-11-18 21:25:40 +01:00
34a27d9ca4 schema 3.0 2022-11-17 21:26:52 +01:00
1671490485 implement a bit of the register.php call (and the DB schema) 2022-11-13 22:31:28 +01:00
0e58a5c5f0 added template for new golang backend 2022-11-13 19:25:44 +01:00
bfb-vserver-wwwdata
bd11d7973c server BF 2022-10-17 21:35:06 +02:00
f3b5b09ed0 A few code fixes 2020-11-04 10:08:06 +01:00
188 changed files with 44361 additions and 14 deletions

View File

@@ -10,7 +10,7 @@ import com.blackforestbytes.simplecloudnotifier.SCNApp;
import com.blackforestbytes.simplecloudnotifier.lib.datatypes.Tuple3; import com.blackforestbytes.simplecloudnotifier.lib.datatypes.Tuple3;
import com.blackforestbytes.simplecloudnotifier.lib.string.Str; import com.blackforestbytes.simplecloudnotifier.lib.string.Str;
import com.blackforestbytes.simplecloudnotifier.service.IABService; import com.blackforestbytes.simplecloudnotifier.service.IABService;
import com.google.firebase.iid.FirebaseInstanceId; import com.google.firebase.installations.FirebaseInstallations;
public class SCNSettings public class SCNSettings
{ {
@@ -182,13 +182,13 @@ public class SCNSettings
return base + "index.php?preset_user_id="+user_id+"&preset_user_key="+user_key; return base + "index.php?preset_user_id="+user_id+"&preset_user_key="+user_key;
} }
public void setServerToken(String token, View loader) public void setServerToken(String token, View loader, boolean force)
{ {
if (isConnected()) if (isConnected())
{ {
fcm_token_local = token; fcm_token_local = token;
save(); save();
if (!fcm_token_local.equals(fcm_token_server)) ServerCommunication.updateFCMToken(user_id, user_key, fcm_token_local, loader); if (!fcm_token_local.equals(fcm_token_server) || force) ServerCommunication.updateFCMToken(user_id, user_key, fcm_token_local, loader);
} }
else else
{ {
@@ -200,13 +200,12 @@ public class SCNSettings
} }
// called at app start // called at app start
public void work(Activity a) public void work(Activity a, boolean force)
{ {
FirebaseInstanceId.getInstance().getInstanceId().addOnSuccessListener(a, instanceIdResult -> FirebaseInstallations.getInstance().getId().addOnSuccessListener(a, newToken ->
{ {
String newToken = instanceIdResult.getToken();
Log.d("FB::GetInstanceId", newToken); Log.d("FB::GetInstanceId", newToken);
SCNSettings.inst().setServerToken(newToken, null); SCNSettings.inst().setServerToken(newToken, null, force);
}).addOnCompleteListener(r -> }).addOnCompleteListener(r ->
{ {
if (isConnected()) ServerCommunication.info(user_id, user_key, null); if (isConnected()) ServerCommunication.info(user_id, user_key, null);
@@ -232,16 +231,15 @@ public class SCNSettings
if (promode_server != promode_local) updateProState(loader); if (promode_server != promode_local) updateProState(loader);
if (!Str.equals(fcm_token_local, fcm_token_server)) work(a); if (!Str.equals(fcm_token_local, fcm_token_server)) work(a, false);
} }
else else
{ {
// get token then register // get token then register
FirebaseInstanceId.getInstance().getInstanceId().addOnSuccessListener(a, instanceIdResult -> FirebaseInstallations.getInstance().getId().addOnSuccessListener(a, newToken ->
{ {
String newToken = instanceIdResult.getToken();
Log.d("FB::GetInstanceId", newToken); Log.d("FB::GetInstanceId", newToken);
SCNSettings.inst().setServerToken(newToken, loader); // does register in here SCNSettings.inst().setServerToken(newToken, loader, false); // does register in here
}).addOnCompleteListener(r -> }).addOnCompleteListener(r ->
{ {
if (isConnected()) ServerCommunication.info(user_id, user_key, null); // info again for safety if (isConnected()) ServerCommunication.info(user_id, user_key, null); // info again for safety

View File

@@ -79,7 +79,7 @@ public class MainActivity extends AppCompatActivity
SCNApp.register(this); SCNApp.register(this);
IABService.startup(this); IABService.startup(this);
SCNSettings.inst().work(this); SCNSettings.inst().work(this, true);
} }
@Override @Override
@@ -207,7 +207,7 @@ public class MainActivity extends AppCompatActivity
tabLayout.setupWithViewPager(viewPager); tabLayout.setupWithViewPager(viewPager);
SCNSettings.inst().work(this); SCNSettings.inst().work(this, true);
SCNApp.showToast("Backup imported", Toast.LENGTH_LONG); SCNApp.showToast("Backup imported", Toast.LENGTH_LONG);

58
androidExportReader/.gitignore vendored Normal file
View File

@@ -0,0 +1,58 @@
# Created by https://www.toptal.com/developers/gitignore/api/java,gradle
# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle
### Java ###
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### Gradle ###
.gradle
**/build/
!src/**/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Avoid ignore Gradle wrappper properties
!gradle-wrapper.properties
# Cache of project
.gradletasknamecache
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
### Gradle Patch ###
# Java heap dump
*.hprof
# End of https://www.toptal.com/developers/gitignore/api/java,gradle

8
androidExportReader/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

6
androidExportReader/.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="18" />
</component>
</project>

17
androidExportReader/.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View File

@@ -0,0 +1,11 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="JavadocReference" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
</component>
</project>

10
androidExportReader/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_18" default="true" project-jdk-name="openjdk-18" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

6
androidExportReader/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,47 @@
buildscript {
repositories {
gradlePluginPortal()
}
dependencies {
classpath 'gradle.plugin.com.github.johnrengelman:shadow:7.1.2'
}
}
plugins {
id 'java'
id("com.github.johnrengelman.shadow") version "7.1.2"
id 'application'
}
group 'com.blackforestbytes'
version '1.0-SNAPSHOT'
repositories {
mavenCentral()
maven { url "https://jitpack.io" }
}
application {
mainClass = 'com.blackforestbytes.Main'
}
jar {
manifest {
attributes 'Main-Class': application.mainClass
}
}
tasks.jar {
manifest.attributes["Main-Class"] = application.mainClass
}
dependencies {
implementation 'com.github.RalleYTN:SimpleJSON:2.1.1'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}
test {
useJUnitPlatform()
}

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

240
androidExportReader/gradlew vendored Executable file
View File

@@ -0,0 +1,240 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original 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
#
# https://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.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

91
androidExportReader/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,2 @@
rootProject.name = 'androidExportReader'

View File

@@ -0,0 +1,104 @@
package com.blackforestbytes;
import de.ralleytn.simple.json.JSONArray;
import de.ralleytn.simple.json.JSONFormatter;
import de.ralleytn.simple.json.JSONObject;
import java.io.ObjectInputStream;
import java.net.URI;
import java.nio.file.FileSystems;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class Main {
@SuppressWarnings("unchecked")
public static void main(String[] args) {
if (args.length != 1) {
System.err.println("call with ./androidExportConvert scn_export.dat");
return;
}
try {
var path = FileSystems.getDefault().getPath(args[0]).normalize().toAbsolutePath().toUri().toURL();
ObjectInputStream stream = new ObjectInputStream(path.openStream());
Map<String, ?> d1 = new HashMap<>((Map<String, ?>)stream.readObject());
Map<String, ?> d2 = new HashMap<>((Map<String, ?>)stream.readObject());
Map<String, ?> d3 = new HashMap<>((Map<String, ?>)stream.readObject());
Map<String, ?> d4 = new HashMap<>((Map<String, ?>)stream.readObject());
stream.close();
JSONObject root = new JSONObject();
var subConfig = new JSONObject();
var subIAB = new JSONArray();
var subCMessageList = new JSONArray();
var subAcks = new JSONArray();
var subQueryLog = new JSONArray();
for (Map.Entry<String, ?> entry : d1.entrySet())
{
if (entry.getValue() instanceof String) subConfig.put(entry.getKey(), (String)entry.getValue());
if (entry.getValue() instanceof Boolean) subConfig.put(entry.getKey(), (Boolean)entry.getValue());
if (entry.getValue() instanceof Float) subConfig.put(entry.getKey(), (Float)entry.getValue());
if (entry.getValue() instanceof Integer) subConfig.put(entry.getKey(), (Integer)entry.getValue());
if (entry.getValue() instanceof Long) subConfig.put(entry.getKey(), (Long)entry.getValue());
if (entry.getValue() instanceof Set<?>) subConfig.put(entry.getKey(), ((Set<String>)entry.getValue()).toArray());
}
for (int i = 0; i < (Integer)d2.get("c"); i++) {
var obj = new JSONObject();
obj.put("key", d2.get("["+i+"]->key"));
obj.put("value", d2.get("["+i+"]->value"));
subIAB.add(obj);
}
for (int i = 0; i < (Integer)d3.get("message_count"); i++) {
if (d3.get("message["+i+"].scnid") == null)
throw new Exception("ONF");
var obj = new JSONObject();
obj.put("timestamp", d3.get("message["+i+"].timestamp"));
obj.put("title", d3.get("message["+i+"].title"));
obj.put("content", d3.get("message["+i+"].content"));
obj.put("priority", d3.get("message["+i+"].priority"));
obj.put("scnid", d3.get("message["+i+"].scnid"));
subCMessageList.add(obj);
}
subAcks.addAll(((Set<String>)d3.get("acks")).stream().map(p -> Long.decode("0x"+p)).toList());
for (int i = 0; i < (Integer)d4.get("history_count"); i++) {
if (d4.get("message["+(i+1000)+"].Name") == null)
throw new Exception("ONF");
var obj = new JSONObject();
obj.put("Level", d4.get("message["+(i+1000)+"].Level"));
obj.put("Timestamp", d4.get("message["+(i+1000)+"].Timestamp"));
obj.put("Name", d4.get("message["+(i+1000)+"].Name"));
obj.put("URL", d4.get("message["+(i+1000)+"].URL"));
obj.put("Response", d4.get("message["+(i+1000)+"].Response"));
obj.put("ResponseCode", d4.get("message["+(i+1000)+"].ResponseCode"));
obj.put("ExceptionString", d4.get("message["+(i+1000)+"].ExceptionString"));
subQueryLog.add(obj);
}
root.put("config", subConfig);
root.put("iab", subIAB);
root.put("cmessagelist", subCMessageList);
root.put("acks", subAcks);
root.put("querylog", subQueryLog);
System.out.println(new JSONFormatter().format(root.toString()));
} catch (Exception e) {
e.printStackTrace();
}
}
}

102
scnserver/.gitignore vendored Normal file
View File

@@ -0,0 +1,102 @@
_build
.run-data
DOCKER_GIT_INFO
scn_export.dat
scn_export.json
scn_export_*.dat
scn_export_*.json
simple_cloud_notifier-202306172202.sql
simple_cloud_notifier-*.sql
identifier.sqlite
.idea/dataSources.xml
##############
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
.idea/**/aws.xml
.idea/**/contentModel.xml
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
.idea/**/gradle.xml
.idea/**/libraries
.idea/**/mongoSettings.xml
.idea/**/sonarlint/
.idea/**/sonarIssues.xml
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
.idea/**/azureSettings.xml
.idea/replstate.xml
.idea/sonarlint/
.idea/httpRequests
.idea/caches/build_file_checksums.ser
.idea/$CACHE_FILE$
.idea/codestream.xml
.idea_modules/
cmake-build-*/
*.iws
out/
atlassian-ide-plugin.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
*.icloud

40
scnserver/.golangci.yml Normal file
View File

@@ -0,0 +1,40 @@
# https://golangci-lint.run/usage/configuration/
run:
go: '1.20'
linters:
enable-all: true
disable:
- golint # deprecated
- exhaustivestruct # deprecated
- deadcode # deprecated
- scopelint # deprecated
- structcheck # deprecated
- varcheck # deprecated
- nosnakecase # deprecated
- maligned # deprecated
- interfacer # deprecated
- ifshort # deprecated
- dupl # (i disagree)
- ireturn # (i disagree)
- wrapcheck # (waiting for bferr)
- goerr113 # (waiting for bferr)
- varnamelen # (too many false-positives)
- gomnd # (i disagree)
- depguard # (not configured)
- gofumpt # (we do not use gofumpt)
- gci # (we do no use gci)
- lll # (i disagree)
- gochecknoglobals # (i disagree)
issues:
exclude-rules:
- path: api/handler/.*.go
linters:
- funlen
linters-settings:
tagalign:
align: true
sort: false

8
scnserver/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,12 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
<inspection_tool class="SqlRedundantOrderingDirectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

8
scnserver/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/server.iml" filepath="$PROJECT_DIR$/.idea/server.iml" />
</modules>
</component>
</project>

21
scnserver/.idea/server.iml generated Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true">
<buildTags>
<option name="customFlags">
<array>
<option value="timetzdata" />
<option value="sqlite_fts5" />
<option value="sqlite_foreign_keys" />
</array>
</option>
</buildTags>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/_pygments" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

11
scnserver/.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/db/schema/primary_3.ddl" dialect="SQLite" />
<file url="PROJECT" dialect="SQLite" />
</component>
<component name="SqlResolveMappings">
<file url="file://$PROJECT_DIR$" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;b3228d61-4c36-41ce-803f-63bd80e198b3&quot; }, &quot;group&quot;:{ &quot;@kind&quot;:&quot;schema&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;schema_3.0.ddl&quot; } } } } } }}" />
<file url="PROJECT" scope="" />
</component>
</project>

6
scnserver/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

34
scnserver/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM golang:1-bullseye AS builder
RUN apt-get update && \
apt-get install -y ca-certificates openssl make git tar coreutils && \
rm -rf /var/lib/apt/lists/*
COPY . /buildsrc
RUN cd /buildsrc && make build
FROM debian:bookworm
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
apt-get install -y --no-install-recommends tzdata && \
rm -rf /var/cache/apt/archives && \
rm -rf /var/lib/apt/lists
COPY --from=builder /buildsrc/_build/scn_backend /app/server
RUN mkdir /data
WORKDIR /app
EXPOSE 80
CMD ["/app/server"]

98
scnserver/Makefile Normal file
View File

@@ -0,0 +1,98 @@
DOCKER_REPO=registry.blackforestbytes.com
DOCKER_NAME=mikescher/simplecloudnotifier
PORT=9090
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
HASH=$(shell git rev-parse HEAD)
.PHONY: test swagger pygmentize docker migrate dgi pygmentize lint docker
build: swagger pygmentize fmt
mkdir -p _build
rm -f ./_build/scn_backend
go generate ./...
CGO_ENABLED=1 go build -v -o _build/scn_backend -tags "timetzdata sqlite_fts5 sqlite_foreign_keys" ./cmd/scnserver
run: build
mkdir -p .run-data
_build/scn_backend
gow:
which gow || go install github.com/mitranim/gow@latest
gow -e "go,mod,html,css,json,yaml,js" run -tags "timetzdata sqlite_fts5 sqlite_foreign_keys" blackforestbytes.com/simplecloudnotifier/cmd/scnserver
dgi:
[ ! -f "DOCKER_GIT_INFO" ] || rm DOCKER_GIT_INFO
echo -n "VCSTYPE=" >> DOCKER_GIT_INFO ; echo "git" >> DOCKER_GIT_INFO
echo -n "BRANCH=" >> DOCKER_GIT_INFO ; git rev-parse --abbrev-ref HEAD >> DOCKER_GIT_INFO
echo -n "HASH=" >> DOCKER_GIT_INFO ; git rev-parse HEAD >> DOCKER_GIT_INFO
echo -n "COMMITTIME=" >> DOCKER_GIT_INFO ; git log -1 --format=%cd --date=iso >> DOCKER_GIT_INFO
echo -n "REMOTE=" >> DOCKER_GIT_INFO ; git config --get remote.origin.url >> DOCKER_GIT_INFO
docker: dgi
docker build \
-t "$(DOCKER_NAME):$(HASH)" \
-t "$(DOCKER_NAME):$(NAMESPACE)-latest" \
-t "$(DOCKER_NAME):latest" \
-t "$(DOCKER_REPO)/$(DOCKER_NAME):$(HASH)" \
-t "$(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest" \
-t "$(DOCKER_REPO)/$(DOCKER_NAME):latest" \
.
swagger:
which swag || go install github.com/swaggo/swag/cmd/swag@v1.8.12
swag init -generalInfo api/router.go --propertyStrategy snakecase --output ./swagger/ --outputTypes "json,yaml"
pygmentize: website/scn_send.html
website/scn_send.html: website/scn_send.sh.txt
_pygments/pygmentizew -l bash -f html "$(shell pwd)/website/scn_send.sh.txt" > "$(shell pwd)/website/scn_send.html"
_pygments/pygmentizew -S monokai -f html > "$(shell pwd)/website/css/pygmnetize-dark.css"
_pygments/pygmentizew -S borland -f html > "$(shell pwd)/website/css/pygmnetize-light.css"
run-docker-local: docker
mkdir -p .run-data
docker run --rm \
--init \
--env "CONF_NS=local-docker" \
--volume "$(shell pwd)/.run-data/docker-local:/data" \
--publish "8080:80" \
$(DOCKER_NAME):latest
inspect-docker: docker
mkdir -p .run-data
docker run -ti \
--rm \
--volume "$(shell pwd)/.run-data/docker-inspect:/data" \
$(DOCKER_NAME):latest \
bash
push-docker: docker
docker image push "$(DOCKER_REPO)/$(DOCKER_NAME):$(HASH)"
docker image push "$(DOCKER_REPO)/$(DOCKER_NAME):$(NAMESPACE)-latest"
docker image push "$(DOCKER_REPO)/$(DOCKER_NAME):latest"
clean:
rm -rf _build/*
rm -rf .run-data/*
git clean -fdx
go clean
go clean -testcache
fmt:
go fmt ./...
swag fmt
test:
which gotestsum || go install gotest.tools/gotestsum@latest
gotestsum --format "testname" -- -tags="timetzdata sqlite_fts5 sqlite_foreign_keys" "./test"
migrate:
CGO_ENABLED=1 go build -v -o _build/scn_migrate -tags "timetzdata sqlite_fts5 sqlite_foreign_keys" ./cmd/migrate
./_build/scn_migrate
lint:
# curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.53.2
golangci-lint run ./...

69
scnserver/TODO.md Normal file
View File

@@ -0,0 +1,69 @@
TODO
========
#### BEFORE RELEASE
- app-store link in HTML
- backups (no longer container in my db_backup, perhaps extend it to sqlite?)
- ios purchase verification
#### UNSURE
- (?) default-priority for channels
- (?) "login" on website and list/search/filter messages
- (?) make channels deleteable (soft-delete) (what do with messages in channel?)
- (?) desktop client for notifications
- (?) add querylog (similar to requestlog/errorlog) - only for main-db
- (?) specify 'type' of message (debug, info, warn, error, fatal) -> distinct from priority
#### LATER
- do i need bool2db()? it seems to work for keytokens without them?
- We no longer have a route to reshuffle all keys (previously in updateUser), add a /user/:uid/keys/reset ?
Would delete all existing keys and create 3 new ones?
- error logging as goroutine, gets all errors via channel,
(channel buffered - nonblocking send, second channel that gets a message when sender failed )
(then all errors end up in _second_ sqlite table)
due to message channel etc everything is non blocking and cant fail in main
- => implement proper error logging in goext, kinda combines zerolog and wrapped-errors
copy basic code from bringman, but remove all bm specific stuff and make it abstract
Register(ErrType) methods, errtypes then as structs
log.xxx package with same interface as zerolog
- jobs to clear error-db to only keep X entries... (requests-db already exists)
- route to re-check all pro-token (for me)
- /send endpoint should be compatible with the [ webhook ] notifier of uptime-kuma
(or add another /kuma endpoint)
-> https://webhook.site/
- endpoint to list all servernames of user (distinct select)
- weblogin, webapp, ...
- Pagination for ListChannels / ListSubscriptions / ListClients / ListChannelSubscriptions / ListUserSubscriptions
- Use only single struct for DB|Model|JSON
* needs sq.Converter implementation
* needs to handle joined data
* rfctime.Time...
- use job superclass (copy from isi/bnet/?), reduce duplicate code
#### FUTURE
- Remove compat, especially do not create compat id for every new message...

View File

@@ -0,0 +1,21 @@
package main
import (
"gogs.mikescher.com/BlackForestBytes/goext/bfcodegen"
"os"
)
func main() {
dest := os.Args[2]
wd, err := os.Getwd()
if err != nil {
panic(err)
}
err = bfcodegen.GenerateEnumSpecs(wd, dest)
if err != nil {
panic(err)
}
}

3
scnserver/_pygments/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.pyc
*.swp
env

16
scnserver/_pygments/pygmentizew Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
set -o nounset # disallow usage of unset vars ( set -u )
set -o errexit # Exit immediately if a pipeline returns non-zero. ( set -e )
set -o errtrace # Allow the above trap be inherited by all functions in the script. ( set -E )
set -o pipefail # Return value of a pipeline is the value of the last (rightmost) command to exit with a non-zero status
IFS=$'\n\t' # Set $IFS to only newline and tab.
cd "$(dirname "$0")" || exit 1
1>&2 virtualenv env
1>&2 source env/bin/activate
1>&2 pip install Pygments
pygmentize "$@"

View File

@@ -0,0 +1,63 @@
package apierr
type APIError int //@enum:type
//goland:noinspection GoSnakeCaseUsage
const (
UNDEFINED APIError = -1
NO_ERROR APIError = 0000
MISSING_UID APIError = 1101
MISSING_TOK APIError = 1102
MISSING_TITLE APIError = 1103
INVALID_PRIO APIError = 1104
REQ_METHOD APIError = 1105
INVALID_CLIENTTYPE APIError = 1106
PAGETOKEN_ERROR APIError = 1121
BINDFAIL_QUERY_PARAM APIError = 1151
BINDFAIL_BODY_PARAM APIError = 1152
BINDFAIL_URI_PARAM APIError = 1153
INVALID_BODY_PARAM APIError = 1161
INVALID_ENUM_VALUE APIError = 1171
NO_TITLE APIError = 1201
TITLE_TOO_LONG APIError = 1202
CONTENT_TOO_LONG APIError = 1203
USR_MSG_ID_TOO_LONG APIError = 1204
TIMESTAMP_OUT_OF_RANGE APIError = 1205
SENDERNAME_TOO_LONG APIError = 1206
CHANNEL_TOO_LONG APIError = 1207
CHANNEL_DESCRIPTION_TOO_LONG APIError = 1208
CHANNEL_NAME_EMPTY APIError = 1209
USER_NOT_FOUND APIError = 1301
CLIENT_NOT_FOUND APIError = 1302
CHANNEL_NOT_FOUND APIError = 1303
SUBSCRIPTION_NOT_FOUND APIError = 1304
MESSAGE_NOT_FOUND APIError = 1305
SUBSCRIPTION_USER_MISMATCH APIError = 1306
KEY_NOT_FOUND APIError = 1307
USER_AUTH_FAILED APIError = 1311
NO_DEVICE_LINKED APIError = 1401
CHANNEL_ALREADY_EXISTS APIError = 1501
CANNOT_SELFDELETE_KEY APIError = 1511
CANNOT_SELFUPDATE_KEY APIError = 1512
QUOTA_REACHED APIError = 2101
FAILED_VERIFY_PRO_TOKEN APIError = 3001
INVALID_PRO_TOKEN APIError = 3002
COMMIT_FAILED = 9001
DATABASE_ERROR = 9002
PERM_QUERY_FAIL = 9003
FIREBASE_COM_FAILED APIError = 9901
FIREBASE_COM_ERRORED APIError = 9902
INTERNAL_EXCEPTION APIError = 9903
PANIC APIError = 9904
NOT_IMPLEMENTED APIError = 9905
)

View File

@@ -0,0 +1,16 @@
package apihighlight
type ErrHighlight int //@enum:type
//goland:noinspection GoSnakeCaseUsage
const (
NONE ErrHighlight = -1
USER_ID ErrHighlight = 101
USER_KEY ErrHighlight = 102
TITLE ErrHighlight = 103
CONTENT ErrHighlight = 104
PRIORITY ErrHighlight = 105
CHANNEL ErrHighlight = 106
SENDER_NAME ErrHighlight = 107
USER_MESSAGE_ID ErrHighlight = 108
)

View File

@@ -0,0 +1,21 @@
package ginext
import (
"github.com/gin-gonic/gin"
"net/http"
)
func CorsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT, PATCH, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
} else {
c.Next()
}
}
}

View File

@@ -0,0 +1,31 @@
package ginext
import (
scn "blackforestbytes.com/simplecloudnotifier"
"github.com/gin-gonic/gin"
)
var SuppressGinLogs = false
func NewEngine(cfg scn.Config) *gin.Engine {
engine := gin.New()
engine.RedirectFixedPath = false
engine.RedirectTrailingSlash = false
if cfg.Cors {
engine.Use(CorsMiddleware())
}
if cfg.GinDebug {
ginlogger := gin.Logger()
engine.Use(func(context *gin.Context) {
if SuppressGinLogs {
return
}
ginlogger(context)
})
}
return engine
}

View File

@@ -0,0 +1,24 @@
package ginext
import (
"github.com/gin-gonic/gin"
"net/http"
)
func RedirectFound(newuri string) gin.HandlerFunc {
return func(g *gin.Context) {
g.Redirect(http.StatusFound, newuri)
}
}
func RedirectTemporary(newuri string) gin.HandlerFunc {
return func(g *gin.Context) {
g.Redirect(http.StatusTemporaryRedirect, newuri)
}
}
func RedirectPermanent(newuri string) gin.HandlerFunc {
return func(g *gin.Context) {
g.Redirect(http.StatusPermanentRedirect, newuri)
}
}

View File

@@ -0,0 +1,23 @@
package ginresp
type apiError struct {
Success bool `json:"success"`
Error int `json:"error"`
ErrorHighlight int `json:"errhighlight"`
Message string `json:"message"`
}
type extendedAPIError struct {
Success bool `json:"success"`
Error int `json:"error"`
ErrorHighlight int `json:"errhighlight"`
Message string `json:"message"`
RawError *string `json:"__error"`
Trace []string `json:"__trace"`
}
type compatAPIError struct {
Success bool `json:"success"`
ErrorID int `json:"errid,omitempty"`
Message string `json:"message"`
}

View File

@@ -0,0 +1,212 @@
package ginresp
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/apihighlight"
"fmt"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
json "gogs.mikescher.com/BlackForestBytes/goext/gojson"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"runtime/debug"
"strings"
)
type HTTPResponse interface {
Write(g *gin.Context)
Statuscode() int
BodyString() *string
ContentType() string
}
type jsonHTTPResponse struct {
statusCode int
data any
}
func (j jsonHTTPResponse) Write(g *gin.Context) {
g.Render(j.statusCode, json.GoJsonRender{Data: j.data, NilSafeSlices: true, NilSafeMaps: true})
}
func (j jsonHTTPResponse) Statuscode() int {
return j.statusCode
}
func (j jsonHTTPResponse) BodyString() *string {
v, err := json.Marshal(j.data)
if err != nil {
return nil
}
return langext.Ptr(string(v))
}
func (j jsonHTTPResponse) ContentType() string {
return "application/json"
}
type emptyHTTPResponse struct {
statusCode int
}
func (j emptyHTTPResponse) Write(g *gin.Context) {
g.Status(j.statusCode)
}
func (j emptyHTTPResponse) Statuscode() int {
return j.statusCode
}
func (j emptyHTTPResponse) BodyString() *string {
return nil
}
func (j emptyHTTPResponse) ContentType() string {
return ""
}
type textHTTPResponse struct {
statusCode int
data string
}
func (j textHTTPResponse) Write(g *gin.Context) {
g.String(j.statusCode, "%s", j.data)
}
func (j textHTTPResponse) Statuscode() int {
return j.statusCode
}
func (j textHTTPResponse) BodyString() *string {
return langext.Ptr(j.data)
}
func (j textHTTPResponse) ContentType() string {
return "text/plain"
}
type dataHTTPResponse struct {
statusCode int
data []byte
contentType string
}
func (j dataHTTPResponse) Write(g *gin.Context) {
g.Data(j.statusCode, j.contentType, j.data)
}
func (j dataHTTPResponse) Statuscode() int {
return j.statusCode
}
func (j dataHTTPResponse) BodyString() *string {
return langext.Ptr(string(j.data))
}
func (j dataHTTPResponse) ContentType() string {
return j.contentType
}
type errorHTTPResponse struct {
statusCode int
data any
error error
}
func (j errorHTTPResponse) Write(g *gin.Context) {
g.JSON(j.statusCode, j.data)
}
func (j errorHTTPResponse) Statuscode() int {
return j.statusCode
}
func (j errorHTTPResponse) BodyString() *string {
v, err := json.Marshal(j.data)
if err != nil {
return nil
}
return langext.Ptr(string(v))
}
func (j errorHTTPResponse) ContentType() string {
return "application/json"
}
func Status(sc int) HTTPResponse {
return &emptyHTTPResponse{statusCode: sc}
}
func JSON(sc int, data any) HTTPResponse {
return &jsonHTTPResponse{statusCode: sc, data: data}
}
func Data(sc int, contentType string, data []byte) HTTPResponse {
return &dataHTTPResponse{statusCode: sc, contentType: contentType, data: data}
}
func Text(sc int, data string) HTTPResponse {
return &textHTTPResponse{statusCode: sc, data: data}
}
func InternalError(e error) HTTPResponse {
return createApiError(nil, "InternalError", 500, apierr.INTERNAL_EXCEPTION, 0, e.Error(), e)
}
func APIError(g *gin.Context, status int, errorid apierr.APIError, msg string, e error) HTTPResponse {
return createApiError(g, "APIError", status, errorid, 0, msg, e)
}
func SendAPIError(g *gin.Context, status int, errorid apierr.APIError, highlight apihighlight.ErrHighlight, msg string, e error) HTTPResponse {
return createApiError(g, "SendAPIError", status, errorid, highlight, msg, e)
}
func NotImplemented(g *gin.Context) HTTPResponse {
return createApiError(g, "NotImplemented", 500, apierr.NOT_IMPLEMENTED, 0, "Not Implemented", nil)
}
func createApiError(g *gin.Context, ident string, status int, errorid apierr.APIError, highlight apihighlight.ErrHighlight, msg string, e error) HTTPResponse {
reqUri := ""
if g != nil && g.Request != nil {
reqUri = g.Request.Method + " :: " + g.Request.RequestURI
}
log.Error().
Int("errorid", int(errorid)).
Int("highlight", int(highlight)).
Str("uri", reqUri).
AnErr("err", e).
Stack().
Msg(fmt.Sprintf("[%s] %s", ident, msg))
if scn.Conf.ReturnRawErrors {
return &errorHTTPResponse{
statusCode: status,
data: extendedAPIError{
Success: false,
Error: int(errorid),
ErrorHighlight: int(highlight),
Message: msg,
RawError: langext.Ptr(langext.Conditional(e == nil, "", fmt.Sprintf("%+v", e))),
Trace: strings.Split(string(debug.Stack()), "\n"),
},
error: e,
}
} else {
return &errorHTTPResponse{
statusCode: status,
data: apiError{
Success: false,
Error: int(errorid),
ErrorHighlight: int(highlight),
Message: msg,
},
error: e,
}
}
}
func CompatAPIError(errid int, msg string) HTTPResponse {
return &jsonHTTPResponse{statusCode: 200, data: compatAPIError{Success: false, ErrorID: errid, Message: msg}}
}

View File

@@ -0,0 +1,184 @@
package ginresp
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/models"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"runtime/debug"
"time"
)
type WHandlerFunc func(*gin.Context) HTTPResponse
type RequestLogAcceptor interface {
InsertRequestLog(data models.RequestLog)
}
func Wrap(rlacc RequestLogAcceptor, fn WHandlerFunc) gin.HandlerFunc {
maxRetry := scn.Conf.RequestMaxRetry
retrySleep := scn.Conf.RequestRetrySleep
return func(g *gin.Context) {
reqctx := g.Request.Context()
if g.Request.Body != nil {
g.Request.Body = dataext.NewBufferedReadCloser(g.Request.Body)
}
t0 := time.Now()
for ctr := 1; ; ctr++ {
wrap, stackTrace, panicObj := callPanicSafe(fn, g)
if panicObj != nil {
log.Error().Interface("panicObj", panicObj).Msg("Panic occured (in gin handler)")
log.Error().Msg(stackTrace)
wrap = APIError(g, 500, apierr.PANIC, "A panic occured in the HTTP handler", errors.New(fmt.Sprintf("%+v\n\n@:\n%s", panicObj, stackTrace)))
}
if g.Writer.Written() {
if scn.Conf.ReqLogEnabled {
rlacc.InsertRequestLog(createRequestLog(g, t0, ctr, nil, langext.Ptr("Writing in WrapperFunc is not supported")))
}
panic("Writing in WrapperFunc is not supported")
}
if ctr < maxRetry && isSqlite3Busy(wrap) {
log.Warn().Int("counter", ctr).Str("url", g.Request.URL.String()).Msg("Retry request (ErrBusy)")
err := resetBody(g)
if err != nil {
panic(err)
}
time.Sleep(retrySleep)
continue
}
if reqctx.Err() == nil {
if scn.Conf.ReqLogEnabled {
rlacc.InsertRequestLog(createRequestLog(g, t0, ctr, wrap, nil))
}
statuscode := wrap.Statuscode()
if statuscode/100 != 2 {
log.Warn().Str("url", g.Request.Method+"::"+g.Request.URL.String()).Msg(fmt.Sprintf("Request failed with statuscode %d", statuscode))
}
wrap.Write(g)
}
return
}
}
}
func createRequestLog(g *gin.Context, t0 time.Time, ctr int, resp HTTPResponse, panicstr *string) models.RequestLog {
t1 := time.Now()
ua := g.Request.UserAgent()
auth := g.Request.Header.Get("Authorization")
ct := g.Request.Header.Get("Content-Type")
var reqbody []byte = nil
if g.Request.Body != nil {
brcbody, err := g.Request.Body.(dataext.BufferedReadCloser).BufferedAll()
if err == nil {
reqbody = brcbody
}
}
var strreqbody *string = nil
if len(reqbody) < scn.Conf.ReqLogMaxBodySize {
strreqbody = langext.Ptr(string(reqbody))
}
var respbody *string = nil
var strrespbody *string = nil
if resp != nil {
respbody = resp.BodyString()
if respbody != nil && len(*respbody) < scn.Conf.ReqLogMaxBodySize {
strrespbody = respbody
}
}
permObj, hasPerm := g.Get("perm")
hasTok := false
if hasPerm {
hasTok = permObj.(models.PermissionSet).Token != nil
}
return models.RequestLog{
Method: g.Request.Method,
URI: g.Request.URL.String(),
UserAgent: langext.Conditional(ua == "", nil, &ua),
Authentication: langext.Conditional(auth == "", nil, &auth),
RequestBody: strreqbody,
RequestBodySize: int64(len(reqbody)),
RequestContentType: ct,
RemoteIP: g.RemoteIP(),
KeyID: langext.ConditionalFn10(hasTok, func() *models.KeyTokenID { return langext.Ptr(permObj.(models.PermissionSet).Token.KeyTokenID) }, nil),
UserID: langext.ConditionalFn10(hasTok, func() *models.UserID { return langext.Ptr(permObj.(models.PermissionSet).Token.OwnerUserID) }, nil),
Permissions: langext.ConditionalFn10(hasTok, func() *string { return langext.Ptr(permObj.(models.PermissionSet).Token.Permissions.String()) }, nil),
ResponseStatuscode: langext.ConditionalFn10(resp != nil, func() *int64 { return langext.Ptr(int64(resp.Statuscode())) }, nil),
ResponseBodySize: langext.ConditionalFn10(strrespbody != nil, func() *int64 { return langext.Ptr(int64(len(*respbody))) }, nil),
ResponseBody: strrespbody,
ResponseContentType: langext.ConditionalFn10(resp != nil, func() string { return resp.ContentType() }, ""),
RetryCount: int64(ctr),
Panicked: panicstr != nil,
PanicStr: panicstr,
ProcessingTime: t1.Sub(t0),
TimestampStart: t0,
TimestampFinish: t1,
}
}
func callPanicSafe(fn WHandlerFunc, g *gin.Context) (res HTTPResponse, stackTrace string, panicObj any) {
defer func() {
if rec := recover(); rec != nil {
res = nil
stackTrace = string(debug.Stack())
panicObj = rec
}
}()
res = fn(g)
return res, "", nil
}
func resetBody(g *gin.Context) error {
if g.Request.Body == nil {
return nil
}
err := g.Request.Body.(dataext.BufferedReadCloser).Reset()
if err != nil {
return err
}
return nil
}
func isSqlite3Busy(r HTTPResponse) bool {
if errwrap, ok := r.(*errorHTTPResponse); ok && errwrap != nil {
if s3err, ok := (errwrap.error).(sqlite3.Error); ok {
if s3err.Code == sqlite3.ErrBusy {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,18 @@
package handler
import (
primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary"
"blackforestbytes.com/simplecloudnotifier/logic"
)
type APIHandler struct {
app *logic.Application
database *primarydb.Database
}
func NewAPIHandler(app *logic.Application) APIHandler {
return APIHandler{
app: app,
database: app.Database.Primary,
}
}

View File

@@ -0,0 +1,456 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
"net/http"
"strings"
)
// ListChannels swaggerdoc
//
// @Summary List channels of a user (subscribed/owned/all)
// @Description The possible values for 'selector' are:
// @Description - "owned" Return all channels of the user
// @Description - "subscribed" Return all channels that the user is subscribing to
// @Description - "all" Return channels that the user owns or is subscribing
// @Description - "subscribed_any" Return all channels that the user is subscribing to (even unconfirmed)
// @Description - "all_any" Return channels that the user owns or is subscribing (even unconfirmed)
//
// @ID api-channels-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param selector query string false "Filter channels (default: owned)" Enums(owned, subscribed, all, subscribed_any, all_any)
//
// @Success 200 {object} handler.ListChannels.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/channels [GET]
func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type query struct {
Selector *string `json:"selector" form:"selector" enums:"owned,subscribed_any,all_any,subscribed,all"`
}
type response struct {
Channels []models.ChannelWithSubscriptionJSON `json:"channels"`
}
var u uri
var q query
ctx, errResp := h.app.StartRequest(g, &u, &q, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
sel := strings.ToLower(langext.Coalesce(q.Selector, "owned"))
var res []models.ChannelWithSubscriptionJSON
if sel == "owned" {
channels, err := h.database.ListChannelsByOwner(ctx, u.UserID, u.UserID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(true) })
} else if sel == "subscribed_any" {
channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else if sel == "all_any" {
channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else if sel == "subscribed" {
channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else if sel == "all" {
channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else {
return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Channels: res}))
}
// GetChannel swaggerdoc
//
// @Summary Get a single channel
// @ID api-channels-get
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param cid path string true "ChannelID"
//
// @Success 200 {object} models.ChannelWithSubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/channels/{cid} [GET]
func (h APIHandler) GetChannel(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
ChannelID models.ChannelID `uri:"cid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
channel, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID, true)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.JSON(true)))
}
// CreateChannel swaggerdoc
//
// @Summary Create a new (empty) channel
// @ID api-channels-create
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param post_body body handler.CreateChannel.body false " "
//
// @Success 200 {object} models.ChannelWithSubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 409 {object} ginresp.apiError "channel already exists"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/channels [POST]
func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type body struct {
Name string `json:"name"`
Subscribe *bool `json:"subscribe"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
if b.Name == "" {
return ginresp.APIError(g, 400, apierr.INVALID_BODY_PARAM, "Missing parameter: name", nil)
}
channelDisplayName := h.app.NormalizeChannelDisplayName(b.Name)
channelInternalName := h.app.NormalizeChannelInternalName(b.Name)
channelExisting, err := h.database.GetChannelByName(ctx, u.UserID, channelInternalName)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
}
user, err := h.database.GetUser(ctx, u.UserID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 400, apierr.USER_NOT_FOUND, "User not found", nil)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query user", err)
}
if len(channelDisplayName) > user.MaxChannelNameLength() {
return ginresp.APIError(g, 400, apierr.CHANNEL_TOO_LONG, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)
}
if len(strings.TrimSpace(channelDisplayName)) == 0 {
return ginresp.APIError(g, 400, apierr.CHANNEL_NAME_EMPTY, fmt.Sprintf("Channel displayname cannot be empty"), nil)
}
if len(channelInternalName) > user.MaxChannelNameLength() {
return ginresp.APIError(g, 400, apierr.CHANNEL_TOO_LONG, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)
}
if len(strings.TrimSpace(channelInternalName)) == 0 {
return ginresp.APIError(g, 400, apierr.CHANNEL_NAME_EMPTY, fmt.Sprintf("Channel internalname cannot be empty"), nil)
}
if channelExisting != nil {
return ginresp.APIError(g, 409, apierr.CHANNEL_ALREADY_EXISTS, "Channel with this name already exists", nil)
}
subscribeKey := h.app.GenerateRandomAuthKey()
channel, err := h.database.CreateChannel(ctx, u.UserID, channelDisplayName, channelInternalName, subscribeKey)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create channel", err)
}
if langext.Coalesce(b.Subscribe, true) {
sub, err := h.database.CreateSubscription(ctx, u.UserID, channel, true)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create subscription", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.WithSubscription(langext.Ptr(sub)).JSON(true)))
} else {
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.WithSubscription(nil).JSON(true)))
}
}
// UpdateChannel swaggerdoc
//
// @Summary (Partially) update a channel
// @ID api-channels-update
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param cid path string true "ChannelID"
//
// @Param subscribe_key body string false "Send `true` to create a new subscribe_key"
// @Param send_key body string false "Send `true` to create a new send_key"
// @Param display_name body string false "Change the cahnnel display-name (only chnages to lowercase/uppercase are allowed - internal_name must stay the same)"
//
// @Success 200 {object} models.ChannelWithSubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/channels/{cid} [PATCH]
func (h APIHandler) UpdateChannel(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
ChannelID models.ChannelID `uri:"cid" binding:"entityid"`
}
type body struct {
RefreshSubscribeKey *bool `json:"subscribe_key"`
DisplayName *string `json:"display_name"`
DescriptionName *string `json:"description_name"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
_, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID, true)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
}
user, err := h.database.GetUser(ctx, u.UserID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 400, apierr.USER_NOT_FOUND, "User not found", nil)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query user", err)
}
if langext.Coalesce(b.RefreshSubscribeKey, false) {
newkey := h.app.GenerateRandomAuthKey()
err := h.database.UpdateChannelSubscribeKey(ctx, u.ChannelID, newkey)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update channel", err)
}
}
if b.DisplayName != nil {
newDisplayName := h.app.NormalizeChannelDisplayName(*b.DisplayName)
if len(newDisplayName) > user.MaxChannelNameLength() {
return ginresp.APIError(g, 400, apierr.CHANNEL_TOO_LONG, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)
}
if len(strings.TrimSpace(newDisplayName)) == 0 {
return ginresp.APIError(g, 400, apierr.CHANNEL_NAME_EMPTY, fmt.Sprintf("Channel displayname cannot be empty"), nil)
}
err := h.database.UpdateChannelDisplayName(ctx, u.ChannelID, newDisplayName)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update channel", err)
}
}
if b.DescriptionName != nil {
var descName *string = nil
if strings.TrimSpace(*b.DescriptionName) != "" {
descName = langext.Ptr(strings.TrimSpace(*b.DescriptionName))
}
if descName != nil && len(*descName) > user.MaxChannelDescriptionNameLength() {
return ginresp.APIError(g, 400, apierr.CHANNEL_DESCRIPTION_TOO_LONG, fmt.Sprintf("Channel-Description too long (max %d characters)", user.MaxChannelNameLength()), nil)
}
err := h.database.UpdateChannelDescriptionName(ctx, u.ChannelID, descName)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update channel", err)
}
}
channel, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID, true)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query (updated) channel", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.JSON(true)))
}
// ListChannelMessages swaggerdoc
//
// @Summary List messages of a channel
// @Description The next_page_token is an opaque token, the special value "@start" (or empty-string) is the beginning and "@end" is the end
// @Description Simply start the pagination without a next_page_token and get the next page by calling this endpoint with the returned next_page_token of the last query
// @Description If there are no more entries the token "@end" will be returned
// @Description By default we return long messages with a trimmed body, if trimmed=false is supplied we return full messages (this reduces the max page_size)
// @ID api-channel-messages
// @Tags API-v2
//
// @Param query_data query handler.ListChannelMessages.query false " "
// @Param uid path string true "UserID"
// @Param cid path string true "ChannelID"
//
// @Success 200 {object} handler.ListChannelMessages.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/channels/{cid}/messages [GET]
func (h APIHandler) ListChannelMessages(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
ChannelUserID models.UserID `uri:"uid" binding:"entityid"`
ChannelID models.ChannelID `uri:"cid" binding:"entityid"`
}
type query struct {
PageSize *int `json:"page_size" form:"page_size"`
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
Filter *string `json:"filter" form:"filter"`
Trimmed *bool `json:"trimmed" form:"trimmed"`
}
type response struct {
Messages []models.MessageJSON `json:"messages"`
NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"`
}
var u uri
var q query
ctx, errResp := h.app.StartRequest(g, &u, &q, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
trimmed := langext.Coalesce(q.Trimmed, true)
maxPageSize := langext.Conditional(trimmed, 16, 256)
pageSize := mathext.Clamp(langext.Coalesce(q.PageSize, 64), 1, maxPageSize)
channel, err := h.database.GetChannel(ctx, u.ChannelUserID, u.ChannelID, false)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
}
if permResp := ctx.CheckPermissionChanMessagesRead(channel.Channel); permResp != nil {
return *permResp
}
tok, err := ct.Decode(langext.Coalesce(q.NextPageToken, ""))
if err != nil {
return ginresp.APIError(g, 400, apierr.PAGETOKEN_ERROR, "Failed to decode next_page_token", err)
}
filter := models.MessageFilter{
ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}),
}
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
var res []models.MessageJSON
if trimmed {
res = langext.ArrMap(messages, func(v models.Message) models.MessageJSON { return v.TrimmedJSON() })
} else {
res = langext.ArrMap(messages, func(v models.Message) models.MessageJSON { return v.FullJSON() })
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
}

View File

@@ -0,0 +1,294 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"net/http"
)
// ListClients swaggerdoc
//
// @Summary List all clients
// @ID api-clients-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Success 200 {object} handler.ListClients.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/clients [GET]
func (h APIHandler) ListClients(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type response struct {
Clients []models.ClientJSON `json:"clients"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
clients, err := h.database.ListClients(ctx, u.UserID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query clients", err)
}
res := langext.ArrMap(clients, func(v models.Client) models.ClientJSON { return v.JSON() })
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Clients: res}))
}
// GetClient swaggerdoc
//
// @Summary Get a single client
// @ID api-clients-get
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param cid path string true "ClientID"
//
// @Success 200 {object} models.ClientJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "client not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/clients/{cid} [GET]
func (h APIHandler) GetClient(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
ClientID models.ClientID `uri:"cid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
client, err := h.database.GetClient(ctx, u.UserID, u.ClientID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.CLIENT_NOT_FOUND, "Client not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, client.JSON()))
}
// AddClient swaggerdoc
//
// @Summary Add a new clients
// @ID api-clients-create
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Param post_body body handler.AddClient.body false " "
//
// @Success 200 {object} models.ClientJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/clients [POST]
func (h APIHandler) AddClient(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type body struct {
FCMToken string `json:"fcm_token" binding:"required"`
AgentModel string `json:"agent_model" binding:"required"`
AgentVersion string `json:"agent_version" binding:"required"`
ClientType string `json:"client_type" binding:"required"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
var clientType models.ClientType
if b.ClientType == string(models.ClientTypeAndroid) {
clientType = models.ClientTypeAndroid
} else if b.ClientType == string(models.ClientTypeIOS) {
clientType = models.ClientTypeIOS
} else {
return ginresp.APIError(g, 400, apierr.INVALID_CLIENTTYPE, "Invalid ClientType", nil)
}
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
err := h.database.DeleteClientsByFCM(ctx, b.FCMToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to delete existing clients in db", err)
}
client, err := h.database.CreateClient(ctx, u.UserID, clientType, b.FCMToken, b.AgentModel, b.AgentVersion)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create client in db", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, client.JSON()))
}
// DeleteClient swaggerdoc
//
// @Summary Delete a client
// @ID api-clients-delete
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param cid path string true "ClientID"
//
// @Success 200 {object} models.ClientJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "client not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/clients/{cid} [DELETE]
func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
ClientID models.ClientID `uri:"cid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
client, err := h.database.GetClient(ctx, u.UserID, u.ClientID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.CLIENT_NOT_FOUND, "Client not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
}
err = h.database.DeleteClient(ctx, u.ClientID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to delete client", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, client.JSON()))
}
// UpdateClient swaggerdoc
//
// @Summary (Partially) update a client
// @Description The body-values are optional, only send the ones you want to update
// @ID api-client-update
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param cid path string true "ClientID"
//
// @Param clientname body string false "Change the clientname (send an empty string to clear it)"
// @Param pro_token body string false "Send a verification of premium purchase"
//
// @Success 200 {object} models.ClientJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "client is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "client not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/clients/{cid} [PATCH]
func (h APIHandler) UpdateClient(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
ClientID models.ClientID `uri:"cid" binding:"entityid"`
}
type body struct {
FCMToken *string `json:"fcm_token"`
AgentModel *string `json:"agent_model"`
AgentVersion *string `json:"agent_version"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
client, err := h.database.GetClient(ctx, u.UserID, u.ClientID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.CLIENT_NOT_FOUND, "Client not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
}
if b.FCMToken != nil && *b.FCMToken != client.FCMToken {
err = h.database.DeleteClientsByFCM(ctx, *b.FCMToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to delete existing clients in db", err)
}
err = h.database.UpdateClientFCMToken(ctx, u.ClientID, *b.FCMToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update client", err)
}
}
if b.AgentModel != nil {
err = h.database.UpdateClientAgentModel(ctx, u.ClientID, *b.AgentModel)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update client", err)
}
}
if b.AgentVersion != nil {
err = h.database.UpdateClientAgentVersion(ctx, u.ClientID, *b.AgentVersion)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update client", err)
}
}
client, err = h.database.GetClient(ctx, u.UserID, u.ClientID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query (updated) client", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, client.JSON()))
}

View File

@@ -0,0 +1,322 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"net/http"
)
// ListUserKeys swaggerdoc
//
// @Summary List keys of the user
// @Description The request must be done with an ADMIN key, the returned keys are without their token.
// @ID api-tokenkeys-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Success 200 {object} handler.ListUserKeys.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys [GET]
func (h APIHandler) ListUserKeys(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type response struct {
Keys []models.KeyTokenJSON `json:"keys"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
toks, err := h.database.ListKeyTokens(ctx, u.UserID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query keys", err)
}
res := langext.ArrMap(toks, func(v models.KeyToken) models.KeyTokenJSON { return v.JSON() })
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Keys: res}))
}
// GetUserKey swaggerdoc
//
// @Summary Get a single key
// @Description The request must be done with an ADMIN key, the returned key does not include its token.
// @ID api-tokenkeys-get
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param kid path string true "TokenKeyID"
//
// @Success 200 {object} models.KeyTokenJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys/{kid} [GET]
func (h APIHandler) GetUserKey(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
KeyID models.KeyTokenID `uri:"kid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
keytoken, err := h.database.GetKeyToken(ctx, u.UserID, u.KeyID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.KEY_NOT_FOUND, "Key not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, keytoken.JSON()))
}
// UpdateUserKey swaggerdoc
//
// @Summary Update a key
// @ID api-tokenkeys-update
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param kid path string true "TokenKeyID"
//
// @Param post_body body handler.UpdateUserKey.body false " "
//
// @Success 200 {object} models.KeyTokenJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys/{kid} [PATCH]
func (h APIHandler) UpdateUserKey(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
KeyID models.KeyTokenID `uri:"kid" binding:"entityid"`
}
type body struct {
Name *string `json:"name"`
AllChannels *bool `json:"all_channels"`
Channels *[]models.ChannelID `json:"channels"`
Permissions *string `json:"permissions"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
keytoken, err := h.database.GetKeyToken(ctx, u.UserID, u.KeyID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.KEY_NOT_FOUND, "Key not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
}
if b.Name != nil {
err := h.database.UpdateKeyTokenName(ctx, u.KeyID, *b.Name)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update name", err)
}
keytoken.Name = *b.Name
}
if b.Permissions != nil {
if keytoken.KeyTokenID == *ctx.GetPermissionKeyTokenID() {
return ginresp.APIError(g, 400, apierr.CANNOT_SELFUPDATE_KEY, "Cannot update the currently used key", err)
}
permlist := models.ParseTokenPermissionList(*b.Permissions)
err := h.database.UpdateKeyTokenPermissions(ctx, u.KeyID, permlist)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update permissions", err)
}
keytoken.Permissions = permlist
}
if b.AllChannels != nil {
if keytoken.KeyTokenID == *ctx.GetPermissionKeyTokenID() {
return ginresp.APIError(g, 400, apierr.CANNOT_SELFUPDATE_KEY, "Cannot update the currently used key", err)
}
err := h.database.UpdateKeyTokenAllChannels(ctx, u.KeyID, *b.AllChannels)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update all_channels", err)
}
keytoken.AllChannels = *b.AllChannels
}
if b.Channels != nil {
if keytoken.KeyTokenID == *ctx.GetPermissionKeyTokenID() {
return ginresp.APIError(g, 400, apierr.CANNOT_SELFUPDATE_KEY, "Cannot update the currently used key", err)
}
err := h.database.UpdateKeyTokenChannels(ctx, u.KeyID, *b.Channels)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update channels", err)
}
keytoken.Channels = *b.Channels
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, keytoken.JSON()))
}
// CreateUserKey swaggerdoc
//
// @Summary Create a new key
// @ID api-tokenkeys-create
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Param post_body body handler.CreateUserKey.body false " "
//
// @Success 200 {object} models.KeyTokenJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys [POST]
func (h APIHandler) CreateUserKey(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type body struct {
Name string `json:"name" binding:"required"`
Permissions string `json:"permissions" binding:"required"`
AllChannels *bool `json:"all_channels"`
Channels *[]models.ChannelID `json:"channels"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
channels := langext.Coalesce(b.Channels, make([]models.ChannelID, 0))
var allChan bool
if b.AllChannels == nil && b.Channels != nil {
allChan = false
} else if b.AllChannels == nil && b.Channels == nil {
allChan = true
} else {
allChan = *b.AllChannels
}
for _, c := range channels {
if err := c.Valid(); err != nil {
return ginresp.APIError(g, 400, apierr.INVALID_BODY_PARAM, "Invalid ChannelID", err)
}
}
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
token := h.app.GenerateRandomAuthKey()
perms := models.ParseTokenPermissionList(b.Permissions)
keytok, err := h.database.CreateKeyToken(ctx, b.Name, *ctx.GetPermissionUserID(), allChan, channels, perms, token)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create keytoken in db", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, keytok.JSON().WithToken(token)))
}
// DeleteUserKey swaggerdoc
//
// @Summary Delete a key
// @Description Cannot be used to delete the key used in the request itself
// @ID api-tokenkeys-delete
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param kid path string true "TokenKeyID"
//
// @Success 200 {object} models.KeyTokenJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys/{kid} [DELETE]
func (h APIHandler) DeleteUserKey(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
KeyID models.KeyTokenID `uri:"kid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
client, err := h.database.GetKeyToken(ctx, u.UserID, u.KeyID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.KEY_NOT_FOUND, "Key not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err)
}
if u.KeyID == *ctx.GetPermissionKeyTokenID() {
return ginresp.APIError(g, 400, apierr.CANNOT_SELFDELETE_KEY, "Cannot delete the currently used key", err)
}
err = h.database.DeleteKeyToken(ctx, u.KeyID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to delete client", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, client.JSON()))
}

View File

@@ -0,0 +1,284 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
"net/http"
"strings"
"time"
)
// ListMessages swaggerdoc
//
// @Summary List all (subscribed) messages
// @Description The next_page_token is an opaque token, the special value "@start" (or empty-string) is the beginning and "@end" is the end
// @Description Simply start the pagination without a next_page_token and get the next page by calling this endpoint with the returned next_page_token of the last query
// @Description If there are no more entries the token "@end" will be returned
// @Description By default we return long messages with a trimmed body, if trimmed=false is supplied we return full messages (this reduces the max page_size)
// @ID api-messages-list
// @Tags API-v2
//
// @Param query_data query handler.ListMessages.query false " "
//
// @Success 200 {object} handler.ListMessages.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/messages [GET]
func (h APIHandler) ListMessages(g *gin.Context) ginresp.HTTPResponse {
type query struct {
PageSize *int `json:"page_size" form:"page_size"`
NextPageToken *string `json:"next_page_token" form:"next_page_token"`
Filter *string `json:"filter" form:"filter"`
Trimmed *bool `json:"trimmed" form:"trimmed"`
Channels []string `json:"channel" form:"channel"`
ChannelIDs []string `json:"channel_id" form:"channel_id"`
Senders []string `json:"sender" form:"sender"`
TimeBefore *string `json:"before" form:"before"` // RFC3339
TimeAfter *string `json:"after" form:"after"` // RFC3339
Priority []int `json:"priority" form:"priority"`
KeyTokens []string `json:"used_key" form:"used_key"`
}
type response struct {
Messages []models.MessageJSON `json:"messages"`
NextPageToken string `json:"next_page_token"`
PageSize int `json:"page_size"`
}
var q query
ctx, errResp := h.app.StartRequest(g, nil, &q, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
trimmed := langext.Coalesce(q.Trimmed, true)
maxPageSize := langext.Conditional(trimmed, 16, 256)
pageSize := mathext.Clamp(langext.Coalesce(q.PageSize, 64), 1, maxPageSize)
if permResp := ctx.CheckPermissionSelfAllMessagesRead(); permResp != nil {
return *permResp
}
userid := *ctx.GetPermissionUserID()
tok, err := ct.Decode(langext.Coalesce(q.NextPageToken, ""))
if err != nil {
return ginresp.APIError(g, 400, apierr.PAGETOKEN_ERROR, "Failed to decode next_page_token", err)
}
err = h.database.UpdateUserLastRead(ctx, userid)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update last-read", err)
}
filter := models.MessageFilter{
ConfirmedSubscriptionBy: langext.Ptr(userid),
}
if q.Filter != nil && strings.TrimSpace(*q.Filter) != "" {
filter.SearchString = langext.Ptr([]string{strings.TrimSpace(*q.Filter)})
}
if len(q.Channels) != 0 {
filter.ChannelNameCS = langext.Ptr(q.Channels)
}
if len(q.ChannelIDs) != 0 {
cids := make([]models.ChannelID, 0, len(q.ChannelIDs))
for _, v := range q.ChannelIDs {
cid := models.ChannelID(v)
if err = cid.Valid(); err != nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Invalid channel-id", err)
}
cids = append(cids, cid)
}
filter.ChannelID = &cids
}
if len(q.Senders) != 0 {
filter.SenderNameCS = langext.Ptr(q.Senders)
}
if q.TimeBefore != nil {
t0, err := time.Parse(time.RFC3339, *q.TimeBefore)
if err != nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Invalid before-time", err)
}
filter.TimestampCoalesceBefore = &t0
}
if q.TimeAfter != nil {
t0, err := time.Parse(time.RFC3339, *q.TimeAfter)
if err != nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Invalid after-time", err)
}
filter.TimestampCoalesceAfter = &t0
}
if len(q.Priority) != 0 {
filter.Priority = langext.Ptr(q.Priority)
}
if len(q.KeyTokens) != 0 {
tids := make([]models.KeyTokenID, 0, len(q.KeyTokens))
for _, v := range q.KeyTokens {
tid := models.KeyTokenID(v)
if err = tid.Valid(); err != nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Invalid keytoken-id", err)
}
tids = append(tids, tid)
}
filter.UsedKeyID = &tids
}
messages, npt, err := h.database.ListMessages(ctx, filter, &pageSize, tok)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query messages", err)
}
var res []models.MessageJSON
if trimmed {
res = langext.ArrMap(messages, func(v models.Message) models.MessageJSON { return v.TrimmedJSON() })
} else {
res = langext.ArrMap(messages, func(v models.Message) models.MessageJSON { return v.FullJSON() })
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Messages: res, NextPageToken: npt.Token(), PageSize: pageSize}))
}
// GetMessage swaggerdoc
//
// @Summary Get a single message (untrimmed)
// @Description The user must either own the message and request the resource with the READ or ADMIN Key
// @Description Or the user must subscribe to the corresponding channel (and be confirmed) and request the resource with the READ or ADMIN Key
// @Description The returned message is never trimmed
// @ID api-messages-get
// @Tags API-v2
//
// @Param mid path string true "MessageID"
//
// @Success 200 {object} models.MessageJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/messages/{mid} [PATCH]
func (h APIHandler) GetMessage(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
MessageID models.MessageID `uri:"mid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionAny(); permResp != nil {
return *permResp
}
msg, err := h.database.GetMessage(ctx, u.MessageID, false)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.MESSAGE_NOT_FOUND, "message not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query message", err)
}
// either we have direct read permissions (it is our message + read/admin key)
// or we subscribe (+confirmed) to the channel and have read/admin key
if ctx.CheckPermissionMessageRead(msg) {
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, msg.FullJSON()))
}
if uid := ctx.GetPermissionUserID(); uid != nil && ctx.CheckPermissionUserRead(*uid) == nil {
sub, err := h.database.GetSubscriptionBySubscriber(ctx, *uid, msg.ChannelID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err)
}
if sub == nil {
// not subbed
return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil)
}
if !sub.Confirmed {
// sub not confirmed
return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil)
}
// => perm okay
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, msg.FullJSON()))
}
return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil)
}
// DeleteMessage swaggerdoc
//
// @Summary Delete a single message
// @Description The user must own the message and request the resource with the ADMIN Key
// @ID api-messages-delete
// @Tags API-v2
//
// @Param mid path string true "MessageID"
//
// @Success 200 {object} models.MessageJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/messages/{mid} [DELETE]
func (h APIHandler) DeleteMessage(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
MessageID models.MessageID `uri:"mid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionAny(); permResp != nil {
return *permResp
}
msg, err := h.database.GetMessage(ctx, u.MessageID, false)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.MESSAGE_NOT_FOUND, "message not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query message", err)
}
if !ctx.CheckPermissionMessageRead(msg) {
return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil)
}
err = h.database.DeleteMessage(ctx, msg.MessageID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to delete message", err)
}
err = h.database.CancelPendingDeliveries(ctx, msg.MessageID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to cancel deliveries", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, msg.FullJSON()))
}

View File

@@ -0,0 +1,443 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"net/http"
"strings"
)
// ListUserSubscriptions swaggerdoc
//
// @Summary List all subscriptions of a user (incoming/owned)
// @Description The possible values for 'selector' are:
// @Description - "outgoing_all" All subscriptions (confirmed/unconfirmed) with the user as subscriber (= subscriptions he can use to read channels)
// @Description - "outgoing_confirmed" Confirmed subscriptions with the user as subscriber
// @Description - "outgoing_unconfirmed" Unconfirmed (Pending) subscriptions with the user as subscriber
// @Description - "incoming_all" All subscriptions (confirmed/unconfirmed) from other users to channels of this user (= incoming subscriptions and subscription requests)
// @Description - "incoming_confirmed" Confirmed subscriptions from other users to channels of this user
// @Description - "incoming_unconfirmed" Unconfirmed subscriptions from other users to channels of this user (= requests)
//
// @ID api-user-subscriptions-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param selector query string true "Filter subscriptions (default: outgoing_all)" Enums(outgoing_all, outgoing_confirmed, outgoing_unconfirmed, incoming_all, incoming_confirmed, incoming_unconfirmed)
//
// @Success 200 {object} handler.ListUserSubscriptions.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/subscriptions [GET]
func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type query struct {
Selector *string `json:"selector" form:"selector" enums:"outgoing_all,outgoing_confirmed,outgoing_unconfirmed,incoming_all,incoming_confirmed,incoming_unconfirmed"`
}
type response struct {
Subscriptions []models.SubscriptionJSON `json:"subscriptions"`
}
var u uri
var q query
ctx, errResp := h.app.StartRequest(g, &u, &q, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
sel := strings.ToLower(langext.Coalesce(q.Selector, "outgoing_all"))
var res []models.Subscription
var err error
if sel == "outgoing_all" {
res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "outgoing_confirmed" {
res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "outgoing_unconfirmed" {
res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(false))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "incoming_all" {
res, err = h.database.ListSubscriptionsByChannelOwner(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "incoming_confirmed" {
res, err = h.database.ListSubscriptionsByChannelOwner(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "incoming_unconfirmed" {
res, err = h.database.ListSubscriptionsByChannelOwner(ctx, u.UserID, langext.Ptr(false))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else {
return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil)
}
jsonres := langext.ArrMap(res, func(v models.Subscription) models.SubscriptionJSON { return v.JSON() })
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Subscriptions: jsonres}))
}
// ListChannelSubscriptions swaggerdoc
//
// @Summary List all subscriptions of a channel
// @ID api-chan-subscriptions-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param cid path string true "ChannelID"
//
// @Success 200 {object} handler.ListChannelSubscriptions.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/channels/{cid}/subscriptions [GET]
func (h APIHandler) ListChannelSubscriptions(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
ChannelID models.ChannelID `uri:"cid" binding:"entityid"`
}
type response struct {
Subscriptions []models.SubscriptionJSON `json:"subscriptions"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
_, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID, true)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
}
clients, err := h.database.ListSubscriptionsByChannel(ctx, u.ChannelID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
res := langext.ArrMap(clients, func(v models.Subscription) models.SubscriptionJSON { return v.JSON() })
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Subscriptions: res}))
}
// GetSubscription swaggerdoc
//
// @Summary Get a single subscription
// @ID api-subscriptions-get
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param sid path string true "SubscriptionID"
//
// @Success 200 {object} models.SubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "subscription not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/subscriptions/{sid} [GET]
func (h APIHandler) GetSubscription(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
SubscriptionID models.SubscriptionID `uri:"sid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
subscription, err := h.database.GetSubscription(ctx, u.SubscriptionID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_NOT_FOUND, "Subscription not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err)
}
if subscription.SubscriberUserID != u.UserID && subscription.ChannelOwnerUserID != u.UserID {
return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_USER_MISMATCH, "Subscription not found", nil)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, subscription.JSON()))
}
// CancelSubscription swaggerdoc
//
// @Summary Cancel (delete) subscription
// @ID api-subscriptions-delete
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param sid path string true "SubscriptionID"
//
// @Success 200 {object} models.SubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "subscription not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/subscriptions/{sid} [DELETE]
func (h APIHandler) CancelSubscription(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
SubscriptionID models.SubscriptionID `uri:"sid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
subscription, err := h.database.GetSubscription(ctx, u.SubscriptionID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_NOT_FOUND, "Subscription not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err)
}
if subscription.SubscriberUserID != u.UserID && subscription.ChannelOwnerUserID != u.UserID {
return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_USER_MISMATCH, "Subscription not found", nil)
}
err = h.database.DeleteSubscription(ctx, u.SubscriptionID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to delete subscription", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, subscription.JSON()))
}
// CreateSubscription swaggerdoc
//
// @Summary Create/Request a subscription
// @Description Either [channel_owner_user_id, channel_internal_name] or [channel_id] must be supplied in the request body
// @ID api-subscriptions-create
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param query_data query handler.CreateSubscription.query false " "
// @Param post_data body handler.CreateSubscription.body false " "
//
// @Success 200 {object} models.SubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/subscriptions [POST]
func (h APIHandler) CreateSubscription(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type body struct {
ChannelOwnerUserID *models.UserID `json:"channel_owner_user_id" binding:"entityid"`
ChannelInternalName *string `json:"channel_internal_name"`
ChannelID *models.ChannelID `json:"channel_id" binding:"entityid"`
}
type query struct {
ChanSubscribeKey *string `json:"chan_subscribe_key" form:"chan_subscribe_key"`
}
var u uri
var q query
var b body
ctx, errResp := h.app.StartRequest(g, &u, &q, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
var channel models.Channel
if b.ChannelOwnerUserID != nil && b.ChannelInternalName != nil && b.ChannelID == nil {
channelInternalName := h.app.NormalizeChannelInternalName(*b.ChannelInternalName)
outchannel, err := h.database.GetChannelByName(ctx, *b.ChannelOwnerUserID, channelInternalName)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
}
if outchannel == nil {
return ginresp.APIError(g, 400, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
channel = *outchannel
} else if b.ChannelOwnerUserID == nil && b.ChannelInternalName == nil && b.ChannelID != nil {
outchannel, err := h.database.GetChannelByID(ctx, *b.ChannelID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channel", err)
}
if outchannel == nil {
return ginresp.APIError(g, 400, apierr.CHANNEL_NOT_FOUND, "Channel not found", err)
}
channel = *outchannel
} else {
return ginresp.APIError(g, 400, apierr.INVALID_BODY_PARAM, "Must either supply [channel_owner_user_id, channel_internal_name] or [channel_id]", nil)
}
if channel.OwnerUserID != u.UserID && (q.ChanSubscribeKey == nil || *q.ChanSubscribeKey != channel.SubscribeKey) {
return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil)
}
existingSub, err := h.database.GetSubscriptionBySubscriber(ctx, u.UserID, channel.ChannelID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query existing subscription", err)
}
if existingSub != nil {
if !existingSub.Confirmed && channel.OwnerUserID == u.UserID {
err = h.database.UpdateSubscriptionConfirmed(ctx, existingSub.SubscriptionID, true)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update subscription", err)
}
existingSub.Confirmed = true
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, existingSub.JSON()))
}
sub, err := h.database.CreateSubscription(ctx, u.UserID, channel, channel.OwnerUserID == u.UserID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create subscription", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, sub.JSON()))
}
// UpdateSubscription swaggerdoc
//
// @Summary Update a subscription (e.g. confirm)
// @ID api-subscriptions-update
// @Tags API-v2
//
// @Param uid path string true "UserID"
// @Param sid path string true "SubscriptionID"
// @Param post_data body handler.UpdateSubscription.body false " "
//
// @Success 200 {object} models.SubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "subscription not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/subscriptions/{sid} [PATCH]
func (h APIHandler) UpdateSubscription(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
SubscriptionID models.SubscriptionID `uri:"sid" binding:"entityid"`
}
type body struct {
Confirmed *bool `form:"confirmed"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
userid := *ctx.GetPermissionUserID()
subscription, err := h.database.GetSubscription(ctx, u.SubscriptionID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_NOT_FOUND, "Subscription not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err)
}
if subscription.SubscriberUserID != u.UserID && subscription.ChannelOwnerUserID != u.UserID {
return ginresp.APIError(g, 404, apierr.SUBSCRIPTION_USER_MISMATCH, "Subscription not found", nil)
}
if b.Confirmed != nil {
if subscription.ChannelOwnerUserID != userid {
return ginresp.APIError(g, 401, apierr.USER_AUTH_FAILED, "You are not authorized for this action", nil)
}
err = h.database.UpdateSubscriptionConfirmed(ctx, u.SubscriptionID, *b.Confirmed)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update subscription", err)
}
}
subscription, err = h.database.GetSubscription(ctx, u.SubscriptionID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscription", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, subscription.JSON()))
}

View File

@@ -0,0 +1,266 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"fmt"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"net/http"
)
// CreateUser swaggerdoc
//
// @Summary Create a new user
// @ID api-user-create
// @Tags API-v2
//
// @Param post_body body handler.CreateUser.body false " "
//
// @Success 200 {object} models.UserJSONWithClientsAndKeys
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users [POST]
func (h APIHandler) CreateUser(g *gin.Context) ginresp.HTTPResponse {
type body struct {
FCMToken string `json:"fcm_token"`
ProToken *string `json:"pro_token"`
Username *string `json:"username"`
AgentModel string `json:"agent_model"`
AgentVersion string `json:"agent_version"`
ClientType string `json:"client_type"`
NoClient bool `json:"no_client"`
}
var b body
ctx, errResp := h.app.StartRequest(g, nil, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
var clientType models.ClientType
if !b.NoClient {
if b.FCMToken == "" {
return ginresp.APIError(g, 400, apierr.INVALID_CLIENTTYPE, "Missing FCMToken", nil)
}
if b.AgentVersion == "" {
return ginresp.APIError(g, 400, apierr.INVALID_CLIENTTYPE, "Missing AgentVersion", nil)
}
if b.ClientType == "" {
return ginresp.APIError(g, 400, apierr.INVALID_CLIENTTYPE, "Missing ClientType", nil)
}
if b.ClientType == string(models.ClientTypeAndroid) {
clientType = models.ClientTypeAndroid
} else if b.ClientType == string(models.ClientTypeIOS) {
clientType = models.ClientTypeIOS
} else {
return ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "Invalid ClientType", nil)
}
}
if b.ProToken != nil {
ptok, err := h.app.VerifyProToken(ctx, *b.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err)
}
if !ptok {
return ginresp.APIError(g, 400, apierr.INVALID_PRO_TOKEN, "Purchase token could not be verified", nil)
}
}
readKey := h.app.GenerateRandomAuthKey()
sendKey := h.app.GenerateRandomAuthKey()
adminKey := h.app.GenerateRandomAuthKey()
err := h.database.ClearFCMTokens(ctx, b.FCMToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
}
if b.ProToken != nil {
err := h.database.ClearProTokens(ctx, *b.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing pro tokens", err)
}
}
username := b.Username
if username != nil {
username = langext.Ptr(h.app.NormalizeUsername(*username))
}
userobj, err := h.database.CreateUser(ctx, b.ProToken, username)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create user in db", err)
}
_, err = h.database.CreateKeyToken(ctx, "AdminKey (default)", userobj.UserID, true, make([]models.ChannelID, 0), models.TokenPermissionList{models.PermAdmin}, adminKey)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create admin-key in db", err)
}
_, err = h.database.CreateKeyToken(ctx, "SendKey (default)", userobj.UserID, true, make([]models.ChannelID, 0), models.TokenPermissionList{models.PermChannelSend}, sendKey)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create send-key in db", err)
}
_, err = h.database.CreateKeyToken(ctx, "ReadKey (default)", userobj.UserID, true, make([]models.ChannelID, 0), models.TokenPermissionList{models.PermUserRead, models.PermChannelRead}, readKey)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create read-key in db", err)
}
log.Info().Msg(fmt.Sprintf("Sucessfully created new user %s (client: %v)", userobj.UserID, b.NoClient))
if b.NoClient {
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, userobj.JSONWithClients(make([]models.Client, 0), adminKey, sendKey, readKey)))
} else {
err := h.database.DeleteClientsByFCM(ctx, b.FCMToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to delete existing clients in db", err)
}
client, err := h.database.CreateClient(ctx, userobj.UserID, clientType, b.FCMToken, b.AgentModel, b.AgentVersion)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create client in db", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, userobj.JSONWithClients([]models.Client{client}, adminKey, sendKey, readKey)))
}
}
// GetUser swaggerdoc
//
// @Summary Get a user
// @ID api-user-get
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Success 200 {object} models.UserJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "user not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid} [GET]
func (h APIHandler) GetUser(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
user, err := h.database.GetUser(ctx, u.UserID)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.USER_NOT_FOUND, "User not found", err)
}
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query user", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, user.JSON()))
}
// UpdateUser swaggerdoc
//
// @Summary (Partially) update a user
// @Description The body-values are optional, only send the ones you want to update
// @ID api-user-update
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Param username body string false "Change the username (send an empty string to clear it)"
// @Param pro_token body string false "Send a verification of premium purchase"
//
// @Success 200 {object} models.UserJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "user not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid} [PATCH]
func (h APIHandler) UpdateUser(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type body struct {
Username *string `json:"username"`
ProToken *string `json:"pro_token"`
}
var u uri
var b body
ctx, errResp := h.app.StartRequest(g, &u, nil, &b, nil)
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
if permResp := ctx.CheckPermissionUserAdmin(u.UserID); permResp != nil {
return *permResp
}
if b.Username != nil {
username := langext.Ptr(h.app.NormalizeUsername(*b.Username))
if *username == "" {
username = nil
}
err := h.database.UpdateUserUsername(ctx, u.UserID, username)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update user", err)
}
}
if b.ProToken != nil {
if *b.ProToken == "" {
err := h.database.UpdateUserProToken(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update user", err)
}
} else {
ptok, err := h.app.VerifyProToken(ctx, *b.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err)
}
if !ptok {
return ginresp.APIError(g, 400, apierr.INVALID_PRO_TOKEN, "Purchase token could not be verified", nil)
}
err = h.database.ClearProTokens(ctx, *b.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
}
err = h.database.UpdateUserProToken(ctx, u.UserID, b.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to update user", err)
}
}
}
user, err := h.database.GetUser(ctx, u.UserID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query (updated) user", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, user.JSON()))
}

View File

@@ -0,0 +1,216 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"bytes"
"context"
"errors"
"github.com/gin-gonic/gin"
sqlite3 "github.com/mattn/go-sqlite3"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"net/http"
"time"
)
type CommonHandler struct {
app *logic.Application
}
func NewCommonHandler(app *logic.Application) CommonHandler {
return CommonHandler{
app: app,
}
}
type pingResponse struct {
Message string `json:"message"`
Info pingResponseInfo `json:"info"`
}
type pingResponseInfo struct {
Method string `json:"method"`
Request string `json:"request"`
Headers map[string][]string `json:"headers"`
URI string `json:"uri"`
Address string `json:"addr"`
}
// Ping swaggerdoc
//
// @Summary Simple endpoint to test connection (any http method)
// @Tags Common
//
// @Success 200 {object} pingResponse
// @Failure 500 {object} ginresp.apiError
//
// @Router /api/ping [get]
// @Router /api/ping [post]
// @Router /api/ping [put]
// @Router /api/ping [delete]
// @Router /api/ping [patch]
func (h CommonHandler) Ping(g *gin.Context) ginresp.HTTPResponse {
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(g.Request.Body)
resuestBody := buf.String()
return ginresp.JSON(http.StatusOK, pingResponse{
Message: "Pong",
Info: pingResponseInfo{
Method: g.Request.Method,
Request: resuestBody,
Headers: g.Request.Header,
URI: g.Request.RequestURI,
Address: g.Request.RemoteAddr,
},
})
}
// DatabaseTest swaggerdoc
//
// @Summary Check for a working database connection
// @ID api-common-dbtest
// @Tags Common
//
// @Success 200 {object} handler.DatabaseTest.response
// @Failure 500 {object} ginresp.apiError
//
// @Router /api/db-test [post]
func (h CommonHandler) DatabaseTest(g *gin.Context) ginresp.HTTPResponse {
type response struct {
Success bool `json:"success"`
LibVersion string `json:"libVersion"`
LibVersionNumber int `json:"libVersionNumber"`
SourceID string `json:"sourceID"`
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
libVersion, libVersionNumber, sourceID := sqlite3.Version()
err := h.app.Database.Ping(ctx)
if err != nil {
return ginresp.InternalError(err)
}
return ginresp.JSON(http.StatusOK, response{
Success: true,
LibVersion: libVersion,
LibVersionNumber: libVersionNumber,
SourceID: sourceID,
})
}
// Health swaggerdoc
//
// @Summary Server Health-checks
// @ID api-common-health
// @Tags Common
//
// @Success 200 {object} handler.Health.response
// @Failure 500 {object} ginresp.apiError
//
// @Router /api/health [get]
func (h CommonHandler) Health(g *gin.Context) ginresp.HTTPResponse {
type response struct {
Status string `json:"status"`
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, libVersionNumber, _ := sqlite3.Version()
if libVersionNumber < 3039000 {
return ginresp.InternalError(errors.New("sqlite version too low"))
}
err := h.app.Database.Ping(ctx)
if err != nil {
return ginresp.InternalError(err)
}
for _, subdb := range h.app.Database.List() {
uuidKey, _ := langext.NewHexUUID()
uuidWrite, _ := langext.NewHexUUID()
err = subdb.WriteMetaString(ctx, uuidKey, uuidWrite)
if err != nil {
return ginresp.InternalError(err)
}
uuidRead, err := subdb.ReadMetaString(ctx, uuidKey)
if err != nil {
return ginresp.InternalError(err)
}
if uuidRead == nil || uuidWrite != *uuidRead {
return ginresp.InternalError(errors.New("writing into DB was not consistent"))
}
err = subdb.DeleteMeta(ctx, uuidKey)
if err != nil {
return ginresp.InternalError(err)
}
}
return ginresp.JSON(http.StatusOK, response{Status: "ok"})
}
// Sleep swaggerdoc
//
// @Summary Return 200 after x seconds
// @ID api-common-sleep
// @Tags Common
//
// @Param secs path number true "sleep delay (in seconds)"
//
// @Success 200 {object} handler.Sleep.response
// @Failure 400 {object} ginresp.apiError
// @Failure 500 {object} ginresp.apiError
//
// @Router /api/sleep/{secs} [post]
func (h CommonHandler) Sleep(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
Seconds float64 `uri:"secs"`
}
type response struct {
Start string `json:"start"`
End string `json:"end"`
Duration float64 `json:"duration"`
}
t0 := time.Now().Format(time.RFC3339Nano)
var u uri
if err := g.ShouldBindUri(&u); err != nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_URI_PARAM, "Failed to read uri", err)
}
time.Sleep(timeext.FromSeconds(u.Seconds))
t1 := time.Now().Format(time.RFC3339Nano)
return ginresp.JSON(http.StatusOK, response{
Start: t0,
End: t1,
Duration: u.Seconds,
})
}
func (h CommonHandler) NoRoute(g *gin.Context) ginresp.HTTPResponse {
return ginresp.JSON(http.StatusNotFound, gin.H{
"": "================ ROUTE NOT FOUND ================",
"FullPath": g.FullPath(),
"Method": g.Request.Method,
"URL": g.Request.URL.String(),
"RequestURI": g.Request.RequestURI,
"Proto": g.Request.Proto,
"Header": g.Request.Header,
"~": "================ ROUTE NOT FOUND ================",
})
}

View File

@@ -0,0 +1,931 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
hl "blackforestbytes.com/simplecloudnotifier/api/apihighlight"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"net/http"
)
type CompatHandler struct {
app *logic.Application
database *primarydb.Database
}
func NewCompatHandler(app *logic.Application) CompatHandler {
return CompatHandler{
app: app,
database: app.Database.Primary,
}
}
// SendMessageCompat swaggerdoc
//
// @Deprecated
//
// @Summary Send a new message (compatibility)
// @Description All parameter can be set via query-parameter or form-data body. Only UserID, UserKey and Title are required
// @Tags External
//
// @Param query_data query handler.SendMessageCompat.combined false " "
// @Param form_data formData handler.SendMessageCompat.combined false " "
//
// @Success 200 {object} handler.SendMessageCompat.response
// @Failure 400 {object} ginresp.apiError
// @Failure 401 {object} ginresp.apiError
// @Failure 403 {object} ginresp.apiError
// @Failure 500 {object} ginresp.apiError
//
// @Router /send.php [POST]
func (h MessageHandler) SendMessageCompat(g *gin.Context) ginresp.HTTPResponse {
type combined struct {
UserID *int64 `json:"user_id" form:"user_id"`
UserKey *string `json:"user_key" form:"user_key"`
Title *string `json:"title" form:"title"`
Content *string `json:"content" form:"content"`
Priority *int `json:"priority" form:"priority"`
UserMessageID *string `json:"msg_id" form:"msg_id"`
SendTimestamp *float64 `json:"timestamp" form:"timestamp"`
}
type response struct {
Success bool `json:"success"`
ErrorID apierr.APIError `json:"error"`
ErrorHighlight int `json:"errhighlight"`
Message string `json:"message"`
SuppressSend bool `json:"suppress_send"`
MessageCount int `json:"messagecount"`
Quota int `json:"quota"`
IsPro bool `json:"is_pro"`
QuotaMax int `json:"quota_max"`
SCNMessageID int64 `json:"scn_msg_id"`
}
var f combined
var q combined
ctx, errResp := h.app.StartRequest(g, nil, &q, nil, &f, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(f, q)
newid, err := h.database.ConvertCompatID(ctx, langext.Coalesce(data.UserID, -1), "userid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query userid<old>", err)
}
if newid == nil {
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found (compat)", nil)
}
okResp, errResp := h.sendMessageInternal(g, ctx, langext.Ptr(models.UserID(*newid)), data.UserKey, nil, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, nil)
if errResp != nil {
return *errResp
} else {
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
ErrorID: apierr.NO_ERROR,
ErrorHighlight: -1,
Message: langext.Conditional(okResp.MessageIsOld, "Message already sent", "Message sent"),
SuppressSend: okResp.MessageIsOld,
MessageCount: okResp.User.MessagesSent,
Quota: okResp.User.QuotaUsedToday(),
IsPro: okResp.User.IsPro,
QuotaMax: okResp.User.QuotaPerDay(),
SCNMessageID: okResp.CompatMessageID,
}))
}
}
// Register swaggerdoc
//
// @Summary Register a new account
// @ID compat-register
// @Tags API-v1
//
// @Deprecated
//
// @Param fcm_token query string true "the (android) fcm token"
// @Param pro query string true "if the user is a paid account" Enums(true, false)
// @Param pro_token query string true "the (android) IAP token"
//
// @Param fcm_token formData string true "the (android) fcm token"
// @Param pro formData string true "if the user is a paid account" Enums(true, false)
// @Param pro_token formData string true "the (android) IAP token"
//
// @Success 200 {object} handler.Register.response
// @Failure default {object} ginresp.compatAPIError
//
// @Router /api/register.php [get]
func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse {
type query struct {
FCMToken *string `json:"fcm_token" form:"fcm_token"`
Pro *string `json:"pro" form:"pro"`
ProToken *string `json:"pro_token" form:"pro_token"`
}
type response struct {
Success bool `json:"success"`
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
QuotaUsed int `json:"quota"`
QuotaMax int `json:"quota_max"`
IsPro bool `json:"is_pro"`
}
var datq query
var datb query
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(datb, datq)
if data.FCMToken == nil {
return ginresp.CompatAPIError(0, "Missing parameter [[fcm_token]]")
}
if data.Pro == nil {
return ginresp.CompatAPIError(0, "Missing parameter [[pro]]")
}
if data.ProToken == nil {
return ginresp.CompatAPIError(0, "Missing parameter [[pro_token]]")
}
if data.ProToken != nil {
data.ProToken = langext.Ptr("ANDROID|v1|" + *data.ProToken)
}
if *data.Pro != "true" {
data.ProToken = nil
}
if data.ProToken != nil {
ptok, err := h.app.VerifyProToken(ctx, *data.ProToken)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query purchase status")
}
if !ptok {
return ginresp.CompatAPIError(0, "Purchase token could not be verified")
}
}
adminKey := h.app.GenerateRandomAuthKey()
err := h.database.ClearFCMTokens(ctx, *data.FCMToken)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to clear existing fcm tokens")
}
if data.ProToken != nil {
err := h.database.ClearProTokens(ctx, *data.ProToken)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to clear existing pro tokens")
}
}
user, err := h.database.CreateUser(ctx, data.ProToken, nil)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to create user in db")
}
_, err = h.database.CreateKeyToken(ctx, "CompatKey", user.UserID, true, make([]models.ChannelID, 0), models.TokenPermissionList{models.PermAdmin}, adminKey)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create admin-key in db", err)
}
_, err = h.database.CreateClient(ctx, user.UserID, models.ClientTypeAndroid, *data.FCMToken, "compat", "compat")
if err != nil {
return ginresp.CompatAPIError(0, "Failed to create client in db")
}
oldid, err := h.database.CreateCompatID(ctx, "userid", user.UserID.String())
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create userid<old>", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "New user registered",
UserID: oldid,
UserKey: adminKey,
QuotaUsed: user.QuotaUsedToday(),
QuotaMax: user.QuotaPerDay(),
IsPro: user.IsPro,
}))
}
// Info swaggerdoc
//
// @Summary Get information about the current user
// @ID compat-info
// @Tags API-v1
//
// @Deprecated
//
// @Param user_id query string true "the user_id"
// @Param user_key query string true "the user_key"
//
// @Param user_id formData string true "the user_id"
// @Param user_key formData string true "the user_key"
//
// @Success 200 {object} handler.Info.response
// @Failure default {object} ginresp.compatAPIError
//
// @Router /api/info.php [get]
func (h CompatHandler) Info(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *int64 `json:"user_id" form:"user_id"`
UserKey *string `json:"user_key" form:"user_key"`
}
type response struct {
Success bool `json:"success"`
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
QuotaUsed int `json:"quota"`
QuotaMax int `json:"quota_max"`
IsPro int `json:"is_pro"`
FCMSet bool `json:"fcm_token_set"`
UnackCount int64 `json:"unack_count"`
}
var datq query
var datb query
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(datb, datq)
if data.UserID == nil {
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
}
if data.UserKey == nil {
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
}
useridCompNew, err := h.database.ConvertCompatID(ctx, *data.UserID, "userid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query userid<old>", err)
}
if useridCompNew == nil {
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found (compat)", nil)
}
user, err := h.database.GetUser(ctx, models.UserID(*useridCompNew))
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(201, "User not found")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(204, "Authentification failed")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token")
}
if !keytok.IsAdmin(user.UserID) {
return ginresp.CompatAPIError(204, "Authentification failed")
}
clients, err := h.database.ListClients(ctx, user.UserID)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query clients")
}
filter := models.MessageFilter{
Owner: langext.Ptr([]models.UserID{user.UserID}),
CompatAcknowledged: langext.Ptr(false),
}
unackCount, err := h.database.CountMessages(ctx, filter)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "ok",
UserID: *data.UserID,
UserKey: keytok.Token,
QuotaUsed: user.QuotaUsedToday(),
QuotaMax: user.QuotaPerDay(),
IsPro: langext.Conditional(user.IsPro, 1, 0),
FCMSet: len(clients) > 0,
UnackCount: unackCount,
}))
}
// Ack swaggerdoc
//
// @Summary Acknowledge that a message was received
// @ID compat-ack
// @Tags API-v1
//
// @Deprecated
//
// @Param user_id query string true "the user_id"
// @Param user_key query string true "the user_key"
// @Param scn_msg_id query string true "the message id"
//
// @Param user_id formData string true "the user_id"
// @Param user_key formData string true "the user_key"
// @Param scn_msg_id formData string true "the message id"
//
// @Success 200 {object} handler.Ack.response
// @Failure default {object} ginresp.compatAPIError
//
// @Router /api/ack.php [get]
func (h CompatHandler) Ack(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *int64 `json:"user_id" form:"user_id"`
UserKey *string `json:"user_key" form:"user_key"`
MessageID *int64 `json:"scn_msg_id" form:"scn_msg_id"`
}
type response struct {
Success bool `json:"success"`
Message string `json:"message"`
PrevAckValue int `json:"prev_ack"`
NewAckValue int `json:"new_ack"`
}
var datq query
var datb query
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(datb, datq)
if data.UserID == nil {
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
}
if data.UserKey == nil {
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
}
if data.MessageID == nil {
return ginresp.CompatAPIError(103, "Missing parameter [[scn_msg_id]]")
}
useridCompNew, err := h.database.ConvertCompatID(ctx, *data.UserID, "userid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query userid<old>", err)
}
if useridCompNew == nil {
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, fmt.Sprintf("User %d not found (compat)", *data.UserID), nil)
}
user, err := h.database.GetUser(ctx, models.UserID(*useridCompNew))
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(201, "User not found")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(204, "Authentification failed")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token")
}
if !keytok.IsAdmin(user.UserID) {
return ginresp.CompatAPIError(204, "Authentification failed")
}
messageIdComp, err := h.database.ConvertCompatID(ctx, *data.MessageID, "messageid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query messageid<old>", err)
}
if messageIdComp == nil {
return ginresp.SendAPIError(g, 400, apierr.MESSAGE_NOT_FOUND, hl.NONE, fmt.Sprintf("Message %d not found (compat)", *data.MessageID), nil)
}
ackBefore, err := h.database.GetAck(ctx, models.MessageID(*messageIdComp))
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query ack", err)
}
if !ackBefore {
err = h.database.SetAck(ctx, user.UserID, models.MessageID(*messageIdComp))
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to set ack", err)
}
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "ok",
PrevAckValue: langext.Conditional(ackBefore, 1, 0),
NewAckValue: 1,
}))
}
// Requery swaggerdoc
//
// @Summary Return all not-acknowledged messages
// @ID compat-requery
// @Tags API-v1
//
// @Deprecated
//
// @Param user_id query string true "the user_id"
// @Param user_key query string true "the user_key"
//
// @Param user_id formData string true "the user_id"
// @Param user_key formData string true "the user_key"
//
// @Success 200 {object} handler.Requery.response
// @Failure default {object} ginresp.compatAPIError
//
// @Router /api/requery.php [get]
func (h CompatHandler) Requery(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *int64 `json:"user_id" form:"user_id"`
UserKey *string `json:"user_key" form:"user_key"`
}
type response struct {
Success bool `json:"success"`
Message string `json:"message"`
Count int `json:"count"`
Data []models.CompatMessage `json:"data"`
}
var datq query
var datb query
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(datb, datq)
if data.UserID == nil {
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
}
if data.UserKey == nil {
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
}
useridCompNew, err := h.database.ConvertCompatID(ctx, *data.UserID, "userid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query userid<old>", err)
}
if useridCompNew == nil {
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found (compat)", nil)
}
user, err := h.database.GetUser(ctx, models.UserID(*useridCompNew))
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(201, "User not found")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(204, "Authentification failed")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token")
}
if !keytok.IsAdmin(user.UserID) {
return ginresp.CompatAPIError(204, "Authentification failed")
}
filter := models.MessageFilter{
Owner: langext.Ptr([]models.UserID{user.UserID}),
CompatAcknowledged: langext.Ptr(false),
}
msgs, _, err := h.database.ListMessages(ctx, filter, langext.Ptr(16), ct.Start())
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
compMsgs := make([]models.CompatMessage, 0, len(msgs))
for _, v := range msgs {
messageIdComp, err := h.database.ConvertToCompatIDOrCreate(ctx, v.MessageID.String(), "messageid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query/create messageid<old>", err)
}
compMsgs = append(compMsgs, models.CompatMessage{
Title: h.app.CompatizeMessageTitle(ctx, v),
Body: v.Content,
Priority: v.Priority,
Timestamp: v.Timestamp().Unix(),
UserMessageID: v.UserMessageID,
SCNMessageID: messageIdComp,
Trimmed: nil,
})
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "ok",
Count: len(compMsgs),
Data: compMsgs,
}))
}
// Update swaggerdoc
//
// @Summary Set the fcm-token (android)
// @ID compat-update
// @Tags API-v1
//
// @Deprecated
//
// @Param user_id query string true "the user_id"
// @Param user_key query string true "the user_key"
// @Param fcm_token query string true "the (android) fcm token"
//
// @Param user_id formData string true "the user_id"
// @Param user_key formData string true "the user_key"
// @Param fcm_token formData string true "the (android) fcm token"
//
// @Success 200 {object} handler.Update.response
// @Failure default {object} ginresp.compatAPIError
//
// @Router /api/update.php [get]
func (h CompatHandler) Update(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *int64 `json:"user_id" form:"user_id"`
UserKey *string `json:"user_key" form:"user_key"`
FCMToken *string `json:"fcm_token" form:"fcm_token"`
}
type response struct {
Success bool `json:"success"`
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
QuotaUsed int `json:"quota"`
QuotaMax int `json:"quota_max"`
IsPro int `json:"is_pro"`
}
var datq query
var datb query
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(datb, datq)
if data.UserID == nil {
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
}
if data.UserKey == nil {
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
}
useridCompNew, err := h.database.ConvertCompatID(ctx, *data.UserID, "userid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query userid<old>", err)
}
if useridCompNew == nil {
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found (compat)", nil)
}
user, err := h.database.GetUser(ctx, models.UserID(*useridCompNew))
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(201, "User not found")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(204, "Authentification failed")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token")
}
if !keytok.IsAdmin(user.UserID) {
return ginresp.CompatAPIError(204, "Authentification failed")
}
clients, err := h.database.ListClients(ctx, user.UserID)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to list clients")
}
newAdminKey := h.app.GenerateRandomAuthKey()
_, err = h.database.CreateKeyToken(ctx, "CompatKey", user.UserID, true, make([]models.ChannelID, 0), models.TokenPermissionList{models.PermAdmin}, newAdminKey)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create admin-key in db", err)
}
err = h.database.DeleteKeyToken(ctx, keytok.KeyTokenID)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to update keys")
}
if data.FCMToken != nil {
for _, client := range clients {
err = h.database.DeleteClient(ctx, client.ClientID)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to delete client")
}
}
_, err = h.database.CreateClient(ctx, user.UserID, models.ClientTypeAndroid, *data.FCMToken, "compat", "compat")
if err != nil {
return ginresp.CompatAPIError(0, "Failed to delete client")
}
}
user, err = h.database.GetUser(ctx, user.UserID)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "user updated",
UserID: *data.UserID,
UserKey: newAdminKey,
QuotaUsed: user.QuotaUsedToday(),
QuotaMax: user.QuotaPerDay(),
IsPro: langext.Conditional(user.IsPro, 1, 0),
}))
}
// Expand swaggerdoc
//
// @Summary Get a whole (potentially truncated) message
// @ID compat-expand
// @Tags API-v1
//
// @Deprecated
//
// @Param user_id query string true "The user_id"
// @Param user_key query string true "The user_key"
// @Param scn_msg_id query string true "The message-id"
//
// @Param user_id formData string true "The user_id"
// @Param user_key formData string true "The user_key"
// @Param scn_msg_id formData string true "The message-id"
//
// @Success 200 {object} handler.Expand.response
// @Failure default {object} ginresp.compatAPIError
//
// @Router /api/expand.php [get]
func (h CompatHandler) Expand(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *int64 `json:"user_id" form:"user_id"`
UserKey *string `json:"user_key" form:"user_key"`
MessageID *int64 `json:"scn_msg_id" form:"scn_msg_id"`
}
type response struct {
Success bool `json:"success"`
Message string `json:"message"`
Data models.CompatMessage `json:"data"`
}
var datq query
var datb query
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(datb, datq)
if data.UserID == nil {
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
}
if data.UserKey == nil {
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
}
if data.MessageID == nil {
return ginresp.CompatAPIError(103, "Missing parameter [[scn_msg_id]]")
}
useridCompNew, err := h.database.ConvertCompatID(ctx, *data.UserID, "userid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query userid<old>", err)
}
if useridCompNew == nil {
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found (compat)", nil)
}
user, err := h.database.GetUser(ctx, models.UserID(*useridCompNew))
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(201, "User not found")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(204, "Authentification failed")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token")
}
if !keytok.IsAdmin(user.UserID) {
return ginresp.CompatAPIError(204, "Authentification failed")
}
messageCompNew, err := h.database.ConvertCompatID(ctx, *data.MessageID, "messageid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query messagid<old>", err)
}
if messageCompNew == nil {
return ginresp.CompatAPIError(301, "Message not found")
}
msg, err := h.database.GetMessage(ctx, models.MessageID(*messageCompNew), false)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(301, "Message not found")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query message")
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "ok",
Data: models.CompatMessage{
Title: h.app.CompatizeMessageTitle(ctx, msg),
Body: msg.Content,
Trimmed: langext.Ptr(false),
Priority: msg.Priority,
Timestamp: msg.Timestamp().Unix(),
UserMessageID: msg.UserMessageID,
SCNMessageID: *data.MessageID,
},
}))
}
// Upgrade swaggerdoc
//
// @Summary Upgrade a free account to a paid account
// @ID compat-upgrade
// @Tags API-v1
//
// @Deprecated
//
// @Param user_id query string true "the user_id"
// @Param user_key query string true "the user_key"
// @Param pro query string true "if the user is a paid account" Enums(true, false)
// @Param pro_token query string true "the (android) IAP token"
//
// @Param user_id formData string true "the user_id"
// @Param user_key formData string true "the user_key"
// @Param pro formData string true "if the user is a paid account" Enums(true, false)
// @Param pro_token formData string true "the (android) IAP token"
//
// @Success 200 {object} handler.Upgrade.response
// @Failure default {object} ginresp.compatAPIError
//
// @Router /api/upgrade.php [get]
func (h CompatHandler) Upgrade(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *int64 `json:"user_id" form:"user_id"`
UserKey *string `json:"user_key" form:"user_key"`
Pro *string `json:"pro" form:"pro"`
ProToken *string `json:"pro_token" form:"pro_token"`
}
type response struct {
Success bool `json:"success"`
Message string `json:"message"`
UserID int64 `json:"user_id"`
QuotaUsed int `json:"quota"`
QuotaMax int `json:"quota_max"`
IsPro bool `json:"is_pro"`
}
var datq query
var datb query
ctx, errResp := h.app.StartRequest(g, nil, &datq, nil, &datb, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
data := dataext.ObjectMerge(datb, datq)
if data.UserID == nil {
return ginresp.CompatAPIError(101, "Missing parameter [[user_id]]")
}
if data.UserKey == nil {
return ginresp.CompatAPIError(102, "Missing parameter [[user_key]]")
}
if data.Pro == nil {
return ginresp.CompatAPIError(103, "Missing parameter [[pro]]")
}
if data.ProToken == nil {
return ginresp.CompatAPIError(104, "Missing parameter [[pro_token]]")
}
useridCompNew, err := h.database.ConvertCompatID(ctx, *data.UserID, "userid")
if err != nil {
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query userid<old>", err)
}
if useridCompNew == nil {
return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found (compat)", nil)
}
user, err := h.database.GetUser(ctx, models.UserID(*useridCompNew))
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(201, "User not found")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
keytok, err := h.database.GetKeyTokenByToken(ctx, *data.UserKey)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(204, "Authentification failed")
}
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query token")
}
if !keytok.IsAdmin(user.UserID) {
return ginresp.CompatAPIError(204, "Authentification failed")
}
if data.ProToken != nil {
data.ProToken = langext.Ptr("ANDROID|v1|" + *data.ProToken)
}
if *data.Pro != "true" {
data.ProToken = nil
}
if data.ProToken != nil {
ptok, err := h.app.VerifyProToken(ctx, *data.ProToken)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query purchase status")
}
if !ptok {
return ginresp.CompatAPIError(0, "Purchase token could not be verified")
}
err = h.database.ClearProTokens(ctx, *data.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to clear existing fcm tokens", err)
}
err = h.database.UpdateUserProToken(ctx, user.UserID, langext.Ptr(*data.ProToken))
if err != nil {
return ginresp.CompatAPIError(0, "Failed to update user")
}
} else {
err = h.database.UpdateUserProToken(ctx, user.UserID, nil)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to update user")
}
}
user, err = h.database.GetUser(ctx, user.UserID)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query user")
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
Message: "user updated",
UserID: *data.UserID,
QuotaUsed: user.QuotaUsedToday(),
QuotaMax: user.QuotaPerDay(),
IsPro: user.IsPro,
}))
}

View File

@@ -0,0 +1,315 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
hl "blackforestbytes.com/simplecloudnotifier/api/apihighlight"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"fmt"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"net/http"
"strings"
"time"
)
type SendMessageResponse struct {
User models.User
Message models.Message
MessageIsOld bool
CompatMessageID int64
}
type MessageHandler struct {
app *logic.Application
database *primarydb.Database
}
func NewMessageHandler(app *logic.Application) MessageHandler {
return MessageHandler{
app: app,
database: app.Database.Primary,
}
}
// SendMessage swaggerdoc
//
// @Summary Send a new message
// @Description All parameter can be set via query-parameter or the json body. Only UserID, UserKey and Title are required
// @Tags External
//
// @Param query_data query handler.SendMessage.combined false " "
// @Param post_body body handler.SendMessage.combined false " "
// @Param form_body formData handler.SendMessage.combined false " "
//
// @Success 200 {object} handler.SendMessage.response
// @Failure 400 {object} ginresp.apiError
// @Failure 401 {object} ginresp.apiError "The user_id was not found or the user_key is wrong"
// @Failure 403 {object} ginresp.apiError "The user has exceeded its daily quota - wait 24 hours or upgrade your account"
// @Failure 500 {object} ginresp.apiError "An internal server error occurred - try again later"
//
// @Router / [POST]
// @Router /send [POST]
func (h MessageHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse {
type combined struct {
UserID *models.UserID `json:"user_id" form:"user_id" example:"7725" `
KeyToken *string `json:"key" form:"key" example:"P3TNH8mvv14fm" `
Channel *string `json:"channel" form:"channel" example:"test" `
Title *string `json:"title" form:"title" example:"Hello World" `
Content *string `json:"content" form:"content" example:"This is a message" `
Priority *int `json:"priority" form:"priority" example:"1" enums:"0,1,2" `
UserMessageID *string `json:"msg_id" form:"msg_id" example:"db8b0e6a-a08c-4646" `
SendTimestamp *float64 `json:"timestamp" form:"timestamp" example:"1669824037" `
SenderName *string `json:"sender_name" form:"sender_name" example:"example-server" `
}
type response struct {
Success bool `json:"success"`
ErrorID apierr.APIError `json:"error"`
ErrorHighlight int `json:"errhighlight"`
Message string `json:"message"`
SuppressSend bool `json:"suppress_send"`
MessageCount int `json:"messagecount"`
Quota int `json:"quota"`
IsPro bool `json:"is_pro"`
QuotaMax int `json:"quota_max"`
SCNMessageID models.MessageID `json:"scn_msg_id"`
}
var b combined
var q combined
var f combined
ctx, errResp := h.app.StartRequest(g, nil, &q, &b, &f, logic.RequestOptions{IgnoreWrongContentType: true})
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
// query has highest prio, then form, then json
data := dataext.ObjectMerge(dataext.ObjectMerge(b, f), q)
okResp, errResp := h.sendMessageInternal(g, ctx, data.UserID, data.KeyToken, data.Channel, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, data.SenderName)
if errResp != nil {
return *errResp
} else {
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
ErrorID: apierr.NO_ERROR,
ErrorHighlight: -1,
Message: langext.Conditional(okResp.MessageIsOld, "Message already sent", "Message sent"),
SuppressSend: okResp.MessageIsOld,
MessageCount: okResp.User.MessagesSent,
Quota: okResp.User.QuotaUsedToday(),
IsPro: okResp.User.IsPro,
QuotaMax: okResp.User.QuotaPerDay(),
SCNMessageID: okResp.Message.MessageID,
}))
}
}
func (h MessageHandler) sendMessageInternal(g *gin.Context, ctx *logic.AppContext, UserID *models.UserID, Key *string, Channel *string, Title *string, Content *string, Priority *int, UserMessageID *string, SendTimestamp *float64, SenderName *string) (*SendMessageResponse, *ginresp.HTTPResponse) {
if Title != nil {
Title = langext.Ptr(strings.TrimSpace(*Title))
}
if UserMessageID != nil {
UserMessageID = langext.Ptr(strings.TrimSpace(*UserMessageID))
}
if UserID == nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_UID, hl.USER_ID, "Missing parameter [[user_id]]", nil))
}
if Key == nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_TOK, hl.USER_KEY, "Missing parameter [[key]]", nil))
}
if Title == nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_TITLE, hl.TITLE, "Missing parameter [[title]]", nil))
}
if Priority != nil && (*Priority != 0 && *Priority != 1 && *Priority != 2) {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.INVALID_PRIO, hl.PRIORITY, "Invalid priority", nil))
}
if len(*Title) == 0 {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.NO_TITLE, hl.TITLE, "No title specified", nil))
}
user, err := h.database.GetUser(ctx, *UserID)
if err == sql.ErrNoRows {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found", err))
}
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query user", err))
}
channelDisplayName := user.DefaultChannel()
channelInternalName := user.DefaultChannel()
if Channel != nil {
channelDisplayName = h.app.NormalizeChannelDisplayName(*Channel)
channelInternalName = h.app.NormalizeChannelInternalName(*Channel)
}
if len(*Title) > user.MaxTitleLength() {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.TITLE_TOO_LONG, hl.TITLE, fmt.Sprintf("Title too long (max %d characters)", user.MaxTitleLength()), nil))
}
if Content != nil && len(*Content) > user.MaxContentLength() {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CONTENT_TOO_LONG, hl.CONTENT, fmt.Sprintf("Content too long (%d characters; max := %d characters)", len(*Content), user.MaxContentLength()), nil))
}
if len(channelDisplayName) > user.MaxChannelNameLength() {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil))
}
if len(strings.TrimSpace(channelDisplayName)) == 0 {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_NAME_EMPTY, hl.CHANNEL, fmt.Sprintf("Channel displayname cannot be empty"), nil))
}
if len(channelInternalName) > user.MaxChannelNameLength() {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil))
}
if len(strings.TrimSpace(channelInternalName)) == 0 {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_NAME_EMPTY, hl.CHANNEL, fmt.Sprintf("Channel internalname cannot be empty"), nil))
}
if SenderName != nil && len(*SenderName) > user.MaxSenderName() {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.SENDERNAME_TOO_LONG, hl.SENDER_NAME, fmt.Sprintf("SenderName too long (max %d characters)", user.MaxSenderName()), nil))
}
if UserMessageID != nil && len(*UserMessageID) > user.MaxUserMessageID() {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.USR_MSG_ID_TOO_LONG, hl.USER_MESSAGE_ID, fmt.Sprintf("MessageID too long (max %d characters)", user.MaxUserMessageID()), nil))
}
if SendTimestamp != nil && mathext.Abs(*SendTimestamp-float64(time.Now().Unix())) > timeext.FromHours(user.MaxTimestampDiffHours()).Seconds() {
return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.TIMESTAMP_OUT_OF_RANGE, hl.NONE, fmt.Sprintf("The timestamp mus be within %d hours of now()", user.MaxTimestampDiffHours()), nil))
}
if UserMessageID != nil {
msg, err := h.database.GetMessageByUserMessageID(ctx, *UserMessageID)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query existing message", err))
}
if msg != nil {
existingCompID, _, err := h.database.ConvertToCompatID(ctx, msg.MessageID.String())
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query compat-id", err))
}
if existingCompID == nil {
v, err := h.database.CreateCompatID(ctx, "messageid", msg.MessageID.String())
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create compat-id", err))
}
existingCompID = &v
}
//the found message can be deleted (!), but we still return NO_ERROR here...
return &SendMessageResponse{
User: user,
Message: *msg,
MessageIsOld: true,
CompatMessageID: *existingCompID,
}, nil
}
}
if user.QuotaRemainingToday() <= 0 {
return nil, langext.Ptr(ginresp.SendAPIError(g, 403, apierr.QUOTA_REACHED, hl.NONE, fmt.Sprintf("Daily quota reached (%d)", user.QuotaPerDay()), nil))
}
channel, err := h.app.GetOrCreateChannel(ctx, *UserID, channelDisplayName, channelInternalName)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query/create (owned) channel", err))
}
keytok, permResp := ctx.CheckPermissionSend(channel, *Key)
if permResp != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 401, apierr.USER_AUTH_FAILED, hl.USER_KEY, "You are not authorized for this action", nil))
}
var sendTimestamp *time.Time = nil
if SendTimestamp != nil {
sendTimestamp = langext.Ptr(timeext.UnixFloatSeconds(*SendTimestamp))
}
priority := langext.Coalesce(Priority, user.DefaultPriority())
clientIP := g.ClientIP()
msg, err := h.database.CreateMessage(ctx, *UserID, channel, sendTimestamp, *Title, Content, priority, UserMessageID, clientIP, SenderName, keytok.KeyTokenID)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create message in db", err))
}
compatMsgID, err := h.database.CreateCompatID(ctx, "messageid", msg.MessageID.String())
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create compat-id", err))
}
subscriptions, err := h.database.ListSubscriptionsByChannel(ctx, channel.ChannelID)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query subscriptions", err))
}
err = h.database.IncUserMessageCounter(ctx, &user)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc user msg-counter", err))
}
err = h.database.IncChannelMessageCounter(ctx, &channel)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc channel msg-counter", err))
}
err = h.database.IncKeyTokenMessageCounter(ctx, keytok)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc token msg-counter", err))
}
log.Info().Msg(fmt.Sprintf("Sending new notification %s for user %s", msg.MessageID, UserID))
for _, sub := range subscriptions {
clients, err := h.database.ListClients(ctx, sub.SubscriberUserID)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query clients", err))
}
if !sub.Confirmed {
continue
}
for _, client := range clients {
isCompatClient, err := h.database.IsCompatClient(ctx, client.ClientID)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query compat_clients", err))
}
var titleOverride *string = nil
var msgidOverride *string = nil
if isCompatClient {
titleOverride = langext.Ptr(h.app.CompatizeMessageTitle(ctx, msg))
msgidOverride = langext.Ptr(fmt.Sprintf("%d", compatMsgID))
}
fcmDelivID, err := h.app.DeliverMessage(ctx, client, msg, titleOverride, msgidOverride)
if err != nil {
_, err = h.database.CreateRetryDelivery(ctx, client, msg)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err))
}
} else {
_, err = h.database.CreateSuccessDelivery(ctx, client, msg, fcmDelivID)
if err != nil {
return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err))
}
}
}
}
return &SendMessageResponse{
User: user,
Message: msg,
MessageIsOld: false,
CompatMessageID: compatMsgID,
}, nil
}

View File

@@ -0,0 +1,177 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/website"
"errors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"net/http"
"regexp"
"strings"
)
type WebsiteHandler struct {
app *logic.Application
rexTemplate rext.Regex
rexConfig rext.Regex
}
func NewWebsiteHandler(app *logic.Application) WebsiteHandler {
return WebsiteHandler{
app: app,
rexTemplate: rext.W(regexp.MustCompile("{{template\\|[A-Za-z0-9_\\-\\[\\].]+}}")),
rexConfig: rext.W(regexp.MustCompile("{{config\\|[A-Za-z0-9_\\-.]+}}")),
}
}
func (h WebsiteHandler) Index(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "index.html", true)
}
func (h WebsiteHandler) APIDocs(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "api.html", true)
}
func (h WebsiteHandler) APIDocsMore(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "api_more.html", true)
}
func (h WebsiteHandler) MessageSent(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "message_sent.html", true)
}
func (h WebsiteHandler) FaviconIco(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "favicon.ico", false)
}
func (h WebsiteHandler) FaviconPNG(g *gin.Context) ginresp.HTTPResponse {
return h.serveAsset(g, "favicon.png", false)
}
func (h WebsiteHandler) Javascript(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
Filename string `uri:"fn"`
}
var u uri
if err := g.ShouldBindUri(&u); err != nil {
return ginresp.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return h.serveAsset(g, "js/"+u.Filename, false)
}
func (h WebsiteHandler) CSS(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
Filename string `uri:"fn"`
}
var u uri
if err := g.ShouldBindUri(&u); err != nil {
return ginresp.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return h.serveAsset(g, "css/"+u.Filename, false)
}
func (h WebsiteHandler) serveAsset(g *gin.Context, fn string, repl bool) ginresp.HTTPResponse {
_data, err := website.Assets.ReadFile(fn)
if err != nil {
return ginresp.Status(http.StatusNotFound)
}
data := string(_data)
if repl {
failed := false
data = h.rexTemplate.ReplaceAllFunc(data, func(match string) string {
prefix := len("{{template|")
suffix := len("}}")
fnSub := match[prefix : len(match)-suffix]
fnSub = strings.ReplaceAll(fnSub, "[theme]", h.getTheme(g))
subdata, err := website.Assets.ReadFile(fnSub)
if err != nil {
log.Error().Str("templ", string(match)).Str("fnSub", fnSub).Str("source", fn).Msg("Failed to replace template")
failed = true
}
return string(subdata)
})
if failed {
return ginresp.InternalError(errors.New("template replacement failed"))
}
data = h.rexConfig.ReplaceAllFunc(data, func(match string) string {
prefix := len("{{config|")
suffix := len("}}")
cfgKey := match[prefix : len(match)-suffix]
cval, ok := h.getReplConfig(cfgKey)
if !ok {
log.Error().Str("templ", match).Str("source", fn).Msg("Failed to replace config")
failed = true
}
return cval
})
if failed {
return ginresp.InternalError(errors.New("config replacement failed"))
}
}
mime := "text/plain"
lowerFN := strings.ToLower(fn)
if strings.HasSuffix(lowerFN, ".html") || strings.HasSuffix(lowerFN, ".htm") {
mime = "text/html"
} else if strings.HasSuffix(lowerFN, ".css") {
mime = "text/css"
} else if strings.HasSuffix(lowerFN, ".js") {
mime = "text/javascript"
} else if strings.HasSuffix(lowerFN, ".json") {
mime = "application/json"
} else if strings.HasSuffix(lowerFN, ".jpeg") || strings.HasSuffix(lowerFN, ".jpg") {
mime = "image/jpeg"
} else if strings.HasSuffix(lowerFN, ".png") {
mime = "image/png"
} else if strings.HasSuffix(lowerFN, ".svg") {
mime = "image/svg+xml"
}
return ginresp.Data(http.StatusOK, mime, []byte(data))
}
func (h WebsiteHandler) getReplConfig(key string) (string, bool) {
key = strings.TrimSpace(strings.ToLower(key))
if key == "baseurl" {
return h.app.Config.BaseURL, true
}
if key == "ip" {
return h.app.Config.ServerIP, true
}
if key == "port" {
return h.app.Config.ServerPort, true
}
if key == "namespace" {
return h.app.Config.Namespace, true
}
return "", false
}
func (h WebsiteHandler) getTheme(g *gin.Context) string {
if c, err := g.Cookie("theme"); err != nil {
return "light"
} else if c == "light" {
return "light"
} else if c == "dark" {
return "dark"
} else {
return "light"
}
}

182
scnserver/api/router.go Normal file
View File

@@ -0,0 +1,182 @@
package api
import (
"blackforestbytes.com/simplecloudnotifier/api/ginext"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/api/handler"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"blackforestbytes.com/simplecloudnotifier/swagger"
"errors"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
type Router struct {
app *logic.Application
commonHandler handler.CommonHandler
compatHandler handler.CompatHandler
websiteHandler handler.WebsiteHandler
apiHandler handler.APIHandler
messageHandler handler.MessageHandler
}
func NewRouter(app *logic.Application) *Router {
return &Router{
app: app,
commonHandler: handler.NewCommonHandler(app),
compatHandler: handler.NewCompatHandler(app),
websiteHandler: handler.NewWebsiteHandler(app),
apiHandler: handler.NewAPIHandler(app),
messageHandler: handler.NewMessageHandler(app),
}
}
// Init swaggerdocs
//
// @title SimpleCloudNotifier API
// @version 2.0
// @description API for SCN
// @host simplecloudnotifier.de
//
// @tag.name External
// @tag.name API-v1
// @tag.name API-v2
// @tag.name Common
//
// @BasePath /
func (r *Router) Init(e *gin.Engine) error {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
err := v.RegisterValidation("entityid", models.ValidateEntityID, true)
if err != nil {
return err
}
} else {
return errors.New("failed to add validators - wrong engine")
}
// ================ General (unversioned) ================
commonAPI := e.Group("/api")
{
commonAPI.Any("/ping", r.Wrap(r.commonHandler.Ping))
commonAPI.POST("/db-test", r.Wrap(r.commonHandler.DatabaseTest))
commonAPI.GET("/health", r.Wrap(r.commonHandler.Health))
commonAPI.POST("/sleep/:secs", r.Wrap(r.commonHandler.Sleep))
}
// ================ Swagger ================
docs := e.Group("/documentation")
{
docs.GET("/swagger", ginext.RedirectTemporary("/documentation/swagger/"))
docs.GET("/swagger/*sub", r.Wrap(swagger.Handle))
}
// ================ Website ================
frontend := e.Group("")
{
frontend.GET("/", r.Wrap(r.websiteHandler.Index))
frontend.GET("/index.php", r.Wrap(r.websiteHandler.Index))
frontend.GET("/index.html", r.Wrap(r.websiteHandler.Index))
frontend.GET("/index", r.Wrap(r.websiteHandler.Index))
frontend.GET("/api", r.Wrap(r.websiteHandler.APIDocs))
frontend.GET("/api.php", r.Wrap(r.websiteHandler.APIDocs))
frontend.GET("/api.html", r.Wrap(r.websiteHandler.APIDocs))
frontend.GET("/api_more", r.Wrap(r.websiteHandler.APIDocsMore))
frontend.GET("/api_more.php", r.Wrap(r.websiteHandler.APIDocsMore))
frontend.GET("/api_more.html", r.Wrap(r.websiteHandler.APIDocsMore))
frontend.GET("/message_sent", r.Wrap(r.websiteHandler.MessageSent))
frontend.GET("/message_sent.php", r.Wrap(r.websiteHandler.MessageSent))
frontend.GET("/message_sent.html", r.Wrap(r.websiteHandler.MessageSent))
frontend.GET("/favicon.ico", r.Wrap(r.websiteHandler.FaviconIco))
frontend.GET("/favicon.png", r.Wrap(r.websiteHandler.FaviconPNG))
frontend.GET("/js/:fn", r.Wrap(r.websiteHandler.Javascript))
frontend.GET("/css/:fn", r.Wrap(r.websiteHandler.CSS))
}
// ================ Compat (v1) ================
compat := e.Group("/api")
{
compat.GET("/register.php", r.Wrap(r.compatHandler.Register))
compat.GET("/info.php", r.Wrap(r.compatHandler.Info))
compat.GET("/ack.php", r.Wrap(r.compatHandler.Ack))
compat.GET("/requery.php", r.Wrap(r.compatHandler.Requery))
compat.GET("/update.php", r.Wrap(r.compatHandler.Update))
compat.GET("/expand.php", r.Wrap(r.compatHandler.Expand))
compat.GET("/upgrade.php", r.Wrap(r.compatHandler.Upgrade))
}
// ================ Manage API (v2) ================
apiv2 := e.Group("/api/v2/")
{
apiv2.POST("/users", r.Wrap(r.apiHandler.CreateUser))
apiv2.GET("/users/:uid", r.Wrap(r.apiHandler.GetUser))
apiv2.PATCH("/users/:uid", r.Wrap(r.apiHandler.UpdateUser))
apiv2.GET("/users/:uid/keys", r.Wrap(r.apiHandler.ListUserKeys))
apiv2.POST("/users/:uid/keys", r.Wrap(r.apiHandler.CreateUserKey))
apiv2.GET("/users/:uid/keys/:kid", r.Wrap(r.apiHandler.GetUserKey))
apiv2.PATCH("/users/:uid/keys/:kid", r.Wrap(r.apiHandler.UpdateUserKey))
apiv2.DELETE("/users/:uid/keys/:kid", r.Wrap(r.apiHandler.DeleteUserKey))
apiv2.GET("/users/:uid/clients", r.Wrap(r.apiHandler.ListClients))
apiv2.GET("/users/:uid/clients/:cid", r.Wrap(r.apiHandler.GetClient))
apiv2.PATCH("/users/:uid/clients/:cid", r.Wrap(r.apiHandler.UpdateClient))
apiv2.POST("/users/:uid/clients", r.Wrap(r.apiHandler.AddClient))
apiv2.DELETE("/users/:uid/clients/:cid", r.Wrap(r.apiHandler.DeleteClient))
apiv2.GET("/users/:uid/channels", r.Wrap(r.apiHandler.ListChannels))
apiv2.POST("/users/:uid/channels", r.Wrap(r.apiHandler.CreateChannel))
apiv2.GET("/users/:uid/channels/:cid", r.Wrap(r.apiHandler.GetChannel))
apiv2.PATCH("/users/:uid/channels/:cid", r.Wrap(r.apiHandler.UpdateChannel))
apiv2.GET("/users/:uid/channels/:cid/messages", r.Wrap(r.apiHandler.ListChannelMessages))
apiv2.GET("/users/:uid/channels/:cid/subscriptions", r.Wrap(r.apiHandler.ListChannelSubscriptions))
apiv2.GET("/users/:uid/subscriptions", r.Wrap(r.apiHandler.ListUserSubscriptions))
apiv2.POST("/users/:uid/subscriptions", r.Wrap(r.apiHandler.CreateSubscription))
apiv2.GET("/users/:uid/subscriptions/:sid", r.Wrap(r.apiHandler.GetSubscription))
apiv2.DELETE("/users/:uid/subscriptions/:sid", r.Wrap(r.apiHandler.CancelSubscription))
apiv2.PATCH("/users/:uid/subscriptions/:sid", r.Wrap(r.apiHandler.UpdateSubscription))
apiv2.GET("/messages", r.Wrap(r.apiHandler.ListMessages))
apiv2.GET("/messages/:mid", r.Wrap(r.apiHandler.GetMessage))
apiv2.DELETE("/messages/:mid", r.Wrap(r.apiHandler.DeleteMessage))
}
// ================ Send API (unversioned) ================
sendAPI := e.Group("")
{
sendAPI.POST("/", r.Wrap(r.messageHandler.SendMessage))
sendAPI.POST("/send", r.Wrap(r.messageHandler.SendMessage))
sendAPI.POST("/send.php", r.Wrap(r.messageHandler.SendMessageCompat))
}
// ================
if r.app.Config.ReturnRawErrors {
e.NoRoute(r.Wrap(r.commonHandler.NoRoute))
}
// ================
return nil
}
func (r *Router) Wrap(fn ginresp.WHandlerFunc) gin.HandlerFunc {
return ginresp.Wrap(r.app, fn)
}

View File

@@ -0,0 +1,53 @@
package main
import (
"blackforestbytes.com/simplecloudnotifier/db/schema"
"context"
"fmt"
"github.com/mattn/go-sqlite3"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sqlite3.Version() // ensure slite3 loaded
{
h0, err := sq.HashSqliteSchema(ctx, schema.PrimarySchema1)
if err != nil {
h0 = "ERR"
}
fmt.Printf("PrimarySchema1 := %s\n", h0)
}
{
h0, err := sq.HashSqliteSchema(ctx, schema.PrimarySchema2)
if err != nil {
h0 = "ERR"
}
fmt.Printf("PrimarySchema2 := %s\n", h0)
}
{
h0, err := sq.HashSqliteSchema(ctx, schema.PrimarySchema3)
if err != nil {
h0 = "ERR"
}
fmt.Printf("PrimarySchema3 := %s\n", h0)
}
{
h0, err := sq.HashSqliteSchema(ctx, schema.RequestsSchema1)
if err != nil {
h0 = "ERR"
}
fmt.Printf("RequestsSchema1 := %s\n", h0)
}
{
h0, err := sq.HashSqliteSchema(ctx, schema.LogsSchema1)
if err != nil {
h0 = "ERR"
}
fmt.Printf("LogsSchema1 := %s\n", h0)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
package main
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/api"
"blackforestbytes.com/simplecloudnotifier/api/ginext"
"blackforestbytes.com/simplecloudnotifier/google"
"blackforestbytes.com/simplecloudnotifier/jobs"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/push"
"fmt"
"github.com/rs/zerolog/log"
)
func main() {
conf := scn.Conf
scn.Init(conf)
log.Info().Msg(fmt.Sprintf("Starting with config-namespace <%s>", conf.Namespace))
sqlite, err := logic.NewDBPool(conf)
if err != nil {
panic(err)
}
app := logic.NewApp(sqlite)
if err := app.Migrate(); err != nil {
log.Fatal().Err(err).Msg("failed to migrate DB")
return
}
ginengine := ginext.NewEngine(conf)
router := api.NewRouter(app)
var nc push.NotificationClient
if conf.DummyFirebase {
nc = push.NewDummy()
} else {
nc, err = push.NewFirebaseConn(conf)
if err != nil {
log.Fatal().Err(err).Msg("failed to init firebase")
return
}
}
var apc google.AndroidPublisherClient
if conf.DummyGoogleAPI {
apc = google.NewDummy()
} else {
apc, err = google.NewAndroidPublisherAPI(conf)
if err != nil {
log.Fatal().Err(err).Msg("failed to init google-api")
return
}
}
jobRetry := jobs.NewDeliveryRetryJob(app)
jobReqCollector := jobs.NewRequestLogCollectorJob(app)
app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry, jobReqCollector})
err = router.Init(ginengine)
if err != nil {
log.Fatal().Err(err).Msg("failed to init router")
return
}
app.Run()
}

463
scnserver/config.go Normal file
View File

@@ -0,0 +1,463 @@
package server
import (
"fmt"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/confext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"os"
"time"
)
type Config struct {
Namespace string
BaseURL string `env:"URL"`
GinDebug bool `env:"GINDEBUG"`
LogLevel zerolog.Level `env:"LOGLEVEL"`
ServerIP string `env:"IP"`
ServerPort string `env:"PORT"`
DBMain DBConfig `env:"DB_MAIN"`
DBRequests DBConfig `env:"DB_REQUESTS"`
DBLogs DBConfig `env:"DB_LOGS"`
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT"`
RequestMaxRetry int `env:"REQUEST_MAXRETRY"`
RequestRetrySleep time.Duration `env:"REQUEST_RETRYSLEEP"`
Cors bool `env:"CORS"`
ReturnRawErrors bool `env:"ERROR_RETURN"`
DummyFirebase bool `env:"DUMMY_FB"`
DummyGoogleAPI bool `env:"DUMMY_GOOG"`
FirebaseTokenURI string `env:"FB_TOKENURI"`
FirebaseProjectID string `env:"FB_PROJECTID"`
FirebasePrivKeyID string `env:"FB_PRIVATEKEYID"`
FirebaseClientMail string `env:"FB_CLIENTEMAIL"`
FirebasePrivateKey string `env:"FB_PRIVATEKEY"`
GoogleAPITokenURI string `env:"GOOG_TOKENURI"`
GoogleAPIPrivKeyID string `env:"GOOG_PRIVATEKEYID"`
GoogleAPIClientMail string `env:"GOOG_CLIENTEMAIL"`
GoogleAPIPrivateKey string `env:"GOOG_PRIVATEKEY"`
GooglePackageName string `env:"GOOG_PACKAGENAME"`
GoogleProProductID string `env:"GOOG_PROPRODUCTID"`
ReqLogEnabled bool `env:"REQUESTLOG_ENABLED"`
ReqLogMaxBodySize int `env:"REQUESTLOG_MAXBODYSIZE"`
ReqLogHistoryMaxCount int `env:"REQUESTLOG_HISTORY_MAXCOUNT"`
ReqLogHistoryMaxDuration time.Duration `env:"REQUESTLOG_HISTORY_MAXDURATION"`
}
type DBConfig struct {
File string `env:"FILE"`
Journal string `env:"JOURNAL"`
Timeout time.Duration `env:"TIMEOUT"`
MaxOpenConns int `env:"MAXOPENCONNECTIONS"`
MaxIdleConns int `env:"MAXIDLECONNECTIONS"`
ConnMaxLifetime time.Duration `env:"CONNEXTIONMAXLIFETIME"`
ConnMaxIdleTime time.Duration `env:"CONNEXTIONMAXIDLETIME"`
CheckForeignKeys bool `env:"CHECKFOREIGNKEYS"`
SingleConn bool `env:"SINGLECONNECTION"`
BusyTimeout time.Duration `env:"BUSYTIMEOUT"`
EnableLogger bool `env:"ENABLELOGGER"`
}
var Conf Config
var configLocHost = func() Config {
return Config{
Namespace: "local-host",
BaseURL: "http://localhost:8080",
GinDebug: false,
LogLevel: zerolog.DebugLevel,
ServerIP: "0.0.0.0",
ServerPort: "8080",
DBMain: DBConfig{
File: ".run-data/loc_main.sqlite3",
Journal: "WAL",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 100 * time.Millisecond,
EnableLogger: true,
},
DBRequests: DBConfig{
File: ".run-data/loc_requests.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
DBLogs: DBConfig{
File: ".run-data/loc_logs.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
RequestTimeout: 16 * time.Second,
RequestMaxRetry: 8,
RequestRetrySleep: 100 * time.Millisecond,
ReturnRawErrors: true,
DummyFirebase: true,
FirebaseTokenURI: "",
FirebaseProjectID: "",
FirebasePrivKeyID: "",
FirebaseClientMail: "",
FirebasePrivateKey: "",
DummyGoogleAPI: true,
GoogleAPITokenURI: "",
GoogleAPIPrivKeyID: "",
GoogleAPIClientMail: "",
GoogleAPIPrivateKey: "",
GooglePackageName: "",
GoogleProProductID: "",
Cors: true,
ReqLogEnabled: true,
ReqLogMaxBodySize: 2048,
ReqLogHistoryMaxCount: 1638,
ReqLogHistoryMaxDuration: timeext.FromDays(60),
}
}
var configLocDocker = func() Config {
return Config{
Namespace: "local-docker",
BaseURL: "http://localhost:8080",
GinDebug: false,
LogLevel: zerolog.DebugLevel,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBMain: DBConfig{
File: "/data/docker_scn_main.sqlite3",
Journal: "WAL",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 100 * time.Millisecond,
EnableLogger: true,
},
DBRequests: DBConfig{
File: "/data/docker_scn_requests.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
DBLogs: DBConfig{
File: "/data/docker_scn_logs.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
RequestTimeout: 16 * time.Second,
RequestMaxRetry: 8,
RequestRetrySleep: 100 * time.Millisecond,
ReturnRawErrors: true,
DummyFirebase: true,
FirebaseTokenURI: "",
FirebaseProjectID: "",
FirebasePrivKeyID: "",
FirebaseClientMail: "",
FirebasePrivateKey: "",
DummyGoogleAPI: true,
GoogleAPITokenURI: "",
GoogleAPIPrivKeyID: "",
GoogleAPIClientMail: "",
GoogleAPIPrivateKey: "",
GooglePackageName: "",
GoogleProProductID: "",
Cors: true,
ReqLogMaxBodySize: 2048,
ReqLogHistoryMaxCount: 1638,
ReqLogHistoryMaxDuration: timeext.FromDays(60),
}
}
var configDev = func() Config {
return Config{
Namespace: "develop",
BaseURL: confEnv("SCN_URL"),
GinDebug: false,
LogLevel: zerolog.DebugLevel,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBMain: DBConfig{
File: "/data/scn_main.sqlite3",
Journal: "WAL",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 100 * time.Millisecond,
EnableLogger: true,
},
DBRequests: DBConfig{
File: "/data/scn_requests.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
DBLogs: DBConfig{
File: "/data/scn_logs.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
RequestTimeout: 16 * time.Second,
RequestMaxRetry: 8,
RequestRetrySleep: 100 * time.Millisecond,
ReturnRawErrors: true,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("SCN_FB_PROJECTID"),
FirebasePrivKeyID: confEnv("SCN_FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("SCN_FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("SCN_FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("SCN_GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("SCN_GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("SCN_GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("SCN_GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("SCN_GOOG_PROPRODUCTID"),
Cors: true,
ReqLogEnabled: true,
ReqLogMaxBodySize: 2048,
ReqLogHistoryMaxCount: 1638,
ReqLogHistoryMaxDuration: timeext.FromDays(60),
}
}
var configStag = func() Config {
return Config{
Namespace: "staging",
BaseURL: confEnv("SCN_URL"),
GinDebug: false,
LogLevel: zerolog.DebugLevel,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBMain: DBConfig{
File: "/data/scn_main.sqlite3",
Journal: "WAL",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 100 * time.Millisecond,
EnableLogger: true,
},
DBRequests: DBConfig{
File: "/data/scn_requests.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
DBLogs: DBConfig{
File: "/data/scn_logs.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
RequestTimeout: 16 * time.Second,
RequestMaxRetry: 8,
RequestRetrySleep: 100 * time.Millisecond,
ReturnRawErrors: true,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("SCN_FB_PROJECTID"),
FirebasePrivKeyID: confEnv("SCN_FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("SCN_FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("SCN_FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("SCN_GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("SCN_GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("SCN_GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("SCN_GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("SCN_GOOG_PROPRODUCTID"),
Cors: true,
ReqLogEnabled: true,
ReqLogMaxBodySize: 2048,
ReqLogHistoryMaxCount: 1638,
ReqLogHistoryMaxDuration: timeext.FromDays(60),
}
}
var configProd = func() Config {
return Config{
Namespace: "production",
BaseURL: confEnv("SCN_URL"),
GinDebug: false,
LogLevel: zerolog.InfoLevel,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBMain: DBConfig{
File: "/data/scn_main.sqlite3",
Journal: "WAL",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 100 * time.Millisecond,
EnableLogger: true,
},
DBRequests: DBConfig{
File: "/data/scn_requests.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
DBLogs: DBConfig{
File: "/data/scn_logs.sqlite3",
Journal: "DELETE",
Timeout: 5 * time.Second,
CheckForeignKeys: false,
SingleConn: false,
MaxOpenConns: 5,
MaxIdleConns: 5,
ConnMaxLifetime: 60 * time.Minute,
ConnMaxIdleTime: 60 * time.Minute,
BusyTimeout: 500 * time.Millisecond,
EnableLogger: true,
},
RequestTimeout: 16 * time.Second,
RequestMaxRetry: 8,
RequestRetrySleep: 100 * time.Millisecond,
ReturnRawErrors: false,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("SCN_FB_PROJECTID"),
FirebasePrivKeyID: confEnv("SCN_FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("SCN_FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("SCN_FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("SCN_GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("SCN_GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("SCN_GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("SCN_GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("SCN_GOOG_PROPRODUCTID"),
Cors: true,
ReqLogEnabled: true,
ReqLogMaxBodySize: 2048,
ReqLogHistoryMaxCount: 1638,
ReqLogHistoryMaxDuration: timeext.FromDays(60),
}
}
var allConfig = map[string]func() Config{
"local-host": configLocHost,
"local-docker": configLocDocker,
"develop": configDev,
"staging": configStag,
"production": configProd,
}
func GetConfig(ns string) (Config, bool) {
if ns == "" {
ns = "local-host"
}
if cfn, ok := allConfig[ns]; ok {
c := cfn()
err := confext.ApplyEnvOverrides("SCN_", &c, "_")
if err != nil {
panic(err)
}
return c, true
}
return Config{}, false
}
func confEnv(key string) string {
if v, ok := os.LookupEnv(key); ok {
return v
} else {
log.Fatal().Msg(fmt.Sprintf("Missing required environment variable '%s'", key))
return ""
}
}
func init() {
ns := os.Getenv("CONF_NS")
cfg, ok := GetConfig(ns)
if !ok {
log.Fatal().Str("ns", ns).Msg("Unknown config-namespace")
}
Conf = cfg
}

View File

@@ -0,0 +1,145 @@
package cursortoken
import (
"encoding/base32"
"encoding/json"
"errors"
"strings"
"time"
)
type Mode string //@enum:type
const (
CTMStart = "START"
CTMNormal = "NORMAL"
CTMEnd = "END"
)
type CursorToken struct {
Mode Mode
Timestamp int64
Id string
Direction string
FilterHash string
}
type cursorTokenSerialize struct {
Timestamp *int64 `json:"ts,omitempty"`
Id *string `json:"id,omitempty"`
Direction *string `json:"dir,omitempty"`
FilterHash *string `json:"f,omitempty"`
}
func Start() CursorToken {
return CursorToken{
Mode: CTMStart,
Timestamp: 0,
Id: "",
Direction: "",
FilterHash: "",
}
}
func End() CursorToken {
return CursorToken{
Mode: CTMEnd,
Timestamp: 0,
Id: "",
Direction: "",
FilterHash: "",
}
}
func Normal(ts time.Time, id string, dir string, filter string) CursorToken {
return CursorToken{
Mode: CTMNormal,
Timestamp: ts.UnixMilli(),
Id: id,
Direction: dir,
FilterHash: filter,
}
}
func (c *CursorToken) Token() string {
if c.Mode == CTMStart {
return "@start"
}
if c.Mode == CTMEnd {
return "@end"
}
// We kinda manually implement omitempty for the CursorToken here
// because omitempty does not work for time.Time and otherwise we would always
// get weird time values when decoding a token that initially didn't have an Timestamp set
// For this usecase we treat Unix=0 as an empty timestamp
sertok := cursorTokenSerialize{}
if c.Id != "" {
sertok.Id = &c.Id
}
if c.Timestamp != 0 {
sertok.Timestamp = &c.Timestamp
}
if c.Direction != "" {
sertok.Direction = &c.Direction
}
if c.FilterHash != "" {
sertok.FilterHash = &c.FilterHash
}
body, err := json.Marshal(sertok)
if err != nil {
panic(err)
}
return "tok_" + base32.StdEncoding.EncodeToString(body)
}
func Decode(tok string) (CursorToken, error) {
if tok == "" {
return Start(), nil
}
if strings.ToLower(tok) == "@start" {
return Start(), nil
}
if strings.ToLower(tok) == "@end" {
return End(), nil
}
if !strings.HasPrefix(tok, "tok_") {
return CursorToken{}, errors.New("could not decode token, missing prefix")
}
body, err := base32.StdEncoding.DecodeString(tok[len("tok_"):])
if err != nil {
return CursorToken{}, err
}
var tokenDeserialize cursorTokenSerialize
err = json.Unmarshal(body, &tokenDeserialize)
if err != nil {
return CursorToken{}, err
}
token := CursorToken{Mode: CTMNormal}
if tokenDeserialize.Timestamp != nil {
token.Timestamp = *tokenDeserialize.Timestamp
}
if tokenDeserialize.Id != nil {
token.Id = *tokenDeserialize.Id
}
if tokenDeserialize.Direction != nil {
token.Direction = *tokenDeserialize.Direction
}
if tokenDeserialize.FilterHash != nil {
token.FilterHash = *tokenDeserialize.FilterHash
}
return token, nil
}

29
scnserver/db/database.go Normal file
View File

@@ -0,0 +1,29 @@
package db
import (
"context"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
type DatabaseImpl interface {
DB() sq.DB
Migrate(ctx context.Context) error
Ping(ctx context.Context) error
BeginTx(ctx context.Context) (sq.Tx, error)
Stop(ctx context.Context) error
ReadSchema(ctx context.Context) (int, error)
WriteMetaString(ctx context.Context, key string, value string) error
WriteMetaInt(ctx context.Context, key string, value int64) error
WriteMetaReal(ctx context.Context, key string, value float64) error
WriteMetaBlob(ctx context.Context, key string, value []byte) error
ReadMetaString(ctx context.Context, key string) (*string, error)
ReadMetaInt(ctx context.Context, key string) (*int64, error)
ReadMetaReal(ctx context.Context, key string) (*float64, error)
ReadMetaBlob(ctx context.Context, key string) (*[]byte, error)
DeleteMeta(ctx context.Context, key string) error
}

View File

@@ -0,0 +1,113 @@
package dbtools
import (
"context"
"fmt"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"regexp"
"strings"
)
var rexWhitespaceRun = rext.W(regexp.MustCompile("\\s{2,}"))
type DBLogger struct {
Ident string
}
func (l DBLogger) PrePing(ctx context.Context) error {
log.Debug().Msg("[SQL-PING]")
return nil
}
func (l DBLogger) PreTxBegin(ctx context.Context, txid uint16) error {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%s|%d>-START]", l.Ident, txid))
return nil
}
func (l DBLogger) PreTxCommit(txid uint16) error {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%s|%d>-COMMIT]", l.Ident, txid))
return nil
}
func (l DBLogger) PreTxRollback(txid uint16) error {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%s|%d>-ROLLBACK]", l.Ident, txid))
return nil
}
func (l DBLogger) PreQuery(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
if txID == nil {
log.Debug().Msg(fmt.Sprintf("[SQL<%s>-QUERY] %s", l.Ident, fmtSQLPrint(*sql)))
} else {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%s|%d>-QUERY] %s", l.Ident, *txID, fmtSQLPrint(*sql)))
}
return nil
}
func (l DBLogger) PreExec(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
if txID == nil {
log.Debug().Msg(fmt.Sprintf("[SQL-<%s>-EXEC] %s", l.Ident, fmtSQLPrint(*sql)))
} else {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%s|%d>-EXEC] %s", l.Ident, *txID, fmtSQLPrint(*sql)))
}
return nil
}
func (l DBLogger) PostPing(result error) {
//
}
func (l DBLogger) PostTxBegin(txid uint16, result error) {
//
}
func (l DBLogger) PostTxCommit(txid uint16, result error) {
//
}
func (l DBLogger) PostTxRollback(txid uint16, result error) {
//
}
func (l DBLogger) PostQuery(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}
func (l DBLogger) PostExec(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}
func fmtSQLPrint(sql string) string {
if strings.Contains(strings.TrimRight(sql, ";\r\n\t "), ";") {
str := "(...multi...)"
for _, v := range strings.Split(sql, ";") {
v = strings.ReplaceAll(v, "\r", "")
v = strings.ReplaceAll(v, "\n", " ")
v = strings.TrimRight(v, ";")
v = strings.TrimSpace(v)
v = rexWhitespaceRun.ReplaceAll(v, " ", true)
str += "\n" + " " + v
}
return str
} else {
sql = strings.ReplaceAll(sql, "\r", "")
sql = strings.ReplaceAll(sql, "\n", " ")
sql = rexWhitespaceRun.ReplaceAll(sql, " ", true)
return sql
}
}

View File

@@ -0,0 +1,252 @@
package dbtools
import (
"context"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"regexp"
"strings"
"sync"
"time"
)
//
// This is..., not good...
//
// for sq.ScanAll to work with (left-)joined tables _need_ to get column names aka "alias.column"
// But sqlite (and all other db server) only return "column" if we don't manually specify `alias.column as "alias.columnname"`
// But always specifying all columns (and their alias) would be __very__ cumbersome...
//
// The "solution" is this preprocessor, which translates queries of the form `SELECT tab1.*, tab2.* From tab1` into `SELECT tab1.col1 AS "tab1.col1", tab1.col2 AS "tab1.col2" ....`
//
// Prerequisites:
// - all aliased tables must be written as `tablename AS alias` (the variant without the AS keyword is invalid)
// - a star only expands to the (single) table in FROM. Use *, table2.* if there exists a second (joined) table
// - No weird SQL syntax, this "parser" is not very robust...
//
type DBPreprocessor struct {
db sq.DB
lock sync.Mutex
dbTables []string
dbColumns map[string][]string
cacheQuery map[string]string
}
var regexAlias = rext.W(regexp.MustCompile("([A-Za-z_\\-0-9]+)\\s+AS\\s+([A-Za-z_\\-0-9]+)"))
func NewDBPreprocessor(db sq.DB) (*DBPreprocessor, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
obj := &DBPreprocessor{
db: db,
lock: sync.Mutex{},
cacheQuery: make(map[string]string),
}
err := obj.Init(ctx)
if err != nil {
return nil, err
}
return obj, nil
}
func (pp *DBPreprocessor) Init(ctx context.Context) error {
dbTables := make([]string, 0)
dbColumns := make(map[string][]string, 0)
type tabInfo struct {
Name string `db:"name"`
}
type colInfo struct {
Name string `db:"name"`
}
rows1, err := pp.db.Query(ctx, "PRAGMA table_list;", sq.PP{})
if err != nil {
return err
}
resrows1, err := sq.ScanAll[tabInfo](rows1, sq.SModeFast, sq.Unsafe, true)
if err != nil {
return err
}
for _, tab := range resrows1 {
rows2, err := pp.db.Query(ctx, fmt.Sprintf("PRAGMA table_info(\"%s\");", tab.Name), sq.PP{})
if err != nil {
return err
}
resrows2, err := sq.ScanAll[colInfo](rows2, sq.SModeFast, sq.Unsafe, true)
if err != nil {
return err
}
columns := langext.ArrMap(resrows2, func(v colInfo) string { return v.Name })
dbTables = append(dbTables, tab.Name)
dbColumns[tab.Name] = columns
}
pp.dbTables = dbTables
pp.dbColumns = dbColumns
return nil
}
func (pp *DBPreprocessor) PrePing(ctx context.Context) error {
return nil
}
func (pp *DBPreprocessor) PreTxBegin(ctx context.Context, txid uint16) error {
return nil
}
func (pp *DBPreprocessor) PreTxCommit(txid uint16) error {
return nil
}
func (pp *DBPreprocessor) PreTxRollback(txid uint16) error {
return nil
}
func (pp *DBPreprocessor) PreQuery(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
sqlOriginal := *sql
pp.lock.Lock()
v, ok := pp.cacheQuery[sqlOriginal]
pp.lock.Unlock()
if ok {
*sql = v
return nil
}
if !strings.HasPrefix(sqlOriginal, "SELECT ") {
return nil
}
idxFrom := strings.Index(sqlOriginal, " FROM ")
if idxFrom < 0 {
return nil
}
fromTableName := strings.Split(strings.TrimSpace(sqlOriginal[idxFrom+len(" FROM"):]), " ")[0]
sels := strings.TrimSpace(sqlOriginal[len("SELECT "):idxFrom])
split := strings.Split(sels, ",")
newsel := make([]string, 0)
aliasMap := make(map[string]string)
for _, v := range regexAlias.MatchAll(sqlOriginal) {
aliasMap[strings.TrimSpace(v.GroupByIndex(2).Value())] = strings.TrimSpace(v.GroupByIndex(1).Value())
}
for _, expr := range split {
expr = strings.TrimSpace(expr)
if expr == "*" {
columns, ok := pp.dbColumns[fromTableName]
if !ok {
return errors.New(fmt.Sprintf("[preprocessor]: table '%s' not found", fromTableName))
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", fromTableName, colname, colname))
}
} else if strings.HasSuffix(expr, ".*") {
tableName := expr[0 : len(expr)-2]
if tableRealName, ok := aliasMap[tableName]; ok {
columns, ok := pp.dbColumns[tableRealName]
if !ok {
return errors.New(fmt.Sprintf("[sql-preprocessor]: table '%s' not found", tableRealName))
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname))
}
} else if tableName == fromTableName {
columns, ok := pp.dbColumns[tableName]
if !ok {
return errors.New(fmt.Sprintf("[sql-preprocessor]: table '%s' not found", tableName))
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", tableName, colname, colname))
}
} else {
columns, ok := pp.dbColumns[tableName]
if !ok {
return errors.New(fmt.Sprintf("[sql-preprocessor]: table '%s' not found", tableName))
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname))
}
}
} else {
return nil
}
}
newSQL := "SELECT " + strings.Join(newsel, ", ") + sqlOriginal[idxFrom:]
pp.lock.Lock()
pp.cacheQuery[sqlOriginal] = newSQL
pp.lock.Unlock()
log.Debug().Msgf("Preprocessed SQL statement from\n'%s'\n--to-->\n'%s'", sqlOriginal, newSQL)
*sql = newSQL
return nil
}
func (pp *DBPreprocessor) PreExec(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
return nil
}
func (pp *DBPreprocessor) PostPing(result error) {
//
}
func (pp *DBPreprocessor) PostTxBegin(txid uint16, result error) {
//
}
func (pp *DBPreprocessor) PostTxCommit(txid uint16, result error) {
//
}
func (pp *DBPreprocessor) PostTxRollback(txid uint16, result error) {
//
}
func (pp *DBPreprocessor) PostQuery(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}
func (pp *DBPreprocessor) PostExec(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}

View File

@@ -0,0 +1,159 @@
package logs
import (
server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema"
"context"
"database/sql"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
type Database struct {
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
}
func NewLogsDatabase(cfg server.Config) (*Database, error) {
conf := cfg.DBLogs
url := fmt.Sprintf("file:%s?_journal=%s&_timeout=%d&_fk=%s&_busy_timeout=%d", conf.File, conf.Journal, conf.Timeout.Milliseconds(), langext.FormatBool(conf.CheckForeignKeys, "true", "false"), conf.BusyTimeout.Milliseconds())
xdb, err := sqlx.Open("sqlite3", url)
if err != nil {
return nil, err
}
if conf.SingleConn {
xdb.SetMaxOpenConns(1)
} else {
xdb.SetMaxOpenConns(5)
xdb.SetMaxIdleConns(5)
xdb.SetConnMaxLifetime(60 * time.Minute)
xdb.SetConnMaxIdleTime(60 * time.Minute)
}
qqdb := sq.NewDB(xdb)
if conf.EnableLogger {
qqdb.AddListener(dbtools.DBLogger{})
}
pp, err := dbtools.NewDBPreprocessor(qqdb)
if err != nil {
return nil, err
}
qqdb.AddListener(pp)
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"}
return scndb, nil
}
func (db *Database) DB() sq.DB {
return db.db
}
func (db *Database) Migrate(ctx context.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
currschema, err := db.ReadSchema(ctx)
if err != nil {
return err
}
if currschema == 0 {
schemastr := schema.LogsSchema1
schemahash, err := sq.HashSqliteSchema(ctx, schemastr)
if err != nil {
return err
}
_, err = db.db.Exec(ctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(ctx, "schema", 1)
if err != nil {
return err
}
err = db.WriteMetaString(ctx, "schema_hash", schemahash)
if err != nil {
return err
}
err = db.pp.Init(ctx) // Re-Init
if err != nil {
return err
}
return nil
} else if currschema == 1 {
schemHashDB, err := sq.HashSqliteDatabase(ctx, db.db)
if err != nil {
return err
}
schemaHashMeta, err := db.ReadMetaString(ctx, "schema_hash")
if err != nil {
return err
}
schemHashAsset := schema.LogsHash1
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schemHashAsset {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (logs db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (logs db)")
log.Debug().Str("schemHashAsset", schemHashAsset).Msg("Schema (logs db)")
return errors.New("database schema does not match (logs db)")
} else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (logs db)")
}
return nil // current
} else {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
}
}
func (db *Database) Ping(ctx context.Context) error {
return db.db.Ping(ctx)
}
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault)
}
func (db *Database) Stop(ctx context.Context) error {
if db.wal {
_, err := db.db.Exec(ctx, "PRAGMA wal_checkpoint;", sq.PP{})
if err != nil {
return err
}
}
err := db.db.Exit()
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,242 @@
package logs
import (
"context"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) ReadSchema(ctx context.Context) (retval int, reterr error) {
r1, err := db.db.Query(ctx, "SELECT name FROM sqlite_master WHERE type = :typ AND name = :name", sq.PP{"typ": "table", "name": "meta"})
if err != nil {
return 0, err
}
defer func() {
err = r1.Close()
if err != nil {
// overwrite return values
retval = 0
reterr = err
}
}()
if !r1.Next() {
return 0, nil
}
err = r1.Close()
if err != nil {
return 0, err
}
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": "schema"})
if err != nil {
return 0, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = 0
reterr = err
}
}()
if !r2.Next() {
return 0, errors.New("no schema entry in meta table")
}
var dbschema int
err = r2.Scan(&dbschema)
if err != nil {
return 0, err
}
err = r2.Close()
if err != nil {
return 0, err
}
return dbschema, nil
}
func (db *Database) WriteMetaString(ctx context.Context, key string, value string) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_txt) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_txt = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaInt(ctx context.Context, key string, value int64) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_int) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_int = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaReal(ctx context.Context, key string, value float64) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_real) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_real = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaBlob(ctx context.Context, key string, value []byte) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_blob) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_blob = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) ReadMetaString(ctx context.Context, key string) (retval *string, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_txt FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value string
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaInt(ctx context.Context, key string) (retval *int64, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value int64
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaReal(ctx context.Context, key string) (retval *float64, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_real FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value float64
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaBlob(ctx context.Context, key string) (retval *[]byte, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_blob FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value []byte
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) DeleteMeta(ctx context.Context, key string) error {
_, err := db.db.Exec(ctx, "DELETE FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,25 @@
package logs
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
func bool2DB(b bool) int {
if b {
return 1
} else {
return 0
}
}
func time2DB(t time.Time) int64 {
return t.UnixMilli()
}
func time2DBOpt(t *time.Time) *int64 {
if t == nil {
return nil
}
return langext.Ptr(t.UnixMilli())
}

View File

@@ -0,0 +1,274 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) GetChannelByName(ctx TxContext, userid models.UserID, chanName string) (*models.Channel, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE owner_user_id = :uid AND internal_name = :nam LIMIT 1", sq.PP{
"uid": userid,
"nam": chanName,
})
if err != nil {
return nil, err
}
channel, err := models.DecodeChannel(rows)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &channel, nil
}
func (db *Database) GetChannelByID(ctx TxContext, chanid models.ChannelID) (*models.Channel, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE channel_id = :cid LIMIT 1", sq.PP{
"cid": chanid,
})
if err != nil {
return nil, err
}
channel, err := models.DecodeChannel(rows)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &channel, nil
}
func (db *Database) CreateChannel(ctx TxContext, userid models.UserID, dispName string, intName string, subscribeKey string) (models.Channel, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Channel{}, err
}
entity := models.ChannelDB{
ChannelID: models.NewChannelID(),
OwnerUserID: userid,
DisplayName: dispName,
InternalName: intName,
SubscribeKey: subscribeKey,
TimestampCreated: time2DB(time.Now()),
TimestampLastSent: nil,
MessagesSent: 0,
}
_, err = sq.InsertSingle(ctx, tx, "channels", entity)
if err != nil {
return models.Channel{}, err
}
return entity.Model(), nil
}
func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID, subUserID models.UserID) ([]models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
order := " ORDER BY channels.timestamp_created ASC, channels.channel_id ASC "
rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub ON channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid"+order, sq.PP{
"ouid": userid,
"subuid": subUserID,
})
if err != nil {
return nil, err
}
data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID, confirmed *bool) ([]models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
confCond := ""
if confirmed != nil && *confirmed {
confCond = " AND sub.confirmed = 1"
} else if confirmed != nil && !*confirmed {
confCond = " AND sub.confirmed = 0"
}
order := " ORDER BY channels.timestamp_created ASC, channels.channel_id ASC "
rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE sub.subscription_id IS NOT NULL "+confCond+order, sq.PP{
"subuid": userid,
})
if err != nil {
return nil, err
}
data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, confirmed *bool) ([]models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
confCond := "OR (sub.subscription_id IS NOT NULL)"
if confirmed != nil && *confirmed {
confCond = "OR (sub.subscription_id IS NOT NULL AND sub.confirmed = 1)"
} else if confirmed != nil && !*confirmed {
confCond = "OR (sub.subscription_id IS NOT NULL AND sub.confirmed = 0)"
}
order := " ORDER BY channels.timestamp_created ASC, channels.channel_id ASC "
rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid "+confCond+order, sq.PP{
"ouid": userid,
"subuid": userid,
})
if err != nil {
return nil, err
}
data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) GetChannel(ctx TxContext, userid models.UserID, channelid models.ChannelID, enforceOwner bool) (models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.ChannelWithSubscription{}, err
}
params := sq.PP{
"cid": channelid,
"subuid": userid,
}
selectors := "channels.*, sub.*"
join := "LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid"
cond := "channels.channel_id = :cid"
if enforceOwner {
cond = "owner_user_id = :ouid AND channels.channel_id = :cid"
params["ouid"] = userid
}
rows, err := tx.Query(ctx, "SELECT "+selectors+" FROM channels "+join+" WHERE "+cond+" LIMIT 1", params)
if err != nil {
return models.ChannelWithSubscription{}, err
}
channel, err := models.DecodeChannelWithSubscription(rows)
if err != nil {
return models.ChannelWithSubscription{}, err
}
return channel, nil
}
func (db *Database) IncChannelMessageCounter(ctx TxContext, channel *models.Channel) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
now := time.Now()
_, err = tx.Exec(ctx, "UPDATE channels SET messages_sent = messages_sent+1, timestamp_lastsent = :ts WHERE channel_id = :cid", sq.PP{
"ts": time2DB(now),
"cid": channel.ChannelID,
})
if err != nil {
return err
}
channel.MessagesSent += 1
channel.TimestampLastSent = &now
return nil
}
func (db *Database) UpdateChannelSubscribeKey(ctx TxContext, channelid models.ChannelID, newkey string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE channels SET subscribe_key = :key WHERE channel_id = :cid", sq.PP{
"key": newkey,
"cid": channelid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateChannelDisplayName(ctx TxContext, channelid models.ChannelID, dispname string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE channels SET display_name = :nam WHERE channel_id = :cid", sq.PP{
"nam": dispname,
"cid": channelid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateChannelDescriptionName(ctx TxContext, channelid models.ChannelID, descname *string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE channels SET description_name = :nam WHERE channel_id = :cid", sq.PP{
"nam": descname,
"cid": channelid,
})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,165 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateClient(ctx TxContext, userid models.UserID, ctype models.ClientType, fcmToken string, agentModel string, agentVersion string) (models.Client, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Client{}, err
}
entity := models.ClientDB{
ClientID: models.NewClientID(),
UserID: userid,
Type: ctype,
FCMToken: fcmToken,
TimestampCreated: time2DB(time.Now()),
AgentModel: agentModel,
AgentVersion: agentVersion,
}
_, err = sq.InsertSingle(ctx, tx, "clients", entity)
if err != nil {
return models.Client{}, err
}
return entity.Model(), nil
}
func (db *Database) ClearFCMTokens(ctx TxContext, fcmtoken string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
if err != nil {
return err
}
return nil
}
func (db *Database) ListClients(ctx TxContext, userid models.UserID) ([]models.Client, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM clients WHERE user_id = :uid ORDER BY clients.timestamp_created DESC, clients.client_id ASC", sq.PP{"uid": userid})
if err != nil {
return nil, err
}
data, err := models.DecodeClients(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) GetClient(ctx TxContext, userid models.UserID, clientid models.ClientID) (models.Client, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Client{}, err
}
rows, err := tx.Query(ctx, "SELECT * FROM clients WHERE user_id = :uid AND client_id = :cid LIMIT 1", sq.PP{
"uid": userid,
"cid": clientid,
})
if err != nil {
return models.Client{}, err
}
client, err := models.DecodeClient(rows)
if err != nil {
return models.Client{}, err
}
return client, nil
}
func (db *Database) DeleteClient(ctx TxContext, clientid models.ClientID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE client_id = :cid", sq.PP{"cid": clientid})
if err != nil {
return err
}
return nil
}
func (db *Database) DeleteClientsByFCM(ctx TxContext, fcmtoken string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DELETE FROM clients WHERE fcm_token = :fcm", sq.PP{"fcm": fcmtoken})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateClientFCMToken(ctx TxContext, clientid models.ClientID, fcmtoken string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE clients SET fcm_token = :vvv WHERE client_id = :cid", sq.PP{
"vvv": fcmtoken,
"cid": clientid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateClientAgentModel(ctx TxContext, clientid models.ClientID, agentModel string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE clients SET agent_model = :vvv WHERE client_id = :cid", sq.PP{
"vvv": agentModel,
"cid": clientid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateClientAgentVersion(ctx TxContext, clientid models.ClientID, agentVersion string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE clients SET agent_version = :vvv WHERE client_id = :cid", sq.PP{
"vvv": agentVersion,
"cid": clientid,
})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,180 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) CreateCompatID(ctx TxContext, idtype string, newid string) (int64, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return 0, err
}
rows, err := tx.Query(ctx, "SELECT COALESCE(MAX(old), 0) FROM compat_ids", sq.PP{})
if err != nil {
return 0, err
}
if !rows.Next() {
return 0, errors.New("failed to query MAX(old)")
}
var oldid int64
err = rows.Scan(&oldid)
if err != nil {
return 0, err
}
oldid++
_, err = tx.Exec(ctx, "INSERT INTO compat_ids (old, new, type) VALUES (:old, :new, :typ)", sq.PP{
"old": oldid,
"new": newid,
"typ": idtype,
})
if err != nil {
return 0, err
}
return oldid, nil
}
func (db *Database) ConvertCompatID(ctx TxContext, oldid int64, idtype string) (*string, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT new FROM compat_ids WHERE old = :old AND type = :typ", sq.PP{
"old": oldid,
"typ": idtype,
})
if err != nil {
return nil, err
}
if !rows.Next() {
return nil, nil
}
var newid string
err = rows.Scan(&newid)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &newid, nil
}
func (db *Database) ConvertToCompatID(ctx TxContext, newid string) (*int64, *string, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, nil, err
}
rows, err := tx.Query(ctx, "SELECT old, type FROM compat_ids WHERE new = :new", sq.PP{"new": newid})
if err != nil {
return nil, nil, err
}
if !rows.Next() {
return nil, nil, nil
}
var oldid int64
var idtype string
err = rows.Scan(&oldid, &idtype)
if err == sql.ErrNoRows {
return nil, nil, nil
}
if err != nil {
return nil, nil, err
}
return &oldid, &idtype, nil
}
func (db *Database) ConvertToCompatIDOrCreate(ctx TxContext, idtype string, newid string) (int64, error) {
id1, _, err := db.ConvertToCompatID(ctx, newid)
if err != nil {
return 0, err
}
if id1 != nil {
return *id1, nil
}
id2, err := db.CreateCompatID(ctx, idtype, newid)
if err != nil {
return 0, err
}
return id2, nil
}
func (db *Database) GetAck(ctx TxContext, msgid models.MessageID) (bool, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return false, err
}
rows, err := tx.Query(ctx, "SELECT * FROM compat_acks WHERE message_id = :msgid LIMIT 1", sq.PP{
"msgid": msgid,
})
if err != nil {
return false, err
}
res := rows.Next()
err = rows.Close()
if err != nil {
return false, err
}
return res, nil
}
func (db *Database) SetAck(ctx TxContext, userid models.UserID, msgid models.MessageID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "INSERT INTO compat_acks (user_id, message_id) VALUES (:uid, :mid)", sq.PP{
"uid": userid,
"mid": msgid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) IsCompatClient(ctx TxContext, clientid models.ClientID) (bool, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return false, err
}
rows, err := tx.Query(ctx, "SELECT * FROM compat_clients WHERE client_id = :id LIMIT 1", sq.PP{
"id": clientid,
})
if err != nil {
return false, err
}
res := rows.Next()
err = rows.Close()
if err != nil {
return false, err
}
return res, nil
}

View File

@@ -0,0 +1,16 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
type TxContext interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
GetOrCreateTransaction(db db.DatabaseImpl) (sq.Tx, error)
}

View File

@@ -0,0 +1,163 @@
package primary
import (
server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema"
"context"
"database/sql"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
type Database struct {
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
}
func NewPrimaryDatabase(cfg server.Config) (*Database, error) {
conf := cfg.DBMain
url := fmt.Sprintf("file:%s?_journal=%s&_timeout=%d&_fk=%s&_busy_timeout=%d", conf.File, conf.Journal, conf.Timeout.Milliseconds(), langext.FormatBool(conf.CheckForeignKeys, "true", "false"), conf.BusyTimeout.Milliseconds())
xdb, err := sqlx.Open("sqlite3", url)
if err != nil {
return nil, err
}
if conf.SingleConn {
xdb.SetMaxOpenConns(1)
} else {
xdb.SetMaxOpenConns(5)
xdb.SetMaxIdleConns(5)
xdb.SetConnMaxLifetime(60 * time.Minute)
xdb.SetConnMaxIdleTime(60 * time.Minute)
}
qqdb := sq.NewDB(xdb)
if conf.EnableLogger {
qqdb.AddListener(dbtools.DBLogger{})
}
pp, err := dbtools.NewDBPreprocessor(qqdb)
if err != nil {
return nil, err
}
qqdb.AddListener(pp)
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"}
return scndb, nil
}
func (db *Database) DB() sq.DB {
return db.db
}
func (db *Database) Migrate(ctx context.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
currschema, err := db.ReadSchema(ctx)
if err != nil {
return err
}
if currschema == 0 {
schemastr := schema.PrimarySchema3
schemahash, err := sq.HashSqliteSchema(ctx, schemastr)
if err != nil {
return err
}
_, err = db.db.Exec(ctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(ctx, "schema", 3)
if err != nil {
return err
}
err = db.WriteMetaString(ctx, "schema_hash", schemahash)
if err != nil {
return err
}
err = db.pp.Init(ctx) // Re-Init
if err != nil {
return err
}
return nil
} else if currschema == 1 {
return errors.New("cannot autom. upgrade schema 1")
} else if currschema == 2 {
return errors.New("cannot autom. upgrade schema 2")
} else if currschema == 3 {
schemHashDB, err := sq.HashSqliteDatabase(ctx, db.db)
if err != nil {
return err
}
schemaHashMeta, err := db.ReadMetaString(ctx, "schema_hash")
if err != nil {
return err
}
schemHashAsset := schema.PrimaryHash3
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schemHashAsset {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemHashAsset", schemHashAsset).Msg("Schema (primary db)")
return errors.New("database schema does not match (primary db)")
} else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (primary db)")
}
return nil // current
} else {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
}
}
func (db *Database) Ping(ctx context.Context) error {
return db.db.Ping(ctx)
}
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault)
}
func (db *Database) Stop(ctx context.Context) error {
if db.wal {
_, err := db.db.Exec(ctx, "PRAGMA wal_checkpoint;", sq.PP{})
if err != nil {
return err
}
}
err := db.db.Exit()
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,163 @@
package primary
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateRetryDelivery(ctx TxContext, client models.Client, msg models.Message) (models.Delivery, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Delivery{}, err
}
now := time.Now()
next := scn.NextDeliveryTimestamp(now)
entity := models.DeliveryDB{
DeliveryID: models.NewDeliveryID(),
MessageID: msg.MessageID,
ReceiverUserID: client.UserID,
ReceiverClientID: client.ClientID,
TimestampCreated: time2DB(now),
TimestampFinalized: nil,
Status: models.DeliveryStatusRetry,
RetryCount: 0,
NextDelivery: langext.Ptr(time2DB(next)),
FCMMessageID: nil,
}
_, err = sq.InsertSingle(ctx, tx, "deliveries", entity)
if err != nil {
return models.Delivery{}, err
}
return entity.Model(), nil
}
func (db *Database) CreateSuccessDelivery(ctx TxContext, client models.Client, msg models.Message, fcmDelivID string) (models.Delivery, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Delivery{}, err
}
now := time.Now()
entity := models.DeliveryDB{
DeliveryID: models.NewDeliveryID(),
MessageID: msg.MessageID,
ReceiverUserID: client.UserID,
ReceiverClientID: client.ClientID,
TimestampCreated: time2DB(now),
TimestampFinalized: langext.Ptr(time2DB(now)),
Status: models.DeliveryStatusSuccess,
RetryCount: 0,
NextDelivery: nil,
FCMMessageID: langext.Ptr(fcmDelivID),
}
_, err = sq.InsertSingle(ctx, tx, "deliveries", entity)
if err != nil {
return models.Delivery{}, err
}
return entity.Model(), nil
}
func (db *Database) ListRetrieableDeliveries(ctx TxContext, pageSize int) ([]models.Delivery, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM deliveries WHERE status = 'RETRY' AND next_delivery < :next ORDER BY next_delivery ASC LIMIT :lim", sq.PP{
"next": time2DB(time.Now()),
"lim": pageSize,
})
if err != nil {
return nil, err
}
data, err := models.DecodeDeliveries(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) SetDeliverySuccess(ctx TxContext, delivery models.Delivery, fcmDelivID string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'SUCCESS', next_delivery = NULL, retry_count = :rc, timestamp_finalized = :ts, fcm_message_id = :fcm WHERE delivery_id = :did", sq.PP{
"rc": delivery.RetryCount + 1,
"ts": time2DB(time.Now()),
"fcm": fcmDelivID,
"did": delivery.DeliveryID,
})
if err != nil {
return err
}
return nil
}
func (db *Database) SetDeliveryFailed(ctx TxContext, delivery models.Delivery) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'FAILED', next_delivery = NULL, retry_count = :rc, timestamp_finalized = :ts WHERE delivery_id = :did",
sq.PP{
"rc": delivery.RetryCount + 1,
"ts": time2DB(time.Now()),
"did": delivery.DeliveryID,
})
if err != nil {
return err
}
return nil
}
func (db *Database) SetDeliveryRetry(ctx TxContext, delivery models.Delivery) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'RETRY', next_delivery = :next, retry_count = :rc WHERE delivery_id = :did", sq.PP{
"next": scn.NextDeliveryTimestamp(time.Now()),
"rc": delivery.RetryCount + 1,
"did": delivery.DeliveryID,
})
if err != nil {
return err
}
return nil
}
func (db *Database) CancelPendingDeliveries(ctx TxContext, messageID models.MessageID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE deliveries SET status = 'FAILED', next_delivery = NULL, timestamp_finalized = :ts WHERE message_id = :mid AND status = 'RETRY'", sq.PP{
"ts": time.Now(),
"mid": messageID,
})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,221 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"strings"
"time"
)
func (db *Database) CreateKeyToken(ctx TxContext, name string, owner models.UserID, allChannels bool, channels []models.ChannelID, permissions models.TokenPermissionList, token string) (models.KeyToken, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.KeyToken{}, err
}
entity := models.KeyTokenDB{
KeyTokenID: models.NewKeyTokenID(),
Name: name,
TimestampCreated: time2DB(time.Now()),
TimestampLastUsed: nil,
OwnerUserID: owner,
AllChannels: allChannels,
Channels: strings.Join(langext.ArrMap(channels, func(v models.ChannelID) string { return v.String() }), ";"),
Token: token,
Permissions: permissions.String(),
MessagesSent: 0,
}
_, err = sq.InsertSingle(ctx, tx, "keytokens", entity)
if err != nil {
return models.KeyToken{}, err
}
return entity.Model(), nil
}
func (db *Database) ListKeyTokens(ctx TxContext, ownerID models.UserID) ([]models.KeyToken, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM keytokens WHERE owner_user_id = :uid ORDER BY keytokens.timestamp_created DESC, keytokens.keytoken_id ASC", sq.PP{"uid": ownerID})
if err != nil {
return nil, err
}
data, err := models.DecodeKeyTokens(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) GetKeyToken(ctx TxContext, userid models.UserID, keyTokenid models.KeyTokenID) (models.KeyToken, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.KeyToken{}, err
}
rows, err := tx.Query(ctx, "SELECT * FROM keytokens WHERE owner_user_id = :uid AND keytoken_id = :cid LIMIT 1", sq.PP{
"uid": userid,
"cid": keyTokenid,
})
if err != nil {
return models.KeyToken{}, err
}
keyToken, err := models.DecodeKeyToken(rows)
if err != nil {
return models.KeyToken{}, err
}
return keyToken, nil
}
func (db *Database) GetKeyTokenByToken(ctx TxContext, key string) (*models.KeyToken, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM keytokens WHERE token = :key LIMIT 1", sq.PP{"key": key})
if err != nil {
return nil, err
}
user, err := models.DecodeKeyToken(rows)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
func (db *Database) DeleteKeyToken(ctx TxContext, keyTokenid models.KeyTokenID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DELETE FROM keytokens WHERE keytoken_id = :tid", sq.PP{"tid": keyTokenid})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateKeyTokenName(ctx TxContext, keyTokenid models.KeyTokenID, name string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE keytokens SET name = :nam WHERE keytoken_id = :tid", sq.PP{
"nam": name,
"tid": keyTokenid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateKeyTokenPermissions(ctx TxContext, keyTokenid models.KeyTokenID, perm models.TokenPermissionList) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE keytokens SET permissions = :prm WHERE keytoken_id = :tid", sq.PP{
"tid": keyTokenid,
"prm": perm.String(),
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateKeyTokenAllChannels(ctx TxContext, keyTokenid models.KeyTokenID, allChannels bool) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE keytokens SET all_channels = :all WHERE keytoken_id = :tid", sq.PP{
"tid": keyTokenid,
"all": bool2DB(allChannels),
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateKeyTokenChannels(ctx TxContext, keyTokenid models.KeyTokenID, channels []models.ChannelID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE keytokens SET channels = :cha WHERE keytoken_id = :tid", sq.PP{
"tid": keyTokenid,
"cha": strings.Join(langext.ArrMap(channels, func(v models.ChannelID) string { return v.String() }), ";"),
})
if err != nil {
return err
}
return nil
}
func (db *Database) IncKeyTokenMessageCounter(ctx TxContext, keyToken *models.KeyToken) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
now := time.Now()
_, err = tx.Exec(ctx, "UPDATE keytokens SET messages_sent = messages_sent+1, timestamp_lastused = :ts WHERE keytoken_id = :tid", sq.PP{
"ts": time2DB(now),
"tid": keyToken.KeyTokenID,
})
if err != nil {
return err
}
keyToken.TimestampLastUsed = &now
keyToken.MessagesSent += 1
return nil
}
func (db *Database) UpdateKeyTokenLastUsed(ctx TxContext, keyTokenid models.KeyTokenID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE keytokens SET timestamp_lastused = :ts WHERE keytoken_id = :tid", sq.PP{
"ts": time2DB(time.Now()),
"tid": keyTokenid,
})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,180 @@
package primary
import (
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) GetMessageByUserMessageID(ctx TxContext, usrMsgId string) (*models.Message, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM messages WHERE usr_message_id = :umid LIMIT 1", sq.PP{"umid": usrMsgId})
if err != nil {
return nil, err
}
msg, err := models.DecodeMessage(rows)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &msg, nil
}
func (db *Database) GetMessage(ctx TxContext, scnMessageID models.MessageID, allowDeleted bool) (models.Message, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Message{}, err
}
var sqlcmd string
if allowDeleted {
sqlcmd = "SELECT * FROM messages WHERE message_id = :mid LIMIT 1"
} else {
sqlcmd = "SELECT * FROM messages WHERE message_id = :mid AND deleted=0 LIMIT 1"
}
rows, err := tx.Query(ctx, sqlcmd, sq.PP{"mid": scnMessageID})
if err != nil {
return models.Message{}, err
}
msg, err := models.DecodeMessage(rows)
if err != nil {
return models.Message{}, err
}
return msg, nil
}
func (db *Database) CreateMessage(ctx TxContext, senderUserID models.UserID, channel models.Channel, timestampSend *time.Time, title string, content *string, priority int, userMsgId *string, senderIP string, senderName *string, usedKeyID models.KeyTokenID) (models.Message, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Message{}, err
}
entity := models.MessageDB{
MessageID: models.NewMessageID(),
SenderUserID: senderUserID,
OwnerUserID: channel.OwnerUserID,
ChannelInternalName: channel.InternalName,
ChannelID: channel.ChannelID,
SenderIP: senderIP,
SenderName: senderName,
TimestampReal: time2DB(time.Now()),
TimestampClient: time2DBOpt(timestampSend),
Title: title,
Content: content,
Priority: priority,
UserMessageID: userMsgId,
UsedKeyID: usedKeyID,
Deleted: bool2DB(false),
}
_, err = sq.InsertSingle(ctx, tx, "messages", entity)
if err != nil {
return models.Message{}, err
}
return entity.Model(), nil
}
func (db *Database) DeleteMessage(ctx TxContext, messageID models.MessageID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE messages SET deleted=1 WHERE message_id = :mid AND deleted=0", sq.PP{"mid": messageID})
if err != nil {
return err
}
return nil
}
func (db *Database) ListMessages(ctx TxContext, filter models.MessageFilter, pageSize *int, inTok ct.CursorToken) ([]models.Message, ct.CursorToken, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, ct.CursorToken{}, err
}
if inTok.Mode == ct.CTMEnd {
return make([]models.Message, 0), ct.End(), nil
}
pageCond := "1=1"
if inTok.Mode == ct.CTMNormal {
pageCond = "timestamp_real < :tokts OR (timestamp_real = :tokts AND message_id < :tokid )"
}
filterCond, filterJoin, prepParams, err := filter.SQL()
orderClause := ""
if pageSize != nil {
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC LIMIT :lim"
prepParams["lim"] = *pageSize + 1
} else {
orderClause = "ORDER BY COALESCE(timestamp_client, timestamp_real) DESC, message_id DESC"
}
sqlQuery := "SELECT " + "messages.*" + " FROM messages " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
prepParams["tokts"] = inTok.Timestamp
prepParams["tokid"] = inTok.Id
rows, err := tx.Query(ctx, sqlQuery, prepParams)
if err != nil {
return nil, ct.CursorToken{}, err
}
data, err := models.DecodeMessages(rows)
if err != nil {
return nil, ct.CursorToken{}, err
}
if pageSize == nil || len(data) <= *pageSize {
return data, ct.End(), nil
} else {
outToken := ct.Normal(data[*pageSize-1].Timestamp(), data[*pageSize-1].MessageID.String(), "DESC", filter.Hash())
return data[0:*pageSize], outToken, nil
}
}
func (db *Database) CountMessages(ctx TxContext, filter models.MessageFilter) (int64, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return 0, err
}
filterCond, filterJoin, prepParams, err := filter.SQL()
sqlQuery := "SELECT " + "COUNT(*)" + " FROM messages " + filterJoin + " WHERE ( " + filterCond + " ) "
rows, err := tx.Query(ctx, sqlQuery, prepParams)
if err != nil {
return 0, err
}
if !rows.Next() {
return 0, errors.New("COUNT query returned no results")
}
var countRes int64
err = rows.Scan(&countRes)
if err != nil {
return 0, err
}
return countRes, nil
}

View File

@@ -0,0 +1,242 @@
package primary
import (
"context"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) ReadSchema(ctx context.Context) (retval int, reterr error) {
r1, err := db.db.Query(ctx, "SELECT name FROM sqlite_master WHERE type = :typ AND name = :name", sq.PP{"typ": "table", "name": "meta"})
if err != nil {
return 0, err
}
defer func() {
err = r1.Close()
if err != nil {
// overwrite return values
retval = 0
reterr = err
}
}()
if !r1.Next() {
return 0, nil
}
err = r1.Close()
if err != nil {
return 0, err
}
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": "schema"})
if err != nil {
return 0, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = 0
reterr = err
}
}()
if !r2.Next() {
return 0, errors.New("no schema entry in meta table")
}
var dbschema int
err = r2.Scan(&dbschema)
if err != nil {
return 0, err
}
err = r2.Close()
if err != nil {
return 0, err
}
return dbschema, nil
}
func (db *Database) WriteMetaString(ctx context.Context, key string, value string) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_txt) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_txt = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaInt(ctx context.Context, key string, value int64) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_int) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_int = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaReal(ctx context.Context, key string, value float64) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_real) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_real = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaBlob(ctx context.Context, key string, value []byte) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_blob) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_blob = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) ReadMetaString(ctx context.Context, key string) (retval *string, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_txt FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value string
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaInt(ctx context.Context, key string) (retval *int64, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value int64
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaReal(ctx context.Context, key string) (retval *float64, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_real FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value float64
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaBlob(ctx context.Context, key string) (retval *[]byte, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_blob FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value []byte
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) DeleteMeta(ctx context.Context, key string) error {
_, err := db.db.Exec(ctx, "DELETE FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,184 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateSubscription(ctx TxContext, subscriberUID models.UserID, channel models.Channel, confirmed bool) (models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Subscription{}, err
}
entity := models.SubscriptionDB{
SubscriptionID: models.NewSubscriptionID(),
SubscriberUserID: subscriberUID,
ChannelOwnerUserID: channel.OwnerUserID,
ChannelID: channel.ChannelID,
ChannelInternalName: channel.InternalName,
TimestampCreated: time2DB(time.Now()),
Confirmed: bool2DB(confirmed),
}
_, err = sq.InsertSingle(ctx, tx, "subscriptions", entity)
if err != nil {
return models.Subscription{}, err
}
return entity.Model(), nil
}
func (db *Database) ListSubscriptionsByChannel(ctx TxContext, channelID models.ChannelID) ([]models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
order := " ORDER BY subscriptions.timestamp_created DESC, subscriptions.subscription_id DESC "
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE channel_id = :cid"+order, sq.PP{"cid": channelID})
if err != nil {
return nil, err
}
data, err := models.DecodeSubscriptions(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) ListSubscriptionsByChannelOwner(ctx TxContext, ownerUserID models.UserID, confirmed *bool) ([]models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
cond := ""
if confirmed != nil && *confirmed {
cond = " AND confirmed = 1"
} else if confirmed != nil && !*confirmed {
cond = " AND confirmed = 0"
}
order := " ORDER BY subscriptions.timestamp_created DESC, subscriptions.subscription_id DESC "
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE channel_owner_user_id = :ouid"+cond+order, sq.PP{"ouid": ownerUserID})
if err != nil {
return nil, err
}
data, err := models.DecodeSubscriptions(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) ListSubscriptionsBySubscriber(ctx TxContext, subscriberUserID models.UserID, confirmed *bool) ([]models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
cond := ""
if confirmed != nil && *confirmed {
cond = " AND confirmed = 1"
} else if confirmed != nil && !*confirmed {
cond = " AND confirmed = 0"
}
order := " ORDER BY subscriptions.timestamp_created DESC, subscriptions.subscription_id DESC "
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE subscriber_user_id = :suid"+cond+order, sq.PP{"suid": subscriberUserID})
if err != nil {
return nil, err
}
data, err := models.DecodeSubscriptions(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) GetSubscription(ctx TxContext, subid models.SubscriptionID) (models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Subscription{}, err
}
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE subscription_id = :sid LIMIT 1", sq.PP{"sid": subid})
if err != nil {
return models.Subscription{}, err
}
sub, err := models.DecodeSubscription(rows)
if err != nil {
return models.Subscription{}, err
}
return sub, nil
}
func (db *Database) GetSubscriptionBySubscriber(ctx TxContext, subscriberId models.UserID, channelId models.ChannelID) (*models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE subscriber_user_id = :suid AND channel_id = :cid LIMIT 1", sq.PP{
"suid": subscriberId,
"cid": channelId,
})
if err != nil {
return nil, err
}
user, err := models.DecodeSubscription(rows)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
func (db *Database) DeleteSubscription(ctx TxContext, subid models.SubscriptionID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DELETE FROM subscriptions WHERE subscription_id = :sid", sq.PP{"sid": subid})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateSubscriptionConfirmed(ctx TxContext, subscriptionID models.SubscriptionID, confirmed bool) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE subscriptions SET confirmed = :conf WHERE subscription_id = :sid", sq.PP{
"conf": confirmed,
"sid": subscriptionID,
})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,150 @@
package primary
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateUser(ctx TxContext, protoken *string, username *string) (models.User, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.User{}, err
}
entity := models.UserDB{
UserID: models.NewUserID(),
Username: username,
TimestampCreated: time2DB(time.Now()),
TimestampLastRead: nil,
TimestampLastSent: nil,
MessagesSent: 0,
QuotaUsed: 0,
QuotaUsedDay: nil,
IsPro: protoken != nil,
ProToken: protoken,
}
_, err = sq.InsertSingle(ctx, tx, "users", entity)
if err != nil {
return models.User{}, err
}
return entity.Model(), nil
}
func (db *Database) ClearProTokens(ctx TxContext, protoken string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE users SET is_pro=0, pro_token=NULL WHERE pro_token = :tok", sq.PP{"tok": protoken})
if err != nil {
return err
}
return nil
}
func (db *Database) GetUser(ctx TxContext, userid models.UserID) (models.User, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.User{}, err
}
rows, err := tx.Query(ctx, "SELECT * FROM users WHERE user_id = :uid LIMIT 1", sq.PP{"uid": userid})
if err != nil {
return models.User{}, err
}
user, err := models.DecodeUser(rows)
if err != nil {
return models.User{}, err
}
return user, nil
}
func (db *Database) UpdateUserUsername(ctx TxContext, userid models.UserID, username *string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE users SET username = :nam WHERE user_id = :uid", sq.PP{
"nam": username,
"uid": userid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) UpdateUserProToken(ctx TxContext, userid models.UserID, protoken *string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE users SET pro_token = :tok, is_pro = :pro WHERE user_id = :uid", sq.PP{
"tok": protoken,
"pro": bool2DB(protoken != nil),
"uid": userid,
})
if err != nil {
return err
}
return nil
}
func (db *Database) IncUserMessageCounter(ctx TxContext, user *models.User) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
now := time.Now()
quota := user.QuotaUsedToday() + 1
user.QuotaUsed = quota
user.QuotaUsedDay = langext.Ptr(scn.QuotaDayString())
_, err = tx.Exec(ctx, "UPDATE users SET timestamp_lastsent = :ts, messages_sent = messages_sent+1, quota_used = :qu, quota_used_day = :qd WHERE user_id = :uid", sq.PP{
"ts": time2DB(now),
"qu": user.QuotaUsed,
"qd": user.QuotaUsedDay,
"uid": user.UserID,
})
if err != nil {
return err
}
user.TimestampLastSent = &now
user.MessagesSent = user.MessagesSent + 1
return nil
}
func (db *Database) UpdateUserLastRead(ctx TxContext, userid models.UserID) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "UPDATE users SET timestamp_lastread = :ts WHERE user_id = :uid", sq.PP{
"ts": time2DB(time.Now()),
"uid": userid,
})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,25 @@
package primary
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
func bool2DB(b bool) int {
if b {
return 1
} else {
return 0
}
}
func time2DB(t time.Time) int64 {
return t.UnixMilli()
}
func time2DBOpt(t *time.Time) *int64 {
if t == nil {
return nil
}
return langext.Ptr(t.UnixMilli())
}

View File

@@ -0,0 +1,159 @@
package requests
import (
server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema"
"context"
"database/sql"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
type Database struct {
db sq.DB
pp *dbtools.DBPreprocessor
wal bool
}
func NewRequestsDatabase(cfg server.Config) (*Database, error) {
conf := cfg.DBRequests
url := fmt.Sprintf("file:%s?_journal=%s&_timeout=%d&_fk=%s&_busy_timeout=%d", conf.File, conf.Journal, conf.Timeout.Milliseconds(), langext.FormatBool(conf.CheckForeignKeys, "true", "false"), conf.BusyTimeout.Milliseconds())
xdb, err := sqlx.Open("sqlite3", url)
if err != nil {
return nil, err
}
if conf.SingleConn {
xdb.SetMaxOpenConns(1)
} else {
xdb.SetMaxOpenConns(5)
xdb.SetMaxIdleConns(5)
xdb.SetConnMaxLifetime(60 * time.Minute)
xdb.SetConnMaxIdleTime(60 * time.Minute)
}
qqdb := sq.NewDB(xdb)
if conf.EnableLogger {
qqdb.AddListener(dbtools.DBLogger{})
}
pp, err := dbtools.NewDBPreprocessor(qqdb)
if err != nil {
return nil, err
}
qqdb.AddListener(pp)
scndb := &Database{db: qqdb, pp: pp, wal: conf.Journal == "WAL"}
return scndb, nil
}
func (db *Database) DB() sq.DB {
return db.db
}
func (db *Database) Migrate(ctx context.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
currschema, err := db.ReadSchema(ctx)
if err != nil {
return err
}
if currschema == 0 {
schemastr := schema.RequestsSchema1
schemahash, err := sq.HashSqliteSchema(ctx, schemastr)
if err != nil {
return err
}
_, err = db.db.Exec(ctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(ctx, "schema", 1)
if err != nil {
return err
}
err = db.WriteMetaString(ctx, "schema_hash", schemahash)
if err != nil {
return err
}
err = db.pp.Init(ctx) // Re-Init
if err != nil {
return err
}
return nil
} else if currschema == 1 {
schemHashDB, err := sq.HashSqliteDatabase(ctx, db.db)
if err != nil {
return err
}
schemaHashMeta, err := db.ReadMetaString(ctx, "schema_hash")
if err != nil {
return err
}
schemHashAsset := schema.RequestsHash1
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schemHashAsset {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (requests db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (requests db)")
log.Debug().Str("schemHashAsset", schemHashAsset).Msg("Schema (requests db)")
return errors.New("database schema does not match (requests db)")
} else {
log.Debug().Str("schemHash", schemHashDB).Msg("Verified Schema consistency (requests db)")
}
return nil // current
} else {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
}
}
func (db *Database) Ping(ctx context.Context) error {
return db.db.Ping(ctx)
}
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault)
}
func (db *Database) Stop(ctx context.Context) error {
if db.wal {
_, err := db.db.Exec(ctx, "PRAGMA wal_checkpoint;", sq.PP{})
if err != nil {
return err
}
}
err := db.db.Exit()
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,242 @@
package requests
import (
"context"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) ReadSchema(ctx context.Context) (retval int, reterr error) {
r1, err := db.db.Query(ctx, "SELECT name FROM sqlite_master WHERE type = :typ AND name = :name", sq.PP{"typ": "table", "name": "meta"})
if err != nil {
return 0, err
}
defer func() {
err = r1.Close()
if err != nil {
// overwrite return values
retval = 0
reterr = err
}
}()
if !r1.Next() {
return 0, nil
}
err = r1.Close()
if err != nil {
return 0, err
}
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": "schema"})
if err != nil {
return 0, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = 0
reterr = err
}
}()
if !r2.Next() {
return 0, errors.New("no schema entry in meta table")
}
var dbschema int
err = r2.Scan(&dbschema)
if err != nil {
return 0, err
}
err = r2.Close()
if err != nil {
return 0, err
}
return dbschema, nil
}
func (db *Database) WriteMetaString(ctx context.Context, key string, value string) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_txt) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_txt = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaInt(ctx context.Context, key string, value int64) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_int) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_int = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaReal(ctx context.Context, key string, value float64) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_real) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_real = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) WriteMetaBlob(ctx context.Context, key string, value []byte) error {
_, err := db.db.Exec(ctx, "INSERT INTO meta (meta_key, value_blob) VALUES (:key, :val) ON CONFLICT(meta_key) DO UPDATE SET value_blob = :val", sq.PP{
"key": key,
"val": value,
})
if err != nil {
return err
}
return nil
}
func (db *Database) ReadMetaString(ctx context.Context, key string) (retval *string, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_txt FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value string
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaInt(ctx context.Context, key string) (retval *int64, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_int FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value int64
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaReal(ctx context.Context, key string) (retval *float64, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_real FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value float64
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) ReadMetaBlob(ctx context.Context, key string) (retval *[]byte, reterr error) {
r2, err := db.db.Query(ctx, "SELECT value_blob FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return nil, err
}
defer func() {
err = r2.Close()
if err != nil {
// overwrite return values
retval = nil
reterr = err
}
}()
if !r2.Next() {
return nil, errors.New("no matching entry in meta table")
}
var value []byte
err = r2.Scan(&value)
if err != nil {
return nil, err
}
err = r2.Close()
if err != nil {
return nil, err
}
return langext.Ptr(value), nil
}
func (db *Database) DeleteMeta(ctx context.Context, key string) error {
_, err := db.db.Exec(ctx, "DELETE FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,92 @@
package requests
import (
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
"blackforestbytes.com/simplecloudnotifier/models"
"context"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) InsertRequestLog(ctx context.Context, requestid models.RequestID, data models.RequestLog) (models.RequestLog, error) {
entity := data.DB()
entity.RequestID = requestid
entity.TimestampCreated = time2DB(time.Now())
_, err := sq.InsertSingle(ctx, db.db, "requests", entity)
if err != nil {
return models.RequestLog{}, err
}
return entity.Model(), nil
}
func (db *Database) Cleanup(ctx context.Context, count int, duration time.Duration) (int64, error) {
res1, err := db.db.Exec(ctx, "DELETE FROM requests WHERE request_id NOT IN ( SELECT request_id FROM requests ORDER BY timestamp_created DESC LIMIT :keep ) ", sq.PP{
"keep": count,
})
if err != nil {
return 0, err
}
affected1, err := res1.RowsAffected()
if err != nil {
return 0, err
}
res2, err := db.db.Exec(ctx, "DELETE FROM requests WHERE timestamp_created < :tslim", sq.PP{
"tslim": time.Now().Add(-duration).UnixMilli(),
})
if err != nil {
return 0, err
}
affected2, err := res2.RowsAffected()
if err != nil {
return 0, err
}
return affected1 + affected2, nil
}
func (db *Database) ListRequestLogs(ctx context.Context, filter models.RequestLogFilter, pageSize *int, inTok ct.CursorToken) ([]models.RequestLog, ct.CursorToken, error) {
if inTok.Mode == ct.CTMEnd {
return make([]models.RequestLog, 0), ct.End(), nil
}
pageCond := "1=1"
if inTok.Mode == ct.CTMNormal {
pageCond = "timestamp_created < :tokts OR (timestamp_created = :tokts AND request_id < :tokid )"
}
filterCond, filterJoin, prepParams, err := filter.SQL()
orderClause := ""
if pageSize != nil {
orderClause = "ORDER BY timestamp_created DESC, request_id DESC LIMIT :lim"
prepParams["lim"] = *pageSize + 1
} else {
orderClause = "ORDER BY timestamp_created DESC, request_id DESC"
}
sqlQuery := "SELECT " + "requests.*" + " FROM requests " + filterJoin + " WHERE ( " + pageCond + " ) AND ( " + filterCond + " ) " + orderClause
prepParams["tokts"] = inTok.Timestamp
prepParams["tokid"] = inTok.Id
rows, err := db.db.Query(ctx, sqlQuery, prepParams)
if err != nil {
return nil, ct.CursorToken{}, err
}
data, err := models.DecodeRequestLogs(rows)
if err != nil {
return nil, ct.CursorToken{}, err
}
if pageSize == nil || len(data) <= *pageSize {
return data, ct.End(), nil
} else {
outToken := ct.Normal(data[*pageSize-1].TimestampCreated, data[*pageSize-1].RequestID.String(), "DESC", filter.Hash())
return data[0:*pageSize], outToken, nil
}
}

View File

@@ -0,0 +1,25 @@
package requests
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
func bool2DB(b bool) int {
if b {
return 1
} else {
return 0
}
}
func time2DB(t time.Time) int64 {
return t.UnixMilli()
}
func time2DBOpt(t *time.Time) *int64 {
if t == nil {
return nil
}
return langext.Ptr(t.UnixMilli())
}

View File

@@ -0,0 +1,28 @@
package schema
import _ "embed"
//go:embed primary_1.ddl
var PrimarySchema1 string
const PrimaryHash1 = "f2b2847f32681a7178e405553beea4a324034915a0c5a5dc70b3c6abbcc852f2"
//go:embed primary_2.ddl
var PrimarySchema2 string
const PrimaryHash2 = "07ed1449114416ed043084a30e0722a5f97bf172161338d2f7106a8dfd387d0a"
//go:embed primary_3.ddl
var PrimarySchema3 string
const PrimaryHash3 = "65c2125ad0e12d02490cf2275f0067ef3c62a8522edf9a35ee8aa3f3c09b12e8"
//go:embed requests_1.ddl
var RequestsSchema1 string
const RequestsHash1 = "ebb0a5748b605e8215437413b738279670190ca8159b6227cfc2aa13418b41e9"
//go:embed logs_1.ddl
var LogsSchema1 string
const LogsHash1 = "65fba477c04095effc3a8e1bb79fe7547b8e52e983f776f156266eddc4f201d7"

View File

@@ -0,0 +1,23 @@
CREATE TABLE `logs`
(
log_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
PRIMARY KEY (log_id)
) STRICT;
CREATE TABLE `meta`
(
meta_key TEXT NOT NULL,
value_int INTEGER NULL,
value_txt TEXT NULL,
value_real REAL NULL,
value_blob BLOB NULL,
PRIMARY KEY (meta_key)
) STRICT;
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 1)

View File

@@ -0,0 +1,38 @@
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users`
(
`user_id` INT(11) NOT NULL AUTO_INCREMENT,
`user_key` VARCHAR(64) NOT NULL,
`fcm_token` VARCHAR(256) NULL DEFAULT NULL,
`messages_sent` INT(11) NOT NULL DEFAULT '0',
`timestamp_created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`timestamp_accessed` DATETIME NULL DEFAULT NULL,
`quota_today` INT(11) NOT NULL DEFAULT '0',
`quota_day` DATE NULL DEFAULT NULL,
`is_pro` BIT NOT NULL DEFAULT 0,
`pro_token` VARCHAR(256) NULL DEFAULT NULL,
PRIMARY KEY (`user_id`)
);
DROP TABLE IF EXISTS `messages`;
CREATE TABLE `messages`
(
`message_id` INT(11) NOT NULL AUTO_INCREMENT,
`sender_user_id` INT(11) NOT NULL,
`timestamp_real` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ack` TINYINT(1) NOT NULL DEFAULT 0,
`title` VARCHAR(256) NOT NULL,
`content` LONGTEXT NULL,
`priority` INT(11) NOT NULL,
`sendtime` BIGINT UNSIGNED NOT NULL,
`fcm_message_id` VARCHAR(256) NULL,
`usr_message_id` VARCHAR(256) NULL,
PRIMARY KEY (`message_id`)
);

View File

@@ -0,0 +1,47 @@
CREATE TABLE `users`
(
`user_id` INTEGER AUTO_INCREMENT,
`user_key` TEXT NOT NULL,
`fcm_token` TEXT NULL DEFAULT NULL,
`messages_sent` INTEGER NOT NULL DEFAULT '0',
`timestamp_created` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
`timestamp_accessed` TEXT NULL DEFAULT NULL,
`quota_today` INTEGER NOT NULL DEFAULT '0',
`quota_day` TEXT NULL DEFAULT NULL,
`is_pro` INTEGER NOT NULL DEFAULT 0,
`pro_token` TEXT NULL DEFAULT NULL,
PRIMARY KEY (`user_id`)
);
CREATE TABLE `messages`
(
`message_id` INTEGER AUTO_INCREMENT,
`sender_user_id` INTEGER NOT NULL,
`timestamp_real` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ack` INTEGER NOT NULL DEFAULT 0,
`title` TEXT NOT NULL,
`content` TEXT NULL,
`priority` INTEGER NOT NULL,
`sendtime` INTEGER NOT NULL,
`fcm_message_id` TEXT NULL,
`usr_message_id` TEXT NULL,
PRIMARY KEY (`message_id`)
);
CREATE TABLE `meta`
(
`key` TEXT NOT NULL,
`value_int` INTEGER NULL,
`value_txt` TEXT NULL,
PRIMARY KEY (`key`)
);
INSERT INTO meta (key, value_int) VALUES ('schema', 2)

View File

@@ -0,0 +1,236 @@
CREATE TABLE users
(
user_id TEXT NOT NULL,
username TEXT NULL DEFAULT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastread INTEGER NULL DEFAULT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
quota_used INTEGER NOT NULL DEFAULT '0',
quota_used_day TEXT NULL DEFAULT NULL,
is_pro INTEGER CHECK(is_pro IN (0, 1)) NOT NULL DEFAULT 0,
pro_token TEXT NULL DEFAULT NULL,
PRIMARY KEY (user_id)
) STRICT;
CREATE UNIQUE INDEX "idx_users_protoken" ON users (pro_token) WHERE pro_token IS NOT NULL;
CREATE TABLE keytokens
(
keytoken_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastused INTEGER NULL DEFAULT NULL,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
all_channels INTEGER CHECK(all_channels IN (0, 1)) NOT NULL,
channels TEXT NOT NULL,
token TEXT NOT NULL,
permissions TEXT NOT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (keytoken_id)
) STRICT;
CREATE UNIQUE INDEX "idx_keytokens_token" ON keytokens (token);
CREATE TABLE clients
(
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
type TEXT CHECK(type IN ('ANDROID', 'IOS')) NOT NULL,
fcm_token TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
agent_model TEXT NOT NULL,
agent_version TEXT NOT NULL,
PRIMARY KEY (client_id)
) STRICT;
CREATE INDEX "idx_clients_userid" ON clients (user_id);
CREATE UNIQUE INDEX "idx_clients_fcmtoken" ON clients (fcm_token);
CREATE TABLE channels
(
channel_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
internal_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description_name TEXT NULL,
subscribe_key TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_lastsent INTEGER NULL DEFAULT NULL,
messages_sent INTEGER NOT NULL DEFAULT '0',
PRIMARY KEY (channel_id)
) STRICT;
CREATE UNIQUE INDEX "idx_channels_identity" ON channels (owner_user_id, internal_name);
CREATE TABLE subscriptions
(
subscription_id TEXT NOT NULL,
subscriber_user_id TEXT NOT NULL,
channel_owner_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
confirmed INTEGER CHECK(confirmed IN (0, 1)) NOT NULL,
PRIMARY KEY (subscription_id)
) STRICT;
CREATE UNIQUE INDEX "idx_subscriptions_ref" ON subscriptions (subscriber_user_id, channel_owner_user_id, channel_internal_name);
CREATE INDEX "idx_subscriptions_chan" ON subscriptions (channel_id);
CREATE INDEX "idx_subscriptions_subuser" ON subscriptions (subscriber_user_id);
CREATE INDEX "idx_subscriptions_ownuser" ON subscriptions (channel_owner_user_id);
CREATE INDEX "idx_subscriptions_tsc" ON subscriptions (timestamp_created);
CREATE INDEX "idx_subscriptions_conf" ON subscriptions (confirmed);
CREATE TABLE messages
(
message_id TEXT NOT NULL,
sender_user_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
channel_internal_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
sender_ip TEXT NOT NULL,
sender_name TEXT NULL,
timestamp_real INTEGER NOT NULL,
timestamp_client INTEGER NULL,
title TEXT NOT NULL,
content TEXT NULL,
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
usr_message_id TEXT NULL,
used_key_id TEXT NOT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0',
PRIMARY KEY (message_id)
) STRICT;
CREATE INDEX "idx_messages_owner_channel" ON messages (owner_user_id, channel_internal_name COLLATE BINARY);
CREATE INDEX "idx_messages_owner_channel_nc" ON messages (owner_user_id, channel_internal_name COLLATE NOCASE);
CREATE INDEX "idx_messages_channel" ON messages (channel_internal_name COLLATE BINARY);
CREATE INDEX "idx_messages_channel_nc" ON messages (channel_internal_name COLLATE NOCASE);
CREATE UNIQUE INDEX "idx_messages_idempotency" ON messages (owner_user_id, usr_message_id COLLATE BINARY);
CREATE INDEX "idx_messages_senderip" ON messages (sender_ip COLLATE BINARY);
CREATE INDEX "idx_messages_sendername" ON messages (sender_name COLLATE BINARY);
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
CREATE INDEX "idx_messages_usedkey" ON messages (owner_user_id, used_key_id);
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
CREATE VIRTUAL TABLE messages_fts USING fts5
(
channel_internal_name,
sender_name,
title,
content,
tokenize = unicode61,
content = 'messages',
content_rowid = 'rowid'
);
CREATE TRIGGER fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (rowid, channel_internal_name, sender_name, title, content) VALUES (new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
INSERT INTO messages_fts ( rowid, channel_internal_name, sender_name, title, content) VALUES ( new.rowid, new.channel_internal_name, new.sender_name, new.title, new.content);
END;
CREATE TRIGGER fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts (messages_fts, rowid, channel_internal_name, sender_name, title, content) VALUES ('delete', old.rowid, old.channel_internal_name, old.sender_name, old.title, old.content);
END;
CREATE TABLE deliveries
(
delivery_id TEXT NOT NULL,
message_id TEXT NOT NULL,
receiver_user_id TEXT NOT NULL,
receiver_client_id TEXT NOT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_finalized INTEGER NULL,
status TEXT CHECK(status IN ('RETRY','SUCCESS','FAILED')) NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_delivery TEXT NULL DEFAULT NULL,
fcm_message_id TEXT NULL,
PRIMARY KEY (delivery_id)
) STRICT;
CREATE INDEX "idx_deliveries_receiver" ON deliveries (message_id, receiver_client_id);
CREATE TABLE compat_ids
(
old INTEGER NOT NULL,
new TEXT NOT NULL,
type TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatids_new" ON compat_ids (new);
CREATE UNIQUE INDEX "idx_compatids_old" ON compat_ids (old, type);
CREATE TABLE compat_acks
(
user_id TEXT NOT NULL,
message_id TEXT NOT NULL
) STRICT;
CREATE INDEX "idx_compatacks_userid" ON compat_acks (user_id);
CREATE UNIQUE INDEX "idx_compatacks_messageid" ON compat_acks (message_id);
CREATE UNIQUE INDEX "idx_compatacks_userid_messageid" ON compat_acks (user_id, message_id);
CREATE TABLE compat_clients
(
client_id TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX "idx_compatclient_clientid" ON compat_clients (client_id);
CREATE TABLE `meta`
(
meta_key TEXT NOT NULL,
value_int INTEGER NULL,
value_txt TEXT NULL,
value_real REAL NULL,
value_blob BLOB NULL,
PRIMARY KEY (meta_key)
) STRICT;
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 3)

View File

@@ -0,0 +1,48 @@
CREATE TABLE `requests`
(
request_id TEXT NOT NULL,
method TEXT NOT NULL,
uri TEXT NOT NULL,
user_agent TEXT NULL,
authentication TEXT NULL,
request_body TEXT NULL,
request_body_size INTEGER NOT NULL,
request_content_type TEXT NOT NULL,
remote_ip TEXT NOT NULL,
key_id TEXT NULL,
userid TEXT NULL,
permissions TEXT NULL,
response_statuscode INTEGER NULL,
response_body_size INTEGER NULL,
response_body TEXT NULL,
response_content_type TEXT NOT NULL,
processing_time INTEGER NOT NULL,
retry_count INTEGER NOT NULL,
panicked INTEGER CHECK(panicked IN (0, 1)) NOT NULL,
panic_str TEXT NULL,
timestamp_created INTEGER NOT NULL,
timestamp_start INTEGER NOT NULL,
timestamp_finish INTEGER NOT NULL,
PRIMARY KEY (request_id)
) STRICT;
CREATE TABLE `meta`
(
meta_key TEXT NOT NULL,
value_int INTEGER NULL,
value_txt TEXT NULL,
value_real REAL NULL,
value_blob BLOB NULL,
PRIMARY KEY (meta_key)
) STRICT;
INSERT INTO meta (meta_key, value_int) VALUES ('schema', 1)

View File

@@ -0,0 +1,7 @@
CREATE TABLE sqlite_master (
type text,
name text,
tbl_name text,
rootpage integer,
sql text
);

25
scnserver/db/utils.go Normal file
View File

@@ -0,0 +1,25 @@
package db
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"time"
)
func bool2DB(b bool) int {
if b {
return 1
} else {
return 0
}
}
func time2DB(t time.Time) int64 {
return t.UnixMilli()
}
func time2DBOpt(t *time.Time) *int64 {
if t == nil {
return nil
}
return langext.Ptr(t.UnixMilli())
}

42
scnserver/dgi.go Normal file
View File

@@ -0,0 +1,42 @@
package server
import (
_ "embed"
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"strings"
)
//go:embed DOCKER_GIT_INFO
var FileDockerGitInfo string
var CommitHash *string
var VCSType *string
var CommitTime *string
var BranchName *string
var RemoteURL *string
func init() {
for _, v := range strings.Split(FileDockerGitInfo, "\n") {
if v == "" {
continue
} else if strings.HasPrefix(v, "VCSTYPE=") {
VCSType = langext.Ptr(v[len("VCSTYPE="):])
fmt.Printf("Found DGI Config: '%s' := '%s'\n", "VCSType", *VCSType)
} else if strings.HasPrefix(v, "BRANCH=") {
BranchName = langext.Ptr(v[len("BRANCH="):])
fmt.Printf("Found DGI Config: '%s' := '%s'\n", "BranchName", *BranchName)
} else if strings.HasPrefix(v, "HASH=") {
CommitHash = langext.Ptr(v[len("HASH="):])
fmt.Printf("Found DGI Config: '%s' := '%s'\n", "CommitHash", *CommitHash)
} else if strings.HasPrefix(v, "COMMITTIME=") {
CommitTime = langext.Ptr(v[len("COMMITTIME="):])
fmt.Printf("Found DGI Config: '%s' := '%s'\n", "CommitTime", *CommitTime)
} else if strings.HasPrefix(v, "REMOTE=") {
RemoteURL = langext.Ptr(v[len("REMOTE="):])
fmt.Printf("Found DGI Config: '%s' := '%s'\n", "RemoteURL", *RemoteURL)
} else {
fmt.Printf("[ERROR] Failed to parse DGI Config '%s'\n", v)
}
}
}

43
scnserver/go.mod Normal file
View File

@@ -0,0 +1,43 @@
module blackforestbytes.com/simplecloudnotifier
go 1.19
require (
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.14.1
github.com/go-sql-driver/mysql v1.7.1
github.com/jmoiron/sqlx v1.3.5
github.com/mattn/go-sqlite3 v1.14.17
github.com/rs/zerolog v1.29.1
gogs.mikescher.com/BlackForestBytes/goext v0.0.163
gopkg.in/loremipsum.v1 v1.1.2
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.10.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/term v0.9.0 // indirect
golang.org/x/text v0.10.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

115
scnserver/go.sum Normal file
View File

@@ -0,0 +1,115 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
gogs.mikescher.com/BlackForestBytes/goext v0.0.163 h1:GYC34wLOdBM/CgAov0AyznfHGd09Km106Ijmp8cZmp4=
gogs.mikescher.com/BlackForestBytes/goext v0.0.163/go.mod h1:Tood+vqmPqS/meYRnUcGz837wqHkP8BykVpY1h8TWoI=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/loremipsum.v1 v1.1.2 h1:12APklfJKuGszqZsrArW5QoQh03/W+qyCCjvnDuS6Tw=
gopkg.in/loremipsum.v1 v1.1.2/go.mod h1:TuRvzFuzuejXj+odBU6Tubp/EPUyGb9wmSvHenyP2Ts=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,154 @@
package google
import (
scn "blackforestbytes.com/simplecloudnotifier"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"io"
"net/http"
"strings"
"time"
)
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/get
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products#ProductPurchase
type AndroidPublisher struct {
client http.Client
auth *GoogleOAuth2
baseURL string
}
func NewAndroidPublisherAPI(conf scn.Config) (AndroidPublisherClient, error) {
pkey := strings.ReplaceAll(conf.GoogleAPIPrivateKey, "\\n", "\n")
googauth, err := NewAuth(conf.GoogleAPITokenURI, conf.GoogleAPIPrivKeyID, conf.GoogleAPIClientMail, pkey)
if err != nil {
return nil, err
}
return &AndroidPublisher{
client: http.Client{Timeout: 5 * time.Second},
auth: googauth,
baseURL: "https://androidpublisher.googleapis.com/androidpublisher",
}, nil
}
type PurchaseType int //@enum:type
const (
PurchaseTypeTest PurchaseType = 0 // i.e. purchased from a license testing account
PurchaseTypePromo PurchaseType = 1 // i.e. purchased using a promo code
PurchaseTypeRewarded PurchaseType = 2 // i.e. from watching a video ad instead of paying
)
type ConsumptionState int //@enum:type
const (
ConsumptionStateYetToBeConsumed ConsumptionState = 0
ConsumptionStateConsumed ConsumptionState = 1
)
type PurchaseState int //@enum:type
const (
PurchaseStatePurchased PurchaseState = 0
PurchaseStateCanceled PurchaseState = 1
PurchaseStatePending PurchaseState = 2
)
type AcknowledgementState int //@enum:type
const (
AcknowledgementStateYetToBeAcknowledged AcknowledgementState = 0
AcknowledgementStateAcknowledged AcknowledgementState = 1
)
type ProductPurchase struct {
Kind string `json:"kind"`
PurchaseTimeMillis string `json:"purchaseTimeMillis"`
PurchaseState *PurchaseState `json:"purchaseState"`
ConsumptionState ConsumptionState `json:"consumptionState"`
DeveloperPayload string `json:"developerPayload"`
OrderId string `json:"orderId"`
PurchaseType *PurchaseType `json:"purchaseType"`
AcknowledgementState AcknowledgementState `json:"acknowledgementState"`
PurchaseToken *string `json:"purchaseToken"`
ProductId *string `json:"productId"`
Quantity *int `json:"quantity"`
ObfuscatedExternalAccountId string `json:"obfuscatedExternalAccountId"`
ObfuscatedExternalProfileId string `json:"obfuscatedExternalProfileId"`
RegionCode string `json:"regionCode"`
}
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (ap AndroidPublisher) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
uri := fmt.Sprintf("%s/v3/applications/%s/purchases/products/%s/tokens/%s", ap.baseURL, packageName, productId, token)
request, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
if err != nil {
return nil, err
}
tok, err := ap.auth.Token(ctx)
if err != nil {
log.Err(err).Msg("Refreshing FB token failed")
return nil, err
}
request.Header.Set("Authorization", "Bearer "+tok)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := ap.client.Do(request)
if err != nil {
return nil, err
}
defer func() { _ = response.Body.Close() }()
respBodyBin, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode == 400 {
var errBody struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(respBodyBin, &errBody); err != nil {
return nil, err
}
if errBody.Error.Code == 400 {
return nil, nil // probably token not found
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
if bstr, err := io.ReadAll(response.Body); err == nil {
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d: %s", response.StatusCode, string(bstr)))
} else {
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d", response.StatusCode))
}
}
var respBody ProductPurchase
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
return nil, err
}
if respBody.Kind != "androidpublisher#productPurchase" {
return nil, errors.New(fmt.Sprintf("Invalid ProductPurchase.kind: '%s'", respBody.Kind))
}
return &respBody, nil
}

View File

@@ -0,0 +1,9 @@
package google
import (
"context"
)
type AndroidPublisherClient interface {
GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error)
}

38
scnserver/google/dummy.go Normal file
View File

@@ -0,0 +1,38 @@
package google
import (
"context"
_ "embed"
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"strings"
"time"
)
type DummyGoogleAPIClient struct{}
func NewDummy() AndroidPublisherClient {
return &DummyGoogleAPIClient{}
}
func (d DummyGoogleAPIClient) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
if strings.HasPrefix(token, "PURCHASED:") {
return &ProductPurchase{
Kind: "",
PurchaseTimeMillis: fmt.Sprintf("%d", time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli()),
PurchaseState: langext.Ptr(PurchaseStatePurchased),
ConsumptionState: ConsumptionStateConsumed,
DeveloperPayload: "{}",
OrderId: "000",
PurchaseType: nil,
AcknowledgementState: AcknowledgementStateAcknowledged,
PurchaseToken: nil,
ProductId: langext.Ptr("1234-5678"),
Quantity: nil,
ObfuscatedExternalAccountId: "000",
ObfuscatedExternalProfileId: "000",
RegionCode: "DE",
}, nil
}
return nil, nil // = purchase not found
}

174
scnserver/google/oauth2.go Normal file
View File

@@ -0,0 +1,174 @@
package google
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type GoogleOAuth2 struct {
client *http.Client
scopes []string
tokenURL string
privateKeyID string
clientMail string
currToken *string
tokenExpiry *time.Time
privateKey *rsa.PrivateKey
}
func NewAuth(tokenURL string, privKeyID string, cmail string, pemstr string) (*GoogleOAuth2, error) {
pkey, err := decodePemKey(pemstr)
if err != nil {
return nil, err
}
return &GoogleOAuth2{
client: &http.Client{Timeout: 3 * time.Second},
tokenURL: tokenURL,
privateKey: pkey,
privateKeyID: privKeyID,
clientMail: cmail,
scopes: []string{
"https://www.googleapis.com/auth/androidpublisher",
},
}, nil
}
func decodePemKey(pemstr string) (*rsa.PrivateKey, error) {
var raw []byte
block, _ := pem.Decode([]byte(pemstr))
if block != nil {
raw = block.Bytes
} else {
raw = []byte(pemstr)
}
pkey8, err1 := x509.ParsePKCS8PrivateKey(raw)
if err1 == nil {
privkey, ok := pkey8.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key is invalid")
}
return privkey, nil
}
pkey1, err2 := x509.ParsePKCS1PrivateKey(raw)
if err2 == nil {
return pkey1, nil
}
return nil, errors.New(fmt.Sprintf("failed to parse private-key: [ %v | %v ]", err1, err2))
}
func (a *GoogleOAuth2) Token(ctx context.Context) (string, error) {
if a.currToken == nil || a.tokenExpiry == nil || a.tokenExpiry.Before(time.Now()) {
err := a.Refresh(ctx)
if err != nil {
return "", err
}
}
return *a.currToken, nil
}
func (a *GoogleOAuth2) Refresh(ctx context.Context) error {
assertion, err := a.encodeAssertion(a.privateKey)
if err != nil {
return err
}
body := url.Values{
"assertion": []string{assertion},
"grant_type": []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
reqNow := time.Now()
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if bstr, err := io.ReadAll(resp.Body); err == nil {
return errors.New(fmt.Sprintf("Auth-Request returned %d: %s", resp.StatusCode, string(bstr)))
} else {
return errors.New(fmt.Sprintf("Auth-Request returned %d", resp.StatusCode))
}
}
respBodyBin, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var respBody struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
return err
}
a.currToken = langext.Ptr(respBody.AccessToken)
a.tokenExpiry = langext.Ptr(reqNow.Add(timeext.FromSeconds(respBody.ExpiresIn)))
return nil
}
func (a *GoogleOAuth2) encodeAssertion(key *rsa.PrivateKey) (string, error) {
headBin, err := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": a.privateKeyID})
if err != nil {
return "", err
}
head := base64.RawURLEncoding.EncodeToString(headBin)
now := time.Now().Add(-10 * time.Second) // jwt hack against unsynced clocks
claimBin, err := json.Marshal(gin.H{"iss": a.clientMail, "scope": strings.Join(a.scopes, " "), "aud": a.tokenURL, "exp": now.Add(time.Hour).Unix(), "iat": now.Unix()})
if err != nil {
return "", err
}
claim := base64.RawURLEncoding.EncodeToString(claimBin)
checksum := sha256.New()
checksum.Write([]byte(head + "." + claim))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, checksum.Sum(nil))
if err != nil {
return "", err
}
return head + "." + claim + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}

85
scnserver/init.go Normal file
View File

@@ -0,0 +1,85 @@
package server
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"os"
"path"
"path/filepath"
"runtime"
)
var callerRoot = ""
func init() {
_, file, _, ok := runtime.Caller(0)
if !ok {
return
}
callerRoot = path.Dir(file)
}
func Init(cfg Config) {
cw := zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "2006-01-02 15:04:05 Z07:00",
FormatCaller: formatCaller,
}
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
multi := zerolog.MultiLevelWriter(cw)
logger := zerolog.New(multi).With().
Timestamp().
Caller().
Logger()
log.Logger = logger
if cfg.GinDebug {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
zerolog.SetGlobalLevel(cfg.LogLevel)
log.Debug().Msg("Initialized")
}
func formatCaller(i any) string {
const (
colorBlack = iota + 30
colorRed
colorGreen
colorYellow
colorBlue
colorMagenta
colorCyan
colorWhite
colorBold = 1
colorDarkGray = 90
)
var c string
if cc, ok := i.(string); ok {
c = cc
}
if len(c) > 0 {
if rel, err := filepath.Rel(callerRoot, c); err == nil {
c = rel
}
c = colorize(c, colorBold, false) + colorize(" >", colorCyan, false)
}
return c
}
func colorize(s interface{}, c int, disabled bool) string {
if disabled {
return fmt.Sprintf("%s", s)
}
return fmt.Sprintf("\x1b[%dm%v\x1b[0m", c, s)
}

View File

@@ -0,0 +1,211 @@
package jobs
import (
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
"time"
)
type DeliveryRetryJob struct {
app *logic.Application
name string
isRunning *syncext.AtomicBool
isStarted bool
sigChannel chan string
}
func NewDeliveryRetryJob(app *logic.Application) *DeliveryRetryJob {
return &DeliveryRetryJob{
app: app,
name: "DeliveryRetryJob",
isRunning: syncext.NewAtomicBool(false),
isStarted: false,
sigChannel: make(chan string, 1),
}
}
func (j *DeliveryRetryJob) Start() error {
if j.isRunning.Get() {
return errors.New("job already running")
}
if j.isStarted {
return errors.New("job was already started") // re-start after stop is not allowed
}
j.isStarted = true
go j.mainLoop()
return nil
}
func (j *DeliveryRetryJob) Stop() {
log.Info().Msg(fmt.Sprintf("Stopping Job [%s]", j.name))
if !syncext.WriteNonBlocking(j.sigChannel, "stop") {
log.Error().Msg(fmt.Sprintf("Failed to send Stop-Signal to Job [%s]", j.name))
}
j.isRunning.Wait(false)
log.Info().Msg(fmt.Sprintf("Stopped Job [%s]", j.name))
}
func (j *DeliveryRetryJob) Running() bool {
return j.isRunning.Get()
}
func (j *DeliveryRetryJob) mainLoop() {
j.isRunning.Set(true)
var fastRerun bool = false
var err error = nil
for {
interval := 30 * time.Second
if fastRerun {
interval = 1 * time.Second
}
signal, okay := syncext.ReadChannelWithTimeout(j.sigChannel, interval)
if okay {
if signal == "stop" {
log.Info().Msg(fmt.Sprintf("Job [%s] received <stop> signal", j.name))
break
} else if signal == "run" {
log.Info().Msg(fmt.Sprintf("Job [%s] received <run> signal", j.name))
} else {
log.Error().Msg(fmt.Sprintf("Received unknown job signal: <%s> in job [%s]", signal, j.name))
}
}
log.Debug().Msg(fmt.Sprintf("Run job [%s]", j.name))
t0 := time.Now()
fastRerun, err = j.execute()
if err != nil {
log.Err(err).Msg(fmt.Sprintf("Failed to execute job [%s]: %s", j.name, err.Error()))
} else {
t1 := time.Now()
log.Debug().Msg(fmt.Sprintf("Job [%s] finished successfully after %f minutes", j.name, (t1.Sub(t0)).Minutes()))
}
}
log.Info().Msg(fmt.Sprintf("Job [%s] exiting main-loop", j.name))
j.isRunning.Set(false)
}
func (j *DeliveryRetryJob) execute() (fastrr bool, err error) {
defer func() {
if rec := recover(); rec != nil {
log.Error().Interface("recover", rec).Msg("Recovered panic in " + j.name)
err = errors.New(fmt.Sprintf("Panic recovered: %v", rec))
fastrr = false
}
}()
ctx := j.app.NewSimpleTransactionContext(10 * time.Second)
defer ctx.Cancel()
deliveries, err := j.app.Database.Primary.ListRetrieableDeliveries(ctx, 32)
if err != nil {
return false, err
}
err = ctx.CommitTransaction()
if err != nil {
return false, err
}
if len(deliveries) == 32 {
log.Warn().Msg("The delivery pipeline is greater than 32 (too much for a single cycle)")
}
for _, delivery := range deliveries {
j.redeliver(ctx, delivery)
}
return len(deliveries) == 32, nil
}
func (j *DeliveryRetryJob) redeliver(ctx *logic.SimpleContext, delivery models.Delivery) {
client, err := j.app.Database.Primary.GetClient(ctx, delivery.ReceiverUserID, delivery.ReceiverClientID)
if err != nil {
log.Err(err).Str("ReceiverUserID", delivery.ReceiverUserID.String()).Str("ReceiverClientID", delivery.ReceiverClientID.String()).Msg("Failed to get client")
ctx.RollbackTransaction()
return
}
msg, err := j.app.Database.Primary.GetMessage(ctx, delivery.MessageID, true)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Msg("Failed to get message")
ctx.RollbackTransaction()
return
}
if msg.Deleted {
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
} else {
isCompatClient, err := j.app.Database.Primary.IsCompatClient(ctx, client.ClientID)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("ClientID", client.ClientID.String()).Msg("Failed to get <IsCompatClient>")
ctx.RollbackTransaction()
return
}
var titleOverride *string = nil
var msgidOverride *string = nil
if isCompatClient {
messageIdComp, err := j.app.Database.Primary.ConvertToCompatIDOrCreate(ctx, msg.MessageID.String(), "messageid")
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("ClientID", client.ClientID.String()).Msg("Failed to query/create messageid")
ctx.RollbackTransaction()
return
}
titleOverride = langext.Ptr(j.app.CompatizeMessageTitle(ctx, msg))
msgidOverride = langext.Ptr(fmt.Sprintf("%d", messageIdComp))
}
fcmDelivID, err := j.app.DeliverMessage(ctx, client, msg, titleOverride, msgidOverride)
if err == nil {
err = j.app.Database.Primary.SetDeliverySuccess(ctx, delivery, fcmDelivID)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
} else if delivery.RetryCount+1 > delivery.MaxRetryCount() {
err = j.app.Database.Primary.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Delivery failed after <max> retries (set to FAILURE)")
} else {
err = j.app.Database.Primary.SetDeliveryRetry(ctx, delivery)
if err != nil {
log.Err(err).Str("MessageID", delivery.MessageID.String()).Str("DeliveryID", delivery.DeliveryID.String()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
}
}
err = ctx.CommitTransaction()
}

View File

@@ -0,0 +1,116 @@
package jobs
import (
"blackforestbytes.com/simplecloudnotifier/logic"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
"time"
)
type RequestLogCleanupJob struct {
app *logic.Application
name string
isRunning *syncext.AtomicBool
isStarted bool
sigChannel chan string
}
func NewRequestLogCleanupJob(app *logic.Application) *RequestLogCleanupJob {
return &RequestLogCleanupJob{
app: app,
name: "RequestLogCleanupJob",
isRunning: syncext.NewAtomicBool(false),
isStarted: false,
sigChannel: make(chan string, 1),
}
}
func (j *RequestLogCleanupJob) Start() error {
if j.isRunning.Get() {
return errors.New("job already running")
}
if j.isStarted {
return errors.New("job was already started") // re-start after stop is not allowed
}
j.isStarted = true
go j.mainLoop()
return nil
}
func (j *RequestLogCleanupJob) Stop() {
log.Info().Msg(fmt.Sprintf("Stopping Job [%s]", j.name))
if !syncext.WriteNonBlocking(j.sigChannel, "stop") {
log.Error().Msg(fmt.Sprintf("Failed to send Stop-Signal to Job [%s]", j.name))
}
j.isRunning.Wait(false)
log.Info().Msg(fmt.Sprintf("Stopped Job [%s]", j.name))
}
func (j *RequestLogCleanupJob) Running() bool {
return j.isRunning.Get()
}
func (j *RequestLogCleanupJob) mainLoop() {
j.isRunning.Set(true)
var err error = nil
for {
interval := 1 * time.Hour
signal, okay := syncext.ReadChannelWithTimeout(j.sigChannel, interval)
if okay {
if signal == "stop" {
log.Info().Msg(fmt.Sprintf("Job [%s] received <stop> signal", j.name))
break
} else if signal == "run" {
log.Info().Msg(fmt.Sprintf("Job [%s] received <run> signal", j.name))
continue
} else {
log.Error().Msg(fmt.Sprintf("Received unknown job signal: <%s> in job [%s]", signal, j.name))
}
}
log.Debug().Msg(fmt.Sprintf("Run job [%s]", j.name))
t0 := time.Now()
err = j.execute()
if err != nil {
log.Err(err).Msg(fmt.Sprintf("Failed to execute job [%s]: %s", j.name, err.Error()))
} else {
t1 := time.Now()
log.Debug().Msg(fmt.Sprintf("Job [%s] finished successfully after %f minutes", j.name, (t1.Sub(t0)).Minutes()))
}
}
log.Info().Msg(fmt.Sprintf("Job [%s] exiting main-loop", j.name))
j.isRunning.Set(false)
}
func (j *RequestLogCleanupJob) execute() (err error) {
defer func() {
if rec := recover(); rec != nil {
log.Error().Interface("recover", rec).Msg("Recovered panic in " + j.name)
err = errors.New(fmt.Sprintf("Panic recovered: %v", rec))
}
}()
ctx := j.app.NewSimpleTransactionContext(10 * time.Second)
defer ctx.Cancel()
deleted, err := j.app.Database.Requests.Cleanup(ctx, j.app.Config.ReqLogHistoryMaxCount, j.app.Config.ReqLogHistoryMaxDuration)
if err != nil {
return err
}
log.Warn().Msgf("Deleted %d entries from the request-log table", deleted)
return nil
}

View File

@@ -0,0 +1,103 @@
package jobs
import (
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"context"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
"time"
)
type RequestLogCollectorJob struct {
app *logic.Application
name string
isRunning *syncext.AtomicBool
isStarted bool
sigChannel chan string
}
func NewRequestLogCollectorJob(app *logic.Application) *RequestLogCollectorJob {
return &RequestLogCollectorJob{
app: app,
name: "RequestLogCollectorJob",
isRunning: syncext.NewAtomicBool(false),
isStarted: false,
sigChannel: make(chan string, 1),
}
}
func (j *RequestLogCollectorJob) Start() error {
if j.isRunning.Get() {
return errors.New("job already running")
}
if j.isStarted {
return errors.New("job was already started") // re-start after stop is not allowed
}
j.isStarted = true
go j.mainLoop()
return nil
}
func (j *RequestLogCollectorJob) Stop() {
log.Info().Msg(fmt.Sprintf("Stopping Job [%s]", j.name))
if !syncext.WriteNonBlocking(j.sigChannel, "stop") {
log.Error().Msg(fmt.Sprintf("Failed to send Stop-Signal to Job [%s]", j.name))
}
j.isRunning.Wait(false)
log.Info().Msg(fmt.Sprintf("Stopped Job [%s]", j.name))
}
func (j *RequestLogCollectorJob) Running() bool {
return j.isRunning.Get()
}
func (j *RequestLogCollectorJob) mainLoop() {
j.isRunning.Set(true)
mainLoop:
for {
select {
case obj := <-j.app.RequestLogQueue:
requestid := models.NewRequestID()
err := j.insertLog(requestid, obj)
if err != nil {
log.Error().Err(err).Msg(fmt.Sprintf("Failed to insert RequestLog {%s} into DB", requestid))
} else {
log.Debug().Msg(fmt.Sprintf("Inserted RequestLog '%s' into DB", requestid))
}
case signal := <-j.sigChannel:
if signal == "stop" {
log.Info().Msg(fmt.Sprintf("Job [%s] received <stop> signal", j.name))
break mainLoop
} else if signal == "run" {
log.Info().Msg(fmt.Sprintf("Job [%s] received <run> signal", j.name))
continue
} else {
log.Error().Msg(fmt.Sprintf("Received unknown job signal: <%s> in job [%s]", signal, j.name))
}
}
}
log.Info().Msg(fmt.Sprintf("Job [%s] exiting main-loop", j.name))
j.isRunning.Set(false)
}
func (j *RequestLogCollectorJob) insertLog(requestid models.RequestID, rl models.RequestLog) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := j.app.Database.Requests.InsertRequestLog(ctx, requestid, rl)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,111 @@
package logic
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"context"
"errors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
type TxContext interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
GetOrCreateTransaction(db db.DatabaseImpl) (sq.Tx, error)
}
type AppContext struct {
app *Application
inner context.Context
cancelFunc context.CancelFunc
cancelled bool
transaction sq.Tx
permissions models.PermissionSet
ginContext *gin.Context
}
func CreateAppContext(app *Application, g *gin.Context, innerCtx context.Context, cancelFn context.CancelFunc) *AppContext {
return &AppContext{
app: app,
inner: innerCtx,
cancelFunc: cancelFn,
cancelled: false,
transaction: nil,
permissions: models.NewEmptyPermissions(),
ginContext: g,
}
}
func (ac *AppContext) Deadline() (deadline time.Time, ok bool) {
return ac.inner.Deadline()
}
func (ac *AppContext) Done() <-chan struct{} {
return ac.inner.Done()
}
func (ac *AppContext) Err() error {
return ac.inner.Err()
}
func (ac *AppContext) Value(key any) any {
return ac.inner.Value(key)
}
func (ac *AppContext) Cancel() {
ac.cancelled = true
if ac.transaction != nil {
log.Error().Str("uri", ac.RequestURI()).Msg("Rollback transaction (ctx-cancel)")
err := ac.transaction.Rollback()
if err != nil {
log.Err(err).Stack().Msg("Failed to rollback transaction")
}
ac.transaction = nil
}
ac.cancelFunc()
}
func (ac *AppContext) RequestURI() string {
if ac.ginContext != nil && ac.ginContext.Request != nil {
return ac.ginContext.Request.Method + " :: " + ac.ginContext.Request.RequestURI
} else {
return ""
}
}
func (ac *AppContext) FinishSuccess(res ginresp.HTTPResponse) ginresp.HTTPResponse {
if ac.cancelled {
panic("Cannot finish a cancelled request")
}
if ac.transaction != nil {
err := ac.transaction.Commit()
if err != nil {
return ginresp.APIError(ac.ginContext, 500, apierr.COMMIT_FAILED, "Failed to comit changes to DB", err)
}
ac.transaction = nil
}
return res
}
func (ac *AppContext) GetOrCreateTransaction(db db.DatabaseImpl) (sq.Tx, error) {
if ac.cancelled {
return nil, errors.New("context cancelled")
}
if ac.transaction != nil {
return ac.transaction, nil
}
tx, err := db.BeginTx(ac)
if err != nil {
return nil, err
}
ac.transaction = tx
return tx, nil
}

View File

@@ -0,0 +1,397 @@
package logic
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/google"
"blackforestbytes.com/simplecloudnotifier/models"
"blackforestbytes.com/simplecloudnotifier/push"
"context"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
"net"
"net/http"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
)
var rexWhitespaceStart = rext.W(regexp.MustCompile("^\\s+"))
var rexWhitespaceEnd = rext.W(regexp.MustCompile("\\s+$"))
var rexNormalizeUsername = rext.W(regexp.MustCompile("[^[:alnum:]\\-_ ]"))
var rexCompatTitleChannel = rext.W(regexp.MustCompile("^\\[(?P<channel>[A-Za-z\\-0-9_ ]+)] (?P<title>(.|\\r|\\n)+)$"))
type Application struct {
Config scn.Config
Gin *gin.Engine
Database *DBPool
Pusher push.NotificationClient
AndroidPublisher google.AndroidPublisherClient
Jobs []Job
stopChan chan bool
Port string
IsRunning *syncext.AtomicBool
RequestLogQueue chan models.RequestLog
}
func NewApp(db *DBPool) *Application {
return &Application{
Database: db,
stopChan: make(chan bool),
IsRunning: syncext.NewAtomicBool(false),
RequestLogQueue: make(chan models.RequestLog, 1024),
}
}
func (app *Application) Init(cfg scn.Config, g *gin.Engine, fb push.NotificationClient, apc google.AndroidPublisherClient, jobs []Job) {
app.Config = cfg
app.Gin = g
app.Pusher = fb
app.AndroidPublisher = apc
app.Jobs = jobs
}
func (app *Application) Stop() {
// non-blocking send
select {
case app.stopChan <- true:
}
}
func (app *Application) Run() {
httpserver := &http.Server{
Addr: net.JoinHostPort(app.Config.ServerIP, app.Config.ServerPort),
Handler: app.Gin,
}
errChan := make(chan error)
go func() {
ln, err := net.Listen("tcp", httpserver.Addr)
if err != nil {
errChan <- err
return
}
_, port, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
errChan <- err
return
}
log.Info().Str("address", httpserver.Addr).Msg("HTTP-Server started on http://localhost:" + port)
app.Port = port
app.IsRunning.Set(true) // the net.Listener a few lines above is at this point actually already buffering requests
errChan <- httpserver.Serve(ln)
}()
sigstop := make(chan os.Signal, 1)
signal.Notify(sigstop, os.Interrupt, syscall.SIGTERM)
for _, job := range app.Jobs {
err := job.Start()
if err != nil {
log.Fatal().Err(err).Msg("Failed to start job")
}
}
select {
case <-sigstop:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
log.Info().Msg("Stopping HTTP-Server")
err := httpserver.Shutdown(ctx)
if err != nil {
log.Info().Err(err).Msg("Error while stopping the http-server")
} else {
log.Info().Msg("Stopped HTTP-Server")
}
case err := <-errChan:
log.Error().Err(err).Msg("HTTP-Server failed")
case _ = <-app.stopChan:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
log.Info().Msg("Manually stopping HTTP-Server")
err := httpserver.Shutdown(ctx)
if err != nil {
log.Info().Err(err).Msg("Error while stopping the http-server")
} else {
log.Info().Msg("Manually stopped HTTP-Server")
}
}
for _, job := range app.Jobs {
job.Stop()
}
log.Info().Msg("Manually stopped Jobs")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := app.Database.Stop(ctx)
if err != nil {
log.Info().Err(err).Msg("Error while stopping the database")
}
log.Info().Msg("Manually closed database connection")
app.IsRunning.Set(false)
}
func (app *Application) GenerateRandomAuthKey() string {
return scn.RandomAuthKey()
}
func (app *Application) QuotaMax(ispro bool) int {
if ispro {
return 1000
} else {
return 50
}
}
func (app *Application) VerifyProToken(ctx *AppContext, token string) (bool, error) {
if strings.HasPrefix(token, "ANDROID|v1|") {
subToken := token[len("ANDROID|v1|"):]
return app.VerifyAndroidProToken(ctx, subToken)
}
if strings.HasPrefix(token, "ANDROID|v2|") {
subToken := token[len("ANDROID|v2|"):]
return app.VerifyAndroidProToken(ctx, subToken)
}
if strings.HasPrefix(token, "IOS|v1|") {
return false, errors.New("invalid token-version: ios-v1")
}
if strings.HasPrefix(token, "IOS|v2|") {
subToken := token[len("IOS|v2|"):]
return app.VerifyIOSProToken(ctx, subToken)
}
return false, nil
}
func (app *Application) VerifyAndroidProToken(ctx *AppContext, token string) (bool, error) {
purchase, err := app.AndroidPublisher.GetProductPurchase(ctx, app.Config.GooglePackageName, app.Config.GoogleProProductID, token)
if err != nil {
return false, err
}
if purchase == nil {
return false, nil
}
if purchase.PurchaseState == nil {
return false, nil
}
if *purchase.PurchaseState != google.PurchaseStatePurchased {
return false, nil
}
return true, nil
}
func (app *Application) VerifyIOSProToken(ctx *AppContext, token string) (bool, error) {
return false, nil //TODO IOS
}
func (app *Application) Migrate() error {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
return app.Database.Migrate(ctx)
}
type RequestOptions struct {
IgnoreWrongContentType bool
}
func (app *Application) StartRequest(g *gin.Context, uri any, query any, body any, form any, opts ...RequestOptions) (*AppContext, *ginresp.HTTPResponse) {
ignoreWrongContentType := langext.ArrAny(opts, func(o RequestOptions) bool { return o.IgnoreWrongContentType })
if uri != nil {
if err := g.ShouldBindUri(uri); err != nil {
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_URI_PARAM, "Failed to read uri", err))
}
}
if query != nil {
if err := g.ShouldBindQuery(query); err != nil {
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Failed to read query", err))
}
}
if body != nil {
if g.ContentType() == "application/json" {
if err := g.ShouldBindJSON(body); err != nil {
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "Failed to read body", err))
}
} else {
if !ignoreWrongContentType {
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing JSON body", nil))
}
}
}
if form != nil {
if g.ContentType() == "multipart/form-data" {
if err := g.ShouldBindWith(form, binding.Form); err != nil {
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "Failed to read multipart-form", err))
}
} else if g.ContentType() == "application/x-www-form-urlencoded" {
if err := g.ShouldBindWith(form, binding.Form); err != nil {
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "Failed to read urlencoded-form", err))
}
} else {
if !ignoreWrongContentType {
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing form body", nil))
}
}
}
ictx, cancel := context.WithTimeout(context.Background(), app.Config.RequestTimeout)
actx := CreateAppContext(app, g, ictx, cancel)
authheader := g.GetHeader("Authorization")
perm, err := app.getPermissions(actx, authheader)
if err != nil {
cancel()
return nil, langext.Ptr(ginresp.APIError(g, 400, apierr.PERM_QUERY_FAIL, "Failed to determine permissions", err))
}
actx.permissions = perm
g.Set("perm", perm)
return actx, nil
}
func (app *Application) NewSimpleTransactionContext(timeout time.Duration) *SimpleContext {
ictx, cancel := context.WithTimeout(context.Background(), timeout)
return CreateSimpleContext(ictx, cancel)
}
func (app *Application) getPermissions(ctx *AppContext, hdr string) (models.PermissionSet, error) {
if hdr == "" {
return models.NewEmptyPermissions(), nil
}
if !strings.HasPrefix(hdr, "SCN ") {
return models.NewEmptyPermissions(), nil
}
key := strings.TrimSpace(hdr[4:])
tok, err := app.Database.Primary.GetKeyTokenByToken(ctx, key)
if err != nil {
return models.PermissionSet{}, err
}
if tok != nil {
err = app.Database.Primary.UpdateKeyTokenLastUsed(ctx, tok.KeyTokenID)
if err != nil {
return models.PermissionSet{}, err
}
return models.PermissionSet{Token: tok}, nil
}
return models.NewEmptyPermissions(), nil
}
func (app *Application) GetOrCreateChannel(ctx *AppContext, userid models.UserID, displayChanName string, intChanName string) (models.Channel, error) {
existingChan, err := app.Database.Primary.GetChannelByName(ctx, userid, intChanName)
if err != nil {
return models.Channel{}, err
}
if existingChan != nil {
return *existingChan, nil
}
subscribeKey := app.GenerateRandomAuthKey()
newChan, err := app.Database.Primary.CreateChannel(ctx, userid, displayChanName, intChanName, subscribeKey)
if err != nil {
return models.Channel{}, err
}
_, err = app.Database.Primary.CreateSubscription(ctx, userid, newChan, true)
if err != nil {
return models.Channel{}, err
}
return newChan, nil
}
func (app *Application) NormalizeChannelDisplayName(v string) string {
return strings.TrimSpace(v)
}
func (app *Application) NormalizeChannelInternalName(v string) string {
return strings.TrimSpace(v)
}
func (app *Application) NormalizeUsername(v string) string {
return strings.TrimSpace(v)
}
func (app *Application) DeliverMessage(ctx context.Context, client models.Client, msg models.Message, compatTitleOverride *string, compatMsgIDOverride *string) (string, error) {
fcmDelivID, err := app.Pusher.SendNotification(ctx, client, msg, compatTitleOverride, compatMsgIDOverride)
if err != nil {
log.Warn().Str("MessageID", msg.MessageID.String()).Str("ClientID", client.ClientID.String()).Err(err).Msg("FCM Delivery failed")
return "", err
}
return fcmDelivID, nil
}
func (app *Application) InsertRequestLog(data models.RequestLog) {
ok := syncext.WriteNonBlocking(app.RequestLogQueue, data)
if !ok {
log.Error().Msg("failed to insert request-log (queue full)")
}
}
func (app *Application) CompatizeMessageTitle(ctx TxContext, msg models.Message) string {
if msg.ChannelInternalName == "main" {
if rexCompatTitleChannel.IsMatch(msg.Title) {
return "!" + msg.Title // channel in title ?!
}
return msg.Title
}
channel, err := app.Database.Primary.GetChannelByID(ctx, msg.ChannelID)
if err != nil {
return fmt.Sprintf("[%s] %s", "%SCN-ERR%", msg.Title)
}
return fmt.Sprintf("[%s] %s", channel.DisplayName, msg.Title)
}

88
scnserver/logic/dbpool.go Normal file
View File

@@ -0,0 +1,88 @@
package logic
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db"
logsdb "blackforestbytes.com/simplecloudnotifier/db/impl/logs"
primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary"
requestsdb "blackforestbytes.com/simplecloudnotifier/db/impl/requests"
"context"
)
type DBPool struct {
Primary *primarydb.Database
Requests *requestsdb.Database
Logs *logsdb.Database
}
func NewDBPool(conf scn.Config) (*DBPool, error) {
dbprimary, err := primarydb.NewPrimaryDatabase(conf)
if err != nil {
return nil, err
}
dbrequests, err := requestsdb.NewRequestsDatabase(conf)
if err != nil {
return nil, err
}
dblogs, err := logsdb.NewLogsDatabase(conf)
if err != nil {
return nil, err
}
return &DBPool{
Primary: dbprimary,
Requests: dbrequests,
Logs: dblogs,
}, nil
}
func (p DBPool) List() []db.DatabaseImpl {
return []db.DatabaseImpl{
p.Primary,
p.Requests,
p.Logs,
}
}
func (p DBPool) Stop(ctx context.Context) error {
var err error = nil
for _, subdb := range p.List() {
err2 := subdb.Stop(ctx)
if err2 != nil && err == nil {
err = err2
}
}
if err != nil {
return err
}
return nil
}
func (p DBPool) Migrate(ctx context.Context) error {
for _, subdb := range p.List() {
err := subdb.Migrate(ctx)
if err != nil {
return err
}
}
return nil
}
func (p DBPool) Ping(ctx context.Context) error {
for _, subdb := range p.List() {
err := subdb.Ping(ctx)
if err != nil {
return err
}
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More