kdrive fix

This commit is contained in:
ValueOn AG 2026-04-29 00:35:21 +02:00
parent fb3a1f0a51
commit b405cebdec
24 changed files with 2367 additions and 565 deletions

View file

@ -0,0 +1,107 @@
# Development Environment Configuration
# System Configuration
APP_ENV_TYPE = dev
APP_ENV_LABEL = Development Instance Patrick
APP_API_URL = http://localhost:8000
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
# PostgreSQL DB Host
DB_HOST=localhost
DB_USER=poweron_dev
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
# Infomaniak OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = http://localhost:8000/api/infomaniak/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
# AI configuration
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0=
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI=
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
# Teamsbot Browser Bot Service
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
# The bot will connect back to localhost:8000 via WebSocket
TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGbEphQ3ZUMlFMQ2EwSGpoSE9NNzRJNTJtaGk1N0RGakdIYnVVeVFHZmF5OXB3QTVWLVNaZk9wNkhfQkZWRnVwRGRxem9iRzJIWXdpX1NIN2FwSExfT3c9PQ==
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a

View file

@ -51,6 +51,8 @@ Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ== Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09 STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09 STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09

View file

@ -0,0 +1,100 @@
# Integration Environment Configuration
# System Configuration
APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_API_URL = https://gateway-int.poweron-center.net
APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
# PostgreSQL DB Host
DB_HOST=gateway-int-server.postgres.database.azure.com
DB_USER=heeshkdlby
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net, https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/clickup/auth/connect/callback
# Infomaniak OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/infomaniak/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
# AI configuration
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0=
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk=
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4UkNBelhvckxCQUVjZm94N3BZUDcxaEMyckE2dm1lRVhqODhrWU1SUjNXZ3dQZlVJOWhveXFkZXpobW5xT0NneGZ2SkNUblFmYXd0WTBYNTl3UmRnSWc9PQ==
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

View file

@ -49,11 +49,13 @@ Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/go
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. # ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ== Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://gateway-int.poweron-center.net/api/clickup/auth/connect/callback Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09 STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
STRIPE_API_VERSION = 2026-01-28.clover STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0 STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0

View file

@ -0,0 +1,101 @@
# Production Environment Configuration
# System Configuration
APP_ENV_TYPE = prod
APP_ENV_LABEL = Production Instance
APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://gateway-prod.poweron-center.net
# PostgreSQL DB Host
DB_HOST=gateway-prod-server.postgres.database.azure.com
DB_USER=gzxxmcrdhn
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback
# Infomaniak OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/infomaniak/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
# AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo=
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

View file

@ -51,6 +51,8 @@ Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ== Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09 STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=

View file

@ -0,0 +1,101 @@
# Production Environment Configuration
# System Configuration
APP_ENV_TYPE = prod
APP_ENV_LABEL = Production Instance Forgejo
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://api.poweron.swiss
# PostgreSQL DB Host
DB_HOST=10.20.0.21
DB_USER=poweron_dev
DB_PASSWORD_SECRET = mypassword
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI =
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI =
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/infomaniak/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
# AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo=
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

View file

@ -11,7 +11,7 @@ APP_API_URL = https://api.poweron.swiss
# PostgreSQL DB Host # PostgreSQL DB Host
DB_HOST=10.20.0.21 DB_HOST=10.20.0.21
DB_USER=poweron_dev DB_USER=poweron_dev
DB_PASSWORD_SECRET = mypassword DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
DB_PORT=5432 DB_PORT=5432
# Security Configuration # Security Configuration
@ -51,6 +51,8 @@ Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ== Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09 STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=

View file

@ -11,6 +11,30 @@ from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingMode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _supportsCustomTemperature(modelName: str) -> bool:
"""Check whether an OpenAI model accepts a custom `temperature` value.
GPT-5.x and the o-series (o1/o3/o4) reasoning models reject every
`temperature` value other than the default (1) with HTTP 400
`unsupported_value`. For these models we must omit `temperature`
from the payload entirely. Older chat-completions models
(gpt-4o, gpt-4o-mini, gpt-4.1, gpt-3.5-*) still accept any value
in [0, 2].
Returns:
True if `temperature` may be sent; False if it must be omitted.
"""
if not modelName:
return True
name = modelName.lower()
if name.startswith("gpt-5"):
return False
if name.startswith("o1") or name.startswith("o3") or name.startswith("o4"):
return False
return True
def loadConfigData(): def loadConfigData():
"""Load configuration data for OpenAI connector""" """Load configuration data for OpenAI connector"""
return { return {
@ -344,13 +368,17 @@ class AiOpenai(BaseConnectorAi):
payload = { payload = {
"model": model.name, "model": model.name,
"messages": messages, "messages": messages,
"temperature": temperature,
# Universal output-length cap. `max_tokens` is deprecated and # Universal output-length cap. `max_tokens` is deprecated and
# rejected outright by gpt-5.x / o-series; `max_completion_tokens` # rejected outright by gpt-5.x / o-series; `max_completion_tokens`
# is accepted by every current chat-completions model (legacy # is accepted by every current chat-completions model (legacy
# gpt-4o, gpt-4.1, gpt-5.x, o1/o3/o4) per OpenAI API reference. # gpt-4o, gpt-4.1, gpt-5.x, o1/o3/o4) per OpenAI API reference.
"max_completion_tokens": maxTokens "max_completion_tokens": maxTokens
} }
# gpt-5.x and o-series only accept the default temperature (1) and
# return HTTP 400 `unsupported_value` for anything else - omit the
# field entirely for those models.
if _supportsCustomTemperature(model.name):
payload["temperature"] = temperature
if modelCall.tools: if modelCall.tools:
payload["tools"] = modelCall.tools payload["tools"] = modelCall.tools
@ -428,13 +456,15 @@ class AiOpenai(BaseConnectorAi):
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"model": model.name, "model": model.name,
"messages": messages, "messages": messages,
"temperature": temperature,
# See callAiBasic for the rationale: `max_completion_tokens` # See callAiBasic for the rationale: `max_completion_tokens`
# is the universal output-length parameter; `max_tokens` is # is the universal output-length parameter; `max_tokens` is
# deprecated and rejected by gpt-5.x / o-series. # deprecated and rejected by gpt-5.x / o-series.
"max_completion_tokens": model.maxTokens, "max_completion_tokens": model.maxTokens,
"stream": True, "stream": True,
} }
if _supportsCustomTemperature(model.name):
payload["temperature"] = temperature
if modelCall.tools: if modelCall.tools:
payload["tools"] = modelCall.tools payload["tools"] = modelCall.tools
payload["tool_choice"] = modelCall.toolChoice or "auto" payload["tool_choice"] = modelCall.toolChoice or "auto"
@ -585,15 +615,15 @@ class AiOpenai(BaseConnectorAi):
# Use the messages directly - they should already contain the image data # Use the messages directly - they should already contain the image data
# in the format: {"type": "image_url", "image_url": {"url": "data:...base64,..."}} # in the format: {"type": "image_url", "image_url": {"url": "data:...base64,..."}}
# Use parameters from model
temperature = model.temperature temperature = model.temperature
# Don't set maxTokens - let the model use its full context length # Don't set maxTokens - let the model use its full context length
payload = { payload = {
"model": model.name, "model": model.name,
"messages": messages, "messages": messages,
"temperature": temperature
} }
if _supportsCustomTemperature(model.name):
payload["temperature"] = temperature
response = await self.httpClient.post( response = await self.httpClient.post(
model.apiUrl, model.apiUrl,

View file

@ -9,13 +9,15 @@ googleAuthScopes = [
"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.profile",
] ]
# Google — Data app (Gmail + Drive + identity for token responses) # Google — Data app (Gmail + Drive + Calendar + Contacts + identity for token responses)
googleDataScopes = [ googleDataScopes = [
"openid", "openid",
"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
] ]
# Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …) # Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …)
@ -34,6 +36,8 @@ msftDataScopes = [
"OnlineMeetings.Read", "OnlineMeetings.Read",
"Chat.ReadWrite", "Chat.ReadWrite",
"ChatMessage.Send", "ChatMessage.Send",
"Calendars.Read",
"Contacts.Read",
] ]
@ -42,14 +46,8 @@ def msftDataScopesForRefresh() -> str:
return " ".join(msftDataScopes) return " ".join(msftDataScopes)
# Infomaniak — Data app (kDrive + Mail; user_info needed for /1/profile lookup) # Infomaniak intentionally has no OAuth scope set: the kDrive + Mail data APIs
infomaniakDataScopes = [ # are only reachable with manually issued Personal Access Tokens (see
"user_info", # wiki/d-guides/infomaniak-token-setup.md). The OAuth /authorize endpoint at
"kdrive", # login.infomaniak.com only accepts identity scopes (openid/profile/email/phone)
"mail", # and does not return tokens that work against /1/* data routes.
]
def infomaniakDataScopesForRefresh() -> str:
"""Space-separated scope string identical to authorization request."""
return " ".join(infomaniakDataScopes)

View file

@ -13,7 +13,7 @@ from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.datamodels.datamodelUam import AuthAuthority from modules.datamodels.datamodelUam import AuthAuthority
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh, infomaniakDataScopesForRefresh from modules.auth.oauthProviderConfig import msftDataScopesForRefresh
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,9 +30,6 @@ class TokenManager:
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID") self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET") self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
# Infomaniak Data OAuth (kDrive + Mail)
self.infomaniak_client_id = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_ID")
self.infomaniak_client_secret = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_SECRET")
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]: def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
"""Refresh Microsoft OAuth token using refresh token""" """Refresh Microsoft OAuth token using refresh token"""
@ -166,65 +163,6 @@ class TokenManager:
logger.error(f"Error refreshing Google token: {str(e)}") logger.error(f"Error refreshing Google token: {str(e)}")
return None return None
def refreshInfomaniakToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
"""Refresh Infomaniak OAuth token using refresh token"""
try:
logger.debug(f"refreshInfomaniakToken: Starting Infomaniak token refresh for user {userId}")
if not self.infomaniak_client_id or not self.infomaniak_client_secret:
logger.error("Infomaniak OAuth configuration not found")
return None
tokenUrl = "https://login.infomaniak.com/token"
data = {
"client_id": self.infomaniak_client_id,
"client_secret": self.infomaniak_client_secret,
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"scope": infomaniakDataScopesForRefresh(),
}
with httpx.Client(timeout=30.0) as client:
response = client.post(tokenUrl, data=data)
logger.debug(f"refreshInfomaniakToken: HTTP response status: {response.status_code}")
if response.status_code == 200:
tokenData = response.json()
if "access_token" not in tokenData:
logger.error("Infomaniak token refresh response missing access_token")
return None
newToken = Token(
userId=userId,
authority=AuthAuthority.INFOMANIAK,
connectionId=oldToken.connectionId,
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=tokenData["access_token"],
tokenRefresh=tokenData.get("refresh_token", refreshToken),
tokenType=tokenData.get("token_type", "bearer"),
expiresAt=createExpirationTimestamp(tokenData.get("expires_in", 3600)),
createdAt=getUtcTimestamp(),
)
return newToken
logger.error(
f"Failed to refresh Infomaniak token: {response.status_code} - {response.text}"
)
if response.status_code == 400:
try:
errorData = response.json()
if errorData.get("error") == "invalid_grant":
logger.warning(
"Infomaniak refresh token is invalid or expired - user needs to re-authenticate"
)
except Exception:
pass
return None
except Exception as e:
logger.error(f"Error refreshing Infomaniak token: {str(e)}")
return None
def refreshToken(self, oldToken: Token) -> Optional[Token]: def refreshToken(self, oldToken: Token) -> Optional[Token]:
"""Refresh an expired token using the appropriate OAuth service""" """Refresh an expired token using the appropriate OAuth service"""
try: try:
@ -268,9 +206,6 @@ class TokenManager:
elif oldToken.authority == AuthAuthority.GOOGLE: elif oldToken.authority == AuthAuthority.GOOGLE:
logger.debug(f"refreshToken: Refreshing Google token") logger.debug(f"refreshToken: Refreshing Google token")
return self.refreshGoogleToken(oldToken.tokenRefresh, oldToken.userId, oldToken) return self.refreshGoogleToken(oldToken.tokenRefresh, oldToken.userId, oldToken)
elif oldToken.authority == AuthAuthority.INFOMANIAK:
logger.debug(f"refreshToken: Refreshing Infomaniak token")
return self.refreshInfomaniakToken(oldToken.tokenRefresh, oldToken.userId, oldToken)
else: else:
logger.warning(f"Unknown authority for token refresh: {oldToken.authority}") logger.warning(f"Unknown authority for token refresh: {oldToken.authority}")
return None return None

View file

@ -144,45 +144,6 @@ class TokenRefreshService:
logger.error(f"Error refreshing Microsoft token for connection {connection.id}: {str(e)}") logger.error(f"Error refreshing Microsoft token for connection {connection.id}: {str(e)}")
return False return False
async def _refresh_infomaniak_token(self, interface, connection: UserConnection) -> bool:
"""Refresh Infomaniak OAuth token"""
try:
logger.debug(f"Refreshing Infomaniak token for connection {connection.id}")
current_token = interface.getConnectionToken(connection.id)
if not current_token:
logger.warning(f"No Infomaniak token found for connection {connection.id}")
return False
from modules.auth.tokenManager import TokenManager
token_manager = TokenManager()
refreshedToken = token_manager.refreshToken(current_token)
if refreshedToken:
interface.saveConnectionToken(refreshedToken)
interface.db.recordModify(UserConnection, connection.id, {
"lastChecked": getUtcTimestamp(),
"expiresAt": refreshedToken.expiresAt,
})
logger.info(f"Successfully refreshed Infomaniak token for connection {connection.id}")
try:
audit_logger.logSecurityEvent(
userId=str(connection.userId),
mandateId="system",
action="token_refresh",
details=f"Infomaniak token refreshed for connection {connection.id}",
)
except Exception:
pass
return True
logger.warning(f"Failed to refresh Infomaniak token for connection {connection.id}")
return False
except Exception as e:
logger.error(f"Error refreshing Infomaniak token for connection {connection.id}: {str(e)}")
return False
async def refresh_expired_tokens(self, user_id: str) -> Dict[str, Any]: async def refresh_expired_tokens(self, user_id: str) -> Dict[str, Any]:
""" """
Refresh expired OAuth tokens for a user Refresh expired OAuth tokens for a user
@ -216,7 +177,7 @@ class TokenRefreshService:
for connection in connections: for connection in connections:
# Only refresh expired OAuth connections # Only refresh expired OAuth connections
if (connection.tokenStatus == 'expired' and if (connection.tokenStatus == 'expired' and
connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT, AuthAuthority.INFOMANIAK]): connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT]):
# Check rate limiting # Check rate limiting
if self._is_rate_limited(connection.id): if self._is_rate_limited(connection.id):
@ -233,8 +194,6 @@ class TokenRefreshService:
success = await self._refresh_google_token(root_interface, connection) success = await self._refresh_google_token(root_interface, connection)
elif connection.authority == AuthAuthority.MSFT: elif connection.authority == AuthAuthority.MSFT:
success = await self._refresh_microsoft_token(root_interface, connection) success = await self._refresh_microsoft_token(root_interface, connection)
elif connection.authority == AuthAuthority.INFOMANIAK:
success = await self._refresh_infomaniak_token(root_interface, connection)
if success: if success:
refreshed_count += 1 refreshed_count += 1
@ -289,7 +248,7 @@ class TokenRefreshService:
# Only refresh active tokens that expire soon # Only refresh active tokens that expire soon
if (connection.tokenStatus == 'active' and if (connection.tokenStatus == 'active' and
connection.tokenExpiresAt and connection.tokenExpiresAt and
connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT, AuthAuthority.INFOMANIAK]): connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT]):
# Check if token expires within 5 minutes # Check if token expires within 5 minutes
time_until_expiry = connection.tokenExpiresAt - current_time time_until_expiry = connection.tokenExpiresAt - current_time
@ -310,8 +269,6 @@ class TokenRefreshService:
success = await self._refresh_google_token(root_interface, connection) success = await self._refresh_google_token(root_interface, connection)
elif connection.authority == AuthAuthority.MSFT: elif connection.authority == AuthAuthority.MSFT:
success = await self._refresh_microsoft_token(root_interface, connection) success = await self._refresh_microsoft_token(root_interface, connection)
elif connection.authority == AuthAuthority.INFOMANIAK:
success = await self._refresh_infomaniak_token(root_interface, connection)
if success: if success:
refreshed_count += 1 refreshed_count += 1

View file

@ -14,6 +14,8 @@ logger = logging.getLogger(__name__)
_DRIVE_BASE = "https://www.googleapis.com/drive/v3" _DRIVE_BASE = "https://www.googleapis.com/drive/v3"
_GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1" _GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1"
_CALENDAR_BASE = "https://www.googleapis.com/calendar/v3"
_PEOPLE_BASE = "https://people.googleapis.com/v1"
async def _googleGet(token: str, url: str) -> Dict[str, Any]: async def _googleGet(token: str, url: str) -> Dict[str, Any]:
@ -274,12 +276,480 @@ class GmailAdapter(ServiceAdapter):
] ]
class CalendarAdapter(ServiceAdapter):
"""Google Calendar ServiceAdapter -- browse calendars, list events, .ics download.
Path conventions:
``""`` / ``"/"`` -> list calendars from ``calendarList``
``"/<calendarId>"`` -> list upcoming events in that calendar
``"/<calendarId>/<eventId>"`` -> reserved for future event detail browse
"""
_DEFAULT_EVENT_LIMIT = 100
_MAX_EVENT_LIMIT = 2500
def __init__(self, accessToken: str):
self._token = accessToken
async def browse(
self,
path: str,
filter: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
cleanPath = (path or "").strip("/")
if not cleanPath:
url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250"
result = await _googleGet(self._token, url)
if "error" in result:
logger.warning(f"Google Calendar list failed: {result['error']}")
return []
calendars = result.get("items", [])
if filter:
f = filter.lower()
calendars = [c for c in calendars if f in (c.get("summary") or "").lower()]
return [
ExternalEntry(
name=c.get("summaryOverride") or c.get("summary", ""),
path=f"/{c.get('id', '')}",
isFolder=True,
metadata={
"id": c.get("id"),
"primary": c.get("primary", False),
"accessRole": c.get("accessRole"),
"backgroundColor": c.get("backgroundColor"),
"timeZone": c.get("timeZone"),
},
)
for c in calendars
]
from urllib.parse import quote
calendarId = cleanPath.split("/", 1)[0]
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
url = (
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
f"?maxResults={effectiveLimit}&orderBy=startTime&singleEvents=true"
)
result = await _googleGet(self._token, url)
if "error" in result:
logger.warning(f"Google Calendar events failed: {result['error']}")
return []
events = result.get("items", [])
return [
ExternalEntry(
name=ev.get("summary", "(no title)"),
path=f"/{calendarId}/{ev.get('id', '')}",
isFolder=False,
mimeType="text/calendar",
metadata={
"id": ev.get("id"),
"start": (ev.get("start") or {}).get("dateTime") or (ev.get("start") or {}).get("date"),
"end": (ev.get("end") or {}).get("dateTime") or (ev.get("end") or {}).get("date"),
"location": ev.get("location"),
"organizer": (ev.get("organizer") or {}).get("email"),
"htmlLink": ev.get("htmlLink"),
"status": ev.get("status"),
},
)
for ev in events
]
async def download(self, path: str) -> DownloadResult:
from urllib.parse import quote
cleanPath = (path or "").strip("/")
if "/" not in cleanPath:
return DownloadResult()
calendarId, eventId = cleanPath.split("/", 1)
url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}"
ev = await _googleGet(self._token, url)
if "error" in ev:
logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
return DownloadResult()
icsBytes = _googleEventToIcs(ev)
summary = ev.get("summary") or eventId
safeName = _googleSafeFileName(summary) or "event"
return DownloadResult(
data=icsBytes,
fileName=f"{safeName}.ics",
mimeType="text/calendar",
)
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
return {"error": "Google Calendar upload not supported"}
async def search(
self,
query: str,
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
from urllib.parse import quote
calendarId = (path or "").strip("/").split("/", 1)[0] or "primary"
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
url = (
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
)
result = await _googleGet(self._token, url)
if "error" in result:
return []
return [
ExternalEntry(
name=ev.get("summary", "(no title)"),
path=f"/{calendarId}/{ev.get('id', '')}",
isFolder=False,
mimeType="text/calendar",
metadata={
"id": ev.get("id"),
"start": (ev.get("start") or {}).get("dateTime") or (ev.get("start") or {}).get("date"),
"end": (ev.get("end") or {}).get("dateTime") or (ev.get("end") or {}).get("date"),
},
)
for ev in result.get("items", [])
]
class ContactsAdapter(ServiceAdapter):
"""Google Contacts ServiceAdapter -- People API (read-only).
Path conventions:
``""`` / ``"/"`` -> list contact groups (incl. virtual ``all`` for the user's connections)
``"/all"`` -> list all ``people/me/connections``
``"/<groupResourceName>"`` -> list members of that contact group (e.g. ``contactGroups/myFriends``)
``"/<group>/<personId>"`` -> reserved for future detail browse;
``personId`` is the suffix after ``people/``
"""
_DEFAULT_CONTACT_LIMIT = 200
_MAX_CONTACT_LIMIT = 1000
_PERSON_FIELDS = (
"names,emailAddresses,phoneNumbers,organizations,addresses,biographies,memberships"
)
def __init__(self, accessToken: str):
self._token = accessToken
async def browse(
self,
path: str,
filter: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
cleanPath = (path or "").strip("/")
if not cleanPath:
entries: List[ExternalEntry] = [
ExternalEntry(
name="Alle Kontakte",
path="/all",
isFolder=True,
metadata={"id": "all", "isVirtual": True},
),
]
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
result = await _googleGet(self._token, url)
if "error" not in result:
for grp in result.get("contactGroups", []):
name = grp.get("formattedName") or grp.get("name") or ""
if not name:
continue
entries.append(
ExternalEntry(
name=name,
path=f"/{grp.get('resourceName', '')}",
isFolder=True,
metadata={
"id": grp.get("resourceName"),
"memberCount": grp.get("memberCount", 0),
"groupType": grp.get("groupType"),
},
)
)
else:
logger.warning(f"Google contactGroups list failed: {result['error']}")
return entries
from urllib.parse import quote
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
groupRef = cleanPath.split("/", 1)[0]
if groupRef == "all":
url = (
f"{_PEOPLE_BASE}/people/me/connections"
f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}"
)
result = await _googleGet(self._token, url)
if "error" in result:
logger.warning(f"Google People connections failed: {result['error']}")
return []
people = result.get("connections", [])
else:
groupResource = groupRef
grpUrl = (
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
f"?maxMembers={min(effectiveLimit, 1000)}"
)
grpResult = await _googleGet(self._token, grpUrl)
if "error" in grpResult:
logger.warning(f"Google contactGroup detail failed: {grpResult['error']}")
return []
memberResourceNames = grpResult.get("memberResourceNames") or []
if not memberResourceNames:
return []
chunkSize = 200
people: List[Dict[str, Any]] = []
for i in range(0, min(len(memberResourceNames), effectiveLimit), chunkSize):
chunk = memberResourceNames[i : i + chunkSize]
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}"
batchResult = await _googleGet(self._token, batchUrl)
if "error" in batchResult:
logger.warning(f"Google People batchGet failed: {batchResult['error']}")
continue
for resp in batchResult.get("responses", []):
person = resp.get("person")
if person:
people.append(person)
if len(people) >= effectiveLimit:
break
return [
ExternalEntry(
name=_googlePersonLabel(p) or "(no name)",
path=f"/{groupRef}/{(p.get('resourceName', '') or '').split('/')[-1]}",
isFolder=False,
mimeType="text/vcard",
metadata={
"id": p.get("resourceName"),
"emails": [e.get("value") for e in (p.get("emailAddresses") or []) if e.get("value")],
"phones": [pn.get("value") for pn in (p.get("phoneNumbers") or []) if pn.get("value")],
"organization": (p.get("organizations") or [{}])[0].get("name") if p.get("organizations") else None,
},
)
for p in people[:effectiveLimit]
]
async def download(self, path: str) -> DownloadResult:
from urllib.parse import quote
cleanPath = (path or "").strip("/")
if "/" not in cleanPath:
return DownloadResult()
personSuffix = cleanPath.split("/")[-1]
if not personSuffix:
return DownloadResult()
url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}"
person = await _googleGet(self._token, url)
if "error" in person:
logger.warning(f"Google People fetch failed: {person['error']}")
return DownloadResult()
vcfBytes = _googlePersonToVcard(person)
label = _googlePersonLabel(person) or personSuffix
safeName = _googleSafeFileName(label) or "contact"
return DownloadResult(
data=vcfBytes,
fileName=f"{safeName}.vcf",
mimeType="text/vcard",
)
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
return {"error": "Google Contacts upload not supported"}
async def search(
self,
query: str,
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
from urllib.parse import quote
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
url = (
f"{_PEOPLE_BASE}/people:searchContacts"
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
f"&readMask={self._PERSON_FIELDS}"
)
result = await _googleGet(self._token, url)
if "error" in result:
return []
entries: List[ExternalEntry] = []
for r in result.get("results", []):
p = r.get("person") or {}
entries.append(
ExternalEntry(
name=_googlePersonLabel(p) or "(no name)",
path=f"/search/{(p.get('resourceName', '') or '').split('/')[-1]}",
isFolder=False,
mimeType="text/vcard",
metadata={
"id": p.get("resourceName"),
"emails": [e.get("value") for e in (p.get("emailAddresses") or []) if e.get("value")],
},
)
)
return entries
def _googleSafeFileName(name: str) -> str:
import re
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
def _googleIcsEscape(value: str) -> str:
if value is None:
return ""
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
)
def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
if not value:
return None
from datetime import datetime, timezone
try:
if "T" not in value:
dt = datetime.strptime(value, "%Y-%m-%d")
return dt.strftime("%Y%m%d")
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
dt = datetime.fromisoformat(normalized)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
except (TypeError, ValueError):
return None
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event."""
from datetime import datetime, timezone
uid = event.get("iCalUID") or event.get("id") or "unknown@poweron"
summary = _googleIcsEscape(event.get("summary") or "")
location = _googleIcsEscape(event.get("location") or "")
description = _googleIcsEscape(event.get("description") or "")
rawStart = (event.get("start") or {}).get("dateTime") or (event.get("start") or {}).get("date")
rawEnd = (event.get("end") or {}).get("dateTime") or (event.get("end") or {}).get("date")
isAllDay = bool((event.get("start") or {}).get("date") and not (event.get("start") or {}).get("dateTime"))
dtstart = _googleIcsDateTime(rawStart)
dtend = _googleIcsDateTime(rawEnd)
dtstamp = _googleIcsDateTime(event.get("updated")) or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//PowerOn//Google-Calendar-Adapter//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{dtstamp}",
]
if dtstart:
lines.append(f"DTSTART;VALUE=DATE:{dtstart}" if isAllDay else f"DTSTART:{dtstart}")
if dtend:
lines.append(f"DTEND;VALUE=DATE:{dtend}" if isAllDay else f"DTEND:{dtend}")
if summary:
lines.append(f"SUMMARY:{summary}")
if location:
lines.append(f"LOCATION:{location}")
if description:
lines.append(f"DESCRIPTION:{description}")
organizer = (event.get("organizer") or {}).get("email")
if organizer:
lines.append(f"ORGANIZER:mailto:{organizer}")
for att in (event.get("attendees") or []):
addr = att.get("email")
if addr:
lines.append(f"ATTENDEE:mailto:{addr}")
lines.append("END:VEVENT")
lines.append("END:VCALENDAR")
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
def _googlePersonLabel(person: Dict[str, Any]) -> str:
names = person.get("names") or []
if names:
primary = names[0]
display = primary.get("displayName") or ""
if display:
return display
given = primary.get("givenName") or ""
family = primary.get("familyName") or ""
full = f"{given} {family}".strip()
if full:
return full
orgs = person.get("organizations") or []
if orgs and orgs[0].get("name"):
return orgs[0]["name"]
emails = person.get("emailAddresses") or []
if emails and emails[0].get("value"):
return emails[0]["value"]
return ""
def _googlePersonToVcard(person: Dict[str, Any]) -> bytes:
"""Build a vCard 3.0 from a Google People API person payload."""
names = person.get("names") or []
primaryName = names[0] if names else {}
given = primaryName.get("givenName") or ""
family = primaryName.get("familyName") or ""
middle = primaryName.get("middleName") or ""
fn = primaryName.get("displayName") or _googlePersonLabel(person) or ""
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
f"N:{family};{given};{middle};;",
f"FN:{fn}",
]
orgs = person.get("organizations") or []
if orgs:
org = orgs[0]
orgVal = org.get("name") or ""
if org.get("department"):
orgVal = f"{orgVal};{org['department']}"
if orgVal:
lines.append(f"ORG:{orgVal}")
if org.get("title"):
lines.append(f"TITLE:{org['title']}")
for em in (person.get("emailAddresses") or []):
addr = em.get("value")
if not addr:
continue
emailType = (em.get("type") or "INTERNET").upper()
lines.append(f"EMAIL;TYPE={emailType}:{addr}")
for ph in (person.get("phoneNumbers") or []):
val = ph.get("value")
if not val:
continue
phType = (ph.get("type") or "VOICE").upper()
lines.append(f"TEL;TYPE={phType}:{val}")
for addr in (person.get("addresses") or []):
street = addr.get("streetAddress") or ""
city = addr.get("city") or ""
region = addr.get("region") or ""
postal = addr.get("postalCode") or ""
country = addr.get("country") or ""
if any([street, city, region, postal, country]):
adrType = (addr.get("type") or "OTHER").upper()
lines.append(f"ADR;TYPE={adrType}:;;{street};{city};{region};{postal};{country}")
bios = person.get("biographies") or []
if bios and bios[0].get("value"):
lines.append(f"NOTE:{_googleIcsEscape(bios[0]['value'])}")
lines.append(f"UID:{person.get('resourceName', '')}")
lines.append("END:VCARD")
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
class GoogleConnector(ProviderConnector): class GoogleConnector(ProviderConnector):
"""Google ProviderConnector -- 1 connection -> Drive + Gmail.""" """Google ProviderConnector -- 1 connection -> Drive + Gmail + Calendar + Contacts."""
_SERVICE_MAP = { _SERVICE_MAP = {
"drive": DriveAdapter, "drive": DriveAdapter,
"gmail": GmailAdapter, "gmail": GmailAdapter,
"calendar": CalendarAdapter,
"contact": ContactsAdapter,
} }
def getAvailableServices(self) -> List[str]: def getAvailableServices(self) -> List[str]:

View file

@ -1,24 +1,41 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Infomaniak ProviderConnector -- kDrive and Mail via Infomaniak OAuth. """Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
All ServiceAdapters share the same OAuth access token obtained from the The PAT carries one or more of these scopes:
UserConnection (authority=infomaniak).
Path conventions (leading slash): - ``drive`` -> kDrive (active here)
kDrive: - ``workspace:calendar`` -> Calendar (active here)
/ -- list drives the user has access to - ``workspace:contact`` -> Contacts (active here)
/{driveId} -- root folder of a drive (children) - ``workspace:mail`` -> Mail (no public PAT-friendly endpoint yet)
Mail is intentionally NOT in ``_SERVICE_MAP`` until we find a
PAT-authenticated endpoint -- the public ``/1/mail`` and
``mail.infomaniak.com/api/pim/mail*`` routes either don't exist (404
nginx) or 302 to OAuth, so wiring a stub adapter would only confuse
users.
Path conventions (leading slash, ``ServiceAdapter`` paths always start with
``/``):
kDrive (api.infomaniak.com, requires ``account_id`` query arg):
/ -- list drives in the user's account
/{driveId} -- root folder of a drive
/{driveId}/{fileId} -- folder children OR file (download) /{driveId}/{fileId} -- folder children OR file (download)
Mail: Calendar (calendar.infomaniak.com PIM):
/ -- list user's mailboxes / -- list calendars accessible to the user
/{mailboxId} -- folders in mailbox /{calendarId} -- events of one calendar
/{mailboxId}/{folderId} -- messages in folder /{calendarId}/{eventId} -- single event (.ics download)
/{mailboxId}/{folderId}/{uid} -- single message (download as .eml) Contacts (contacts.infomaniak.com PIM):
/ -- list address books
/{addressBookId} -- contacts in that address book
/{addressBookId}/{contactId} -- single contact (.vcf download)
""" """
import logging import logging
from typing import Any, Dict, List, Optional import re
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, TypedDict
from urllib.parse import quote
import aiohttp import aiohttp
@ -32,39 +49,74 @@ from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_API_BASE = "https://api.infomaniak.com" _API_BASE = "https://api.infomaniak.com"
_CALENDAR_BASE = "https://calendar.infomaniak.com"
_CONTACTS_BASE = "https://contacts.infomaniak.com"
_PIM_PREFIX = "/api/pim"
async def _infomaniakGet(token: str, endpoint: str) -> Dict[str, Any]: class InfomaniakOwnerIdentity(TypedDict):
"""Single GET call against the Infomaniak API. Returns parsed JSON or {'error': ...}.""" """Minimal identity payload for the PAT owner.
url = f"{_API_BASE}/{endpoint.lstrip('/')}"
``accountId`` is the only field the kDrive adapter needs at runtime.
``displayName`` is harvested for the connection UI; both fields come
from the same PIM Owner record.
"""
accountId: int
displayName: Optional[str]
class InfomaniakIdentityError(RuntimeError):
"""Raised when no owner identity can be derived from a PAT."""
async def _infomaniakGet(
token: str,
endpoint: str,
baseUrl: str = _API_BASE,
) -> Dict[str, Any]:
"""Single GET against an Infomaniak host.
``endpoint`` is appended to ``baseUrl`` (handles leading slash). Returns
parsed JSON, or ``{'error': ...}`` for non-2xx / network failures.
"""
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
timeout = aiohttp.ClientTimeout(total=20) timeout = aiohttp.ClientTimeout(total=20)
try: try:
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as resp: async with session.get(url, headers=headers, allow_redirects=False) as resp:
if resp.status in (200, 201): if resp.status in (200, 201):
return await resp.json() return await resp.json()
errorText = await resp.text() errorText = await resp.text()
logger.warning(f"Infomaniak API {resp.status}: {errorText[:300]}") logger.warning(f"Infomaniak GET {url} -> {resp.status}: {errorText[:300]}")
return {"error": f"{resp.status}: {errorText[:200]}"} return {"error": f"{resp.status}: {errorText[:200]}"}
except Exception as e: except Exception as e:
logger.error(f"Infomaniak GET {url} crashed: {e}")
return {"error": str(e)} return {"error": str(e)}
async def _infomaniakDownload(token: str, endpoint: str) -> Optional[bytes]: async def _infomaniakDownload(
"""Binary download from the Infomaniak API. Returns bytes or None on error.""" token: str,
url = f"{_API_BASE}/{endpoint.lstrip('/')}" endpoint: str,
baseUrl: str = _API_BASE,
) -> Optional[bytes]:
"""Binary download from an Infomaniak host. Returns bytes or ``None``."""
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
timeout = aiohttp.ClientTimeout(total=120) timeout = aiohttp.ClientTimeout(total=120)
try: try:
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as resp: async with session.get(url, headers=headers, allow_redirects=False) as resp:
if resp.status == 200: if resp.status == 200:
return await resp.read() return await resp.read()
logger.warning(f"Infomaniak download {resp.status}: {(await resp.text())[:300]}") logger.warning(
f"Infomaniak download {url} -> {resp.status}: "
f"{(await resp.text())[:300]}"
)
return None return None
except Exception as e: except Exception as e:
logger.error(f"Infomaniak download error: {e}") logger.error(f"Infomaniak download {url} crashed: {e}")
return None return None
@ -75,11 +127,136 @@ def _unwrapData(payload: Any) -> Any:
return payload return payload
def _firstOwnerRecord(payload: Any, listKey: str) -> Optional[Dict[str, Any]]:
"""Pick the first user-owned record from a PIM list response.
Both PIM Calendar (``calendars``) and PIM Contacts (``addressbooks``)
return ``{result, data: {<listKey>: [...]}}``. Owner-records have a
positive numeric ``user_id`` and an integer ``account_id``; shared /
public records (e.g. holiday calendars) carry ``user_id = -1`` and
``account_id = null`` and are skipped.
"""
data = _unwrapData(payload) if payload else None
if not isinstance(data, dict):
return None
records = data.get(listKey)
if not isinstance(records, list):
return None
for rec in records:
if not isinstance(rec, dict):
continue
userId = rec.get("user_id")
accountId = rec.get("account_id")
if isinstance(userId, int) and userId > 0 and isinstance(accountId, int):
return rec
return None
async def resolveOwnerIdentity(token: str) -> InfomaniakOwnerIdentity:
"""Derive the PAT owner's display identity from PIM Calendar / Contacts.
Used purely for UI display on the connection (``externalUsername`` /
``externalId``). The PIM endpoints embed the kSuite ``account_id``
and the user's display name in their owner records, which is what
the ConnectionsPage shows.
Calendar is queried first because it is the more universally
provisioned PIM service; Contacts is the equivalent fallback.
Raises :class:`InfomaniakIdentityError` when neither yields an
owner record.
"""
sources = (
(_CALENDAR_BASE, f"{_PIM_PREFIX}/calendar", "calendars"),
(_CONTACTS_BASE, f"{_PIM_PREFIX}/addressbook", "addressbooks"),
)
for baseUrl, endpoint, listKey in sources:
payload = await _infomaniakGet(token, endpoint, baseUrl=baseUrl)
if isinstance(payload, dict) and payload.get("error"):
continue
owner = _firstOwnerRecord(payload, listKey)
if owner is None:
continue
return InfomaniakOwnerIdentity(
accountId=int(owner["account_id"]),
displayName=owner.get("name") or None,
)
raise InfomaniakIdentityError(
"Could not resolve Infomaniak owner identity from PIM Calendar or "
"Contacts. The PAT must carry 'workspace:calendar' or "
"'workspace:contact' so we can label the connection."
)
async def resolveAccessibleAccountIds(token: str) -> List[int]:
"""Return every Infomaniak account_id the PAT has access to.
Hits ``GET /1/accounts`` -- the only Infomaniak endpoint that lists
*all* account_ids of a token in one call. Requires the PAT scope
``accounts`` (Infomaniak responds 403 with
``code: 'all_scopes', context: {scopes: ['accounts']}`` if missing).
The kSuite account_id from PIM (``resolveOwnerIdentity``) is **not**
sufficient for kDrive: a standalone or free-tier kDrive lives on a
different account_id than its kSuite counterpart. ``/2/drive`` is
queried per account_id, so we resolve them all here and union the
drive listings in :class:`KdriveAdapter`.
Raises :class:`InfomaniakIdentityError` when the PAT does not carry
the ``accounts`` scope or the response is malformed.
"""
payload = await _infomaniakGet(token, "/1/accounts")
if isinstance(payload, dict) and payload.get("error"):
raise InfomaniakIdentityError(
"Could not list Infomaniak accounts. The PAT must carry the "
"'accounts' scope so kDrive can discover the owning account "
f"(/1/accounts said: {payload['error']})."
)
data = _unwrapData(payload)
if not isinstance(data, list):
raise InfomaniakIdentityError(
"Unexpected /1/accounts response shape (expected a list)."
)
accountIds: List[int] = []
for entry in data:
if not isinstance(entry, dict):
continue
accountId = entry.get("id")
if isinstance(accountId, int):
accountIds.append(accountId)
if not accountIds:
raise InfomaniakIdentityError(
"/1/accounts returned no accounts -- the PAT cannot reach any "
"Infomaniak account."
)
return accountIds
class KdriveAdapter(ServiceAdapter): class KdriveAdapter(ServiceAdapter):
"""kDrive ServiceAdapter -- browse drives, folders, and files.""" """kDrive ServiceAdapter -- browse drives, folders, files within all
accounts the PAT can reach.
Infomaniak's ``/2/drive`` listing endpoint requires the integer
``account_id`` of the *drive-owning* account as a query arg. A user
may own kDrives in several accounts (typically a kSuite account
plus a standalone / free-tier kDrive account), and the kSuite
account_id from PIM does **not** cover the standalone case.
The only PAT-friendly way to enumerate every account_id is
:func:`resolveAccessibleAccountIds` (``GET /1/accounts`` with the
``accounts`` scope). This adapter therefore resolves the full
account list once per instance and unions the ``/2/drive`` listing
across all of them in :meth:`_listDrives`.
"""
def __init__(self, accessToken: str): def __init__(self, accessToken: str):
self._token = accessToken self._token = accessToken
self._accountIds: Optional[List[int]] = None
async def _ensureAccountIds(self) -> List[int]:
if self._accountIds is not None:
return self._accountIds
self._accountIds = await resolveAccessibleAccountIds(self._token)
return self._accountIds
async def browse( async def browse(
self, self,
@ -101,25 +278,40 @@ class KdriveAdapter(ServiceAdapter):
return await self._listChildren(driveId, fileId=fileId, limit=limit) return await self._listChildren(driveId, fileId=fileId, limit=limit)
async def _listDrives(self) -> List[ExternalEntry]: async def _listDrives(self) -> List[ExternalEntry]:
result = await _infomaniakGet(self._token, "/2/drive") accountIds = await self._ensureAccountIds()
if isinstance(result, dict) and result.get("error"): seen: set = set()
logger.warning(f"kDrive list-drives failed: {result['error']}")
return []
data = _unwrapData(result)
drives = data.get("drives", {}).get("accounts", []) if isinstance(data, dict) else []
if not drives and isinstance(data, list):
drives = data
entries: List[ExternalEntry] = [] entries: List[ExternalEntry] = []
for accountId in accountIds:
result = await _infomaniakGet(
self._token, f"/2/drive?account_id={accountId}"
)
if isinstance(result, dict) and result.get("error"):
logger.warning(
f"kDrive list-drives for account {accountId} failed: {result['error']}"
)
continue
data = _unwrapData(result)
drives: List[Dict[str, Any]]
if isinstance(data, list):
drives = [d for d in data if isinstance(d, dict)]
elif isinstance(data, dict):
drives = data.get("drives", {}).get("accounts", []) or []
else:
drives = []
for drive in drives: for drive in drives:
driveId = str(drive.get("id", "")) driveId = str(drive.get("id", ""))
if not driveId: if not driveId or driveId in seen:
continue continue
name = drive.get("name") or driveId seen.add(driveId)
entries.append(ExternalEntry( entries.append(ExternalEntry(
name=name, name=drive.get("name") or driveId,
path=f"/{driveId}", path=f"/{driveId}",
isFolder=True, isFolder=True,
metadata={"id": driveId, "kind": "drive"}, metadata={
"id": driveId,
"kind": "drive",
"accountId": accountId,
},
)) ))
return entries return entries
@ -129,9 +321,6 @@ class KdriveAdapter(ServiceAdapter):
fileId: Optional[str], fileId: Optional[str],
limit: Optional[int], limit: Optional[int],
) -> List[ExternalEntry]: ) -> List[ExternalEntry]:
# Infomaniak treats every folder (including drive root) as a file-id.
# When fileId is None, we ask the drive for root children via the
# documented `/files` collection endpoint.
if fileId is None: if fileId is None:
endpoint = f"/2/drive/{driveId}/files" endpoint = f"/2/drive/{driveId}/files"
else: else:
@ -142,7 +331,9 @@ class KdriveAdapter(ServiceAdapter):
result = await _infomaniakGet(self._token, endpoint) result = await _infomaniakGet(self._token, endpoint)
if isinstance(result, dict) and result.get("error"): if isinstance(result, dict) and result.get("error"):
logger.warning(f"kDrive list-children {driveId}/{fileId or 'root'} failed: {result['error']}") logger.warning(
f"kDrive list-children {driveId}/{fileId or 'root'} failed: {result['error']}"
)
return [] return []
data = _unwrapData(result) data = _unwrapData(result)
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else [] items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
@ -179,7 +370,9 @@ class KdriveAdapter(ServiceAdapter):
fileName = data.get("name") or fileId fileName = data.get("name") or fileId
mimeType = data.get("mime_type") or mimeType mimeType = data.get("mime_type") or mimeType
content = await _infomaniakDownload(self._token, f"/2/drive/{driveId}/files/{fileId}/download") content = await _infomaniakDownload(
self._token, f"/2/drive/{driveId}/files/{fileId}/download"
)
if content is None: if content is None:
return DownloadResult() return DownloadResult()
return DownloadResult(data=content, fileName=fileName, mimeType=mimeType) return DownloadResult(data=content, fileName=fileName, mimeType=mimeType)
@ -227,11 +420,40 @@ class KdriveAdapter(ServiceAdapter):
return entries return entries
class MailAdapter(ServiceAdapter): def _safeFileName(label: str, fallback: str) -> str:
"""Infomaniak Mail ServiceAdapter -- browse mailboxes, folders and messages.""" """Sanitize a string for use as a filename. Trims and caps at 80 chars."""
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", str(label or "")).strip(". ")
return cleaned[:80] or fallback
_DEFAULT_MESSAGE_LIMIT = 100
_MAX_MESSAGE_LIMIT = 500 class CalendarAdapter(ServiceAdapter):
"""Infomaniak Calendar adapter -- browse calendars + events, .ics download.
Uses the public PIM endpoints at ``calendar.infomaniak.com/api/pim``,
which authenticate with the PAT scope ``workspace:calendar``.
Path layout:
``/`` -> list calendars
``/{calendarId}`` -> list events of that calendar
``/{calendarId}/{eventId}`` -> single event (download as .ics)
Endpoint particulars:
Listing events runs against ``/api/pim/event`` with the calendar
id as a query arg (the per-calendar nested route
``/calendar/{id}/event`` is **not** PAT-friendly -- it 302s to the
OAuth login page). Infomaniak enforces a hard ``from``/``to``
window of less than 3 months, so this adapter queries a fixed
90-day window centered on today (30 days back, 60 days forward),
which covers typical UDB browsing. Date format is ``Y-m-d H:i:s``.
Event detail and ``.ics`` export are addressed by event id alone
(``/api/pim/event/{eventId}`` and ``.../export``); the calendar
id from the path is kept only for tree-navigation continuity.
"""
# Vendor enforces ``Range must be lower than 3 months``. We stay
# comfortably below to keep one call per browse.
_PAST_DAYS = 30
_FUTURE_DAYS = 60
def __init__(self, accessToken: str): def __init__(self, accessToken: str):
self._token = accessToken self._token = accessToken
@ -242,169 +464,461 @@ class MailAdapter(ServiceAdapter):
filter: Optional[str] = None, filter: Optional[str] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> List[ExternalEntry]: ) -> List[ExternalEntry]:
cleanPath = (path or "").strip("/") segments = [s for s in (path or "").strip("/").split("/") if s]
segments = [s for s in cleanPath.split("/") if s]
if not segments: if not segments:
return await self._listMailboxes() return await self._listCalendars()
if len(segments) == 1: if len(segments) == 1:
return await self._listFolders(segments[0]) return await self._listEvents(segments[0], limit=limit)
if len(segments) == 2:
return await self._listMessages(segments[0], segments[1], limit=limit)
return [] return []
async def _listMailboxes(self) -> List[ExternalEntry]: async def _listCalendars(self) -> List[ExternalEntry]:
result = await _infomaniakGet(self._token, "/1/mail") result = await _infomaniakGet(
if isinstance(result, dict) and result.get("error"): self._token, f"{_PIM_PREFIX}/calendar", baseUrl=_CALENDAR_BASE
logger.warning(f"Mail list-mailboxes failed: {result['error']}")
return []
data = _unwrapData(result)
mailboxes = data if isinstance(data, list) else data.get("mailboxes", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = []
for mb in mailboxes:
mbId = str(mb.get("id") or mb.get("mailbox_id") or "")
if not mbId:
continue
entries.append(ExternalEntry(
name=mb.get("email") or mb.get("name") or mbId,
path=f"/{mbId}",
isFolder=True,
metadata={"id": mbId, "kind": "mailbox"},
))
return entries
async def _listFolders(self, mailboxId: str) -> List[ExternalEntry]:
result = await _infomaniakGet(self._token, f"/1/mail/{mailboxId}/folder")
if isinstance(result, dict) and result.get("error"):
logger.warning(f"Mail list-folders {mailboxId} failed: {result['error']}")
return []
data = _unwrapData(result)
folders = data if isinstance(data, list) else data.get("folders", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = []
for f in folders:
folderId = str(f.get("id") or f.get("path") or "")
if not folderId:
continue
entries.append(ExternalEntry(
name=f.get("name") or folderId,
path=f"/{mailboxId}/{folderId}",
isFolder=True,
metadata={"id": folderId, "kind": "folder"},
))
return entries
async def _listMessages(
self,
mailboxId: str,
folderId: str,
limit: Optional[int],
) -> List[ExternalEntry]:
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(
1, min(int(limit), self._MAX_MESSAGE_LIMIT),
) )
endpoint = f"/1/mail/{mailboxId}/folder/{folderId}/message?per_page={effectiveLimit}"
result = await _infomaniakGet(self._token, endpoint)
if isinstance(result, dict) and result.get("error"): if isinstance(result, dict) and result.get("error"):
logger.warning(f"Calendar list-calendars failed: {result['error']}")
return [] return []
data = _unwrapData(result) data = _unwrapData(result)
messages = data if isinstance(data, list) else data.get("messages", []) if isinstance(data, dict) else [] calendars = data.get("calendars", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = [] entries: List[ExternalEntry] = []
for msg in messages: for cal in calendars:
uid = str(msg.get("uid") or msg.get("id") or "") calId = str(cal.get("id", ""))
if not uid: if not calId:
continue continue
subject = msg.get("subject") or "(no subject)" isShared = (cal.get("user_id") or 0) <= 0 or cal.get("account_id") is None
entries.append(ExternalEntry( entries.append(ExternalEntry(
name=subject, name=cal.get("name") or calId,
path=f"/{mailboxId}/{folderId}/{uid}", path=f"/{calId}",
isFolder=False, isFolder=True,
lastModified=msg.get("date") or msg.get("internal_date"),
metadata={ metadata={
"uid": uid, "id": calId,
"from": msg.get("from") or msg.get("sender", ""), "kind": "calendar",
"snippet": msg.get("preview", ""), "color": cal.get("color"),
"shared": isShared,
"default": bool(cal.get("default")),
}, },
)) ))
return entries return entries
def _eventWindow(self) -> tuple:
now = datetime.now(timezone.utc)
fromStr = (now - timedelta(days=self._PAST_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
toStr = (now + timedelta(days=self._FUTURE_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
return fromStr, toStr
async def _listEvents(
self,
calendarId: str,
limit: Optional[int],
) -> List[ExternalEntry]:
fromStr, toStr = self._eventWindow()
endpoint = (
f"{_PIM_PREFIX}/event"
f"?calendar_id={calendarId}"
f"&from={quote(fromStr)}"
f"&to={quote(toStr)}"
)
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CALENDAR_BASE)
if isinstance(result, dict) and result.get("error"):
logger.warning(f"Calendar list-events {calendarId} failed: {result['error']}")
return []
data = _unwrapData(result)
events = data if isinstance(data, list) else data.get("events", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = []
for ev in events:
evId = str(ev.get("id") or ev.get("uid") or "")
if not evId:
continue
title = ev.get("title") or ev.get("summary") or "(no title)"
entries.append(ExternalEntry(
name=title,
path=f"/{calendarId}/{evId}",
isFolder=False,
metadata={
"id": evId,
"kind": "event",
"start": ev.get("start"),
"end": ev.get("end"),
"location": ev.get("location"),
"updated": ev.get("updated_at"),
},
))
if limit is not None:
return entries[: int(limit)]
return entries
async def download(self, path: str) -> DownloadResult: async def download(self, path: str) -> DownloadResult:
import re
segments = [s for s in (path or "").strip("/").split("/") if s] segments = [s for s in (path or "").strip("/").split("/") if s]
if len(segments) < 3: if len(segments) < 2:
return DownloadResult() return DownloadResult()
mailboxId, folderId, uid = segments[0], segments[1], segments[2] eventId = segments[1]
content = await _infomaniakDownload( content = await _infomaniakDownload(
self._token, f"/1/mail/{mailboxId}/folder/{folderId}/message/{uid}/download", self._token,
f"{_PIM_PREFIX}/event/{eventId}/export",
baseUrl=_CALENDAR_BASE,
) )
if content is None: if content is None:
return DownloadResult() return DownloadResult()
title = eventId
meta = await _infomaniakGet( meta = await _infomaniakGet(
self._token, f"/1/mail/{mailboxId}/folder/{folderId}/message/{uid}", self._token,
f"{_PIM_PREFIX}/event/{eventId}",
baseUrl=_CALENDAR_BASE,
) )
subject = uid
if isinstance(meta, dict) and not meta.get("error"): if isinstance(meta, dict) and not meta.get("error"):
unwrapped = _unwrapData(meta) unwrapped = _unwrapData(meta)
if isinstance(unwrapped, dict): if isinstance(unwrapped, dict):
subject = unwrapped.get("subject") or uid event = unwrapped.get("event") if "event" in unwrapped else unwrapped
safeName = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", subject)[:80].strip(". ") or "email" if isinstance(event, dict):
title = event.get("title") or event.get("summary") or eventId
return DownloadResult( return DownloadResult(
data=content, data=content,
fileName=f"{safeName}.eml", fileName=f"{_safeFileName(title, 'event')}.ics",
mimeType="message/rfc822", mimeType="text/calendar",
) )
async def upload(self, path: str, data: bytes, fileName: str) -> dict: async def upload(self, path: str, data: bytes, fileName: str) -> dict:
return {"error": "Mail upload not applicable"} return {"error": "Calendar upload not yet implemented"}
async def search( async def search(
self, self,
query: str, query: str,
path: Optional[str] = None, path: Optional[str] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> List[ExternalEntry]:
# The PIM Calendar API has no public search endpoint we can rely on.
# Cheap fallback: list events in the current calendar (or all of
# them) within the default window and filter case-insensitively on
# title/location.
calendars = (
await self._listCalendars()
if not path
else [ExternalEntry(name="", path=path, isFolder=True)]
)
if not calendars:
return []
needle = (query or "").strip().lower()
results: List[ExternalEntry] = []
for cal in calendars:
calId = (cal.metadata or {}).get("id") or cal.path.strip("/")
for ev in await self._listEvents(calId, limit=limit):
hay = " ".join(
str(v) for v in (
ev.name,
(ev.metadata or {}).get("location") or "",
)
).lower()
if not needle or needle in hay:
results.append(ev)
if limit is not None and len(results) >= int(limit):
break
return results[: int(limit)] if limit is not None else results
def _vcardEscape(value: Any) -> str:
"""Escape a value for vCard 3.0 -- backslash, comma, semicolon, newline."""
text = "" if value is None else str(value)
return (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
)
def _renderInfomaniakVcard(record: Dict[str, Any]) -> str:
"""Render an Infomaniak contact record as a vCard 3.0 string.
The Contacts PIM ``/contact/{id}/export`` endpoint is not PAT-friendly
(302s to the OAuth login page), and ``/contact/{id}`` returns 500 with
a PAT, so we cannot retrieve the canonical .vcf or detail blob from
Infomaniak. Instead we synthesize a vCard 3.0 payload from the
listing record fetched with ``with=emails,phones,addresses,details``.
vCard 3.0 is the common-denominator format universally accepted by
Outlook, Google Contacts, Apple Contacts and Thunderbird (4.0 still
has poor Outlook import compatibility).
"""
firstname = record.get("firstname") or ""
lastname = record.get("lastname") or ""
fullName = (
record.get("name")
or " ".join(p for p in (firstname, lastname) if p).strip()
or "Contact"
)
organization = record.get("organization") or ""
note = record.get("note") or ""
emails = record.get("emails") or []
phones = record.get("phones") or []
addresses = record.get("addresses") or []
websites = record.get("websites") or []
lines = ["BEGIN:VCARD", "VERSION:3.0"]
# N: Last;First;Middle;Prefix;Suffix
lines.append(f"N:{_vcardEscape(lastname)};{_vcardEscape(firstname)};;;")
lines.append(f"FN:{_vcardEscape(fullName)}")
if organization:
lines.append(f"ORG:{_vcardEscape(organization)}")
for email in emails:
if isinstance(email, str) and email:
lines.append(f"EMAIL;TYPE=INTERNET:{_vcardEscape(email)}")
elif isinstance(email, dict) and email.get("address"):
lines.append(f"EMAIL;TYPE=INTERNET:{_vcardEscape(email['address'])}")
for phone in phones:
if isinstance(phone, str) and phone:
lines.append(f"TEL:{_vcardEscape(phone)}")
elif isinstance(phone, dict) and phone.get("number"):
lines.append(f"TEL:{_vcardEscape(phone['number'])}")
for addr in addresses:
if isinstance(addr, dict):
# ADR: PO-Box;Extended;Street;City;Region;Postal;Country
lines.append(
"ADR:;;"
f"{_vcardEscape(addr.get('street'))};"
f"{_vcardEscape(addr.get('city'))};"
f"{_vcardEscape(addr.get('region'))};"
f"{_vcardEscape(addr.get('zip') or addr.get('postal_code'))};"
f"{_vcardEscape(addr.get('country'))}"
)
for site in websites:
if isinstance(site, str) and site:
lines.append(f"URL:{_vcardEscape(site)}")
elif isinstance(site, dict) and site.get("url"):
lines.append(f"URL:{_vcardEscape(site['url'])}")
if note:
lines.append(f"NOTE:{_vcardEscape(note)}")
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
class ContactAdapter(ServiceAdapter):
"""Infomaniak Contacts adapter -- browse address books + contacts, .vcf download.
Uses the public PIM endpoint at ``contacts.infomaniak.com/api/pim``,
which authenticates with the PAT scope ``workspace:contact``.
Path layout:
``/`` -> list address books
``/{addressBookId}`` -> list contacts in that book
``/{addressBookId}/{contactId}`` -> single contact (download as .vcf)
Endpoint particulars:
Listing both address books and contacts is PAT-friendly. The
contact-listing call uses ``with=emails,phones,addresses,details``
so each record arrives with all the fields needed for vCard
synthesis -- Infomaniak skips them by default. Detail and export
endpoints (``/contact/{id}``, ``/contact/{id}/export``) are **not**
PAT-friendly (the former 500s, the latter 302s to OAuth), so the
``download`` path re-fetches the listing and renders the vCard
ourselves via :func:`_renderInfomaniakVcard`.
"""
_DEFAULT_CONTACT_LIMIT = 200
_MAX_CONTACT_LIMIT = 1000
_CONTACT_FIELDS = "emails,phones,addresses,details"
def __init__(self, accessToken: str):
self._token = accessToken
async def browse(
self,
path: str,
filter: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]: ) -> List[ExternalEntry]:
segments = [s for s in (path or "").strip("/").split("/") if s] segments = [s for s in (path or "").strip("/").split("/") if s]
if not segments: if not segments:
mailboxes = await self._listMailboxes() return await self._listAddressBooks()
if not mailboxes: if len(segments) == 1:
return await self._listContacts(segments[0], limit=limit)
return [] return []
mailboxId = (mailboxes[0].metadata or {}).get("id") or mailboxes[0].path.strip("/")
else:
mailboxId = segments[0]
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max( async def _listAddressBooks(self) -> List[ExternalEntry]:
1, min(int(limit), self._MAX_MESSAGE_LIMIT), result = await _infomaniakGet(
self._token, f"{_PIM_PREFIX}/addressbook", baseUrl=_CONTACTS_BASE
) )
endpoint = f"/1/mail/{mailboxId}/message/search?query={query}&per_page={effectiveLimit}"
result = await _infomaniakGet(self._token, endpoint)
if isinstance(result, dict) and result.get("error"): if isinstance(result, dict) and result.get("error"):
logger.warning(f"Contacts list-addressbooks failed: {result['error']}")
return [] return []
data = _unwrapData(result) data = _unwrapData(result)
messages = data if isinstance(data, list) else data.get("messages", []) if isinstance(data, dict) else [] books = data.get("addressbooks", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = [] entries: List[ExternalEntry] = []
for msg in messages: for book in books:
uid = str(msg.get("uid") or msg.get("id") or "") bookId = str(book.get("id", ""))
if not uid: if not bookId:
continue continue
folderId = str(msg.get("folder_id") or msg.get("folderId") or "") isShared = bool(book.get("is_shared")) or (book.get("user_id") or 0) <= 0
# The shared organisation directory has an empty name -- give it a
# human label so the UDB tree is not blank.
name = book.get("name") or (
"Organisation" if book.get("is_dynamic_organisation_member_directory") else bookId
)
entries.append(ExternalEntry( entries.append(ExternalEntry(
name=msg.get("subject") or uid, name=name,
path=f"/{mailboxId}/{folderId}/{uid}" if folderId else f"/{mailboxId}/{uid}", path=f"/{bookId}",
isFolder=False, isFolder=True,
metadata={"uid": uid, "from": msg.get("from", "")}, metadata={
"id": bookId,
"kind": "addressbook",
"color": book.get("color"),
"shared": isShared,
"default": bool(book.get("default")),
},
)) ))
return entries return entries
async def _fetchContacts(
self,
addressBookId: str,
perPage: int,
) -> List[Dict[str, Any]]:
"""Raw listing call -- shared by browse and download."""
endpoint = (
f"{_PIM_PREFIX}/addressbook/{addressBookId}/contact"
f"?per_page={perPage}&with={self._CONTACT_FIELDS}"
)
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CONTACTS_BASE)
if isinstance(result, dict) and result.get("error"):
logger.warning(
f"Contacts list-contacts {addressBookId} failed: {result['error']}"
)
return []
data = _unwrapData(result)
if isinstance(data, list):
return [c for c in data if isinstance(c, dict)]
if isinstance(data, dict):
contacts = data.get("contacts", [])
return [c for c in contacts if isinstance(c, dict)]
return []
async def _listContacts(
self,
addressBookId: str,
limit: Optional[int],
) -> List[ExternalEntry]:
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(
1, min(int(limit), self._MAX_CONTACT_LIMIT),
)
contacts = await self._fetchContacts(addressBookId, perPage=effectiveLimit)
entries: List[ExternalEntry] = []
for c in contacts:
cId = str(c.get("id") or c.get("uid") or "")
if not cId:
continue
firstName = c.get("firstname")
lastName = c.get("lastname")
displayName = (
c.get("name")
or " ".join(p for p in (firstName, lastName) if p).strip()
or (c.get("emails") or [None])[0]
or cId
)
firstEmail = (c.get("emails") or [None])[0]
firstPhone = (c.get("phones") or [None])[0]
entries.append(ExternalEntry(
name=str(displayName),
path=f"/{addressBookId}/{cId}",
isFolder=False,
metadata={
"id": cId,
"kind": "contact",
"email": firstEmail,
"phone": firstPhone,
"organization": c.get("organization"),
},
))
return entries
async def download(self, path: str) -> DownloadResult:
segments = [s for s in (path or "").strip("/").split("/") if s]
if len(segments) < 2:
return DownloadResult()
addressBookId, contactId = segments[0], segments[1]
# The PIM contact-detail endpoint (``/contact/{id}``) returns 500
# against a PAT, and ``/contact/{id}/export`` 302s to OAuth. We
# therefore re-fetch the listing (which IS PAT-friendly) with all
# vCard-relevant fields, then synthesize the .vcf ourselves.
contacts = await self._fetchContacts(
addressBookId, perPage=self._MAX_CONTACT_LIMIT
)
record = next((c for c in contacts if str(c.get("id")) == contactId), None)
if record is None:
logger.warning(
f"Contacts download: contact {contactId} not found in book "
f"{addressBookId}"
)
return DownloadResult()
firstName = record.get("firstname") or ""
lastName = record.get("lastname") or ""
displayName = (
record.get("name")
or " ".join(p for p in (firstName, lastName) if p).strip()
or contactId
)
vcardText = _renderInfomaniakVcard(record)
return DownloadResult(
data=vcardText.encode("utf-8"),
fileName=f"{_safeFileName(displayName, 'contact')}.vcf",
mimeType="text/vcard",
)
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
return {"error": "Contacts upload not yet implemented"}
async def search(
self,
query: str,
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
# No public search endpoint -- list contacts of the current (or all)
# address books and filter client-side on display name / email.
books = (
await self._listAddressBooks()
if not path
else [ExternalEntry(name="", path=path, isFolder=True)]
)
if not books:
return []
needle = (query or "").strip().lower()
results: List[ExternalEntry] = []
for book in books:
bookId = (book.metadata or {}).get("id") or book.path.strip("/")
for c in await self._listContacts(bookId, limit=limit):
hay = " ".join(
str(v) for v in (
c.name,
(c.metadata or {}).get("email") or "",
(c.metadata or {}).get("organization") or "",
)
).lower()
if not needle or needle in hay:
results.append(c)
if limit is not None and len(results) >= int(limit):
break
return results[: int(limit)] if limit is not None else results
class InfomaniakConnector(ProviderConnector): class InfomaniakConnector(ProviderConnector):
"""Infomaniak ProviderConnector -- 1 connection -> kDrive + Mail.""" """Infomaniak ProviderConnector -- kDrive + Calendar + Contacts today.
Mail is reserved on the PAT (scope ``workspace:mail``) but not wired
up here yet -- Infomaniak has no public PAT-friendly Mail endpoint
today (the PIM Mail routes 302 to OAuth, the legacy ``/api/mail`` route
301-redirects to an internal Cyrus port). Once a working endpoint is
found, the corresponding adapter can be slotted into ``_SERVICE_MAP``
without any token rotation on the user side.
"""
_SERVICE_MAP = { _SERVICE_MAP = {
"kdrive": KdriveAdapter, "kdrive": KdriveAdapter,
"mail": MailAdapter, "calendar": CalendarAdapter,
"contact": ContactAdapter,
} }
def getAvailableServices(self) -> List[str]: def getAvailableServices(self) -> List[str]:

View file

@ -841,6 +841,285 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
return entries return entries
# ---------------------------------------------------------------------------
# Calendar Adapter
# ---------------------------------------------------------------------------
class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
"""ServiceAdapter for Outlook Calendar via Microsoft Graph.
Path conventions:
``""`` / ``"/"`` -> list user calendars
``"/<calendarId>"`` -> list events in that calendar
``"/<calendarId>/<eventId>"`` -> reserved for future event detail browse
Downloads return a synthesised ``.ics`` (VCALENDAR/VEVENT) since Microsoft
Graph does not expose a ``/$value`` endpoint for events.
"""
_DEFAULT_EVENT_LIMIT = 100
_MAX_EVENT_LIMIT = 1000
_PAGE_SIZE = 100
async def browse(
self,
path: str,
filter: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
cleanPath = (path or "").strip("/")
if not cleanPath:
result = await self._graphGet("me/calendars?$top=100")
if "error" in result:
logger.warning(f"MSFT Calendar list failed: {result['error']}")
return []
calendars = result.get("value", [])
if filter:
calendars = [c for c in calendars if filter.lower() in (c.get("name") or "").lower()]
return [
ExternalEntry(
name=c.get("name", ""),
path=f"/{c.get('id', '')}",
isFolder=True,
metadata={
"id": c.get("id"),
"color": c.get("color"),
"owner": (c.get("owner") or {}).get("address"),
"isDefaultCalendar": c.get("isDefaultCalendar", False),
"canEdit": c.get("canEdit", False),
},
)
for c in calendars
]
calendarId = cleanPath.split("/", 1)[0]
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
pageSize = min(self._PAGE_SIZE, effectiveLimit)
endpoint: Optional[str] = (
f"me/calendars/{calendarId}/events"
f"?$top={pageSize}&$orderby=start/dateTime desc"
)
events: List[Dict[str, Any]] = []
while endpoint and len(events) < effectiveLimit:
result = await self._graphGet(endpoint)
if "error" in result:
logger.warning(f"MSFT Calendar events failed: {result['error']}")
break
for ev in result.get("value", []):
events.append(ev)
if len(events) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = _stripGraphBase(nextLink) if nextLink else None
return [
ExternalEntry(
name=ev.get("subject", "(no subject)"),
path=f"/{calendarId}/{ev.get('id', '')}",
isFolder=False,
mimeType="text/calendar",
metadata={
"id": ev.get("id"),
"start": (ev.get("start") or {}).get("dateTime"),
"end": (ev.get("end") or {}).get("dateTime"),
"location": (ev.get("location") or {}).get("displayName"),
"organizer": (ev.get("organizer") or {}).get("emailAddress", {}).get("address"),
"isAllDay": ev.get("isAllDay", False),
"webLink": ev.get("webLink"),
},
)
for ev in events
]
async def download(self, path: str) -> DownloadResult:
cleanPath = (path or "").strip("/")
if "/" not in cleanPath:
return DownloadResult()
eventId = cleanPath.split("/")[-1]
ev = await self._graphGet(f"me/events/{eventId}")
if "error" in ev:
logger.warning(f"MSFT Calendar event fetch failed: {ev['error']}")
return DownloadResult()
icsBytes = _eventToIcs(ev)
subject = ev.get("subject") or eventId
safeName = _safeFileName(subject) or "event"
return DownloadResult(
data=icsBytes,
fileName=f"{safeName}.ics",
mimeType="text/calendar",
)
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
return {"error": "Calendar upload not supported"}
async def search(
self,
query: str,
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
safeQuery = query.replace("'", "''")
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
endpoint = f"me/events?$search=\"{safeQuery}\"&$top={effectiveLimit}"
result = await self._graphGet(endpoint)
if "error" in result:
return []
return [
ExternalEntry(
name=ev.get("subject", "(no subject)"),
path=f"/search/{ev.get('id', '')}",
isFolder=False,
mimeType="text/calendar",
metadata={
"id": ev.get("id"),
"start": (ev.get("start") or {}).get("dateTime"),
"end": (ev.get("end") or {}).get("dateTime"),
},
)
for ev in result.get("value", [])
]
# ---------------------------------------------------------------------------
# Contacts Adapter
# ---------------------------------------------------------------------------
class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
"""ServiceAdapter for Outlook Contacts via Microsoft Graph.
Path conventions:
``""`` -> list contact folders (default + custom)
``"/<folderId>"`` -> list contacts in that folder; the
virtual id ``default`` maps to
``/me/contacts`` (the user's primary
contact list)
``"/<folderId>/<contactId>"`` -> reserved for future detail browse
Downloads return a synthesised vCard 3.0 (.vcf) since Microsoft Graph
does not expose a ``/$value`` endpoint for contacts.
"""
_DEFAULT_CONTACT_LIMIT = 200
_MAX_CONTACT_LIMIT = 1000
_PAGE_SIZE = 100
_DEFAULT_FOLDER_ID = "default"
async def browse(
self,
path: str,
filter: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
cleanPath = (path or "").strip("/")
if not cleanPath:
folders: List[ExternalEntry] = [
ExternalEntry(
name="Kontakte",
path=f"/{self._DEFAULT_FOLDER_ID}",
isFolder=True,
metadata={"id": self._DEFAULT_FOLDER_ID, "isDefault": True},
),
]
result = await self._graphGet("me/contactFolders?$top=100")
if "error" not in result:
for f in result.get("value", []):
folders.append(
ExternalEntry(
name=f.get("displayName", ""),
path=f"/{f.get('id', '')}",
isFolder=True,
metadata={"id": f.get("id"), "parentFolderId": f.get("parentFolderId")},
)
)
else:
logger.warning(f"MSFT contactFolders list failed: {result['error']}")
return folders
folderId = cleanPath.split("/", 1)[0]
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
pageSize = min(self._PAGE_SIZE, effectiveLimit)
if folderId == self._DEFAULT_FOLDER_ID:
endpoint: Optional[str] = f"me/contacts?$top={pageSize}&$orderby=displayName"
else:
endpoint = f"me/contactFolders/{folderId}/contacts?$top={pageSize}&$orderby=displayName"
contacts: List[Dict[str, Any]] = []
while endpoint and len(contacts) < effectiveLimit:
result = await self._graphGet(endpoint)
if "error" in result:
logger.warning(f"MSFT contacts list failed: {result['error']}")
break
for c in result.get("value", []):
contacts.append(c)
if len(contacts) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = _stripGraphBase(nextLink) if nextLink else None
return [
ExternalEntry(
name=c.get("displayName") or _personLabel(c) or "(no name)",
path=f"/{folderId}/{c.get('id', '')}",
isFolder=False,
mimeType="text/vcard",
metadata={
"id": c.get("id"),
"givenName": c.get("givenName"),
"surname": c.get("surname"),
"companyName": c.get("companyName"),
"emailAddresses": [e.get("address") for e in (c.get("emailAddresses") or []) if e.get("address")],
"businessPhones": c.get("businessPhones") or [],
"mobilePhone": c.get("mobilePhone"),
},
)
for c in contacts
]
async def download(self, path: str) -> DownloadResult:
cleanPath = (path or "").strip("/")
if "/" not in cleanPath:
return DownloadResult()
contactId = cleanPath.split("/")[-1]
c = await self._graphGet(f"me/contacts/{contactId}")
if "error" in c:
logger.warning(f"MSFT contact fetch failed: {c['error']}")
return DownloadResult()
vcfBytes = _contactToVcard(c)
label = c.get("displayName") or _personLabel(c) or contactId
safeName = _safeFileName(label) or "contact"
return DownloadResult(
data=vcfBytes,
fileName=f"{safeName}.vcf",
mimeType="text/vcard",
)
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
return {"error": "Contacts upload not supported"}
async def search(
self,
query: str,
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
safeQuery = query.replace("'", "''")
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
endpoint = f"me/contacts?$search=\"{safeQuery}\"&$top={effectiveLimit}"
result = await self._graphGet(endpoint)
if "error" in result:
return []
return [
ExternalEntry(
name=c.get("displayName") or _personLabel(c) or "(no name)",
path=f"/search/{c.get('id', '')}",
isFolder=False,
mimeType="text/vcard",
metadata={"id": c.get("id")},
)
for c in result.get("value", [])
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MsftConnector (1:n) # MsftConnector (1:n)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -853,6 +1132,8 @@ class MsftConnector(ProviderConnector):
"outlook": OutlookAdapter, "outlook": OutlookAdapter,
"teams": TeamsAdapter, "teams": TeamsAdapter,
"onedrive": OneDriveAdapter, "onedrive": OneDriveAdapter,
"calendar": CalendarAdapter,
"contact": ContactsAdapter,
} }
def getAvailableServices(self) -> List[str]: def getAvailableServices(self) -> List[str]:
@ -891,3 +1172,143 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool:
"""Simple glob-like filter (supports * wildcard).""" """Simple glob-like filter (supports * wildcard)."""
import fnmatch import fnmatch
return fnmatch.fnmatch(entry.name.lower(), pattern.lower()) return fnmatch.fnmatch(entry.name.lower(), pattern.lower())
def _safeFileName(name: str) -> str:
"""Strip path-unsafe characters and trim length so the result is a usable file name."""
import re
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
def _personLabel(contact: Dict[str, Any]) -> str:
given = (contact.get("givenName") or "").strip()
surname = (contact.get("surname") or "").strip()
if given or surname:
return f"{given} {surname}".strip()
company = (contact.get("companyName") or "").strip()
return company
def _icsEscape(value: str) -> str:
"""Escape RFC 5545 reserved characters in TEXT properties."""
if value is None:
return ""
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
)
def _icsDateTime(value: Optional[str]) -> Optional[str]:
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
if not value:
return None
from datetime import datetime, timezone
try:
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
dt = datetime.fromisoformat(normalized)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
except (TypeError, ValueError):
return None
def _eventToIcs(event: Dict[str, Any]) -> bytes:
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload."""
from datetime import datetime, timezone
uid = event.get("iCalUId") or event.get("id") or "unknown@poweron"
summary = _icsEscape(event.get("subject") or "")
location = _icsEscape((event.get("location") or {}).get("displayName") or "")
body = (event.get("body") or {}).get("content") or ""
description = _icsEscape(body)
dtstart = _icsDateTime((event.get("start") or {}).get("dateTime"))
dtend = _icsDateTime((event.get("end") or {}).get("dateTime"))
dtstamp = _icsDateTime(event.get("lastModifiedDateTime")) or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//PowerOn//MSFT-Calendar-Adapter//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{dtstamp}",
]
if dtstart:
lines.append(f"DTSTART:{dtstart}")
if dtend:
lines.append(f"DTEND:{dtend}")
if summary:
lines.append(f"SUMMARY:{summary}")
if location:
lines.append(f"LOCATION:{location}")
if description:
lines.append(f"DESCRIPTION:{description}")
organizer = (event.get("organizer") or {}).get("emailAddress", {}).get("address")
if organizer:
lines.append(f"ORGANIZER:mailto:{organizer}")
for att in (event.get("attendees") or []):
addr = (att.get("emailAddress") or {}).get("address")
if addr:
lines.append(f"ATTENDEE:mailto:{addr}")
lines.append("END:VEVENT")
lines.append("END:VCALENDAR")
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
def _contactToVcard(contact: Dict[str, Any]) -> bytes:
"""Build a vCard 3.0 from a Graph /me/contacts payload."""
given = contact.get("givenName") or ""
surname = contact.get("surname") or ""
middle = contact.get("middleName") or ""
fn = contact.get("displayName") or _personLabel(contact) or contact.get("companyName") or ""
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
f"N:{surname};{given};{middle};;",
f"FN:{fn}",
]
if contact.get("companyName"):
org = contact["companyName"]
if contact.get("department"):
org = f"{org};{contact['department']}"
lines.append(f"ORG:{org}")
if contact.get("jobTitle"):
lines.append(f"TITLE:{contact['jobTitle']}")
for em in (contact.get("emailAddresses") or []):
addr = em.get("address")
if addr:
lines.append(f"EMAIL;TYPE=INTERNET:{addr}")
for phone in (contact.get("businessPhones") or []):
if phone:
lines.append(f"TEL;TYPE=WORK,VOICE:{phone}")
if contact.get("mobilePhone"):
lines.append(f"TEL;TYPE=CELL,VOICE:{contact['mobilePhone']}")
for phone in (contact.get("homePhones") or []):
if phone:
lines.append(f"TEL;TYPE=HOME,VOICE:{phone}")
def _appendAddress(addr: Dict[str, Any], typ: str) -> None:
if not addr:
return
street = addr.get("street") or ""
city = addr.get("city") or ""
state = addr.get("state") or ""
postal = addr.get("postalCode") or ""
country = addr.get("countryOrRegion") or ""
if any([street, city, state, postal, country]):
lines.append(f"ADR;TYPE={typ}:;;{street};{city};{state};{postal};{country}")
_appendAddress(contact.get("businessAddress") or {}, "WORK")
_appendAddress(contact.get("homeAddress") or {}, "HOME")
_appendAddress(contact.get("otherAddress") or {}, "OTHER")
if contact.get("personalNotes"):
lines.append(f"NOTE:{_icsEscape(contact['personalNotes'])}")
lines.append(f"UID:{contact.get('id', '')}")
lines.append("END:VCARD")
return ("\r\n".join(lines) + "\r\n").encode("utf-8")

View file

@ -1160,6 +1160,9 @@ async def list_connection_services(
"drive": "Google Drive", "drive": "Google Drive",
"gmail": "Gmail", "gmail": "Gmail",
"files": "Files (FTP)", "files": "Files (FTP)",
"kdrive": "kDrive",
"calendar": "Calendar",
"contact": "Contacts",
} }
_serviceIcons = { _serviceIcons = {
"sharepoint": "sharepoint", "sharepoint": "sharepoint",
@ -1170,6 +1173,9 @@ async def list_connection_services(
"drive": "cloud", "drive": "cloud",
"gmail": "mail", "gmail": "mail",
"files": "folder", "files": "folder",
"kdrive": "cloud",
"calendar": "calendar",
"contact": "contact",
} }
items = [ items = [
{"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")} {"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")}

View file

@ -1818,6 +1818,9 @@ async def listConnectionServices(
"drive": "Google Drive", "drive": "Google Drive",
"gmail": "Gmail", "gmail": "Gmail",
"files": "Files (FTP)", "files": "Files (FTP)",
"kdrive": "kDrive",
"calendar": "Calendar",
"contact": "Contacts",
} }
_serviceIcons = { _serviceIcons = {
"sharepoint": "sharepoint", "sharepoint": "sharepoint",
@ -1827,6 +1830,9 @@ async def listConnectionServices(
"drive": "cloud", "drive": "cloud",
"gmail": "mail", "gmail": "mail",
"files": "folder", "files": "folder",
"kdrive": "cloud",
"calendar": "calendar",
"contact": "contact",
} }
items = [ items = [
{ {

View file

@ -3331,7 +3331,10 @@ class AppObjects:
) )
if not tokens: if not tokens:
logger.warning( # Pending connections legitimately have no token yet (PAT not
# submitted, OAuth callback not completed). Keep at DEBUG to
# avoid noisy warnings on every connection-list refresh.
logger.debug(
f"No connection token found for connectionId: {connectionId}" f"No connection token found for connectionId: {connectionId}"
) )
return None return None

View file

@ -484,9 +484,16 @@ def update_connection(
def connect_service( def connect_service(
request: Request, request: Request,
connectionId: str = Path(..., description="The ID of the connection to connect"), connectionId: str = Path(..., description="The ID of the connection to connect"),
body: Optional[Dict[str, Any]] = Body(default=None),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Connect a service for the current user """Connect a service for the current user.
Optional body: ``{"reauth": true}`` -- forces the OAuth provider to re-show
the consent screen, which is required when new scopes have been added (e.g.
Calendar + Contacts after the connection was first created). Without this
flag the provider silently re-uses the previous consent and never grants
the new scopes, leaving the connection in a degraded state.
SECURITY: This endpoint is secure - users can only connect their own connections. SECURITY: This endpoint is secure - users can only connect their own connections.
""" """
@ -510,16 +517,27 @@ def connect_service(
detail=routeApiMsg("Connection not found") detail=routeApiMsg("Connection not found")
) )
reauth = bool((body or {}).get("reauth")) if isinstance(body, dict) else False
reauthSuffix = "&reauth=1" if reauth else ""
# Data-app OAuth (JWT state issued server-side in /auth/connect) # Data-app OAuth (JWT state issued server-side in /auth/connect)
auth_url = None auth_url = None
if connection.authority == AuthAuthority.MSFT: if connection.authority == AuthAuthority.MSFT:
auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}" auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
elif connection.authority == AuthAuthority.GOOGLE: elif connection.authority == AuthAuthority.GOOGLE:
auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}" auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
elif connection.authority == AuthAuthority.CLICKUP: elif connection.authority == AuthAuthority.CLICKUP:
auth_url = f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}" auth_url = f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
elif connection.authority == AuthAuthority.INFOMANIAK: elif connection.authority == AuthAuthority.INFOMANIAK:
auth_url = f"/api/infomaniak/auth/connect?connectionId={quote(connectionId, safe='')}" # Infomaniak does not use OAuth for data access; the frontend posts a
# Personal Access Token directly to /api/infomaniak/connections/{id}/token.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg(
"Infomaniak uses a Personal Access Token instead of OAuth. "
"Submit the token via POST /api/infomaniak/connections/{connectionId}/token."
),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -281,9 +281,17 @@ async def auth_login_callback(
def auth_connect( def auth_connect(
request: Request, request: Request,
connectionId: str = Query(..., description="UserConnection id"), connectionId: str = Query(..., description="UserConnection id"),
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(getCurrentUser),
) -> RedirectResponse: ) -> RedirectResponse:
"""Start Google Data OAuth for an existing connection (requires gateway session).""" """Start Google Data OAuth for an existing connection (requires gateway session).
Google already defaults to ``prompt=consent`` here, but ``include_granted_scopes=true``
can cause newly added scopes (e.g. calendar.readonly, contacts.readonly) to be
silently dropped on subsequent re-authorisations. With ``reauth=1`` we drop
``include_granted_scopes`` so Google re-issues a token strictly for the
current scope list.
"""
try: try:
_require_google_data_config() _require_google_data_config()
interface = getInterface(currentUser) interface = getInterface(currentUser)
@ -310,9 +318,10 @@ def auth_connect(
) )
extra_params: Dict[str, Any] = { extra_params: Dict[str, Any] = {
"access_type": "offline", "access_type": "offline",
"include_granted_scopes": "true",
"state": state_jwt, "state": state_jwt,
} }
if not reauth:
extra_params["include_granted_scopes"] = "true"
login_hint = connection.externalEmail or connection.externalUsername login_hint = connection.externalEmail or connection.externalUsername
if login_hint: if login_hint:
extra_params["login_hint"] = login_hint extra_params["login_hint"] = login_hint

View file

@ -1,69 +1,72 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Infomaniak OAuth for data connections (UserConnection + Token). """Infomaniak Personal-Access-Token onboarding for data connections.
Pure DATA_CONNECTION flow -- Infomaniak is NOT a login authority for PowerOn. Infomaniak does NOT support OAuth scopes for kDrive/kSuite data access.
The user must create a Personal Access Token (PAT) at
https://manager.infomaniak.com/v3/ng/accounts/token/list with the API
scopes:
- ``accounts`` -> account discovery (REQUIRED for kDrive)
- ``drive`` -> kDrive (active adapter)
- ``workspace:calendar`` -> Calendar (active adapter)
- ``workspace:contact`` -> Contacts (active adapter)
- ``workspace:mail`` -> Mail (adapter pending; scope reserved)
Validation strategy
-------------------
The submit endpoint validates the PAT in three deterministic steps,
each addressing exactly one scope:
1. ``resolveAccessibleAccountIds(pat)`` -> ``GET /1/accounts`` proves
the ``accounts`` scope is on the PAT. Without this scope, kDrive
cannot enumerate the owning account_ids (a standalone or free-tier
kDrive lives on a *different* account_id than its kSuite
counterpart, so the kSuite account_id from PIM is not enough).
2. ``resolveOwnerIdentity(pat)`` -> PIM Calendar (preferred) or PIM
Contacts (fallback) yields the user's display name + their kSuite
account_id, used purely for connection labelling. This also proves
that at least one of ``workspace:calendar`` / ``workspace:contact``
is on the PAT (the connection would otherwise be blank in the UI).
3. ``GET /2/drive?account_id={firstAccountId}`` is the final scope
probe -- 200 means the ``drive`` scope is present. 401/403 means
the scope is missing.
Mail has no separate probe: its scope is recorded in ``grantedScopes``
so a future adapter can pick it up without re-issuing the token.
""" """
from fastapi import APIRouter, HTTPException, Request, status, Depends, Query from fastapi import APIRouter, HTTPException, Request, status, Depends, Path, Body
from fastapi.responses import HTMLResponse, RedirectResponse
import logging import logging
import json
import time
from typing import Dict, Any from typing import Dict, Any
from urllib.parse import urlencode import hashlib
import httpx import httpx
from jose import jwt as jose_jwt
from jose import JWTError
from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceDbApp import getInterface
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.auth import getCurrentUser, limiter
from modules.auth.oauthProviderConfig import infomaniakDataScopes from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext
from modules.connectors.providerInfomaniak.connectorInfomaniak import (
resolveOwnerIdentity,
resolveAccessibleAccountIds,
InfomaniakIdentityError,
)
routeApiMsg = apiRouteContext("routeSecurityInfomaniak") routeApiMsg = apiRouteContext("routeSecurityInfomaniak")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_FLOW_CONNECT = "infomaniak_connect"
INFOMANIAK_AUTHORIZE_URL = "https://login.infomaniak.com/authorize"
INFOMANIAK_TOKEN_URL = "https://login.infomaniak.com/token"
INFOMANIAK_API_BASE = "https://api.infomaniak.com" INFOMANIAK_API_BASE = "https://api.infomaniak.com"
CLIENT_ID = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_ID") # Infomaniak PATs do not expire unless the user sets an explicit lifetime in
CLIENT_SECRET = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_SECRET") # the Manager (up to 30 years). We persist a 10-year horizon so the central
REDIRECT_URI = APP_CONFIG.get("Service_INFOMANIAK_OAUTH_REDIRECT_URI") # tokenStatus helper does not flag the connection as "no token". Mirrors
# ClickUp.
_INFOMANIAK_TOKEN_EXPIRES_IN_SEC = 10 * 365 * 24 * 3600
def _issue_oauth_state(claims: Dict[str, Any]) -> str:
body = {**claims, "exp": int(time.time()) + 600}
return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM)
def _parse_oauth_state(state: str) -> Dict[str, Any]:
try:
return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}"
) from e
def _require_infomaniak_config():
if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg(
"Infomaniak OAuth is not configured "
"(Service_INFOMANIAK_DATA_CLIENT_ID, Service_INFOMANIAK_DATA_CLIENT_SECRET, "
"Service_INFOMANIAK_OAUTH_REDIRECT_URI)"
),
)
router = APIRouter( router = APIRouter(
@ -78,20 +81,92 @@ router = APIRouter(
) )
@router.get("/auth/connect") async def _probeDriveScope(client: httpx.AsyncClient, pat: str, accountId: int) -> None:
@limiter.limit("5/minute") """Confirm the ``drive`` scope is on the PAT.
def auth_connect(
request: Request, Issues ``GET /2/drive?account_id={accountId}`` -- a clean 200 means
connectionId: str = Query(..., description="UserConnection id"), both the ``drive`` scope is present and the resolved ``account_id``
currentUser: User = Depends(getCurrentUser), is correct. 401/403 means the scope is missing; anything else means
) -> RedirectResponse: Infomaniak is misbehaving and we refuse to persist.
"""Start Infomaniak OAuth for an existing connection (requires gateway session).""" """
url = f"{INFOMANIAK_API_BASE}/2/drive?account_id={accountId}"
try: try:
_require_infomaniak_config() resp = await client.get(
url,
headers={"Authorization": f"Bearer {pat}", "Accept": "application/json"},
)
except httpx.HTTPError as e:
logger.error(f"Infomaniak drive-probe network error ({url}): {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=routeApiMsg("Could not reach Infomaniak to validate the token"),
)
if resp.status_code == 200:
return
if resp.status_code in (401, 403):
logger.warning(
f"Infomaniak drive-probe rejected PAT ({url}): "
f"{resp.status_code} {resp.text[:200]}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg(
"Token rejected by Infomaniak (missing scope 'drive'). "
"Required scopes: 'drive' (kDrive) and 'workspace:calendar' "
"(or 'workspace:contact'). Recommended for upcoming "
"services: 'workspace:mail'."
),
)
logger.error(
f"Infomaniak drive-probe unexpected response ({url}): "
f"{resp.status_code} {resp.text[:200]}"
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=routeApiMsg(
"Infomaniak drive-probe returned an unexpected response."
),
)
@router.post("/connections/{connectionId}/token")
@limiter.limit("10/minute")
async def submit_infomaniak_token(
request: Request,
connectionId: str = Path(..., description="UserConnection id"),
body: Dict[str, Any] = Body(..., description="{ 'token': '<PAT>' }"),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Validate and persist an Infomaniak Personal Access Token (PAT).
Body:
{ "token": "<personal-access-token from Infomaniak Manager>" }
Validation order (all three must succeed before persisting):
1. ``resolveAccessibleAccountIds(pat)`` -> proves the
``accounts`` scope is on the PAT (required for kDrive
account discovery).
2. ``resolveOwnerIdentity(pat)`` -> display name + kSuite
account_id for the connection UI label.
3. ``/2/drive?account_id=<first>`` -> proves the ``drive``
scope is on the PAT.
No data derived from the PAT is stored as adapter state -- both
account list and owner identity are re-resolved lazily by the
adapters at request time.
"""
pat = (body or {}).get("token")
if not isinstance(pat, str) or not pat.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("Missing 'token' in request body"),
)
pat = pat.strip()
interface = getInterface(currentUser) interface = getInterface(currentUser)
connections = interface.getUserConnections(currentUser.id)
connection = None connection = None
for conn in connections: for conn in interface.getUserConnections(currentUser.id):
if conn.id == connectionId and conn.authority == AuthAuthority.INFOMANIAK: if conn.id == connectionId and conn.authority == AuthAuthority.INFOMANIAK:
connection = conn connection = conn
break break
@ -101,228 +176,97 @@ def auth_connect(
detail=routeApiMsg("Infomaniak connection not found"), detail=routeApiMsg("Infomaniak connection not found"),
) )
state_jwt = _issue_oauth_state(
{
"flow": _FLOW_CONNECT,
"connectionId": connectionId,
"userId": str(currentUser.id),
}
)
query = urlencode(
{
"client_id": CLIENT_ID,
"response_type": "code",
"access_type": "offline",
"redirect_uri": REDIRECT_URI,
"scope": " ".join(infomaniakDataScopes),
"state": state_jwt,
}
)
auth_url = f"{INFOMANIAK_AUTHORIZE_URL}?{query}"
return RedirectResponse(auth_url)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error initiating Infomaniak connect: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to initiate Infomaniak connect: {str(e)}",
)
@router.get("/auth/connect/callback")
async def auth_connect_callback(
code: str = Query(...),
state: str = Query(...),
) -> HTMLResponse:
"""OAuth callback for Infomaniak data connection."""
state_data = _parse_oauth_state(state)
if state_data.get("flow") != _FLOW_CONNECT:
raise HTTPException(
status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")
)
connection_id = state_data.get("connectionId")
user_id = state_data.get("userId")
if not connection_id or not user_id:
raise HTTPException(
status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state")
)
_require_infomaniak_config()
async with httpx.AsyncClient() as client:
token_resp = await client.post(
INFOMANIAK_TOKEN_URL,
data={
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
if token_resp.status_code != 200:
logger.error(
f"Infomaniak token exchange failed: {token_resp.status_code} {token_resp.text}"
)
return HTMLResponse(
content=f"<html><body><h1>Connection Failed</h1><p>{token_resp.text}</p></body></html>",
status_code=400,
)
token_json = token_resp.json()
access_token = token_json.get("access_token")
refresh_token = token_json.get("refresh_token", "")
expires_in = int(token_json.get("expires_in", 0))
granted_scopes = token_json.get("scope", "")
if not access_token:
return HTMLResponse(
content="<html><body><h1>Connection Failed</h1><p>No access token.</p></body></html>",
status_code=400,
)
rootInterface = getRootInterface()
if not refresh_token:
try: try:
existing_tokens = rootInterface.getTokensByConnectionIdAndAuthority( accountIds = await resolveAccessibleAccountIds(pat)
connection_id, AuthAuthority.INFOMANIAK except InfomaniakIdentityError as e:
logger.warning(
f"Infomaniak token submit for connection {connectionId} could not "
f"list accounts: {e}"
) )
if existing_tokens: raise HTTPException(
existing_tokens.sort( status_code=status.HTTP_400_BAD_REQUEST,
key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True detail=routeApiMsg(
) "Token rejected by Infomaniak (missing scope 'accounts'). "
refresh_token = existing_tokens[0].tokenRefresh or "" "kDrive needs the 'accounts' scope to discover the owning "
except Exception: "Infomaniak account. Required scopes: 'accounts', 'drive', "
pass "'workspace:calendar', 'workspace:contact'."
),
async with httpx.AsyncClient() as client:
profile_resp = await client.get(
f"{INFOMANIAK_API_BASE}/1/profile",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
timeout=30.0,
)
if profile_resp.status_code != 200:
logger.error(
f"Infomaniak profile lookup failed: {profile_resp.status_code} {profile_resp.text}"
)
return HTMLResponse(
content="<html><body><h1>Connection Failed</h1><p>Could not load Infomaniak profile.</p></body></html>",
status_code=400,
)
profile_payload = profile_resp.json()
profile = profile_payload.get("data") if isinstance(profile_payload, dict) else None
profile = profile or {}
user = rootInterface.getUser(user_id)
if not user:
return HTMLResponse(
content="""
<html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'infomaniak_connection_error', error: 'User not found' }, '*');
setTimeout(() => window.close(), 1000);
} else window.close();
</script></body></html>
""",
status_code=404,
) )
interface = getInterface(user) try:
connections = interface.getUserConnections(user_id) identity = await resolveOwnerIdentity(pat)
connection = None except InfomaniakIdentityError as e:
for conn in connections: logger.warning(
if conn.id == connection_id: f"Infomaniak token submit for connection {connectionId} could not "
connection = conn f"resolve owner identity: {e}"
break )
if not connection: raise HTTPException(
return HTMLResponse( status_code=status.HTTP_400_BAD_REQUEST,
content=""" detail=routeApiMsg(
<html><body><script> "Could not derive your Infomaniak account from the token. "
if (window.opener) { "Please ensure the PAT carries 'workspace:calendar' or "
window.opener.postMessage({ type: 'infomaniak_connection_error', error: 'Connection not found' }, '*'); "'workspace:contact' so we can identify your account."
setTimeout(() => window.close(), 1000); ),
} else window.close();
</script></body></html>
""",
status_code=404,
) )
ext_id = str(profile.get("id", "")) if profile.get("id") is not None else "" async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client:
username = profile.get("login") or profile.get("email") or ext_id await _probeDriveScope(client, pat, accountIds[0])
email = profile.get("email")
expires_at = createExpirationTimestamp(expires_in) tokenFingerprint = "pat-" + hashlib.sha256(pat.encode("utf-8")).hexdigest()[:8]
granted_scopes_list = ( username = identity["displayName"] or f"infomaniak-{tokenFingerprint}"
granted_scopes expiresAt = createExpirationTimestamp(_INFOMANIAK_TOKEN_EXPIRES_IN_SEC)
if isinstance(granted_scopes, list)
else (granted_scopes.split(" ") if granted_scopes else infomaniakDataScopes)
)
try: try:
connection.status = ConnectionStatus.ACTIVE connection.status = ConnectionStatus.ACTIVE
connection.lastChecked = getUtcTimestamp() connection.lastChecked = getUtcTimestamp()
connection.expiresAt = expires_at connection.expiresAt = expiresAt
connection.externalId = ext_id connection.externalId = str(identity["accountId"])
connection.externalUsername = username connection.externalUsername = username
if email: connection.grantedScopes = [
connection.externalEmail = email "accounts",
connection.grantedScopes = granted_scopes_list "drive",
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) "workspace:mail",
"workspace:calendar",
"workspace:contact",
]
interface.db.recordModify(UserConnection, connectionId, connection.model_dump())
token = Token( token = Token(
userId=user.id, userId=currentUser.id,
authority=AuthAuthority.INFOMANIAK, authority=AuthAuthority.INFOMANIAK,
connectionId=connection_id, connectionId=connectionId,
tokenPurpose=TokenPurpose.DATA_CONNECTION, tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=access_token, tokenAccess=pat,
tokenRefresh=refresh_token, tokenRefresh=None,
tokenType=token_json.get("token_type", "bearer"), tokenType="bearer",
expiresAt=expires_at, expiresAt=expiresAt,
createdAt=getUtcTimestamp(), createdAt=getUtcTimestamp(),
) )
interface.saveConnectionToken(token) interface.saveConnectionToken(token)
return HTMLResponse( logger.info(
content=f""" f"Infomaniak PAT stored for connection {connectionId} "
<html> f"(user {currentUser.id}, externalUsername={username}, "
<head><title>Connection Successful</title></head> f"kSuiteAccountId={identity['accountId']}, "
<body> f"accessibleAccounts={accountIds})"
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'infomaniak_connection_success',
connection: {{
id: '{connection.id}',
status: 'connected',
type: 'infomaniak',
lastChecked: {getUtcTimestamp()},
expiresAt: {expires_at}
}}
}}, '*');
setTimeout(() => window.close(), 1000);
}} else {{
window.close();
}}
</script>
</body>
</html>
"""
) )
return {
"id": connection.id,
"status": "connected",
"type": "infomaniak",
"externalUsername": username,
"externalEmail": None,
"lastChecked": connection.lastChecked,
}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Error updating Infomaniak connection: {str(e)}", exc_info=True) logger.error(
return HTMLResponse( f"Error persisting Infomaniak token for connection {connectionId}: {e}",
content=f""" exc_info=True,
<html><body><script> )
if (window.opener) {{ raise HTTPException(
window.opener.postMessage({{ type: 'infomaniak_connection_error', error: {json.dumps(str(e))} }}, '*'); status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
setTimeout(() => window.close(), 1000); detail=routeApiMsg("Failed to store Infomaniak token"),
}} else window.close();
</script></body></html>
""",
status_code=500,
) )

View file

@ -244,9 +244,15 @@ async def auth_login_callback(
def auth_connect( def auth_connect(
request: Request, request: Request,
connectionId: str = Query(..., description="UserConnection id"), connectionId: str = Query(..., description="UserConnection id"),
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(getCurrentUser),
) -> RedirectResponse: ) -> RedirectResponse:
"""Start Microsoft Data OAuth for an existing connection.""" """Start Microsoft Data OAuth for an existing connection.
With ``reauth=1`` the consent screen is forced (``prompt=consent``) so the
user re-grants permissions and any newly added scopes (e.g. Calendars.Read,
Contacts.Read) actually land on the access token.
"""
try: try:
_require_msft_data_config() _require_msft_data_config()
interface = getInterface(currentUser) interface = getInterface(currentUser)
@ -280,6 +286,8 @@ def auth_connect(
if "@" in login_hint: if "@" in login_hint:
login_kwargs["domain_hint"] = login_hint.split("@", 1)[1] login_kwargs["domain_hint"] = login_hint.split("@", 1)[1]
login_kwargs["prompt"] = "login" login_kwargs["prompt"] = "login"
if reauth:
login_kwargs["prompt"] = "consent"
auth_url = msal_app.get_authorization_request_url( auth_url = msal_app.get_authorization_request_url(
scopes=msftDataScopes, scopes=msftDataScopes,

View file

View file

@ -0,0 +1,66 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Unit tests: temperature handling for OpenAI chat-completions models.
Historical regression: every payload sent ``temperature=0.2``. After the
GPT-5 launch OpenAI rejects any non-default temperature for the GPT-5.x
and o-series (o1/o3/o4) reasoning models with HTTP 400::
"Unsupported value: 'temperature' does not support 0.2 with this
model. Only the default (1) value is supported."
The fix is a single helper, ``_supportsCustomTemperature``, that is
consulted before adding the field to the outgoing payload. These tests
pin the contract:
* legacy chat models (gpt-4o, gpt-4o-mini, gpt-4.1, gpt-3.5-*) keep
honoring custom temperatures,
* every gpt-5.x and o1/o3/o4 variant must omit the field entirely.
"""
from __future__ import annotations
import pytest
from modules.aicore.aicorePluginOpenai import _supportsCustomTemperature
class TestSupportsCustomTemperature:
"""Pure model-name classification - no network, no payload assembly."""
@pytest.mark.parametrize(
"modelName",
[
"gpt-4o",
"gpt-4o-mini",
"gpt-4.1",
"gpt-3.5-turbo",
"text-embedding-3-small",
"dall-e-3",
],
)
def testLegacyModelsAcceptCustomTemperature(self, modelName):
assert _supportsCustomTemperature(modelName) is True
@pytest.mark.parametrize(
"modelName",
[
"gpt-5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.5",
"GPT-5.5",
"o1",
"o1-mini",
"o3",
"o3-mini",
"o4-mini",
],
)
def testReasoningModelsRejectCustomTemperature(self, modelName):
assert _supportsCustomTemperature(modelName) is False
def testEmptyOrNoneModelDefaultsToSupported(self):
# Defensive: unknown/empty names should not silently break legacy paths.
assert _supportsCustomTemperature("") is True
assert _supportsCustomTemperature(None) is True