Compare commits

...

214 Commits

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

View File

@ -0,0 +1,43 @@
# https://docs.gitea.com/next/usage/actions/quickstart
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
# https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
name: Build Docker and Deploy
run-name: Build & Deploy ${{ gitea.ref }} on ${{ gitea.actor }}
on:
push:
branches: ['master']
jobs:
build_job:
name: Build Docker Container
runs-on: bfb-cicd-latest
steps:
- run: echo -n "${{ secrets.DOCKER_REG_PASS }}" | docker login registry.blackforestbytes.com -u docker --password-stdin
- name: Check out code
uses: actions/checkout@v3
- run: cd "${{ gitea.workspace }}/scnserver" && make clean
- run: cd "${{ gitea.workspace }}/scnserver" && make docker
- run: cd "${{ gitea.workspace }}/scnserver" && make push-docker
deploy_job:
name: Deploy to Server
needs: [build_job]
runs-on: ubuntu-latest
steps:
- name: Execute deploy on remote (via ssh)
uses: appleboy/ssh-action@v1.0.0
with:
host: simplecloudnotifier.de
username: bfb-deploy-bot
port: 4477
key: "${{ secrets.SSH_KEY_BFBDEPLOYBOT }}"
script: cd /var/docker/deploy-scripts/simplecloudnotifier && ./deploy.sh master "${{ gitea.sha }}" || exit 1

View File

@ -1,85 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WizardSettings">
<option name="children">
<map>
<entry key="imageWizard">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="imageAssetPanel">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="launcherLegacy">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="assetType" value="IMAGE" />
<entry key="cropped" value="true" />
<entry key="iconShape" value="NONE" />
<entry key="imageAsset" value="F:\Eigene Dateien\Dropbox\Programming\Java\AndroidStudioProjects\SimpleCloudNotifier\data\icon_512_nobox.png" />
<entry key="outputName" value="ic_notification_full" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="notification">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="assetType" value="IMAGE" />
<entry key="imageAsset" value="F:\Eigene Dateien\Dropbox\Programming\Java\AndroidStudioProjects\SimpleCloudNotifier\data\icon_512_transparent.png" />
<entry key="outputName" value="ic_notification_white" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="outputIconType" value="LAUNCHER_LEGACY" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="vectorWizard">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="vectorAssetStep">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="assetSourceType" value="FILE" />
<entry key="outputName" value="ic_share" />
<entry key="sourceFile" value="C:\Users\Mike\Downloads\baseline-share-24px.svg" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@ -1,29 +1,113 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<Objective-C-extensions>
<file>
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Import" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Macro" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Typedef" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Enum" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Constant" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Global" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Struct" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="FunctionPredecl" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Function" />
</file>
<class>
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Property" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Synthesize" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="InitMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="StaticMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="InstanceMethod" />
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="DeallocMethod" />
</class>
<extensions>
<pair source="cpp" header="h" fileNamingConvention="NONE" />
<pair source="c" header="h" fileNamingConvention="NONE" />
</extensions>
</Objective-C-extensions>
<codeStyleSettings language="XML">
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,40 @@
<?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="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://dl.bintray.com/gericop/maven" />
</remote-repository>
<remote-repository>
<option name="id" value="maven3" />
<option name="name" value="maven3" />
<option name="url" value="https://maven.google.com" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
</component>
</project>

View File

@ -1,7 +1,7 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion 28
compileSdkVersion 30
def versionPropsFile = file('version.properties')
def vNumber
@ -16,7 +16,7 @@ android {
defaultConfig {
applicationId "com.blackforestbytes.simplecloudnotifier"
minSdkVersion 21
targetSdkVersion 28
targetSdkVersion 30
versionCode vNumber
versionName vName
}
@ -35,78 +35,82 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'com.google.firebase:firebase-core:16.0.6'
implementation 'com.google.firebase:firebase-messaging:17.3.4'
implementation 'com.google.android.gms:play-services-ads:17.1.2'
implementation 'com.android.billingclient:billing:1.2'
implementation 'com.google.android.material:material:1.2.1'
implementation 'com.google.firebase:firebase-core:18.0.0'
implementation 'com.google.firebase:firebase-messaging:21.0.0'
implementation 'com.google.android.gms:play-services-ads:19.5.0'
implementation 'com.android.billingclient:billing:3.0.1'
implementation 'com.squareup.okhttp3:okhttp:3.10.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.github.kenglxn.QRGen:android:2.5.0'
implementation "com.github.DeweyReed:UltimateMusicPicker:2.0.0"
implementation 'com.github.duanhong169:colorpicker:1.1.5'
implementation 'net.danlew:android.joda:2.9.9.2'
implementation 'net.danlew:android.joda:2.10.7.1'
}
apply plugin: 'com.google.gms.google-services'
task updateVersion << {
def lastTag = ['git', 'describe', "--abbrev=0", "--tags"].execute().text.trim()
tasks.register("updateVersion") {
group = 'Custom'
def versionPropsFile = file('version.properties')
if (!versionPropsFile.canRead()) throw new FileNotFoundException("Could not read version.properties!")
Properties versionProps = new Properties()
new FileInputStream(versionPropsFile).withCloseable { fis -> versionProps.load(fis) }
doLast {
def lastTag = ['git', 'describe', "--abbrev=0", "--tags"].execute().text.trim()
def matcher = lastTag =~ /^v([0-9]+)\.([0-9]+)\.([0-9]+)$/
def versionPropsFile = file('version.properties')
if (!versionPropsFile.canRead()) throw new FileNotFoundException("Could not read version.properties!")
Properties versionProps = new Properties()
new FileInputStream(versionPropsFile).withCloseable { fis -> versionProps.load(fis) }
if (!matcher.matches()) throw new Exception("Last Tag ('" + lastTag + "') has invalid format :(")
def matcher = lastTag =~ /^v([0-9]+)\.([0-9]+)\.([0-9]+)$/
def vName = (matcher[0][1] as Integer) + "." + (matcher[0][2] as Integer) + "." + (matcher[0][3] as Integer)
def vCode = versionProps['VERSION_CODE'] as Integer
if (!matcher.matches()) throw new Exception("Last Tag ('" + lastTag + "') has invalid format :(")
if (new File(".do_publish_beta_release").exists()) new File(".do_publish_beta_release").delete()
if (new File(".do_publish_prod_release").exists()) new File(".do_publish_prod_release").delete()
def vName = (matcher[0][1] as Integer) + "." + (matcher[0][2] as Integer) + "." + (matcher[0][3] as Integer)
def vCode = versionProps['VERSION_CODE'] as Integer
if (vName == versionProps['VERSION_NAME'].toString()) {
println "This version was already built - skip deployment"
} else if (vName.endsWith(".0")) {
println ""
println "====================================================================="
println "====================================================================="
println "(!) This is a new PRODUCTION release - create deployment trigger file"
println "====================================================================="
println "====================================================================="
println ""
if (new File(".do_publish_beta_release").exists()) new File(".do_publish_beta_release").delete()
if (new File(".do_publish_prod_release").exists()) new File(".do_publish_prod_release").delete()
vCode++
new File(".do_publish_prod_release").createNewFile()
if (vName == versionProps['VERSION_NAME'].toString()) {
println "This version was already built - skip deployment"
} else if (vName.endsWith(".0")) {
println ""
println "====================================================================="
println "====================================================================="
println "(!) This is a new PRODUCTION release - create deployment trigger file"
println "====================================================================="
println "====================================================================="
println ""
versionProps['VERSION_NAME'] = vName.toString()
versionProps['VERSION_CODE'] = vCode.toString()
vCode++
new File(".do_publish_prod_release").createNewFile()
versionPropsFile.newWriter().withCloseable { w -> versionProps.store(w, null) }
} else {
println ""
println "==============================================================="
println "(!) This is a new beta release - create deployment trigger file"
println "==============================================================="
println ""
versionProps['VERSION_NAME'] = vName.toString()
versionProps['VERSION_CODE'] = vCode.toString()
vCode++
new File(".do_publish_beta_release").createNewFile()
versionPropsFile.newWriter().withCloseable { w -> versionProps.store(w, null) }
} else {
println ""
println "==============================================================="
println "(!) This is a new beta release - create deployment trigger file"
println "==============================================================="
println ""
versionProps['VERSION_NAME'] = vName.toString()
versionProps['VERSION_CODE'] = vCode.toString()
vCode++
new File(".do_publish_beta_release").createNewFile()
versionPropsFile.newWriter().withCloseable { w -> versionProps.store(w, null) }
versionProps['VERSION_NAME'] = vName.toString()
versionProps['VERSION_CODE'] = vCode.toString()
versionPropsFile.newWriter().withCloseable { w -> versionProps.store(w, null) }
}
}
}

View File

@ -24,6 +24,15 @@
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.blackforestbytes.simplecloudnotifier.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/filepaths" />
</provider>
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/icon" />
@ -53,7 +62,7 @@
android:name=".view.debug.QueryLogActivity"
android:label="@string/title_activity_query_log"
android:theme="@style/AppTheme" />
<activity android:name=".view.debug.SingleQueryLogActivity"></activity>
<activity android:name=".view.debug.SingleQueryLogActivity" />
</application>
</manifest>

View File

@ -35,6 +35,11 @@ public class CMessageList
}
private CMessageList()
{
reloadPrefs();
}
public void reloadPrefs()
{
synchronized (msg_lock)
{

View File

@ -15,9 +15,9 @@ public class QueryLog
private final static int MAX_HISTORY_SIZE = 192;
private static QueryLog _instance;
public static QueryLog instance() { if (_instance == null) synchronized (QueryLog.class) { if (_instance == null) _instance = new QueryLog(); } return _instance; }
public static QueryLog inst() { if (_instance == null) synchronized (QueryLog.class) { if (_instance == null) _instance = new QueryLog(); } return _instance; }
private QueryLog(){ load(); }
private QueryLog(){ reloadPrefs(); }
private final List<SingleQuery> history = new ArrayList<>();
@ -50,7 +50,7 @@ public class QueryLog
e.apply();
}
public synchronized void load()
public synchronized void reloadPrefs()
{
try
{

View File

@ -10,7 +10,7 @@ import com.blackforestbytes.simplecloudnotifier.SCNApp;
import com.blackforestbytes.simplecloudnotifier.lib.datatypes.Tuple3;
import com.blackforestbytes.simplecloudnotifier.lib.string.Str;
import com.blackforestbytes.simplecloudnotifier.service.IABService;
import com.google.firebase.iid.FirebaseInstanceId;
import com.google.firebase.installations.FirebaseInstallations;
public class SCNSettings
{
@ -62,6 +62,11 @@ public class SCNSettings
// ------------------------------------------------------------
public SCNSettings()
{
reloadPrefs();
}
public void reloadPrefs()
{
SharedPreferences sharedPref = SCNApp.getContext().getSharedPreferences("Config", Context.MODE_PRIVATE);
@ -177,13 +182,13 @@ public class SCNSettings
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())
{
fcm_token_local = token;
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
{
@ -195,13 +200,12 @@ public class SCNSettings
}
// 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);
SCNSettings.inst().setServerToken(newToken, null);
SCNSettings.inst().setServerToken(newToken, null, force);
}).addOnCompleteListener(r ->
{
if (isConnected()) ServerCommunication.info(user_id, user_key, null);
@ -227,16 +231,15 @@ public class SCNSettings
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
{
// 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);
SCNSettings.inst().setServerToken(newToken, loader); // does register in here
SCNSettings.inst().setServerToken(newToken, loader, false); // does register in here
}).addOnCompleteListener(r ->
{
if (isConnected()) ServerCommunication.info(user_id, user_key, null); // info again for safety

View File

@ -68,6 +68,7 @@ public class ServerCommunication
if (!json_bool(json, "success"))
{
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("register", call, response, r);
return;
}
@ -105,7 +106,7 @@ public class ServerCommunication
try
{
Request request = new Request.Builder()
.url(BASE_URL + "updateFCMToken.php?user_id="+id+"&user_key="+key+"&fcm_token="+token)
.url(BASE_URL + "update.php?user_id="+id+"&user_key="+key+"&fcm_token="+token)
.build();
client.newCall(request).enqueue(new Callback()
@ -134,6 +135,7 @@ public class ServerCommunication
if (!json_bool(json, "success"))
{
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("update<1>", call, response, r);
return;
}
@ -172,7 +174,7 @@ public class ServerCommunication
try
{
Request request = new Request.Builder()
.url(BASE_URL + "updateFCMToken.php?user_id=" + id + "&user_key=" + key)
.url(BASE_URL + "update.php?user_id=" + id + "&user_key=" + key)
.build();
client.newCall(request).enqueue(new Callback() {
@ -200,6 +202,7 @@ public class ServerCommunication
if (!json_bool(json, "success")) {
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("update<2>", call, response, r);
return;
}
@ -269,6 +272,7 @@ public class ServerCommunication
if (!json_bool(json, "success"))
{
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("info", call, response, r);
int errid = json.optInt("errid", 0);
@ -356,6 +360,7 @@ public class ServerCommunication
if (!json_bool(json, "success"))
{
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("requery", call, response, r);
return;
}
@ -420,8 +425,7 @@ public class ServerCommunication
String r = Str.Empty;
try (ResponseBody responseBody = response.body())
{
if (!response.isSuccessful())
throw new IOException("Unexpected code " + response);
if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);
if (responseBody == null) throw new IOException("No response");
r = responseBody.string();
@ -431,6 +435,7 @@ public class ServerCommunication
if (!json_bool(json, "success")) {
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("upgrade", call, response, r);
return;
}
@ -492,7 +497,11 @@ public class ServerCommunication
JSONObject json = (JSONObject) new JSONTokener(r).nextValue();
if (!json_bool(json, "success")) SCNApp.showToast(json_str(json, "message"), 4000);
if (!json_bool(json, "success"))
{
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("ack", call, response, r);
}
handleSuccess("ack", call, response, r);
}
@ -542,6 +551,7 @@ public class ServerCommunication
if (!json_bool(json, "success"))
{
SCNApp.showToast(json_str(json, "message"), 4000);
handleNonSuccess("expand", call, response, r);
return;
}
@ -613,7 +623,29 @@ public class ServerCommunication
LogLevel l = LogLevel.INFO;
SingleQuery q = new SingleQuery(l, i, s, u, r, rc, "SUCCESS");
QueryLog.instance().add(q);
QueryLog.inst().add(q);
}
catch (Exception e2)
{
Log.e("SC:HandleSuccess", e2.toString());
}
}
private static void handleNonSuccess(String source, Call call, Response resp, String respBody)
{
Log.d("SC:"+source, respBody);
try
{
Instant i = Instant.now();
String s = source;
String u = call.request().url().toString();
int rc = resp.code();
String r = respBody;
LogLevel l = LogLevel.WARN;
SingleQuery q = new SingleQuery(l, i, s, u, r, rc, "NON-SUCCESS");
QueryLog.inst().add(q);
}
catch (Exception e2)
{
@ -644,7 +676,7 @@ public class ServerCommunication
LogLevel l = isio?LogLevel.WARN:LogLevel.ERROR;
SingleQuery q = new SingleQuery(l, i, s, u, r, rc, e.toString());
QueryLog.instance().add(q);
QueryLog.inst().add(q);
}
catch (Exception e2)
{

View File

@ -4,8 +4,6 @@ import android.util.Log;
import android.widget.Toast;
import com.blackforestbytes.simplecloudnotifier.SCNApp;
import com.blackforestbytes.simplecloudnotifier.lib.datatypes.Tuple4;
import com.blackforestbytes.simplecloudnotifier.lib.datatypes.Tuple5;
import com.blackforestbytes.simplecloudnotifier.lib.string.Str;
import com.blackforestbytes.simplecloudnotifier.model.CMessage;
import com.blackforestbytes.simplecloudnotifier.model.CMessageList;
@ -15,7 +13,6 @@ import com.blackforestbytes.simplecloudnotifier.model.QueryLog;
import com.blackforestbytes.simplecloudnotifier.model.SCNSettings;
import com.blackforestbytes.simplecloudnotifier.model.ServerCommunication;
import com.blackforestbytes.simplecloudnotifier.model.SingleQuery;
import com.google.android.gms.common.util.JsonUtils;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
@ -52,7 +49,7 @@ public class FBMService extends FirebaseMessagingService
SingleQuery q = new SingleQuery(LogLevel.INFO, Instant.now(), "FBM<recieve>", Str.Empty, new JSONObject(remoteMessage.getData()).toString(), 0, "SUCCESS");
QueryLog.instance().add(q);
QueryLog.inst().add(q);
if (trimmed)
{

View File

@ -9,8 +9,12 @@ import android.widget.Toast;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.SkuDetails;
import com.android.billingclient.api.SkuDetailsParams;
import com.android.billingclient.api.SkuDetailsResponseListener;
import com.blackforestbytes.simplecloudnotifier.SCNApp;
import com.blackforestbytes.simplecloudnotifier.lib.datatypes.Tuple2;
import com.blackforestbytes.simplecloudnotifier.lib.datatypes.Tuple3;
@ -20,11 +24,15 @@ import com.blackforestbytes.simplecloudnotifier.model.SCNSettings;
import com.blackforestbytes.simplecloudnotifier.view.MainActivity;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import static androidx.constraintlayout.widget.Constraints.TAG;
@ -58,7 +66,7 @@ public class IABService implements PurchasesUpdatedListener
private final List<Purchase> purchases = new ArrayList<>();
private boolean _isInitialized = false;
private Map<String, Boolean> _localCache= new HashMap<>();
private final Map<String, Boolean> _localCache= new HashMap<>();
public IABService(Context c)
{
@ -72,6 +80,12 @@ public class IABService implements PurchasesUpdatedListener
.build();
startServiceConnection(this::queryPurchases, false);
startServiceConnection(this::querySkuDetails, false);
}
public void reloadPrefs()
{
loadCache();
}
private void loadCache()
@ -121,9 +135,9 @@ public class IABService implements PurchasesUpdatedListener
Purchase.PurchasesResult purchasesResult = client.queryPurchases(BillingClient.SkuType.INAPP);
Log.i(TAG, "Querying purchases elapsed time: " + (System.currentTimeMillis() - time) + "ms");
if (purchasesResult.getResponseCode() == BillingClient.BillingResponse.OK)
if (purchasesResult.getResponseCode() == BillingClient.BillingResponseCode.OK)
{
for (Purchase p : purchasesResult.getPurchasesList())
for (Purchase p : Objects.requireNonNull(purchasesResult.getPurchasesList()))
{
handlePurchase(p, false);
}
@ -145,17 +159,35 @@ public class IABService implements PurchasesUpdatedListener
executeServiceRequest(queryToExecute, false);
}
public void querySkuDetails() {
}
public void purchase(Activity a, String id)
{
executeServiceRequest(() ->
{
BillingFlowParams flowParams = BillingFlowParams
.newBuilder()
.setSku(id)
.setType(BillingClient.SkuType.INAPP) // SkuType.SUB for subscription
.build();
client.launchBillingFlow(a, flowParams);
}, true);
Func0to0 queryRequest = () -> {
// Query the purchase async
SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
params.setSkusList(Collections.singletonList(id)).setType(BillingClient.SkuType.INAPP);
client.querySkuDetailsAsync(params.build(), (billingResult, skuDetailsList) ->
{
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK || skuDetailsList == null || skuDetailsList.size() != 1)
{
SCNApp.showToast("Could not find product", Toast.LENGTH_SHORT);
return;
}
executeServiceRequest(() ->
{
BillingFlowParams flowParams = BillingFlowParams
.newBuilder()
.setSkuDetails(skuDetailsList.get(0))
.build();
client.launchBillingFlow(a, flowParams);
}, true);
});
};
executeServiceRequest(queryRequest, false);
}
private void executeServiceRequest(Func0to0 runnable, final boolean userRequest)
@ -181,16 +213,16 @@ public class IABService implements PurchasesUpdatedListener
}
@Override
public void onPurchasesUpdated(int responseCode, @Nullable List<Purchase> purchases)
public void onPurchasesUpdated(@NonNull BillingResult billingResult, @Nullable List<Purchase> purchases)
{
if (responseCode == BillingClient.BillingResponse.OK && purchases != null)
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null)
{
for (Purchase purchase : purchases)
{
handlePurchase(purchase, true);
}
}
else if (responseCode == BillingClient.BillingResponse.ITEM_ALREADY_OWNED && purchases != null)
else if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED && purchases != null)
{
for (Purchase purchase : purchases)
{
@ -223,9 +255,9 @@ public class IABService implements PurchasesUpdatedListener
client.startConnection(new BillingClientStateListener()
{
@Override
public void onBillingSetupFinished(@BillingClient.BillingResponse int billingResponseCode)
public void onBillingSetupFinished(@NonNull BillingResult billingResult)
{
if (billingResponseCode == BillingClient.BillingResponse.OK)
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK)
{
isServiceConnected = true;
if (executeOnSuccess != null) executeOnSuccess.invoke();

View File

@ -229,10 +229,10 @@ public class NotificationService
if (msg.Priority == PriorityEnum.NORMAL) mBuilder.setPriority(NotificationCompat.PRIORITY_DEFAULT);
if (msg.Priority == PriorityEnum.HIGH) mBuilder.setPriority(NotificationCompat.PRIORITY_HIGH);
Intent intnt_click = new Intent(SCNApp.getContext(), BroadcastReceiverService.class);
intnt_click.putExtra(BroadcastReceiverService.ID_KEY, BroadcastReceiverService.NOTIF_SHOW_MAIN);
PendingIntent pi = PendingIntent.getBroadcast(ctxt, 0, intnt_click, 0);
Intent intent = new Intent(ctxt, MainActivity.class);
PendingIntent pi = PendingIntent.getActivity(ctxt, 0, intent, 0);
mBuilder.setContentIntent(pi);
NotificationManager mNotificationManager = (NotificationManager) ctxt.getSystemService(Context.NOTIFICATION_SERVICE);
if (mNotificationManager == null) return;

View File

@ -1,11 +1,13 @@
package com.blackforestbytes.simplecloudnotifier.view;
import android.content.Context;
import android.content.Intent;
import android.icu.text.SymbolTable;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;
import com.blackforestbytes.simplecloudnotifier.R;
@ -23,6 +25,12 @@ import androidx.appcompat.widget.Toolbar;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import java.io.File;
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.util.Map;
import java.util.Set;
public class MainActivity extends AppCompatActivity
{
public TabAdapter adpTabs;
@ -31,7 +39,7 @@ public class MainActivity extends AppCompatActivity
@Override
protected void onCreate(Bundle savedInstanceState)
{
QueryLog.instance();
QueryLog.inst();
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
@ -71,7 +79,7 @@ public class MainActivity extends AppCompatActivity
SCNApp.register(this);
IABService.startup(this);
SCNSettings.inst().work(this);
SCNSettings.inst().work(this, true);
}
@Override
@ -103,4 +111,114 @@ public class MainActivity extends AppCompatActivity
if (clickCount == 4) startActivity(new Intent(this, QueryLogActivity.class));
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if(requestCode == 1991 && resultCode == RESULT_OK)
{
Uri uri = data.getData(); //The uri with the location of the file
Context ctxt = this;
try
{
ObjectInputStream stream = new ObjectInputStream(getContentResolver().openInputStream(uri));
Map<String, ?> d1 = (Map<String, ?>)stream.readObject();
Map<String, ?> d2 = (Map<String, ?>)stream.readObject();
Map<String, ?> d3 = (Map<String, ?>)stream.readObject();
Map<String, ?> d4 = (Map<String, ?>)stream.readObject();
stream.close();
runOnUiThread(() ->
{
SharedPreferences.Editor e1 = ctxt.getSharedPreferences("Config", Context.MODE_PRIVATE).edit();
SharedPreferences.Editor e2 = ctxt.getSharedPreferences("IAB", Context.MODE_PRIVATE).edit();
SharedPreferences.Editor e3 = ctxt.getSharedPreferences("CMessageList", Context.MODE_PRIVATE).edit();
SharedPreferences.Editor e4 = ctxt.getSharedPreferences("QueryLog", Context.MODE_PRIVATE).edit();
e1.clear();
for (Map.Entry<String, ?> entry : d1.entrySet())
{
if (entry.getValue() instanceof String) e1.putString(entry.getKey(), (String)entry.getValue());
if (entry.getValue() instanceof Boolean) e1.putBoolean(entry.getKey(), (Boolean)entry.getValue());
if (entry.getValue() instanceof Float) e1.putFloat(entry.getKey(), (Float)entry.getValue());
if (entry.getValue() instanceof Integer) e1.putInt(entry.getKey(), (Integer)entry.getValue());
if (entry.getValue() instanceof Long) e1.putLong(entry.getKey(), (Long)entry.getValue());
if (entry.getValue() instanceof Set<?>) e1.putStringSet(entry.getKey(), (Set<String>)entry.getValue());
}
e2.clear();
for (Map.Entry<String, ?> entry : d2.entrySet())
{
if (entry.getValue() instanceof String) e2.putString(entry.getKey(), (String)entry.getValue());
if (entry.getValue() instanceof Boolean) e2.putBoolean(entry.getKey(), (Boolean)entry.getValue());
if (entry.getValue() instanceof Float) e2.putFloat(entry.getKey(), (Float)entry.getValue());
if (entry.getValue() instanceof Integer) e2.putInt(entry.getKey(), (Integer)entry.getValue());
if (entry.getValue() instanceof Long) e2.putLong(entry.getKey(), (Long)entry.getValue());
if (entry.getValue() instanceof Set<?>) e2.putStringSet(entry.getKey(), (Set<String>)entry.getValue());
}
e2.clear();
for (Map.Entry<String, ?> entry : d3.entrySet())
{
if (entry.getValue() instanceof String) e3.putString(entry.getKey(), (String)entry.getValue());
if (entry.getValue() instanceof Boolean) e3.putBoolean(entry.getKey(), (Boolean)entry.getValue());
if (entry.getValue() instanceof Float) e3.putFloat(entry.getKey(), (Float)entry.getValue());
if (entry.getValue() instanceof Integer) e3.putInt(entry.getKey(), (Integer)entry.getValue());
if (entry.getValue() instanceof Long) e3.putLong(entry.getKey(), (Long)entry.getValue());
if (entry.getValue() instanceof Set<?>) e3.putStringSet(entry.getKey(), (Set<String>)entry.getValue());
}
e4.clear();
for (Map.Entry<String, ?> entry : d4.entrySet())
{
if (entry.getValue() instanceof String) e4.putString(entry.getKey(), (String)entry.getValue());
if (entry.getValue() instanceof Boolean) e4.putBoolean(entry.getKey(), (Boolean)entry.getValue());
if (entry.getValue() instanceof Float) e4.putFloat(entry.getKey(), (Float)entry.getValue());
if (entry.getValue() instanceof Integer) e4.putInt(entry.getKey(), (Integer)entry.getValue());
if (entry.getValue() instanceof Long) e4.putLong(entry.getKey(), (Long)entry.getValue());
if (entry.getValue() instanceof Set<?>) e4.putStringSet(entry.getKey(), (Set<String>)entry.getValue());
}
e1.apply();
e2.apply();
e3.apply();
e4.apply();
SCNSettings.inst().reloadPrefs();
IABService.inst().reloadPrefs();
CMessageList.inst().reloadPrefs();
QueryLog.inst().reloadPrefs();
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ViewPager viewPager = findViewById(R.id.pager);
PagerAdapter adapter = adpTabs = new TabAdapter(getSupportFragmentManager());
viewPager.setAdapter(adapter);
TabLayout tabLayout = findViewById(R.id.tab_layout);
tabLayout.setupWithViewPager(viewPager);
SCNSettings.inst().work(this, true);
SCNApp.showToast("Backup imported", Toast.LENGTH_LONG);
finish();
});
}
catch (Exception e)
{
Log.e("Import:Err", e.toString());
SCNApp.showToast("Import failed", Toast.LENGTH_LONG);
}
}
}
}

View File

@ -2,14 +2,12 @@ package com.blackforestbytes.simplecloudnotifier.view;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.util.Log;
@ -27,10 +25,12 @@ import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import com.android.billingclient.api.Purchase;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import com.blackforestbytes.simplecloudnotifier.R;
import com.blackforestbytes.simplecloudnotifier.SCNApp;
import com.blackforestbytes.simplecloudnotifier.lib.android.ThreadUtils;
import com.blackforestbytes.simplecloudnotifier.lib.lambda.FI;
import com.blackforestbytes.simplecloudnotifier.lib.string.Str;
import com.blackforestbytes.simplecloudnotifier.model.SCNSettings;
@ -39,10 +39,12 @@ import com.blackforestbytes.simplecloudnotifier.util.TextChangedListener;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.Map;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import top.defaults.colorpicker.ColorPickerPopup;
import xyz.aprildown.ultimatemusicpicker.MusicPickerListener;
import xyz.aprildown.ultimatemusicpicker.UltimateMusicPicker;
@ -93,6 +95,9 @@ public class SettingsFragment extends Fragment implements MusicPickerListener
private SeekBar prefMsgHighVolume;
private ImageView prefMsgHighVolumeTest;
private Button prefBtnImport;
private Button prefBtnExport;
private int musicPickerSwitch = -1;
private MediaPlayer[] mPlayers = new MediaPlayer[3];
@ -160,6 +165,9 @@ public class SettingsFragment extends Fragment implements MusicPickerListener
prefMsgHighVolume = v.findViewById(R.id.prefMsgHighVolume);
prefMsgHighVolumeTest = v.findViewById(R.id.btnHighVolumeTest);
prefBtnExport = v.findViewById(R.id.prefExport);
prefBtnImport = v.findViewById(R.id.prefImport);
ArrayAdapter<Integer> plcsa = new ArrayAdapter<>(v.getContext(), android.R.layout.simple_spinner_item, SCNSettings.CHOOSABLE_CACHE_SIZES);
plcsa.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
prefLocalCacheSize.setAdapter(plcsa);
@ -246,6 +254,9 @@ public class SettingsFragment extends Fragment implements MusicPickerListener
prefUpgradeAccount.setOnClickListener(a -> onUpgradeAccount());
prefBtnExport.setOnClickListener(a -> onExport());
prefBtnImport.setOnClickListener(a -> onImport());
prefMsgLowEnableSound.setOnCheckedChangeListener((a,b) -> { s.PriorityLow.EnableSound=b; saveAndUpdate(); });
prefMsgLowRingtone_container.setOnClickListener(a -> chooseRingtoneLow());
prefMsgLowRepeatSound.setOnCheckedChangeListener((a,b) -> { s.PriorityLow.RepeatSound=b; saveAndUpdate(); });
@ -277,6 +288,55 @@ public class SettingsFragment extends Fragment implements MusicPickerListener
prefMsgHighVolumeTest.setOnClickListener((v) -> { if (s.PriorityHigh.ForceVolume) playTestSound(2, prefMsgHighVolumeTest, s.PriorityHigh.SoundSource, s.PriorityHigh.ForceVolumeValue); });
}
private void onExport()
{
Context ctxt = getContext();
if (ctxt == null) return;
try
{
File outputDir = ctxt.getCacheDir(); // context being the Activity pointer
File outputFile = File.createTempFile("scn_export_", ".dat", outputDir);
ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream(outputFile));
Map<String, ?> d1 = ctxt.getSharedPreferences("Config", Context.MODE_PRIVATE).getAll();
Map<String, ?> d2 = ctxt.getSharedPreferences("IAB", Context.MODE_PRIVATE).getAll();
Map<String, ?> d3 = ctxt.getSharedPreferences("CMessageList", Context.MODE_PRIVATE).getAll();
Map<String, ?> d4 = ctxt.getSharedPreferences("QueryLog", Context.MODE_PRIVATE).getAll();
output.writeObject(d1);
output.writeObject(d2);
output.writeObject(d3);
output.writeObject(d4);
Intent intent = new Intent(Intent.ACTION_SEND);
Uri uri = FileProvider.getUriForFile(ctxt, "com.blackforestbytes.simplecloudnotifier.fileprovider", outputFile);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.setType("*/*");
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(intent, "Export"));
}
catch (IOException e)
{
Log.e("Export:Err", e.toString());
SCNApp.showToast("Export failed", Toast.LENGTH_LONG);
}
}
private void onImport()
{
SCNApp.getMainActivity().setContentView(R.layout.activity_main);
Intent intent = new Intent()
.setType("*/*")
.setAction(Intent.ACTION_GET_CONTENT);
((MainActivity)getActivity()).startActivityForResult(Intent.createChooser(intent, "Select a file"), 1991);
}
private void updateEnabled(boolean prev, boolean now)
{
if (!prev && now)

View File

@ -22,7 +22,7 @@ public class QueryLogActivity extends AppCompatActivity
setContentView(R.layout.activity_querylog);
ListView lvMain = findViewById(R.id.lvQueryList);
SingleQuery[] arr = QueryLog.instance().get().toArray(new SingleQuery[0]);
SingleQuery[] arr = QueryLog.inst().get().toArray(new SingleQuery[0]);
QueryLogAdapter a = new QueryLogAdapter(this, arr);
lvMain.setAdapter(a);

View File

@ -805,6 +805,24 @@
</androidx.cardview.widget.CardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/prefExport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp"
app:cornerRadius="0dp"
android:backgroundTint="#444444"
android:text="@string/export_settings" />
<com.google.android.material.button.MaterialButton
android:id="@+id/prefImport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp"
app:cornerRadius="0dp"
android:backgroundTint="#666666"
android:text="@string/import_settings" />
</LinearLayout>
</ScrollView>

View File

@ -37,4 +37,6 @@
<string name="play_test_sound">Play test sound</string>
<string name="delete">DELETE</string>
<string name="title_activity_query_log">QueryLogActivity</string>
<string name="import_settings">Import settings</string>
<string name="export_settings">Export settings</string>
</resources>

View File

@ -0,0 +1,7 @@
<paths>
<files-path path="/" name="files" />
<cache-path path="/" name="cache" />
<external-path path="/" name="external" />
<external-files-path path="/" name="external-files" />
<external-cache-path path="/" name="external-cache" />
</paths>

View File

@ -1,3 +1,3 @@
#Fri Dec 14 19:31:54 CET 2018
VERSION_NAME=1.5.0
VERSION_CODE=19
#Thu Mar 05 15:29:10 UTC 2020
VERSION_NAME=1.8.0
VERSION_CODE=23

View File

@ -7,8 +7,8 @@ buildscript {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
classpath 'com.google.gms:google-services:4.2.0'
classpath 'com.android.tools.build:gradle:4.1.0'
classpath 'com.google.gms:google-services:4.3.4'
}
}

View File

@ -1,6 +1,6 @@
#Wed Sep 26 22:10:14 CEST 2018
#Tue Nov 03 14:10:19 CET 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip

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 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,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="18" />
</component>
</project>

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>

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>

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();
}
}
}

35
examples/scn_send.php Normal file
View File

@ -0,0 +1,35 @@
<?php
/**
* @param string $title
* @param string $content
* @param int $priority
* @return bool
*/
function sendSCN($title, $content, $priority) {
global $config;
$data =
[
'user_id' => '', //TODO set your userid
'user_key' => '', //TODO set your userkey
'title' => $title,
'content' => $content,
'priority' => $priority,
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://simplecloudnotifier.blackforestbytes.com/send.php");
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
if ($result === false) return false;
$json = json_decode($result, true);
return $json['success'];
}

184
scn_send.sh Normal file
View File

@ -0,0 +1,184 @@
#!/usr/bin/env bash
#
# Wrapper around SCN ( https://simplecloudnotifier.de/ )
# ======================================================
#
# ./scn_send [@channel] title [content] [priority]
#
#
# Call with scn_send "${title}"
# or scn_send "${title}" ${content}"
# or scn_send "${title}" ${content}" "${priority:0|1|2}"
# or scn_send "@${channel} "${title}"
# or scn_send "@${channel} "${title}" ${content}"
# or scn_send "@${channel} "${title}" ${content}" "${priority:0|1|2}"
#
# content can be of format "--scnsend-read-body-from-file={path}" to read body from file
# (this circumvents max commandline length)
#
################################################################################
usage() {
echo "Usage: "
echo " scn_send [@channel] title [content] [priority]"
echo ""
}
function cfgcol { [ -t 1 ] && [ -n "$(tput colors)" ] && [ "$(tput colors)" -ge 8 ]; }
function rederr() { if cfgcol; then >&2 echo -e "\x1B[31m$1\x1B[0m"; else >&2 echo "$1"; fi; }
function green() { if cfgcol; then echo -e "\x1B[32m$1\x1B[0m"; else echo "$1"; fi; }
################################################################################
#
# Get env 'SCN_UID' and 'SCN_KEY' from conf file
#
# shellcheck source=/dev/null
. "/etc/scn.conf"
SCN_UID=${SCN_UID:-}
SCN_KEY=${SCN_KEY:-}
[ -z "${SCN_UID}" ] && { rederr "Missing config value 'SCN_UID' in /etc/scn.conf"; exit 1; }
[ -z "${SCN_KEY}" ] && { rederr "Missing config value 'SCN_KEY' in /etc/scn.conf"; exit 1; }
################################################################################
args=( "$@" )
title=""
content=""
channel=""
priority=""
usr_msg_id="$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32)"
sendtime="$(date +%s)"
sender="$(hostname)"
if command -v srvname &> /dev/null; then
sender="$( srvname )"
fi
if [[ "${args[0]}" = "--" ]]; then
# only positional args form here on (currently not handled)
args=("${args[@]:1}")
fi
if [ ${#args[@]} -lt 1 ]; then
rederr "[ERROR]: no title supplied via parameter"
usage
exit 1
fi
if [[ "${args[0]}" =~ ^@.* ]]; then
channel="${args[0]}"
args=("${args[@]:1}")
channel="${channel:1}"
fi
if [ ${#args[@]} -lt 1 ]; then
rederr "[ERROR]: no title supplied via parameter"
usage
exit 1
fi
title="${args[0]}"
args=("${args[@]:1}")
content=""
if [ ${#args[@]} -gt 0 ]; then
content="${args[0]}"
args=("${args[@]:1}")
fi
if [ ${#args[@]} -gt 0 ]; then
priority="${args[0]}"
args=("${args[@]:1}")
fi
if [ ${#args[@]} -gt 0 ]; then
rederr "Too many arguments to scn_send"
usage
exit 1
fi
if [[ "$content" == --scnsend-read-body-from-file=* ]]; then
path="$( awk '{ print substr($0, 31) }' <<< "$content" )"
content="$( cat "$path" )"
fi
curlparams=()
curlparams+=( "--data-urlencode" "user_id=${SCN_UID}" )
curlparams+=( "--data-urlencode" "key=${SCN_KEY}" )
curlparams+=( "--data-urlencode" "title=$title" )
curlparams+=( "--data-urlencode" "timestamp=$sendtime" )
curlparams+=( "--data-urlencode" "msg_id=$usr_msg_id" )
if [[ -n "$content" ]]; then
curlparams+=("--data-urlencode" "content=$content")
fi
if [[ -n "$priority" ]]; then
curlparams+=("--data-urlencode" "priority=$priority")
fi
if [[ -n "$channel" ]]; then
curlparams+=("--data-urlencode" "channel=$channel")
fi
if [[ -n "$sender" ]]; then
curlparams+=("--data-urlencode" "sender_name=$sender")
fi
while true ; do
outf="$(mktemp)"
curlresp=$(curl --silent \
--output "${outf}" \
--write-out "%{http_code}" \
"${curlparams[@]}" \
"https://simplecloudnotifier.de/" )
curlout="$(cat "$outf")"
rm "$outf"
if [ "$curlresp" == 200 ] ; then
green "Successfully send"
exit 0
fi
if [ "$curlresp" == 400 ] ; then
rederr "Bad request - something went wrong"
echo "$curlout"
echo ""
exit 1
fi
if [ "$curlresp" == 401 ] ; then
rederr "Unauthorized - wrong userid/userkey"
exit 1
fi
if [ "$curlresp" == 403 ] ; then
rederr "Quota exceeded - wait 5 min before re-try"
sleep 300
fi
if [ "$curlresp" == 412 ] ; then
rederr "Precondition Failed - No device linked"
exit 1
fi
if [ "$curlresp" == 500 ] ; then
rederr "Internal server error - waiting for better times"
sleep 60
fi
# if none of the above matched we probably have no network ...
rederr "Send failed (response code $curlresp) ... try again in 5s"
sleep 5
done

106
scnserver/.gitignore vendored Normal file
View File

@ -0,0 +1,106 @@
_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
.swaggobin
scn_send.sh
##############
.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 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,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoLinterSettings">
<option name="checkGoLinterExe" value="false" />
</component>
</project>

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>

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>

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>

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 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>

36
scnserver/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM golang:1-bullseye AS builder
RUN apt-get update && \
apt-get install -y ca-certificates openssl make git tar coreutils && \
apt-get install -y python3 python3-pip && \
pip install virtualenv && \
rm -rf /var/lib/apt/lists/*
COPY . /buildsrc
RUN cd /buildsrc && cp "scn_send.sh" "../scn_send.sh" && 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"]

107
scnserver/Makefile Normal file
View File

@ -0,0 +1,107 @@
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
SWAGGO_VERSION=v1.8.12
SWAGGO=github.com/swaggo/swag/cmd/swag@$(SWAGGO_VERSION)
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
cp ../scn_send.sh .
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" \
.
[ -f "scn_send.sh" ] && rm scn_send.sh
swagger-setup:
mkdir -p ".swaggobin"
[ -f ".swaggobin/swag_$(SWAGGO_VERSION)" ] || { GOBIN=/tmp/_swaggo go install $(SWAGGO); cp "/tmp/_swaggo/swag" ".swaggobin/swag_$(SWAGGO_VERSION)"; rm -rf "/tmp/_swaggo"; }
swagger: swagger-setup
".swaggobin/swag_$(SWAGGO_VERSION)" init -generalInfo ./api/router.go --propertyStrategy camelcase --output ./swagger/ --outputTypes "json,yaml"
pygmentize: website/scn_send.html
website/scn_send.html: ../scn_send.sh
_pygments/pygmentizew -l bash -f html "$(shell pwd)/../scn_send.sh" > "$(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 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/*
rm -rf _pygments/env
git clean -fdx
! which go 2>&1 >> /dev/null || go clean
! which go 2>&1 >> /dev/null || go clean -testcache
fmt: swagger-setup
go fmt ./...
".swaggobin/swag_$(SWAGGO_VERSION)" 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 ./...

73
scnserver/TODO.md Normal file
View File

@ -0,0 +1,73 @@
TODO
========
#### DO DO DO
- app-store link in HTML
- ios purchase verification
- (!) use goext.ginWrapper
- (!) use goext.exerr
- use bfcodegen (enums+id)
#### 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)
- 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
- admin panel (especially errors and requests)
- cli app (?)
#### 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,468 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
"blackforestbytes.com/simplecloudnotifier/db/impl/primary"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"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 errors.Is(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"`
Description *string `json:"description"`
}
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 errors.Is(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()
cChannel := primary.CreateChanel{
UserId: u.UserID,
IntName: channelInternalName,
SubscribeKey: subscribeKey,
DisplayName: channelDisplayName,
Description: b.Description,
}
channel, err := h.database.CreateChannel(ctx, cChannel)
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 errors.Is(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 errors.Is(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.MaxChannelDescriptionLength() {
return ginresp.APIError(g, 400, apierr.CHANNEL_DESCRIPTION_TOO_LONG, fmt.Sprintf("Channel-Description too long (max %d characters)", user.MaxChannelDescriptionLength()), 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 errors.Is(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,295 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"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 errors.Is(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 errors.Is(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 errors.Is(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,323 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"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 errors.Is(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 errors.Is(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 errors.Is(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,286 @@
package handler
import (
"database/sql"
"errors"
"net/http"
"strings"
"time"
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
ct "blackforestbytes.com/simplecloudnotifier/db/cursortoken"
"blackforestbytes.com/simplecloudnotifier/models"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
)
// 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} [GET]
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 errors.Is(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 errors.Is(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.CheckPermissionMessageDelete(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,459 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"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 'direction' are:
// @Description - "outgoing" Subscriptions with the user as subscriber (= subscriptions he can use to read channels)
// @Description - "incoming" Subscriptions to channels of this user (= incoming subscriptions and subscription requests)
// @Description - "both" Combines "outgoing" and "incoming" (default)
// @Description
// @Description The possible values for 'confirmation' are:
// @Description - "confirmed" Confirmed (active) subscriptions
// @Description - "unconfirmed" Unconfirmed (pending) subscriptions
// @Description - "all" Combines "confirmed" and "unconfirmed" (default)
// @Description
// @Description The possible values for 'external' are:
// @Description - "true" Subscriptions with subscriber_user_id != channel_owner_user_id (subscriptions from other users)
// @Description - "false" Subscriptions with subscriber_user_id == channel_owner_user_id (subscriptions from this user to his own channels)
// @Description - "all" Combines "external" and "internal" (default)
// @Description
// @Description The `subscriber_user_id` parameter can be used to additionally filter the subscriber_user_id (return subscribtions from a specific user)
// @Description
// @Description The `channel_owner_user_id` parameter can be used to additionally filter the channel_owner_user_id (return subscribtions to a specific user)
//
// @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 {
Direction *string `json:"direction" form:"direction" enums:"incoming,outgoing,both"`
Confirmation *string `json:"confirmation" form:"confirmation" enums:"confirmed,unconfirmed,all"`
External *string `json:"external" form:"external" enums:"true,false,all"`
SubscriberUserID *models.UserID `json:"subscriber_user_id" form:"subscriber_user_id"`
ChannelOwnerUserID *models.UserID `json:"channel_owner_user_id" form:"channel_owner_user_id"`
}
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
}
filter := models.SubscriptionFilter{}
filter.AnyUserID = langext.Ptr(u.UserID)
if q.Direction != nil {
if strings.EqualFold(*q.Direction, "incoming") {
filter.ChannelOwnerUserID = langext.Ptr([]models.UserID{u.UserID})
} else if strings.EqualFold(*q.Direction, "outgoing") {
filter.SubscriberUserID = langext.Ptr([]models.UserID{u.UserID})
} else if strings.EqualFold(*q.Direction, "both") {
// both
} else {
return ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Invalid value for param 'direction'", nil)
}
}
if q.Confirmation != nil {
if strings.EqualFold(*q.Confirmation, "confirmed") {
filter.Confirmed = langext.PTrue
} else if strings.EqualFold(*q.Confirmation, "unconfirmed") {
filter.Confirmed = langext.PFalse
} else if strings.EqualFold(*q.Confirmation, "all") {
// both
} else {
return ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Invalid value for param 'confirmation'", nil)
}
}
if q.External != nil {
if strings.EqualFold(*q.External, "true") {
filter.SubscriberIsChannelOwner = langext.PFalse
} else if strings.EqualFold(*q.External, "false") {
filter.SubscriberIsChannelOwner = langext.PTrue
} else if strings.EqualFold(*q.External, "all") {
// both
} else {
return ginresp.APIError(g, 400, apierr.BINDFAIL_QUERY_PARAM, "Invalid value for param 'external'", nil)
}
}
if q.SubscriberUserID != nil {
filter.SubscriberUserID2 = langext.Ptr([]models.UserID{*q.SubscriberUserID})
}
if q.ChannelOwnerUserID != nil {
filter.ChannelOwnerUserID2 = langext.Ptr([]models.UserID{*q.ChannelOwnerUserID})
}
res, err := h.database.ListSubscriptions(ctx, filter)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
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 errors.Is(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.ListSubscriptions(ctx, models.SubscriptionFilter{AnyUserID: langext.Ptr(u.UserID), ChannelID: langext.Ptr([]models.ChannelID{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 errors.Is(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 errors.Is(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 errors.Is(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,267 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"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 errors.Is(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,219 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
"blackforestbytes.com/simplecloudnotifier/db/simplectx"
"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"))
}
tctx := simplectx.CreateSimpleContext(ctx, nil)
err := h.app.Database.Ping(tctx)
if err != nil {
return ginresp.InternalError(err)
}
for _, subdb := range h.app.Database.List() {
uuidKey, _ := langext.NewHexUUID()
uuidWrite, _ := langext.NewHexUUID()
err = subdb.WriteMetaString(tctx, uuidKey, uuidWrite)
if err != nil {
return ginresp.InternalError(err)
}
uuidRead, err := subdb.ReadMetaString(tctx, 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(tctx, 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,932 @@
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"
"errors"
"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,
}
}
// SendMessage 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.SendMessage.combined false " "
// @Param form_data formData handler.SendMessage.combined false " "
//
// @Success 200 {object} handler.SendMessage.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 CompatHandler) SendMessage(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.app.SendMessage(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 errors.Is(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 errors.Is(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{
Sender: 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 errors.Is(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 errors.Is(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 errors.Is(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 errors.Is(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{
Sender: 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, "messageid", v.MessageID.String())
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 errors.Is(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 errors.Is(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 errors.Is(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 errors.Is(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 errors.Is(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 errors.Is(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 errors.Is(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,134 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"net/http"
"time"
)
type ExternalHandler struct {
app *logic.Application
database *primarydb.Database
}
func NewExternalHandler(app *logic.Application) ExternalHandler {
return ExternalHandler{
app: app,
database: app.Database.Primary,
}
}
// UptimeKuma 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.UptimeKuma.query false " "
// @Param post_body body handler.UptimeKuma.body false " "
//
// @Success 200 {object} handler.UptimeKuma.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 /external/v1/uptime-kuma [POST]
func (h ExternalHandler) UptimeKuma(g *gin.Context) ginresp.HTTPResponse {
type query struct {
UserID *models.UserID `form:"user_id" example:"7725"`
KeyToken *string `form:"key" example:"P3TNH8mvv14fm"`
Channel *string `form:"channel"`
ChannelUp *string `form:"channel_up"`
ChannelDown *string `form:"channel_down"`
Priority *int `form:"priority"`
PriorityUp *int `form:"priority_up"`
PriorityDown *int `form:"priority_down"`
SenderName *string `form:"senderName"`
}
type body struct {
Heartbeat *struct {
Time string `json:"time"`
Status int `json:"status"`
Msg string `json:"msg"`
Timezone string `json:"timezone"`
TimezoneOffset string `json:"timezoneOffset"`
LocalDateTime string `json:"localDateTime"`
} `json:"heartbeat"`
Monitor *struct {
Name string `json:"name"`
Url *string `json:"url"`
} `json:"monitor"`
Msg *string `json:"msg"`
}
type response struct {
MessageID models.MessageID `json:"message_id"`
}
var b body
var q query
ctx, httpErr := h.app.StartRequest(g, nil, &q, &b, nil)
if httpErr != nil {
return *httpErr
}
defer ctx.Cancel()
if b.Heartbeat == nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing field 'heartbeat' in request body", nil)
}
if b.Monitor == nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing field 'monitor' in request body", nil)
}
if b.Msg == nil {
return ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing field 'msg' in request body", nil)
}
title := langext.Conditional(b.Heartbeat.Status == 1, fmt.Sprintf("Monitor %v is back online", b.Monitor.Name), fmt.Sprintf("Monitor %v went down!", b.Monitor.Name))
content := b.Heartbeat.Msg
var timestamp *float64 = nil
if tz, err := time.LoadLocation(b.Heartbeat.Timezone); err == nil {
if ts, err := time.ParseInLocation("2006-01-02 15:04:05", b.Heartbeat.LocalDateTime, tz); err == nil {
timestamp = langext.Ptr(float64(ts.Unix()))
}
}
var channel *string = nil
if q.Channel != nil {
channel = q.Channel
}
if q.ChannelUp != nil && b.Heartbeat.Status == 1 {
channel = q.ChannelUp
}
if q.ChannelDown != nil && b.Heartbeat.Status != 1 {
channel = q.ChannelDown
}
var priority *int = nil
if q.Priority != nil {
priority = q.Priority
}
if q.PriorityUp != nil && b.Heartbeat.Status == 1 {
priority = q.PriorityUp
}
if q.PriorityDown != nil && b.Heartbeat.Status != 1 {
priority = q.PriorityDown
}
okResp, errResp := h.app.SendMessage(g, ctx, q.UserID, q.KeyToken, channel, &title, &content, priority, nil, timestamp, q.SenderName)
if errResp != nil {
return *errResp
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
MessageID: okResp.Message.MessageID,
}))
}

View File

@ -0,0 +1,107 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/api/ginresp"
primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/dataext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"net/http"
)
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.app.SendMessage(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,
}))
}
}

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"
}
}

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

@ -0,0 +1,186 @@
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
externalHandler handler.ExternalHandler
}
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),
externalHandler: handler.NewExternalHandler(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.compatHandler.SendMessage))
sendAPI.POST("/external/v1/uptime-kuma", r.Wrap(r.externalHandler.UptimeKuma))
}
// ================
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,60 @@
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.PrimarySchema4)
if err != nil {
h0 = "ERR"
}
fmt.Printf("PrimarySchema4 := %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
}

15
scnserver/db/context.go Normal file
View File

@ -0,0 +1,15 @@
package db
import (
"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 DatabaseImpl) (sq.Tx, error)
}

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 TxContext) (int, error)
WriteMetaString(ctx TxContext, key string, value string) error
WriteMetaInt(ctx TxContext, key string, value int64) error
WriteMetaReal(ctx TxContext, key string, value float64) error
WriteMetaBlob(ctx TxContext, key string, value []byte) error
ReadMetaString(ctx TxContext, key string) (*string, error)
ReadMetaInt(ctx TxContext, key string) (*int64, error)
ReadMetaReal(ctx TxContext, key string) (*float64, error)
ReadMetaBlob(ctx TxContext, key string) (*[]byte, error)
DeleteMeta(ctx TxContext, 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,176 @@
package logs
import (
server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema"
"blackforestbytes.com/simplecloudnotifier/db/simplectx"
"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(outerctx context.Context) error {
innerctx, cancel := context.WithTimeout(outerctx, 24*time.Second)
tctx := simplectx.CreateSimpleContext(innerctx, cancel)
tx, err := tctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
defer func() {
if tx.Status() == sq.TxStatusInitial || tx.Status() == sq.TxStatusActive {
err = tx.Rollback()
if err != nil {
log.Err(err).Msg("failed to rollback transaction")
}
}
}()
ppReInit := false
currschema, err := db.ReadSchema(tctx)
if err != nil {
return err
}
if currschema == 0 {
schemastr := schema.LogsSchema[schema.LogsSchemaVersion].SQL
schemahash := schema.LogsSchema[schema.LogsSchemaVersion].Hash
_, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(schema.LogsSchemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = schema.LogsSchemaVersion
}
if currschema == 1 {
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.LogsSchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (logs db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (logs db)")
log.Debug().Str("schemaHashAsset", schema.LogsSchema[currschema].Hash).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)")
}
}
if currschema != schema.LogsSchemaVersion {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
}
err = tx.Commit()
if err != nil {
return err
}
if ppReInit {
log.Debug().Msg("Re-Init preprocessor")
err = db.pp.Init(outerctx) // Re-Init
if err != nil {
return err
}
}
return nil
}
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,291 @@
package logs
import (
"blackforestbytes.com/simplecloudnotifier/db"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) ReadSchema(ctx db.TxContext) (retval int, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return 0, err
}
r1, err := tx.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 := tx.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 db.TxContext, key string, value string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value int64) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value float64) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value []byte) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string) (retval *string, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *int64, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *float64, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *[]byte, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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,285 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) GetChannelByName(ctx db.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 errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &channel, nil
}
func (db *Database) GetChannelByID(ctx db.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 errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &channel, nil
}
type CreateChanel struct {
UserId models.UserID
DisplayName string
IntName string
SubscribeKey string
Description *string
}
func (db *Database) CreateChannel(ctx db.TxContext, channel CreateChanel) (models.Channel, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Channel{}, err
}
entity := models.ChannelDB{
ChannelID: models.NewChannelID(),
OwnerUserID: channel.UserId,
DisplayName: channel.DisplayName,
InternalName: channel.IntName,
SubscribeKey: channel.SubscribeKey,
DescriptionName: channel.Description,
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 db.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 db.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 db.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 db.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 db.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 db.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 db.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 db.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,166 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateClient(ctx db.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 db.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 db.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 db.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 db.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 db.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 db.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 db.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 db.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,181 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) CreateCompatID(ctx db.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 db.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 errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &newid, nil
}
func (db *Database) ConvertToCompatID(ctx db.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 errors.Is(err, sql.ErrNoRows) {
return nil, nil, nil
}
if err != nil {
return nil, nil, err
}
return &oldid, &idtype, nil
}
func (db *Database) ConvertToCompatIDOrCreate(ctx db.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 db.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 db.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 db.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,230 @@
package primary
import (
server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema"
"blackforestbytes.com/simplecloudnotifier/db/simplectx"
"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(outerctx context.Context) error {
innerctx, cancel := context.WithTimeout(outerctx, 24*time.Second)
tctx := simplectx.CreateSimpleContext(innerctx, cancel)
tx, err := tctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
defer func() {
if tx.Status() == sq.TxStatusInitial || tx.Status() == sq.TxStatusActive {
err = tx.Rollback()
if err != nil {
log.Err(err).Msg("failed to rollback transaction")
}
}
}()
ppReInit := false
currschema, err := db.ReadSchema(tctx)
if err != nil {
return err
}
if currschema == 0 {
schemastr := schema.PrimarySchema[schema.PrimarySchemaVersion].SQL
schemahash := schema.PrimarySchema[schema.PrimarySchemaVersion].Hash
_, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(schema.PrimarySchemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = schema.PrimarySchemaVersion
}
if currschema == 1 {
return errors.New("cannot autom. upgrade schema 1")
}
if currschema == 2 {
return errors.New("cannot autom. upgrade schema 2")
}
if currschema == 3 {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).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)")
}
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 3 -> 4")
_, err = tx.Exec(tctx, schema.PrimaryMigration_3_4, sq.PP{})
if err != nil {
return err
}
currschema = 4
err = db.WriteMetaInt(tctx, "schema", int64(currschema))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schema.PrimarySchema[currschema].Hash)
if err != nil {
return err
}
log.Info().Int("currschema", currschema).Msg("Upgrade schema from 3 -> 4 succesfuly")
ppReInit = true
}
if currschema == 4 {
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.PrimarySchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (primary db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (primary db)")
log.Debug().Str("schemaHashAsset", schema.PrimarySchema[currschema].Hash).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)")
}
}
if currschema != schema.PrimarySchemaVersion {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
}
err = tx.Commit()
if err != nil {
return err
}
if ppReInit {
log.Debug().Msg("Re-Init preprocessor")
err = db.pp.Init(outerctx) // Re-Init
if err != nil {
return err
}
}
return nil
}
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,164 @@
package primary
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateRetryDelivery(ctx db.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 db.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 db.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 db.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 db.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 db.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 db.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,223 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"strings"
"time"
)
func (db *Database) CreateKeyToken(ctx db.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 db.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 db.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 db.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 errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
func (db *Database) DeleteKeyToken(ctx db.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 db.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 db.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 db.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 db.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 db.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 db.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 (
"blackforestbytes.com/simplecloudnotifier/db"
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 db.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 errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &msg, nil
}
func (db *Database) GetMessage(ctx db.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 db.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,
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 db.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 db.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 db.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,291 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) ReadSchema(ctx db.TxContext) (retval int, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return 0, err
}
r1, err := tx.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 := tx.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 db.TxContext, key string, value string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value int64) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value float64) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value []byte) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string) (retval *string, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *int64, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *float64, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *[]byte, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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,134 @@
package primary
import (
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"database/sql"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateSubscription(ctx db.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) ListSubscriptions(ctx db.TxContext, filter models.SubscriptionFilter) ([]models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
filterCond, filterJoin, prepParams, err := filter.SQL()
orderClause := " ORDER BY subscriptions.timestamp_created DESC, subscriptions.subscription_id DESC "
sqlQuery := "SELECT " + "subscriptions.*" + " FROM subscriptions " + filterJoin + " WHERE ( " + filterCond + " ) " + orderClause
rows, err := tx.Query(ctx, sqlQuery, prepParams)
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 db.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 db.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 errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
func (db *Database) DeleteSubscription(ctx db.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 db.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,151 @@
package primary
import (
scn "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
)
func (db *Database) CreateUser(ctx db.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 db.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 db.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 db.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 db.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 db.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 db.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,181 @@
package requests
import (
server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema"
"blackforestbytes.com/simplecloudnotifier/db/simplectx"
"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(outerctx context.Context) error {
innerctx, cancel := context.WithTimeout(outerctx, 24*time.Second)
tctx := simplectx.CreateSimpleContext(innerctx, cancel)
tx, err := tctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
defer func() {
if tx.Status() == sq.TxStatusInitial || tx.Status() == sq.TxStatusActive {
err = tx.Rollback()
if err != nil {
log.Err(err).Msg("failed to rollback transaction")
}
}
}()
ppReInit := false
currschema, err := db.ReadSchema(tctx)
if err != nil {
return err
}
if currschema == 0 {
schemastr := schema.RequestsSchema[schema.RequestsSchemaVersion].SQL
schemahash := schema.RequestsSchema[schema.RequestsSchemaVersion].Hash
schemahash, err := sq.HashSqliteSchema(tctx, schemastr)
if err != nil {
return err
}
_, err = tx.Exec(tctx, schemastr, sq.PP{})
if err != nil {
return err
}
err = db.WriteMetaInt(tctx, "schema", int64(schema.RequestsSchemaVersion))
if err != nil {
return err
}
err = db.WriteMetaString(tctx, "schema_hash", schemahash)
if err != nil {
return err
}
ppReInit = true
currschema = schema.LogsSchemaVersion
}
if currschema == 1 {
schemHashDB, err := sq.HashSqliteDatabase(tctx, tx)
if err != nil {
return err
}
schemaHashMeta, err := db.ReadMetaString(tctx, "schema_hash")
if err != nil {
return err
}
if schemHashDB != langext.Coalesce(schemaHashMeta, "") || langext.Coalesce(schemaHashMeta, "") != schema.RequestsSchema[currschema].Hash {
log.Debug().Str("schemHashDB", schemHashDB).Msg("Schema (requests db)")
log.Debug().Str("schemaHashMeta", langext.Coalesce(schemaHashMeta, "")).Msg("Schema (requests db)")
log.Debug().Str("schemaHashAsset", schema.RequestsSchema[currschema].Hash).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)")
}
}
if currschema != schema.RequestsSchemaVersion {
return errors.New(fmt.Sprintf("Unknown DB schema: %d", currschema))
}
err = tx.Commit()
if err != nil {
return err
}
if ppReInit {
log.Debug().Msg("Re-Init preprocessor")
err = db.pp.Init(outerctx) // Re-Init
if err != nil {
return err
}
}
return nil
}
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,291 @@
package requests
import (
"blackforestbytes.com/simplecloudnotifier/db"
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
)
func (db *Database) ReadSchema(ctx db.TxContext) (retval int, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return 0, err
}
r1, err := tx.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 := tx.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 db.TxContext, key string, value string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value int64) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value float64) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string, value []byte) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.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 db.TxContext, key string) (retval *string, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *int64, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *float64, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) (retval *[]byte, reterr error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
r2, err := tx.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 db.TxContext, key string) error {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DELETE FROM meta WHERE meta_key = :key", sq.PP{"key": key})
if err != nil {
return err
}
return nil
}

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