commit de4ff128cbb7cc47a61734be956e6702a98ab799 Author: Jonathan Flietstra Date: Sun Sep 27 14:21:31 2026 +0000 Eerste commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0be511 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +.vs/ +*.user +keys/ +kluisbestanden/ +mail-uitgaand/ +appsettings.*.local.json +uitroller.secrets.json diff --git a/Claude outputs/Stappenplan-App-Store-en-Play-Store.pdf b/Claude outputs/Stappenplan-App-Store-en-Play-Store.pdf new file mode 100644 index 0000000..70ed7ad --- /dev/null +++ b/Claude outputs/Stappenplan-App-Store-en-Play-Store.pdf @@ -0,0 +1,137 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +9 0 obj +<< +/PageMode /UseNone /Pages 11 0 R /Type /Catalog +>> +endobj +10 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260918062200+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918062200+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Stappenplan: Google Play en Apple App Store \204 MijnBesluit) /Trapped /False +>> +endobj +11 0 obj +<< +/Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages +>> +endobj +12 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1971 +>> +stream +Gatm<96])L(u=@VHqVm$>g&F?Z^TtI0rJ_ISMi:WYRsuW"LdEs%@lS4c]nsl%$(sg;G<$#l$M96"X=s>em'R?dEN]FrP44LFSA%Z>Fb+Fe,-b8IkTI>8`-fNaGC`;?=:%ZO"_*^QJ;<^b7/;IElbORa7W[eP7+J6HJ@4dfYSf0ftpuY'D"Q6F+VJl`\dH?6qOYZWn+#J0'R7GGAh0)r=k4e$a4u5ERlp^CNjB6B?d$-S'9MmEBJ)nh5;4e4CBk6#%,k:^QDMW=#GsW5G4'@-`Nn[WVB%?VsC(m*No%P(q[6LLiJI(1QKiqmnXa#(idR"FVoeeAb^A8)n>P=j$hh+O:h43.h&0a2_"\b[(IL6rM2,3oUUND:EJ37:/62[>A&XEo/BPhRMZKOA6c/!6^>_\/nA;96L0fS;B\+gjI&\OPp\=Ok]^E=U$jd4*r#Pd7;h%.*a+Wg^iT:o"5A`mA]A)sD;q.&GoRPV1j>dlLGp#mZYF9)P1F)/k8$q5>h,dK?2rmhduS&)RGn;`k/16^mE"L.V$*Z_%nQ_0ClRtdd);G?2J7LUC#G`7]GlS8!#!uNR`A1$iK$3-d%OeY>+^QJ>l$$j$++Wm2e06^F4f0_kp'>=\9S#FEZ,2)ofT9FP6rD2pus"Ep-:1e>FqD"1\R?l6?NA%7Ym:F`
5/:'a3KS9H[ca)B\+T"hU.306YJ@u0KAPX3.U.,'W1g6A4RZV#?"LsCJfRf18>EZg?C&_jC4YKL'KL^CpZep.bb6=e=Z3%d.deJJO-02_uQ3EBX+X^f\\1&'ce`pFGLrNn1HFj.[6.?4"JH(^2SIW.AdY-h$*dHJ0/%@bMHM@'W6/]N-N]RbZ.Lfmr:m7&gVIf5`=/QHPCNf$el)V'CJl7'2A=-]TIDn!ueWeQ%IZirJ$#_jCt?5uYbH*(K2X*c6mGeX%-"U2E'Vs0nJhT0I&#QS/gd4;4D@(*^oe9,n&'2cq@QO7snd$'%8Ki^]3mP*uL;PK@d/+-`im1$a?D]MM)_*VXIhdYghff.I-X2@O.YVQ$[O0]](cg$W+uS~>endstream +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 494 +>> +stream +Garo=c#9++&B4,9(%8Yl[#U-j=-'C?OfJ,:L/Z'F_!I@mYUO@nCB+,?YTKj=@Y*T!Dr,EN-qHO:r`1&Da`7?RTL.^D-mK/kYP=Um^B/P$-;pXZX:,`,$C/RJpMj>HWR7f)bX=3Z+js&)'uMZ.g_VK.8;kQ&NAY&I_Y,UDF!b*D=?0m/G>Kof$\t;<3jU^g6/Fmr;oei(a@;l$$mmEg`"4s:1SZ(WSfVKP[@pN%%Nr'%.BC@4m."7_HpGF/%"6B&IJR%[hjV77d'4T1s4l&dWBD"ca5Y8dY04Nq#Prl@DP2'hD2a`>9BmuM^Vf>lZS`2j9+T>pfkL[aRK]BKj6gR>oK1m[TtWAqDJX[O'R1Mm1U3k&nX=]Nsu%eIoMfM.L:U%-'`1`RH<8BWhSakm*aJLWV.if_CKGiCN]j9ra"i.$7OqdUb3#cGl"]A)?@f&Cs-aLAbY8!Z*k)oqiCVthJ1$[IW;kl~>endstream +endobj +14 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2384 +>> +stream +Gatm-Ei.'ngKD)c[6VC<8-hWu!F&E_Va)fmquL3FkN:fJ6oK-9lJ9A3A8;C/nQ[k=H]'5O<`LXl!O9ojB8Wh?jTu`N;[D,W3PmY@9Ufq1`5=EN*L(=c-*MED"ftQgaiakgkI#cJJnu/u3KkT!,HfZC/DV^?;p(VGcJes#guTc%T:^V5716,>:raUEYuO/V]od`'U9)6XL1P$Rm^G:n#Wi5"mRmOj$C)XR@7GMs3/LHOt=A^32\a`gqL>AYN^o0?Z$M`g9;_9HJ,BDbc#d7$L$VHapLG.2_inDE%'?DAeHDV@A*T5BH%2T])pQR[u[0R&)l"#W1SAH&>ik';`&IINI1HNl]>KFpPCLfEGPqBJm?^*m"/Af(?2Te+;6mY'>F[l!u[5Yg-2cdnEu\%cUFWl:t>UQ4JFYti2FRL8A.jN$K-fV*4]0+ab\=i=5.oC"GqeOgJX`Y=aDendU+LGm)lc&=3HPa0[Ar8HoEa#)mo$i>0COie$!_?,Wa"Up\d!/mKZQ3L?mB&jA_Y#fNrKB;tnQiD-<&X_,:#9X;?*1I@@'#1M,g-#6#,#^bN-^=]-Gl(7[FM#l<9^l`>\:a2Oo\kEVo*0cJI9JJo!K7;$"=0?L22@mqSpP7@/^b1CpXF(;/)]\hb<4`<$R?*I4n_gfU$KO;Wh>qen%qWBq:h5L[>8u?]I3MrOX3Af>;6lnf=6)9aV+Ub"%4V+)VYlQ+7?HJVJ.s,[e4u[Ssa+qt+s&8ahX(8=pjZRTC6K`WoP,&kilk\Fhm6cH1%r]8']"Jn(I^os:?/?Pa>O:u(mEFE1Xn*h']KhGW5HqY[+hbVfXHC?O"mIc)1>-]_N_[Op*N]]&(OD*@Ck0/(l!3>]'88k7Q)QT)X)*]U/YtoMmgn3t^hS"RlsMMW93]^HQXlUO[+nB/g_4^5H@Uo`m\Zrr0k#dAj1f1(&?AdgK!Qn1E((;g<.:nSQa.l>!p;\*G@nTsgEb[V:=FgJ,1`2[)9D"I0S6*BV4/qO9j2_]A[SFtn=p;L]A:E(U3_a.B`T69]8^%\6E"JHN[j-J&j4@W;@XMsCi?[g7Q,JW6?ZE]k6oF9:(S(q@"Nlj]FSm%#0O7o).W\T.q-ojP]Af<*Y%U]>6BpS+sePL)&>(bcmV,G(#7a'j\Mj/]s,K#%"g@o^oD^iU?Us7b_04;^Fh_ZkI,!)k(`'C'GB>0]d;-hCFkei`2gZh9:.XAZbo0SFaji:Va?aT2g@66Y(m2bG4W@_+_U.#"395fA'F)b(je5ZDc2kST%H&1mH4[SdStP>ZS7P-#Alp_N`>f;6oJ@=B4]mb;U8p7qX0Kjj/_(jDk\&T9]k1A+'*3==+eM.r".r7QTH>Qt)bsfUhACK;s6o+Xa0EDKH`M-eBi$1M[_N;DTgPVN>c#R^I&S))eqT`T`D73M)Np]@e3@&J1N2+3JrQ0E~>endstream +endobj +15 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 969 +>> +stream +Gas1]bEC=.&A70Vjqt$bCKZ+,K7]qm[Yi+o>;!3EnLCsn,:hH!Y@hAN-OU<%-'"dhmN-(G*!1H]oNUtgD[O"bjhO53p:qmg6#d.uMUhG95=_RObE@NKOJIKu4NT%5Q=Kjat!k*Pg&b[kZ_1YEH^PRT"3;e]#(:#_p*-%Ps^dK1)&I6=0s1m7\`'cC4M_bi0Y_\&hoH8D$dNfUOFGB"U"fepR2nOE-;'V8X3mNR2'IO/a4k_+mu@:?`)HS1cMj+8"EoTi7``$QFtiYhlL#AVKX4m05&nUQle^Xn+R,Z#>4h#4(h4T)@1Z6^mbPO2l\9S1rWfWbq^p>UfAoTOVH1sY\QbhDk]husqs=],WO/jM"!.KWID,69/YXQNWHLN)"Kkeh>CgO01GfCe*HWQpPYmF,ndnD=]epfk-2MI'Y`k?Dh[Jjq+;s,G4l-Shl$N2t"]u\\j'oRu@QHaGSZes"^KLo79F"XM=!>liI9At_5"\/-jC(f5P0:i6NrXWOF)]Rn$_nYnWq&)=lE:hH$SKl6YKrZ$S7NSo0#=:?3VY;AJq)d%55SU/53J#'-BBQ9a=_Cn-;ho5PJ=@lCXfO?)$OZYs7E$PnngO8~>endstream +endobj +xref +0 16 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000436 00000 n +0000000641 00000 n +0000000846 00000 n +0000001051 00000 n +0000001256 00000 n +0000001325 00000 n +0000001653 00000 n +0000001731 00000 n +0000003794 00000 n +0000004379 00000 n +0000006855 00000 n +trailer +<< +/ID +[<9da3931a87c0b69c417bac11bd5b9730><9da3931a87c0b69c417bac11bd5b9730>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 10 0 R +/Root 9 0 R +/Size 16 +>> +startxref +7915 +%%EOF diff --git a/Claude outputs/kluis-360.png b/Claude outputs/kluis-360.png new file mode 100644 index 0000000..8811ea6 Binary files /dev/null and b/Claude outputs/kluis-360.png differ diff --git a/Claude outputs/momenten-360.png b/Claude outputs/momenten-360.png new file mode 100644 index 0000000..4040f69 Binary files /dev/null and b/Claude outputs/momenten-360.png differ diff --git a/Claude outputs/proef.png b/Claude outputs/proef.png new file mode 100644 index 0000000..77a3964 Binary files /dev/null and b/Claude outputs/proef.png differ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..cb52078 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + + net8.0 + + 12.0 + enable + enable + false + true + + diff --git a/MijnBesluit.sln b/MijnBesluit.sln new file mode 100644 index 0000000..094f181 --- /dev/null +++ b/MijnBesluit.sln @@ -0,0 +1,132 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Domain", "src\MijnBesluit.Domain\MijnBesluit.Domain.csproj", "{78C68EDC-CD50-4B72-969F-DA7FFD9CC953}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Application", "src\MijnBesluit.Application\MijnBesluit.Application.csproj", "{ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Protocol", "src\MijnBesluit.Protocol\MijnBesluit.Protocol.csproj", "{05A2CA84-85FB-4043-B86B-1558521484BE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Server", "src\MijnBesluit.Server\MijnBesluit.Server.csproj", "{9EB558C7-5637-4E6E-9A42-5874423778CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Infrastructure", "src\MijnBesluit.Infrastructure\MijnBesluit.Infrastructure.csproj", "{CFEB6CB0-D389-4C0A-9909-82671191C261}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.SocketServer", "src\MijnBesluit.SocketServer\MijnBesluit.SocketServer.csproj", "{D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Web", "src\MijnBesluit.Web\MijnBesluit.Web.csproj", "{30ABA559-48CF-4EAF-A197-58A6514E641F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A8A4E659-A6B3-4C25-A7B9-7B894462ACE3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.SelfCheck", "tests\MijnBesluit.SelfCheck\MijnBesluit.SelfCheck.csproj", "{93F37592-FAB2-453C-9E60-B058F7BDB736}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.ServiceBeheer", "src\MijnBesluit.ServiceBeheer\MijnBesluit.ServiceBeheer.csproj", "{EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Client", "src\MijnBesluit.Client\MijnBesluit.Client.csproj", "{2A06B933-9B91-4CC9-A7B1-D54398EC053C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.AppUi", "src\MijnBesluit.AppUi\MijnBesluit.AppUi.csproj", "{61BAC227-5B8D-47B6-9F9E-A7B7921992E4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.App", "src\MijnBesluit.App\MijnBesluit.App.csproj", "{D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Uitrol", "src\MijnBesluit.Uitrol\MijnBesluit.Uitrol.csproj", "{6426481C-74D7-4F38-910E-026B64DB8655}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Uitrolagent", "src\MijnBesluit.Uitrolagent\MijnBesluit.Uitrolagent.csproj", "{C00A7624-20AA-41B8-A4FF-EA672C4FC440}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Uitroller", "src\MijnBesluit.Uitroller\MijnBesluit.Uitroller.csproj", "{8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MijnBesluit.Agentvenster", "src\MijnBesluit.Agentvenster\MijnBesluit.Agentvenster.csproj", "{2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78C68EDC-CD50-4B72-969F-DA7FFD9CC953}.Release|Any CPU.Build.0 = Release|Any CPU + {ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED94DF5F-A8A1-447A-917D-B75ACE9DA78A}.Release|Any CPU.Build.0 = Release|Any CPU + {05A2CA84-85FB-4043-B86B-1558521484BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05A2CA84-85FB-4043-B86B-1558521484BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05A2CA84-85FB-4043-B86B-1558521484BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05A2CA84-85FB-4043-B86B-1558521484BE}.Release|Any CPU.Build.0 = Release|Any CPU + {9EB558C7-5637-4E6E-9A42-5874423778CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EB558C7-5637-4E6E-9A42-5874423778CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EB558C7-5637-4E6E-9A42-5874423778CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EB558C7-5637-4E6E-9A42-5874423778CA}.Release|Any CPU.Build.0 = Release|Any CPU + {CFEB6CB0-D389-4C0A-9909-82671191C261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CFEB6CB0-D389-4C0A-9909-82671191C261}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CFEB6CB0-D389-4C0A-9909-82671191C261}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CFEB6CB0-D389-4C0A-9909-82671191C261}.Release|Any CPU.Build.0 = Release|Any CPU + {D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2FB773B-BFDD-4292-831F-5CF4D3C2DB31}.Release|Any CPU.Build.0 = Release|Any CPU + {30ABA559-48CF-4EAF-A197-58A6514E641F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30ABA559-48CF-4EAF-A197-58A6514E641F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30ABA559-48CF-4EAF-A197-58A6514E641F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30ABA559-48CF-4EAF-A197-58A6514E641F}.Release|Any CPU.Build.0 = Release|Any CPU + {93F37592-FAB2-453C-9E60-B058F7BDB736}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93F37592-FAB2-453C-9E60-B058F7BDB736}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93F37592-FAB2-453C-9E60-B058F7BDB736}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93F37592-FAB2-453C-9E60-B058F7BDB736}.Release|Any CPU.Build.0 = Release|Any CPU + {EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8}.Release|Any CPU.Build.0 = Release|Any CPU + {2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A06B933-9B91-4CC9-A7B1-D54398EC053C}.Release|Any CPU.Build.0 = Release|Any CPU + {61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61BAC227-5B8D-47B6-9F9E-A7B7921992E4}.Release|Any CPU.Build.0 = Release|Any CPU + {D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1}.Release|Any CPU.Build.0 = Release|Any CPU + {6426481C-74D7-4F38-910E-026B64DB8655}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6426481C-74D7-4F38-910E-026B64DB8655}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6426481C-74D7-4F38-910E-026B64DB8655}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6426481C-74D7-4F38-910E-026B64DB8655}.Release|Any CPU.Build.0 = Release|Any CPU + {C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C00A7624-20AA-41B8-A4FF-EA672C4FC440}.Release|Any CPU.Build.0 = Release|Any CPU + {8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8484E1EE-82AA-4CF8-AE43-9ABF93B70A84}.Release|Any CPU.Build.0 = Release|Any CPU + {2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C3F1A57-9B4D-4E62-8A71-5D6E0C48B913}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {78C68EDC-CD50-4B72-969F-DA7FFD9CC953} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {ED94DF5F-A8A1-447A-917D-B75ACE9DA78A} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {05A2CA84-85FB-4043-B86B-1558521484BE} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {9EB558C7-5637-4E6E-9A42-5874423778CA} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {CFEB6CB0-D389-4C0A-9909-82671191C261} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {D2FB773B-BFDD-4292-831F-5CF4D3C2DB31} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {30ABA559-48CF-4EAF-A197-58A6514E641F} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {93F37592-FAB2-453C-9E60-B058F7BDB736} = {A8A4E659-A6B3-4C25-A7B9-7B894462ACE3} + {EF9D3638-EBB0-447C-AE9B-C1A08FBB12D8} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {2A06B933-9B91-4CC9-A7B1-D54398EC053C} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {61BAC227-5B8D-47B6-9F9E-A7B7921992E4} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {D9E4EDB8-2209-4FB1-B737-2EBC7BF00BB1} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {6426481C-74D7-4F38-910E-026B64DB8655} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + {C00A7624-20AA-41B8-A4FF-EA672C4FC440} = {D0E3CF39-A5FF-4341-BAA9-2CD8D13A5B30} + EndGlobalSection +EndGlobal diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..7604762 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/NuGet.offline.config b/NuGet.offline.config new file mode 100644 index 0000000..08d1f1d --- /dev/null +++ b/NuGet.offline.config @@ -0,0 +1,4 @@ + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..89c2a86 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# MijnBesluit + +Een platform om je beslissingen bij leven vast te leggen in een digitale kluis, en om berichten +te versturen aan familie en vrienden — nu, op een datum in de toekomst, of pas nadat je er niet +meer bent. + +Dit is de .NET-opzet: één solution in Visual Studio, een website, een zelfstandige SocketServer +voor de app, en een MariaDB-database. + +## Snel aan de praat + +### 1. Database + +``` +mysql -u root -p < db\01_schema.sql +mysql -u root -p < db\02_applicatiegebruiker.sql +``` + +Draaide er al een oudere versie? Draai dan ook `db\03_inloggen_met_code.sql`. + +Pas eerst het wachtwoord in `02_applicatiegebruiker.sql` aan. Wil je een andere schemanaam dan +`mijnbesluit`, vervang die dan in beide scripts. + +### 2. Sleutelbestand + +Bij ontwikkelen hoef je niets te doen: de website maakt `keys/masterkey.json` bij de eerste start +zelf aan, omdat `Security:MaakSleutelbestandAlsHetOntbreekt` in `appsettings.Development.json` +aanstaat. + +Voor een echte omgeving maak je het bestand eenmalig zelf aan en zet je die instelling **niet** aan: + +```csharp +MijnBesluit.Infrastructure.Security.FileMasterKeyProtector.CreateKeyFile(@"D:\MijnBesluit\keys\masterkey.json"); +``` + +Zonder dit bestand is geen enkele kluis meer te openen. Maak er een aparte back-up van, op een +andere plek dan de database-back-up. Zie `docs/Beveiliging.md`. + +### 3. Instellingen + +``` +dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=..." --project src\MijnBesluit.Web +dotnet user-secrets set "ConnectionStrings:MijnBesluit" "..." --project src\MijnBesluit.SocketServer +``` + +Tijdens ontwikkelen staat `Smtp:WriteToDiskInsteadOfSending` aan: uitnodigingsmails worden dan +als html-bestand in `mail-uitgaand` gezet in plaats van verstuurd. Handig om de uitnodigingslink +op te zoeken. + +### 4. Draaien + +``` +dotnet run --project src\MijnBesluit.Web # de website +dotnet run --project src\MijnBesluit.SocketServer -- --service # de server voor de app +``` + +In Visual Studio: stel beide projecten in als startprojecten (rechtsklik op de solution → +*Opstartprojecten configureren* → *Meerdere startprojecten*). Geef het SocketServer-project +`--service` mee als argument, anders verschijnt het beheerschermpje in plaats van de server. + +### 5. De SocketServer als Windows-service + +``` +dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish +``` + +Klik daarna met de rechtermuisknop op `MijnBesluit.SocketServer.exe` en kies *Als administrator +uitvoeren*. Er verschijnt een schermpje met een poortnummer en een knop **Install**. Is de +service al geïnstalleerd, dan staat er **UnInstall**. Zie `docs/SocketServer-installeren.md`. + +Uitgebreidere uitleg met een rondje door alle schermen staat in `docs/Website-testen.md`. + +## De regels controleren + +``` +dotnet run --project tests\MijnBesluit.SelfCheck +``` + +Dit loopt vijfenveertig scenario's door zonder database, webserver of packages: wachtwoorden, +versleuteling, inloggen met een code per e-mail, de claim-overgang van een profiel, de overlijdensprocedure met bezwaartermijn, de +kluistoegang van een nalatenschapsbeheerder, geplande berichten, de socketverbinding van app tot +kluis, het draadgedrag van de server en het opslaan van het poortnummer. Het draait in een paar seconden — goed om even te doen voordat je iets aan de regels +verandert. + +## Mappen + +``` +db/ SQL voor MariaDB +docs/ Architectuur, beveiliging, socketprotocol +src/MijnBesluit.Domain Entiteiten en regels +src/MijnBesluit.Application Handelingen en versleuteling +src/MijnBesluit.Protocol Socketprotocol: framing, server, client +src/MijnBesluit.Server Dispatcher en sessies voor de socketkant +src/MijnBesluit.Infrastructure EF Core, sleutels, bestanden, e-mail +src/MijnBesluit.ServiceBeheer De Windows-service installeren en verwijderen +src/MijnBesluit.SocketServer De zelfstandige service, het beheerschermpje en de achtergrondtaken +src/MijnBesluit.Web De website (Blazor) +tests/MijnBesluit.SelfCheck Controle van de regels +``` + +## .NET-versie + +Alles staat op `net8.0`; `MijnBesluit.SocketServer` op `net8.0-windows` en vast op 64 bits, +omdat daar het WinForms-schermpje en de Windows-service in zitten. Wil je naar .NET 10 (zoals RenHDashboardMaui), pas dan +`Directory.Build.props` en de `` in de csproj-bestanden aan. De MAUI-app op +`net10.0-android` kan deze bibliotheken ook op net8.0 gewoon gebruiken. + +## Naar de server + +Zie `docs/Uitrollen-naar-IIS.md` voor het uitrollen op een Windows-server met IIS en MariaDB. + +## Wat er nog moet gebeuren + +Zie `docs/Volgende-stappen.md`. + +## Waarom het is zoals het is + +Een paar keuzes zijn niet vanzelfsprekend en niet uit de code af te lezen. Die staan apart: + +- `docs/Identiteit.md` — dubbele profielen, claimen en samenvoegen. Waarom er niet op naam en + geboortedatum wordt gezocht, en waarom de kleine versie van een profielfoto niet versleuteld is. +- `docs/Beveiliging.md` — de drie lagen versleuteling en wat er gebeurt als het sleutelbestand weg is. +- `docs/E-mail.md` — de eigen mailserver, SPF, DKIM en DMARC. diff --git a/db/01_schema.sql b/db/01_schema.sql new file mode 100644 index 0000000..a5df10c --- /dev/null +++ b/db/01_schema.sql @@ -0,0 +1,368 @@ +-- ============================================================================ +-- MijnBesluit - databaseschema voor MariaDB 10.6 of nieuwer +-- +-- Aanmaken (in cmd, NIET in PowerShell - die kent "<" niet): +-- mysql -u root -p < 01_schema.sql +-- In PowerShell of HeidiSQL: zie docs/Website-testen.md. +-- +-- Het script mag meerdere keren gedraaid worden; bestaande tabellen blijven ongemoeid. +-- +-- De schemanaam staat hieronder één keer. Wil je een bestaande database +-- gebruiken, vervang dan `mijnbesluit` overal (of laat CREATE DATABASE weg +-- en zet er een USE ; voor in de plaats). +-- +-- Opzet: +-- * Alles utf8mb4 en InnoDB, zodat namen met accenten en emoji goed gaan. +-- * Geheimen staan als VARBINARY in de tabel: versleuteld met AES-256-GCM, +-- nooit leesbaar. Zie docs/Beveiliging.md voor hoe de sleutels lopen. +-- * Datums worden in UTC bewaard (DATETIME, geen TIMESTAMP), zodat zomertijd +-- nooit een bezorgmoment kan verschuiven. +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS `mijnbesluit` + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE `mijnbesluit`; + +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------------------------------------------- personen --- +-- Een profiel. Kan bestaan zonder account: dan is het aangemaakt door iemand +-- anders en wacht het op een uitnodiging. +CREATE TABLE IF NOT EXISTS `persons` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `first_name` VARCHAR(80) NOT NULL DEFAULT '', + `last_name` VARCHAR(80) NOT NULL DEFAULT '', + `nickname` VARCHAR(80) NULL, + `email` VARCHAR(255) NULL, + `mobile_number` VARCHAR(50) NULL, + `birth_date` DATE NULL, + `date_of_death` DATE NULL, + + -- Wie het profiel aanmaakte (historie) en wie het nu beheert. + `created_by_user_id` BIGINT NULL, + `managed_by_user_id` BIGINT NULL, + + -- Zodra dit gevuld is, is het profiel van de persoon zelf. + `claimed_by_user_id` BIGINT NULL, + `claimed_at` DATETIME(3) NULL, + + -- Aantal meldingen dat nodig is om overlijden te bevestigen, en hoeveel dagen + -- de betrokkene daarna nog heeft om bezwaar te maken. + `required_death_declarations` INT NOT NULL DEFAULT 3, + `death_objection_days` INT NOT NULL DEFAULT 14, + `death_confirmed_at` DATETIME(3) NULL, + `death_objected_at` DATETIME(3) NULL, + + -- Wie heeft gezegd dat deze aantekening niet over hem gaat. Zie db/17_aantekening_afgewezen.sql: + -- dan komt die vraag niet bij elke volgende keer opnieuw. + `link_declined_by_user_id` BIGINT NULL, + `link_declined_at` DATETIME(3) NULL, + + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`), + -- Met opzet NIET uniek. Twee mensen mogen allebei hun eigen aantekening over dezelfde + -- persoon hebben; ze worden pas één zodra die persoon zelf beide uitnodigingen aanneemt. + -- Zie docs/Identiteit.md en db/15_adres_niet_uniek.sql. Bij `users.email` is uniek juist + -- wél goed: dat is een account. + KEY `ix_persons_email` (`email`), + KEY `ix_persons_claimed_by` (`claimed_by_user_id`), + KEY `ix_persons_managed_by` (`managed_by_user_id`), + KEY `ix_persons_death` (`death_confirmed_at`) +) ENGINE=InnoDB; + +-- ---------------------------------------------------------------- accounts --- +CREATE TABLE IF NOT EXISTS `users` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `person_id` BIGINT NOT NULL, + `email` VARCHAR(255) NOT NULL, + + `security_stamp` CHAR(36) NOT NULL, + + -- De datasleutel van deze gebruiker, versleuteld met de mastersleutel. + `wrapped_data_key` VARBINARY(255) NOT NULL, + + `status` TINYINT NOT NULL DEFAULT 0, -- 0 actief, 1 geblokkeerd, 2 overleden + `email_confirmed_at` DATETIME(3) NULL, + `two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `failed_login_count` INT NOT NULL DEFAULT 0, + `locked_until` DATETIME(3) NULL, + `last_login_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`), + UNIQUE KEY `ux_users_email` (`email`), + UNIQUE KEY `ux_users_person` (`person_id`), + CONSTRAINT `fk_users_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) +) ENGINE=InnoDB; + +-- `persons` en `users` verwijzen naar elkaar, dus deze twee sleutels kunnen pas gelegd worden +-- als beide tabellen bestaan. ALTER TABLE kent geen "IF NOT EXISTS" voor een constraint, dus +-- wordt er eerst gekeken of hij er al ligt: zo kun je dit script gerust nog een keer draaien. +SET @fk_claimed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'persons' + AND CONSTRAINT_NAME = 'fk_persons_claimed_by'); + +SET @sql := IF(@fk_claimed = 0, + 'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_claimed_by` FOREIGN KEY (`claimed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL', + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @fk_managed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'persons' + AND CONSTRAINT_NAME = 'fk_persons_managed_by'); + +SET @sql := IF(@fk_managed = 0, + 'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_managed_by` FOREIGN KEY (`managed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL', + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- families --- +CREATE TABLE IF NOT EXISTS `families` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(120) NOT NULL, + `owner_person_id` BIGINT NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + KEY `ix_families_owner` (`owner_person_id`), + CONSTRAINT `fk_families_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `family_members` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `family_id` BIGINT NOT NULL, + `person_id` BIGINT NOT NULL, + `relation` INT NOT NULL DEFAULT 0, + `relation_label` VARCHAR(50) NULL, + `added_by_user_id` BIGINT NULL, + `created_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_family_member` (`family_id`, `person_id`), + KEY `ix_family_members_person` (`person_id`), + CONSTRAINT `fk_fm_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_fm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- ------------------------------------------------------------ uitnodigingen --- +-- Alleen de hash van het token staat hier; het token zelf bestaat enkel in de e-mail. +CREATE TABLE IF NOT EXISTS `invitations` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `person_id` BIGINT NOT NULL, + `family_id` BIGINT NOT NULL, + `email` VARCHAR(255) NOT NULL, + `token_hash` VARBINARY(32) NOT NULL, + `status` INT NOT NULL DEFAULT 0, + `created_by_user_id` BIGINT NOT NULL, + `created_at` DATETIME(3) NOT NULL, + `sent_at` DATETIME(3) NULL, + `expires_at` DATETIME(3) NOT NULL, + `accepted_at` DATETIME(3) NULL, + `resend_count` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_invitations_token` (`token_hash`), + KEY `ix_invitations_person` (`person_id`, `status`), + CONSTRAINT `fk_inv_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_inv_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- ------------------------------------------------------------------- kluis --- +-- Bijlagen: de bytes staan versleuteld buiten de database, hier alleen de verwijzing. +CREATE TABLE IF NOT EXISTS `vault_files` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `owner_person_id` BIGINT NOT NULL, + `file_name` VARCHAR(255) NOT NULL, + `content_type` VARCHAR(120) NOT NULL DEFAULT 'application/octet-stream', + `size_bytes` BIGINT NOT NULL DEFAULT 0, + `storage_key` VARCHAR(255) NOT NULL, + `nonce` VARBINARY(12) NOT NULL, + `tag` VARBINARY(16) NOT NULL, + `wrapped_content_key` VARBINARY(96) NOT NULL, + `sha256` VARBINARY(32) NOT NULL, + `created_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + KEY `ix_vault_files_owner` (`owner_person_id`), + CONSTRAINT `fk_vf_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- Kluisitems. `title` en `category` zijn leesbaar zodat er gezocht kan worden; +-- de inhoud zit versleuteld in `ciphertext`. +CREATE TABLE IF NOT EXISTS `vault_items` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `owner_person_id` BIGINT NOT NULL, + `category` INT NOT NULL, + `title` VARCHAR(150) NOT NULL, + `ciphertext` LONGBLOB NOT NULL, + `nonce` VARBINARY(12) NOT NULL, + `tag` VARBINARY(16) NOT NULL, + `wrapped_content_key` VARBINARY(96) NOT NULL, + `release_policy` INT NOT NULL DEFAULT 0, -- 0 alleen ikzelf, 1 na overlijden, 2 op datum + `release_at` DATETIME(3) NULL, + `vault_file_id` BIGINT NULL, + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NOT NULL, + `deleted_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + KEY `ix_vault_items_owner` (`owner_person_id`, `category`), + KEY `ix_vault_items_release` (`release_policy`, `release_at`), + CONSTRAINT `fk_vi_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vi_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB; + +-- Toegang van een ander tot een item: dezelfde contentsleutel, ingepakt met +-- de datasleutel van die ander. Zonder rij hier is er geen toegang, punt. +CREATE TABLE IF NOT EXISTS `content_grants` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `resource_type` VARCHAR(30) NOT NULL, -- VaultItem | VaultFile | Message + `resource_id` BIGINT NOT NULL, + `user_id` BIGINT NOT NULL, + `wrapped_content_key` VARBINARY(96) NOT NULL, + `reason` VARCHAR(50) NOT NULL, -- ontvanger | nalatenschap | gedeeld + `granted_at` DATETIME(3) NOT NULL, + `revoked_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_grant` (`resource_type`, `resource_id`, `user_id`), + KEY `ix_grants_user` (`user_id`), + CONSTRAINT `fk_grant_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- ---------------------------------------------------------------- berichten --- +CREATE TABLE IF NOT EXISTS `messages` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `from_person_id` BIGINT NOT NULL, + `to_person_id` BIGINT NOT NULL, + `kind` INT NOT NULL DEFAULT 0, -- 0 tekst, 1 video, 2 audio, 3 document + `subject` VARCHAR(200) NOT NULL, + `ciphertext` LONGBLOB NOT NULL, + `nonce` VARBINARY(12) NOT NULL, + `tag` VARBINARY(16) NOT NULL, + `wrapped_content_key` VARBINARY(96) NOT NULL, + `vault_file_id` BIGINT NULL, + `deliver_at` DATETIME(3) NULL, + `deliver_after_death` TINYINT(1) NOT NULL DEFAULT 0, + `status` INT NOT NULL DEFAULT 0, -- 0 concept, 1 gepland, 2 bezorgd, 3 gelezen, 4 ingetrokken + `delivered_at` DATETIME(3) NULL, + `read_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + KEY `ix_messages_to` (`to_person_id`, `status`), + KEY `ix_messages_from` (`from_person_id`), + -- De index waarop de bezorgtaak draait. + KEY `ix_messages_due` (`status`, `deliver_at`, `deliver_after_death`), + CONSTRAINT `fk_msg_from` FOREIGN KEY (`from_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_msg_to` FOREIGN KEY (`to_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_msg_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB; + +-- ------------------------------------------------------- overlijden & nalatenschap --- +-- Eén melding per gebruiker per profiel; dat dwingt de unieke sleutel af. +CREATE TABLE IF NOT EXISTS `death_declarations` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `person_id` BIGINT NOT NULL, + `declared_by_user_id` BIGINT NOT NULL, + `date_of_death` DATE NULL, + `note` VARCHAR(500) NULL, + `declared_at` DATETIME(3) NOT NULL, + `revoked_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_declaration` (`person_id`, `declared_by_user_id`), + CONSTRAINT `fk_dd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dd_user` FOREIGN KEY (`declared_by_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `legacy_managers` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `person_id` BIGINT NOT NULL, + `manager_person_id` BIGINT NOT NULL, + `priority` INT NOT NULL DEFAULT 1, + `accepted_at` DATETIME(3) NULL, + `access_granted_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_legacy_manager` (`person_id`, `manager_person_id`), + CONSTRAINT `fk_lm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_lm_manager` FOREIGN KEY (`manager_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- ------------------------------------------------------------------ sessies --- +CREATE TABLE IF NOT EXISTS `refresh_tokens` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `token_hash` VARBINARY(32) NOT NULL, + `device` VARCHAR(120) NULL, + `created_by_ip` VARCHAR(45) NULL, + `created_at` DATETIME(3) NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `revoked_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_refresh_token` (`token_hash`), + KEY `ix_refresh_user` (`user_id`, `expires_at`), + CONSTRAINT `fk_rt_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- ---------------------------------------------------------------- sessies --- +-- Een aanmelding van de app. Staat in de database en niet in het geheugen van de SocketServer, +-- want anders logt elke herstart van de service iedereen uit - en dan is de vingerafdruk waarmee +-- je de app opent niets waard. Het token zelf staat er niet in, alleen de SHA-256 ervan. +CREATE TABLE IF NOT EXISTS `sessies` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `token_hash` VARBINARY(32) NOT NULL, + `user_id` BIGINT NOT NULL, + `person_id` BIGINT NOT NULL, + `toestel` VARCHAR(120) NULL, + `aangemaakt_op` DATETIME(3) NOT NULL, + `verloopt_op` DATETIME(3) NOT NULL, + `laatst_gezien` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_sessies_token` (`token_hash`), + KEY `ix_sessies_verloopt` (`verloopt_op`), + CONSTRAINT `fk_sessies_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- ------------------------------------------------------------- inlogcodes --- +-- Er zijn geen wachtwoorden: je logt in met een code die per e-mail komt. +-- De code staat hier niet leesbaar in, alleen de hash ervan. +CREATE TABLE IF NOT EXISTS `login_codes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `purpose` INT NOT NULL DEFAULT 0, -- 0 inloggen, 1 registreren + `code_hash` VARCHAR(255) NOT NULL, + `first_name` VARCHAR(80) NULL, -- alleen bij registreren + `last_name` VARCHAR(80) NULL, + `created_at` DATETIME(3) NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `consumed_at` DATETIME(3) NULL, + `attempts` INT NOT NULL DEFAULT 0, + `requested_from_ip` VARCHAR(45) NULL, + PRIMARY KEY (`id`), + -- De index waarop het opzoeken van een openstaande code draait. + KEY `ix_login_codes_open` (`email`, `purpose`, `expires_at`), + KEY `ix_login_codes_created` (`email`, `created_at`) +) ENGINE=InnoDB; + +-- ----------------------------------------------------------------- logboek --- +-- Alleen toevoegen, nooit wijzigen. Bij een kluis wil je kunnen nagaan wie wat wanneer deed. +CREATE TABLE IF NOT EXISTS `audit_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NULL, + `action` INT NOT NULL, + `entity_type` VARCHAR(50) NULL, + `entity_id` BIGINT NULL, + `ip_address` VARCHAR(45) NULL, + `user_agent` VARCHAR(255) NULL, + `details` VARCHAR(1000) NULL, + `created_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + KEY `ix_audit_user_time` (`user_id`, `created_at`), + KEY `ix_audit_entity` (`entity_type`, `entity_id`) +) ENGINE=InnoDB; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/db/02_applicatiegebruiker.sql b/db/02_applicatiegebruiker.sql new file mode 100644 index 0000000..fd004b3 --- /dev/null +++ b/db/02_applicatiegebruiker.sql @@ -0,0 +1,63 @@ +-- ============================================================================ +-- Een aparte databasegebruiker voor de applicatie, met alleen de rechten die +-- hij nodig heeft: geen CREATE, geen DROP, geen ALTER. Schemawijzigingen doe +-- je met een migratie onder je eigen account, niet vanuit de draaiende +-- applicatie. +-- +-- Vervang het wachtwoord hieronder en bewaar het niet in de broncode -- +-- zie docs/Beveiliging.md, "Waar de geheimen staan". +-- +-- Draaien (in cmd, NIET in PowerShell): +-- mysql -u root -p < 02_applicatiegebruiker.sql +-- +-- Opnieuw draaien mag en is veilig: CREATE USER IF NOT EXISTS slaat de hele regel over als de +-- gebruiker al bestaat, dus het wachtwoord verandert niet. Dat is precies wat je doet als er +-- tabellen bij zijn gekomen. +-- +-- LET OP: komt er een tabel bij, dan hoort hij hier ook bij te staan. Zonder recht krijgt de +-- applicatie "SELECT command denied" - en dat merk je pas als iemand dat scherm opent. Er is +-- een zelftest die elke CREATE TABLE in deze map naast deze lijst legt. +-- +-- En hier, in DIT bestand, en nergens anders. Sommige migratiescripts deelden vroeger zelf +-- rechten uit, vlak onder hun CREATE TABLE. Dat leest prettig, maar de scripts vanaf 03 worden +-- ook door de uitrolagent gedraaid met een beperkt databaseaccount, en dat mag geen rechten +-- uitdelen: de uitrol struikelde er dan op. Rechten uitdelen is inrichten, en dat doet een mens +-- één keer als root. De zelftest bewaakt ook dat. +-- ============================================================================ + +CREATE USER IF NOT EXISTS 'mijnbesluit_app'@'localhost' + IDENTIFIED BY 'VERVANG_DIT_WACHTWOORD'; + +-- De rechten worden per tabel gegeven en niet in één keer op de hele database. +-- Dat is met opzet: rechten in MariaDB zijn optellend, dus een recht dat op +-- databaseniveau is gegeven kun je op tabelniveau niet meer afnemen. Wil je +-- het logboek onwijzigbaar houden, dan mag UPDATE/DELETE op de database dus +-- nooit gegeven worden. + +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`persons` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`users` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`families` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`family_members` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`invitations` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`vault_items` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`vault_files` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`content_grants` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`messages` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`death_declarations` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`legacy_managers` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`refresh_tokens` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`login_codes` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`toestellen` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`samenvoegingen` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`adverteerders` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`campagnes` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`campagne_uren` TO 'mijnbesluit_app'@'localhost'; +GRANT SELECT, INSERT, UPDATE, DELETE ON `mijnbesluit`.`sessies` TO 'mijnbesluit_app'@'localhost'; + +-- Het logboek mag er alleen bij komen en uit gelezen worden, niet uit verdwijnen. +GRANT SELECT, INSERT ON `mijnbesluit`.`audit_log` TO 'mijnbesluit_app'@'localhost'; + +FLUSH PRIVILEGES; + +-- Controle: audit_log hoort alleen SELECT en INSERT te hebben, de rest alles behalve DDL. +SHOW GRANTS FOR 'mijnbesluit_app'@'localhost'; diff --git a/db/03_inloggen_met_code.sql b/db/03_inloggen_met_code.sql new file mode 100644 index 0000000..0bc2b31 --- /dev/null +++ b/db/03_inloggen_met_code.sql @@ -0,0 +1,58 @@ +-- ============================================================================ +-- Overstap van wachtwoorden naar inloggen met een code per e-mail. +-- +-- Draai dit op een database die met een eerdere versie van 01_schema.sql is +-- aangemaakt. Op een verse database is dit niet nodig: 01_schema.sql bevat +-- alles al. +-- +-- Draaien (in cmd, NIET in PowerShell), of in HeidiSQL via +-- Bestand -> SQL-bestand uitvoeren: +-- mysql -u root -p < 03_inloggen_met_code.sql +-- +-- Het script mag meerdere keren gedraaid worden. +-- ============================================================================ + +USE `mijnbesluit`; + +-- 1. De tabel met eenmalige codes. +CREATE TABLE IF NOT EXISTS `login_codes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `purpose` INT NOT NULL DEFAULT 0, -- 0 inloggen, 1 registreren + `code_hash` VARCHAR(255) NOT NULL, + `first_name` VARCHAR(80) NULL, + `last_name` VARCHAR(80) NULL, + `created_at` DATETIME(3) NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `consumed_at` DATETIME(3) NULL, + `attempts` INT NOT NULL DEFAULT 0, + `requested_from_ip` VARCHAR(45) NULL, + PRIMARY KEY (`id`), + KEY `ix_login_codes_open` (`email`, `purpose`, `expires_at`), + KEY `ix_login_codes_created` (`email`, `created_at`) +) ENGINE=InnoDB; + +-- 2. De wachtwoordkolom weg. ALTER TABLE kent geen "DROP COLUMN IF EXISTS" in +-- elke MariaDB-versie, dus eerst kijken of hij er staat. +SET @kolom := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = 'password_hash'); + +SET @sql := IF(@kolom > 0, 'ALTER TABLE `users` DROP COLUMN `password_hash`', 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 3. De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql. +-- +-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt +-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen, +-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat +-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de +-- lijst in 02, dus vergeten kan niet ongemerkt. + +-- Controle. +SELECT 'login_codes' AS tabel, + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'login_codes') AS bestaat, + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'password_hash') AS wachtwoordkolom_nog_aanwezig; diff --git a/db/04_bijlagen.sql b/db/04_bijlagen.sql new file mode 100644 index 0000000..4d5a499 --- /dev/null +++ b/db/04_bijlagen.sql @@ -0,0 +1,55 @@ +-- MijnBesluit - bijlagen bij kluisitems +-- +-- Voegt twee kolommen toe aan `vault_files`: +-- vault_item_id : bij welk kluisitem dit bestand hoort. Leeg bij een bestand dat op zichzelf +-- staat, zoals de opname bij een videobericht. +-- deleted_at : gevuld zodra het bestand verwijderd is. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\04_bijlagen.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +-- ---------------------------------------------------------------- vault_item_id +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'vault_item_id'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `vault_item_id` BIGINT NULL AFTER `owner_person_id`', + 'SELECT "vault_item_id bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- deleted_at +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'deleted_at'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `deleted_at` DATETIME(3) NULL', + 'SELECT "deleted_at bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- index + relatie +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND INDEX_NAME = 'ix_vault_files_item'); + +SET @sql := IF(@bestaat = 0, + 'CREATE INDEX `ix_vault_files_item` ON `vault_files` (`vault_item_id`)', + 'SELECT "index bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Verdwijnt het kluisitem, dan verdwijnt de koppeling; het bestand zelf ruimt de applicatie op. +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND CONSTRAINT_NAME = 'fk_vf_item'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD CONSTRAINT `fk_vf_item` FOREIGN KEY (`vault_item_id`) REFERENCES `vault_items` (`id`) ON DELETE SET NULL', + 'SELECT "fk_vf_item bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar: bijlagen toegevoegd aan vault_files.' AS melding; diff --git a/db/05_meldingen.sql b/db/05_meldingen.sql new file mode 100644 index 0000000..6532332 --- /dev/null +++ b/db/05_meldingen.sql @@ -0,0 +1,38 @@ +-- MijnBesluit - toestellen voor push-meldingen +-- +-- Elke app meldt zich met een kenmerk van Firebase (Android) of APNs (iOS). Daarmee kan de +-- SocketServer een melding sturen als er een bericht wordt bezorgd, ook als de app dicht is. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\05_meldingen.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +CREATE TABLE IF NOT EXISTS `toestellen` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + -- Het kenmerk van Google is lang; 512 is ruim genoeg en past nog in een unieke index. + `kenmerk` VARCHAR(512) NOT NULL, + `platform` VARCHAR(20) NOT NULL DEFAULT 'android', + `naam` VARCHAR(120) NULL, + `aangemeld_op` DATETIME(3) NOT NULL, + `laatst_gezien` DATETIME(3) NOT NULL, + `afgemeld_op` DATETIME(3) NULL, + PRIMARY KEY (`id`), + -- Eén kenmerk hoort bij één toestel. Wisselt de gebruiker, dan verhuist de regel mee. + UNIQUE KEY `ux_toestellen_kenmerk` (`kenmerk`), + KEY `ix_toestellen_user` (`user_id`), + CONSTRAINT `fk_toestel_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql. +-- +-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt +-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen, +-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat +-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de +-- lijst in 02, dus vergeten kan niet ongemerkt. + +SELECT 'Klaar: tabel toestellen aangemaakt.' AS melding; diff --git a/db/06_vooruitblik.sql b/db/06_vooruitblik.sql new file mode 100644 index 0000000..b7d09dd --- /dev/null +++ b/db/06_vooruitblik.sql @@ -0,0 +1,22 @@ +-- MijnBesluit - instelling voor het overzicht +-- +-- Voegt aan `users` toe hoeveel dagen vooruit iemand op zijn overzicht wil zien wat eraan komt. +-- 0 betekent: niets tonen. Standaard 30, maximaal 60. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\06_vooruitblik.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'vooruitblik_dagen'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `users` ADD COLUMN `vooruitblik_dagen` INT NOT NULL DEFAULT 30', + 'SELECT "vooruitblik_dagen bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar: vooruitblik-instelling toegevoegd aan users.' AS melding; diff --git a/db/07_fotos.sql b/db/07_fotos.sql new file mode 100644 index 0000000..6838029 --- /dev/null +++ b/db/07_fotos.sql @@ -0,0 +1,89 @@ +-- MijnBesluit - foto's bij personen en berichten +-- +-- Tot nu toe kon een bestand alleen bij een kluisitem horen. Er komen twee koppelingen bij, +-- plus een vinkje voor de profielfoto: +-- +-- subject_person_id : bij welke persoon deze foto hoort. Let op het verschil met +-- owner_person_id: dat is wie de foto bewaart, dit is wie erop staat. +-- message_id : bij welk bericht deze foto hoort. +-- is_profile_photo : dit is de foto die bij de persoon op de kaart komt te staan. +-- Er is er hoogstens een per persoon; de applicatie bewaakt dat. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\07_fotos.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +-- ---------------------------------------------------------------- subject_person_id +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'subject_person_id'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `subject_person_id` BIGINT NULL AFTER `vault_item_id`', + 'SELECT "subject_person_id bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- message_id +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'message_id'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `message_id` BIGINT NULL AFTER `subject_person_id`', + 'SELECT "message_id bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- is_profile_photo +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'is_profile_photo'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `is_profile_photo` TINYINT(1) NOT NULL DEFAULT 0', + 'SELECT "is_profile_photo bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- indexen +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND INDEX_NAME = 'ix_vault_files_subject'); + +SET @sql := IF(@bestaat = 0, + 'CREATE INDEX `ix_vault_files_subject` ON `vault_files` (`subject_person_id`)', + 'SELECT "ix_vault_files_subject bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND INDEX_NAME = 'ix_vault_files_message'); + +SET @sql := IF(@bestaat = 0, + 'CREATE INDEX `ix_vault_files_message` ON `vault_files` (`message_id`)', + 'SELECT "ix_vault_files_message bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- relaties +-- Verdwijnt de persoon of het bericht, dan verdwijnt de koppeling; het bestand zelf ruimt de +-- applicatie op, want de versleutelde inhoud staat buiten de database. +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND CONSTRAINT_NAME = 'fk_vf_subject'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD CONSTRAINT `fk_vf_subject` FOREIGN KEY (`subject_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL', + 'SELECT "fk_vf_subject bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND CONSTRAINT_NAME = 'fk_vf_message'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD CONSTRAINT `fk_vf_message` FOREIGN KEY (`message_id`) REFERENCES `messages` (`id`) ON DELETE SET NULL', + 'SELECT "fk_vf_message bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar: foto''s bij personen en berichten toegevoegd aan vault_files.' AS melding; diff --git a/db/08_thumbnails.sql b/db/08_thumbnails.sql new file mode 100644 index 0000000..865e40a --- /dev/null +++ b/db/08_thumbnails.sql @@ -0,0 +1,55 @@ +-- MijnBesluit - kleine versie van een foto, om in lijstjes te tonen +-- +-- Een profielfoto op de kaart van een persoon is een plaatje van hooguit veertig pixels. De +-- foto zelf is al gauw een paar honderd kilobyte, en tien personen in een lijst zijn dan een +-- paar megabyte - over een mobiele verbinding merk je dat meteen. +-- +-- Daarom slaan we bij een foto meteen een kleine versie op. Die is versleuteld met dezelfde +-- sleutel als de foto zelf, dus er staat niets leesbaars in de database: +-- +-- thumbnail : de kleine versie, versleuteld +-- thumbnail_nonce : het eenmalige getal van die versleuteling +-- thumbnail_tag : het echtheidskenmerk van die versleuteling +-- +-- Alle drie leeg betekent: er is geen kleine versie. Dat is geen fout - bij een document of een +-- video is er niets te verkleinen, en bij foto's van voor deze wijziging is hij er nog niet. +-- Het scherm valt dan terug op de beginletters, precies zoals het zonder foto ook doet. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\08_thumbnails.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +-- ---------------------------------------------------------------- thumbnail +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'thumbnail'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `thumbnail` BLOB NULL', + 'SELECT "thumbnail bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- thumbnail_nonce +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'thumbnail_nonce'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `thumbnail_nonce` VARBINARY(12) NULL', + 'SELECT "thumbnail_nonce bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- thumbnail_tag +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_files' AND COLUMN_NAME = 'thumbnail_tag'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_files` ADD COLUMN `thumbnail_tag` VARBINARY(16) NULL', + 'SELECT "thumbnail_tag bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar. De kleine versies worden vanaf nu bij het opslaan van een foto meegemaakt.' AS melding; diff --git a/db/09_samenvoegen.sql b/db/09_samenvoegen.sql new file mode 100644 index 0000000..99052b1 --- /dev/null +++ b/db/09_samenvoegen.sql @@ -0,0 +1,54 @@ +-- MijnBesluit - profielen samenvoegen +-- +-- Twee mensen kunnen allebei een profiel aanmaken voor dezelfde persoon. Jij voegt je dochter +-- toe, je ex-partner doet dat ook: twee rijen in `persons`, allebei een eerlijke aantekening +-- van degene die hem maakte. Dat is geen fout, zolang niemand het profiel heeft geclaimd. +-- +-- Zodra zij beide uitnodigingen aanneemt, zegt zij zelf dat het om haar gaat. Dan gaat het ene +-- profiel op in het andere. Het duplicaat blijft staan - er hangt geschiedenis aan, en een +-- verwijderde rij is niet terug te halen - maar wijst voortaan naar waar hij in is opgegaan: +-- +-- merged_into_person_id : het profiel waarin deze is opgegaan. Leeg = gewoon in gebruik. +-- +-- Alles wat naar het duplicaat verwees is op dat moment verhuisd: lidmaatschappen, berichten, +-- foto's, uitnodigingen en rollen in een nalatenschap. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\09_samenvoegen.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +-- ---------------------------------------------------------------- merged_into_person_id +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'merged_into_person_id'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `persons` ADD COLUMN `merged_into_person_id` BIGINT NULL', + 'SELECT "merged_into_person_id bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- index +-- Elke lijst met mensen filtert hierop, dus hij wordt vaak gelezen. +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND INDEX_NAME = 'ix_persons_merged'); + +SET @sql := IF(@bestaat = 0, + 'CREATE INDEX `ix_persons_merged` ON `persons` (`merged_into_person_id`)', + 'SELECT "ix_persons_merged bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- verwijzing naar zichzelf +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND CONSTRAINT_NAME = 'fk_persons_merged'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_merged` FOREIGN KEY (`merged_into_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL', + 'SELECT "fk_persons_merged bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar. Profielen kunnen vanaf nu worden samengevoegd.' AS melding; diff --git a/db/10_bezwaarlink.sql b/db/10_bezwaarlink.sql new file mode 100644 index 0000000..f330886 --- /dev/null +++ b/db/10_bezwaarlink.sql @@ -0,0 +1,53 @@ +-- MijnBesluit - bezwaar maken zonder in te loggen +-- +-- Als er gemeld wordt dat je bent overleden, krijg je daar bericht van met de vraag of dat +-- klopt. Tot nu toe moest je inloggen om te zeggen dat je er nog bent. Dat gaat mis op precies +-- het moment dat het ertoe doet: zodra het overlijden bevestigd is, staat je account op +-- "overleden" en kun je niet meer inloggen. Dan is er geen weg terug. +-- +-- Daarom gaat er voortaan een link mee in die e-mail, die werkt zonder inloggen - net als een +-- uitnodiging. Alleen de versleutelde vorm van dat kenmerk staat hier; de link zelf staat +-- uitsluitend in de e-mail. +-- +-- objection_token_hash : SHA-256 van het kenmerk uit de link +-- objection_token_expires : tot wanneer die link werkt +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\10_bezwaarlink.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +-- ---------------------------------------------------------------- objection_token_hash +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'objection_token_hash'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `persons` ADD COLUMN `objection_token_hash` VARBINARY(32) NULL', + 'SELECT "objection_token_hash bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- objection_token_expires +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'objection_token_expires'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `persons` ADD COLUMN `objection_token_expires` DATETIME(3) NULL', + 'SELECT "objection_token_expires bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- index +-- De link wordt opgezocht op dit kenmerk, dus zonder index is dat een tabelscan. +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND INDEX_NAME = 'ix_persons_objection'); + +SET @sql := IF(@bestaat = 0, + 'CREATE INDEX `ix_persons_objection` ON `persons` (`objection_token_hash`)', + 'SELECT "ix_persons_objection bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar. De waarschuwingsmail krijgt vanaf nu een bezwaarlink.' AS melding; diff --git a/db/11_beheerder.sql b/db/11_beheerder.sql new file mode 100644 index 0000000..d172a9c --- /dev/null +++ b/db/11_beheerder.sql @@ -0,0 +1,37 @@ +-- MijnBesluit - een beheerder +-- +-- Er is één situatie waar niemand meer uit komt: een overlijden dat ten onrechte is bevestigd, +-- terwijl de bezwaarlink uit de waarschuwingsmail is verlopen. Het account staat dan op +-- "overleden", inloggen kan niet meer, en de enige weg terug is weg. +-- +-- Daarvoor is een beheerder. Bewust zo klein mogelijk gehouden: +-- +-- * een beheerder kan een profiel opzoeken op het VOLLEDIGE e-mailadres, niet op naam. Zoeken +-- op naam zou een zoekmachine op mensen zijn - zie docs/Identiteit.md. +-- * een beheerder kan een bevestigd overlijden terugdraaien, met een reden die wordt vastgelegd. +-- * een beheerder kan GEEN kluis openen. Die handeling geeft nooit toegang, hij neemt alleen +-- toegang weg. +-- +-- Beheerder word je alleen hier, met de hand: +-- UPDATE `users` SET `is_beheerder` = 1 WHERE `email` = 'jij@voorbeeld.nl'; +-- +-- Er is met opzet geen scherm om iemand beheerder te maken. Wat niet via de applicatie kan, +-- kan ook niet via een lek in de applicatie. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\11_beheerder.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'is_beheerder'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `users` ADD COLUMN `is_beheerder` TINYINT(1) NOT NULL DEFAULT 0', + 'SELECT "is_beheerder bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar. Maak iemand beheerder met: UPDATE `users` SET `is_beheerder` = 1 WHERE `email` = ...' AS melding; diff --git a/db/12_samenvoegingen.sql b/db/12_samenvoegingen.sql new file mode 100644 index 0000000..55847f7 --- /dev/null +++ b/db/12_samenvoegingen.sql @@ -0,0 +1,51 @@ +-- MijnBesluit - het bonnetje van een samenvoeging +-- +-- Twee profielen van dezelfde persoon worden er één zodra zij zelf beide uitnodigingen aanneemt. +-- Tot nu toe was dat onomkeerbaar, en dat stond ook zo in docs/Identiteit.md bij "wat hier nog +-- niet is opgelost". +-- +-- Waarom terugdraaien niet zomaar kan: na afloop is niet meer te zien wélke lidmaatschappen, +-- berichten en bestanden bij het duplicaat hoorden. Ze staan gewoon bij het doel, tussen alles +-- wat daar al stond. Gokken zou andermans gegevens meesleuren. +-- +-- Daarom deze tabel. Bij elke samenvoeging komt hier één regel in te staan met precies wat er is +-- verhuisd, als JSON. Terugdraaien is dan die lijst in omgekeerde volgorde aflopen. Regels die +-- bij het samenvoegen zijn weggegooid - een dubbel lidmaatschap, een dubbele beheerder - staan +-- er als volledige kopie in, want die moeten opnieuw worden aangemaakt. +-- +-- De regel blijft ook na het terugdraaien staan, met undone_at gevuld. Dat er ooit is +-- samengevoegd is zelf ook geschiedenis. +-- +-- Let op: samenvoegingen van VOOR deze tabel hebben geen bonnetje en zijn dus niet terug te +-- draaien. Dat is geen fout die te repareren valt; de gegevens om het mee te doen bestaan niet. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\12_samenvoegingen.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +CREATE TABLE IF NOT EXISTS `samenvoegingen` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `duplicate_person_id` BIGINT NOT NULL, + `target_person_id` BIGINT NOT NULL, + `by_user_id` BIGINT NOT NULL, + `merged_at` DATETIME(6) NOT NULL, + `steps` LONGTEXT NOT NULL, + `undone_at` DATETIME(6) NULL, + `undone_by_user_id` BIGINT NULL, + `reason` VARCHAR(500) NULL, + PRIMARY KEY (`id`), + KEY `ix_samenvoegingen_duplicaat` (`duplicate_person_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql. +-- +-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt +-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen, +-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat +-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de +-- lijst in 02, dus vergeten kan niet ongemerkt. + +SELECT 'Klaar. Samenvoegingen worden vanaf nu vastgelegd en kunnen worden teruggedraaid.' AS melding; diff --git a/db/13_adverteren.sql b/db/13_adverteren.sql new file mode 100644 index 0000000..b3f0952 --- /dev/null +++ b/db/13_adverteren.sql @@ -0,0 +1,123 @@ +-- ============================================================================ +-- Adverteren op MijnBesluit: adverteerders, campagnes en tellers. +-- +-- Lees docs/Adverteren.md voordat je hier iets aan verandert. Daar staat waarom de balk laag +-- is, hoe er wordt gekozen wie er getoond wordt, en waarom goedkeuring vooraf gaat. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\13_adverteren.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. +-- ============================================================================ + +USE `mijnbesluit`; + +-- ------------------------------------------------------------ adverteerders --- +-- Een bedrijf dat adverteert. Bewust een eigen tabel en géén regel in `users`: +-- +-- * Een adverteerder is een bedrijf, geen persoon met een kluis, familie of nalatenschap. +-- Hem in `users` zetten zou betekenen dat elke query over gebruikers hem moet uitzonderen, +-- en één vergeten uitzondering is er één te veel in een applicatie die over nalatenschappen +-- gaat. +-- * Het scheidt de twee werelden ook fysiek: wie in het adverteerdersdeel inbreekt, staat nog +-- steeds nergens bij de kluizen in de buurt. +-- +-- Inloggen gaat net als bij gebruikers met een code per e-mail; die codes staan in dezelfde +-- tabel `login_codes`, met een eigen doel-nummer. +CREATE TABLE IF NOT EXISTS `adverteerders` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `company_name` VARCHAR(160) NOT NULL, + `contact_name` VARCHAR(160) NOT NULL DEFAULT '', + `email` VARCHAR(255) NOT NULL, + `phone` VARCHAR(50) NULL, + `address` VARCHAR(160) NULL, + `postal_code` VARCHAR(20) NULL, + `city` VARCHAR(120) NULL, + `country` VARCHAR(80) NULL, + `coc_number` VARCHAR(40) NULL, -- KvK + `vat_number` VARCHAR(40) NULL, -- btw + `created_at` DATETIME(6) NOT NULL, + `last_seen_at` DATETIME(6) NULL, + `blocked_at` DATETIME(6) NULL, -- geen nieuwe campagnes meer; bestaande blijven staan + PRIMARY KEY (`id`), + UNIQUE KEY `ux_adverteerders_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ---------------------------------------------------------------- campagnes --- +-- Eén geboekte advertentie: wat er staat, wanneer hij mag draaien, hoe vaak per uur, wat hij +-- kost en hoe ver hij is. +-- +-- De afbeelding staat hier als blob en niet in `vault_files`. Die tabel is voor spullen van +-- gebruikers en is versleuteld met sleutels per gebruiker; een advertentielogo is juist +-- openbaar en hoort daar niet tussen te staan. Hij wordt bij het opslaan verkleind, dus groot +-- kan hij niet worden. +-- +-- `amount_cents` wordt bij het boeken berekend en dan vastgelegd. Verandert het tarief later, +-- dan verandert een al geboekte campagne niet mee - je hebt afgesproken wat je afsprak. +CREATE TABLE IF NOT EXISTS `campagnes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `advertiser_id` BIGINT NOT NULL, + + `text` VARCHAR(300) NOT NULL, + `link` VARCHAR(500) NOT NULL, + `image` LONGBLOB NULL, + `image_type` VARCHAR(100) NULL, + + `starts_on` DATE NOT NULL, + `ends_on` DATE NOT NULL, + `per_hour` INT NOT NULL, + + `amount_cents` INT NOT NULL, + `rate_cents` INT NOT NULL, -- het tarief waarmee gerekend is, voor de verantwoording + `payment_reference` VARCHAR(40) NULL, -- wat de adverteerder bij zijn bunq-betaling zet + `paid_at` DATETIME(6) NULL, + `paid_by_user_id` BIGINT NULL, -- welke beheerder het afvinkte + + `state` TINYINT NOT NULL DEFAULT 0, + `approved_at` DATETIME(6) NULL, + `approved_by_user_id` BIGINT NULL, + `rejection_reason` VARCHAR(500) NULL, + + `created_at` DATETIME(6) NOT NULL, + `updated_at` DATETIME(6) NULL, + + PRIMARY KEY (`id`), + KEY `ix_campagnes_adverteerder` (`advertiser_id`), + + -- De vraag "welke campagnes mogen nu draaien" komt bij élke keer dat iemand het overzicht + -- opent. Die moet op een index kunnen landen en niet op de hele tabel. + KEY `ix_campagnes_lopend` (`state`, `starts_on`, `ends_on`), + + CONSTRAINT `fk_campagnes_adverteerder` FOREIGN KEY (`advertiser_id`) + REFERENCES `adverteerders` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- --------------------------------------------------------------- vertoningen --- +-- Eén regel per campagne per klokuur, met tellers erin. Níét één regel per vertoning: bij een +-- paar honderd bezoekers per uur loopt zo'n tabel eindeloos vol, en voor het kiezen én voor de +-- verantwoording is een teller per uur genoeg. +-- +-- Het uur staat in UTC, op het hele uur afgerond. De unieke sleutel is wat het optellen veilig +-- maakt: twee bezoekers tegelijk leveren een INSERT ... ON DUPLICATE KEY UPDATE op en geen +-- tweede regel. +CREATE TABLE IF NOT EXISTS `campagne_uren` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `campaign_id` BIGINT NOT NULL, + `hour_utc` DATETIME(6) NOT NULL, + `shown` INT NOT NULL DEFAULT 0, + `clicks` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_campagne_uren` (`campaign_id`, `hour_utc`), + CONSTRAINT `fk_campagne_uren_campagne` FOREIGN KEY (`campaign_id`) + REFERENCES `campagnes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- De rechten voor de applicatiegebruiker staan NIET hier maar in 02_applicatiegebruiker.sql. +-- +-- Waarom: de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt +-- databaseaccount dat geen rechten mág uitdelen. Een GRANT hier laat die hele uitrol struikelen, +-- en dat account meer macht geven is de verkeerde kant op. Rechten uitdelen is inrichten; dat +-- doet een mens één keer als root. Een zelfcontrole legt elke CREATE TABLE in deze map naast de +-- lijst in 02, dus vergeten kan niet ongemerkt. + +SELECT 'Klaar. Adverteerders, campagnes en tellers staan klaar.' AS melding; diff --git a/db/14_inlogcode_gebruikt.sql b/db/14_inlogcode_gebruikt.sql new file mode 100644 index 0000000..1c56027 --- /dev/null +++ b/db/14_inlogcode_gebruikt.sql @@ -0,0 +1,38 @@ +-- MijnBesluit - onthouden welke inlogcode ECHT is gebruikt +-- +-- Er zit een rem op het aanvragen van codes: niet meer dan vijf per uur voor hetzelfde adres. +-- Die rem is er tegen één ding: iemand die het postvak van een ander volgooit door steeds +-- opnieuw een code voor dat adres aan te vragen. +-- +-- Die telling keek naar alle codes van het afgelopen uur, ook de codes die gewoon zijn gebruikt +-- om in te loggen. Wie vijf keer in een uur inlogde, sloot zichzelf dus een uur buiten - en dat +-- gebeurt precies wanneer er íéts anders aan de hand is en je het een paar keer opnieuw +-- probeert. Dat is de verkeerde kant om te falen: de rem hoort een vreemde te stoppen, niet de +-- eigenaar. +-- +-- Waarom daar een nieuwe kolom voor nodig is: `consumed_at` betekent "deze code werkt niet +-- meer", en dat wordt ook gezet als een nieuwe aanvraag de vorige ongeldig maakt, en als een +-- code te vaak fout is ingevuld. Precies die twee gevallen MOETEN meetellen - anders kan een +-- vreemde de rem leegtrekken door codes aan te vragen of expres fout in te vullen. +-- +-- `signed_in_at` staat alleen gevuld als de code goed is ingevuld en er iemand mee is +-- binnengekomen. Alleen dát is het bewijs dat de aanvrager zelf in dat postvak zit, en alleen +-- die codes tellen niet mee voor de rem. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\14_inlogcode_gebruikt.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'login_codes' AND COLUMN_NAME = 'signed_in_at'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `login_codes` ADD COLUMN `signed_in_at` DATETIME(3) NULL', + 'SELECT "signed_in_at bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar.' AS melding; diff --git a/db/15_adres_niet_uniek.sql b/db/15_adres_niet_uniek.sql new file mode 100644 index 0000000..8ad5325 --- /dev/null +++ b/db/15_adres_niet_uniek.sql @@ -0,0 +1,46 @@ +-- MijnBesluit - twee mensen mogen dezelfde persoon kennen +-- +-- Op `persons.email` stond een UNIQUE-index. Dat botste met het uitgangspunt van +-- docs/Identiteit.md: iedereen heeft zijn EIGEN aantekening over een ander, en die aantekeningen +-- worden pas één zodra de persoon zelf beide uitnodigingen aanneemt. +-- +-- Wat er misging: voegde je iemand toe met een e-mailadres dat al ergens in de database stond - +-- bij wie dan ook, ook bij een wildvreemde - dan liep het opslaan stuk op die index. Voor de +-- gebruiker was dat een knop die niets deed. En het gevolg was erger dan een foutmelding: wie +-- het adres van iemand het eerst invulde, bepaalde daarmee dat niemand anders die persoon nog +-- kon toevoegen. In een applicatie waarin twee ouders allebei hun dochter toevoegen is dat +-- precies de verkeerde kant om. +-- +-- Uniek blijft het wél bij `users.email`: één account per e-mailadres. Dat is iets anders dan +-- een aantekening over iemand, en dat moet uniek blijven, anders weet niemand wie er inlogt. +-- +-- De index zelf blijft staan, alleen niet meer uniek: er wordt op adres gezocht. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\15_adres_niet_uniek.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +SET @uniek := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' + AND INDEX_NAME = 'ux_persons_email'); + +SET @sql := IF(@uniek > 0, + 'ALTER TABLE `persons` DROP INDEX `ux_persons_email`', + 'SELECT "ux_persons_email bestaat niet (meer)" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @gewoon := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' + AND INDEX_NAME = 'ix_persons_email'); + +SET @sql := IF(@gewoon = 0, + 'ALTER TABLE `persons` ADD INDEX `ix_persons_email` (`email`)', + 'SELECT "ix_persons_email bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar.' AS melding; diff --git a/db/16_sessies.sql b/db/16_sessies.sql new file mode 100644 index 0000000..a69b426 --- /dev/null +++ b/db/16_sessies.sql @@ -0,0 +1,54 @@ +-- MijnBesluit - aanmeldingen van de app bewaren, zodat ze een herstart overleven +-- +-- Waarom deze tabel er komt: de app blijft aangemeld en komt er daarna in met de vingerafdruk +-- of de code van de telefoon. Dat werkt alleen zolang de server het sessietoken nog kent. De +-- sessies stonden in het geheugen van de SocketServer, dus elke herstart van de service - een +-- uitrol, een Windows-update 's nachts, een reboot van de server - gooide ze allemaal weg. Het +-- toestel wist dan nog wie je was, de server niet meer, en je moest weer een code uit je mail +-- halen. Precies wat de vingerafdruk zou moeten voorkomen. +-- +-- Het token zelf staat er NIET in, alleen de SHA-256 ervan - dezelfde afspraak als bij de +-- inlogcodes. Wie de database in handen krijgt, krijgt daarmee dus geen werkende sessies mee: +-- uit een hash valt geen token terug te rekenen. +-- +-- `verloopt_op` schuift op bij gebruik, maar niet bij elk verzoek. Dat zou een schrijfactie per +-- aanroep betekenen voor iets waar een uur speling volstaat bij een termijn van veertien dagen. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\16_sessies.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +CREATE TABLE IF NOT EXISTS `sessies` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `token_hash` VARBINARY(32) NOT NULL, -- SHA-256 van het token; nooit het token zelf + `user_id` BIGINT NOT NULL, + `person_id` BIGINT NOT NULL, + `toestel` VARCHAR(120) NULL, + `aangemaakt_op` DATETIME(3) NOT NULL, + `verloopt_op` DATETIME(3) NOT NULL, + `laatst_gezien` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ux_sessies_token` (`token_hash`), + KEY `ix_sessies_verloopt` (`verloopt_op`), + CONSTRAINT `fk_sessies_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- DE RECHTEN STAAN HIER MET OPZET NIET. +-- +-- De verleiding was groot: een nieuwe tabel zonder GRANT is onzichtbaar tot iemand hem gebruikt, +-- en dan zegt de server "INSERT command denied" terwijl de gebruiker alleen "Er ging iets mis" +-- ziet. Precies wat er op 17 september gebeurde - inloggen in de app liep erop stuk. +-- +-- Maar de scripts vanaf 03 worden ook door de uitrolagent gedraaid, met een beperkt +-- databaseaccount dat helemaal geen rechten mág uitdelen. Een GRANT hier zou die hele uitrol +-- laten struikelen, en de oplossing "geef dat account dan GRANT-rechten" maakt het uitrolkanaal +-- machtiger dan het hoeft te zijn. Rechten uitdelen is inrichten, en dat doet een mens één keer. +-- +-- De rechten staan dus in 02_applicatiegebruiker.sql, en een zelfcontrole +-- ("Elke tabel geeft de applicatiegebruiker rechten") legt elke CREATE TABLE in deze map naast +-- die lijst. Die vangt het al vóór de uitrol, en dat is de goede plek. + +SELECT 'Klaar. Vergeet 02_applicatiegebruiker.sql niet als dit een nieuwe server is.' AS melding; diff --git a/db/17_aantekening_afgewezen.sql b/db/17_aantekening_afgewezen.sql new file mode 100644 index 0000000..6b1b2f8 --- /dev/null +++ b/db/17_aantekening_afgewezen.sql @@ -0,0 +1,34 @@ +-- MijnBesluit - onthouden dat iemand "nee, dat ben ik niet" heeft gezegd +-- +-- Waar dit voor is: maakt iemand een account aan op een adres waarover een ander al een +-- aantekening heeft staan, dan vragen we hem of hij dat is. Hij beslist; koppelen op adres doen +-- we niet, want een adres is geen identiteit (twee mensen kunnen er een delen, en één mens heeft +-- er vaak meerdere). Zie docs/Identiteit.md. +-- +-- Zegt hij nee, dan hoort die vraag niet bij elke volgende keer opnieuw te komen. Dat is wat +-- hier wordt bewaard. Alleen "nee": een "ja" is geen vlaggetje maar een samenvoeging, en dan +-- bestaat die aantekening als aparte rij niet meer (merged_into_person_id). +-- +-- Degene die de aantekening maakte merkt hier niets van. Hij krijgt geen melding dat er iets +-- gevraagd is en geen melding dat het antwoord nee was - anders zou het weigeren zelf een +-- mededeling worden over wie er wel of niet een account heeft. +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\17_aantekening_afgewezen.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'link_declined_by_user_id'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `persons` + ADD COLUMN `link_declined_by_user_id` BIGINT NULL, + ADD COLUMN `link_declined_at` DATETIME(3) NULL', + 'SELECT "link_declined_by_user_id bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar.' AS melding; diff --git a/db/18_kijklink.sql b/db/18_kijklink.sql new file mode 100644 index 0000000..93f379f --- /dev/null +++ b/db/18_kijklink.sql @@ -0,0 +1,105 @@ +-- MijnBesluit - kijklink voor een moment aan iemand zonder account +-- +-- Zet je een moment klaar voor iemand die (nog) geen MijnBesluit-account heeft, dan krijgt hij +-- voortaan een e-mail met een link naar een landingspagina: daar is het bericht - tekst en een +-- eventuele foto, video of document - te zien zonder in te loggen. Zelfde opzet als de +-- uitnodigings- en bezwaarlink: alleen de hash van het token staat in de database, het token +-- zelf staat uitsluitend in de e-mail. +-- +-- view_token_hash : SHA-256 van het token uit de link +-- view_token_expires : tot wanneer die link werkt (30 dagen na het versturen) +-- view_token_sent_at : wanneer de linkmail voor het eerst is verstuurd +-- viewed_at : wanneer de landingspagina daadwerkelijk is geopend +-- view_reminder_sent_at : wanneer de herinnering is verstuurd (nooit meer dan één keer) +-- wrapped_content_key_for_token: de contentsleutel, ingepakt met een sleutel uit het token zelf +-- wrapped_vault_key_for_token : dezelfde inpakslag, voor de bijlage bij dit bericht +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\18_kijklink.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +-- ---------------------------------------------------------------- view_token_hash +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_token_hash'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `messages` ADD COLUMN `view_token_hash` VARBINARY(32) NULL', + 'SELECT "view_token_hash bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- view_token_expires +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_token_expires'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `messages` ADD COLUMN `view_token_expires` DATETIME(3) NULL', + 'SELECT "view_token_expires bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- view_token_sent_at +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_token_sent_at'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `messages` ADD COLUMN `view_token_sent_at` DATETIME(3) NULL', + 'SELECT "view_token_sent_at bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- viewed_at +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'viewed_at'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `messages` ADD COLUMN `viewed_at` DATETIME(3) NULL', + 'SELECT "viewed_at bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- view_reminder_sent_at +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'view_reminder_sent_at'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `messages` ADD COLUMN `view_reminder_sent_at` DATETIME(3) NULL', + 'SELECT "view_reminder_sent_at bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- wrapped_content_key_for_token +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'wrapped_content_key_for_token'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `messages` ADD COLUMN `wrapped_content_key_for_token` VARBINARY(96) NULL', + 'SELECT "wrapped_content_key_for_token bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- wrapped_vault_key_for_token +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'wrapped_vault_key_for_token'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `messages` ADD COLUMN `wrapped_vault_key_for_token` VARBINARY(96) NULL', + 'SELECT "wrapped_vault_key_for_token bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------------------------------------------------------------- index +-- De landingspagina zoekt het bericht op dit kenmerk op, dus zonder index is dat een tabelscan. +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages' AND INDEX_NAME = 'ix_messages_view_token'); + +SET @sql := IF(@bestaat = 0, + 'CREATE INDEX `ix_messages_view_token` ON `messages` (`view_token_hash`)', + 'SELECT "ix_messages_view_token bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar. Momenten voor iemand zonder account krijgen vanaf nu een kijklink per e-mail.' AS melding; diff --git a/db/19_uitvaartwens_beheerder.sql b/db/19_uitvaartwens_beheerder.sql new file mode 100644 index 0000000..7a4d8e2 --- /dev/null +++ b/db/19_uitvaartwens_beheerder.sql @@ -0,0 +1,28 @@ +-- MijnBesluit - beheerder kiezen om een uitvaartwens vooraf te bespreken +-- +-- Bij een kluisitem van het soort Uitvaartwens kan de eigenaar voortaan één van zijn +-- nalatenschapsbeheerders kiezen. Die beheerder krijgt dan meteen leestoegang tot dit ene item +-- (via content_grants, reden "gedeeld"), nog vóór er sprake is van een overlijden - zodat het te +-- bespreken is en vooraf al duidelijk is hoe en wat. Kiest de eigenaar niemand, dan blijft het +-- item zoals altijd alleen voor hemzelf. +-- +-- shared_manager_person_id : de gekozen beheerder (persons.id); NULL = niemand gekozen +-- +-- Draaien met: +-- mysql -u root -p mijnbesluit < db\19_uitvaartwens_beheerder.sql +-- +-- Opnieuw draaien mag: er gebeurt dan niets. + +USE `mijnbesluit`; + +-- ---------------------------------------------------------------- shared_manager_person_id +SET @bestaat := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_items' AND COLUMN_NAME = 'shared_manager_person_id'); + +SET @sql := IF(@bestaat = 0, + 'ALTER TABLE `vault_items` ADD COLUMN `shared_manager_person_id` BIGINT NULL', + 'SELECT "shared_manager_person_id bestaat al" AS melding'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT 'Klaar. Bij een uitvaartwens is voortaan een beheerder te kiezen om vooraf mee te bespreken.' AS melding; diff --git a/db/20_bericht_verbergen.sql b/db/20_bericht_verbergen.sql new file mode 100644 index 0000000..88cb872 --- /dev/null +++ b/db/20_bericht_verbergen.sql @@ -0,0 +1,8 @@ +-- MijnBesluit - een bezorgd bericht uit je eigen lijst verwijderen +-- +-- Wie een bezorgd bericht "verwijdert" haalt het alleen bij zichzelf weg; de ander heeft er nog +-- steeds recht op. Vandaar twee vlaggen in plaats van één verwijderdatum: de afzender en de +-- ontvanger ruimen onafhankelijk van elkaar op. De rij zelf blijft altijd bestaan. +ALTER TABLE `messages` + ADD COLUMN `hidden_by_sender` TINYINT(1) NOT NULL DEFAULT 0 AFTER `read_at`, + ADD COLUMN `hidden_by_recipient` TINYINT(1) NOT NULL DEFAULT 0 AFTER `hidden_by_sender`; diff --git a/docs/Adverteren.md b/docs/Adverteren.md new file mode 100644 index 0000000..5acb0e6 --- /dev/null +++ b/docs/Adverteren.md @@ -0,0 +1,256 @@ +# Adverteren op MijnBesluit + +Een balk op het overzichtsscherm met een korte tekst en een klein logo. Adverteerders boeken +zelf een periode en een aantal vertoningen per uur, betalen via bunq, en na goedkeuring door de +beheerder gaat de campagne draaien. + +Dit stuk beschrijft hoe dat werkt en waarom. Lees het voordat je eraan begint te bouwen. + +## Waar de advertentie wél en niet komt + +**Wel:** op het overzichtsscherm, op de website en in de app. Een brede tegel met een lichte +achtergrond, bóven de vier tegels maar **onder "wat er speelt"** — die regel waarschuwt je dat er +een overlijden op jouw naam is gemeld of dat er iets voor je klaarstaat, en die gaat altijd voor. + +**Niet:** in de kluis, bij momenten, bij de nalatenschap, en nergens in de schermen rond een +overlijdensmelding. Iemand die een bericht aan zijn kinderen klaarzet of net heeft gehoord dat +er een overlijden op zijn naam is gemeld, hoort daar geen reclame naast te zien. Dat is niet +preutsheid maar productbehoud: dat is precies het moment waarop mensen weglopen. + +De tegel is laag — een afbeelding, een regel of twee tekst — en draagt zichtbaar het woord +**Advertentie**. Een advertentie die zich voordoet als onderdeel van de applicatie kost je meer +vertrouwen dan hij oplevert. + +*(Eerder stond hier dat de balk ónder de tegels hoort. Dat is op 16 september 2026 gewijzigd: hij +staat nu bovenaan, maar wel achter "wat er speelt". De reden om hem laag en herkenbaar te houden +blijft staan.)* + +## Hoe wordt gekozen wie er getoond wordt + +Per keer dat iemand het overzicht opent is er ruimte voor één advertentie. Er kunnen meerdere +campagnes tegelijk lopen, elk met hun eigen aantal per uur. De keuze gaat zo: + +1. Zoek alle campagnes die nu mogen draaien: goedgekeurd, betaald, en binnen hun periode. +2. Kijk per campagne hoe vaak hij dit klokuur al is getoond. +3. Laat de campagne zien die het verst achterloopt op zijn eigen aantal - dus de laagste + verhouding *getoond gedeeld door geboekt*. +4. Zijn ze allemaal op hun aantal, dan verschijnt er niets. Geen opvulling, geen herhaling. +5. Tel de vertoning op. + +Zo krijgt wie meer boekt ook meer, zonder dat de een de ander verdringt, en zonder dat iemand +over zijn ingekochte aantal heen gaat. + +**Let op wat dit betekent voor je voorwaarden.** Het geboekte aantal is een *maximum*, geen +belofte. Zijn er in een uur minder bezoekers dan er vertoningen zijn ingekocht, dan haalt +niemand zijn aantal. De adverteerder moet daarom in zijn eigen scherm kunnen zien wat er +werkelijk is getoond. + +## Wat er wordt opgeslagen + +### Adverteerder + +Bedrijfsnaam, contactpersoon, e-mailadres, telefoonnummer, adres, postcode, plaats, land, +KvK-nummer en btw-nummer. Dit zijn bedrijfsgegevens, geen persoonsgegevens van bezoekers, maar +ze vallen wel onder je administratie: de belastingdienst wil ze zeven jaar kunnen zien. + +### Campagne + +- Wie hem heeft geboekt +- De tekst, het logo en de link waar hij heen gaat +- Begindatum en einddatum +- Aantal vertoningen per uur +- Het bedrag, het betaalkenmerk, en wanneer de betaling is gezien +- De stand: `concept`, `wacht-op-betaling`, `wacht-op-goedkeuring`, `loopt`, `afgewezen`, + `gestopt` - met bij een afwijzing de reden erbij, zodat de adverteerder weet waarom + +### Vertoningen + +Eén regel per campagne per klokuur met een teller erin, niet één regel per vertoning. Bij een +paar honderd bezoekers per uur scheelt dat een tabel die anders eindeloos volloopt, en voor het +kiezen én voor de verantwoording is het genoeg. + +Kliks worden apart geteld, ook per uur. + +## Het betaalpad + +Bunq.me is een betaalverzoek, geen kassa. Er komt **geen bericht terug** naar MijnBesluit als er +betaald is. De applicatie kan dus niet zelf vaststellen dat een campagne betaald is. + +Daarom loopt het zo: + +1. De adverteerder stelt zijn campagne samen en ziet wat het kost. +2. Hij krijgt de bunq.me-link, met het campagnenummer als omschrijving. +3. De campagne komt op `wacht-op-betaling`. +4. De beheerder ziet in zijn bunq dat er betaald is, met dat nummer erbij, en vinkt de campagne + af als betaald. +5. In hetzelfde scherm keurt hij de inhoud goed of af. +6. Pas dan gaat hij draaien, vanaf de begindatum. + +### De betaallink + +`AdvertentieService.Betaallink` zet hem in elkaar: + +``` +https://bunq.me/KwikBit?amount=30.00&description=MB-00012 +``` + +Het adres staat in de instellingen onder `Adverteren:BunqMe`; een rekening wisselt eerder dan de +code. Laat je die instelling leeg, dan komt er geen knop en staan het bedrag en het kenmerk er +gewoon om met de hand over te maken - een knop naar de verkeerde rekening is erger dan geen knop. + +Twee dingen die makkelijk misgaan en daarom in een zelfcontrole vastliggen: + +* **Het bedrag krijgt een punt**, wat de taalinstelling van de server ook is. Zonder dat maakt + een Nederlandse Windows er `30,00` van, en dan leest bunq een ander bedrag of helemaal geen. +* **Het kenmerk gaat gecodeerd mee.** Het is `MB-00012`: kort genoeg om over te typen als iemand + de link niet gebruikt. + +Op de knop staat wat je doet en voor hoeveel ("Betaal € 30,00 via bunq"), niet waar de link heen +gaat. Dezelfde link staat in de bevestigingsmail, want niet iedereen komt terug in het scherm. + +Stap 4 en 5 zijn twee vinkjes in één scherm. Wil je dit later automatisch, dan kan dat met de +bunq-API in plaats van een bunq.me-link; dat is een aparte verbouwing en vraagt sleutels en een +terugmeldadres. + +## Waarom goedkeuring vooraf + +Zonder goedkeuring kan iedereen die betaalt zijn advertentie naast jouw tekst over uitvaarten +krijgen. Je zou het achteraf kunnen weghalen, maar dan is het al gezien. De vertraging voor de +adverteerder is klein - je kijkt er dezelfde dag naar - en het verschil in wat er mis kan gaan +is groot. + +## De prijs + + bedrag = dagen x vertoningen per uur x tarief + +met een bodem eronder. Begin- en einddag tellen allebei mee. Het tarief en de bodem staan in de +instellingen (`Adverteren:TariefCenten`, `Adverteren:MinimumCenten`) en niet in de code: het is +het soort getal dat je na drie maanden wilt bijstellen. Standaard 20 cent en een bodem van +10 euro. + +Een voorbeeld: 30 dagen, 5 keer per uur = 150 x 20 cent = **30 euro**. + +Hoe kom je aan zo'n getal? Reken terug naar wat de adverteerder hoogstens kan krijgen. Vijf keer +per uur, 24 uur, 30 dagen is 3600 vertoningen; 30 euro is dan ruim 8 euro per duizend. Aan de +lage kant voor een klein, goed omschreven publiek — en dat hoort ook, want dat maximum haalt hij +nooit: 's nachts opent niemand het overzicht. + +Bij een campagne wordt het gebruikte tarief mee opgeslagen (`rate_cents`). Verandert het tarief +later, dan verandert een al geboekte campagne niet mee — anders valt een oude factuur niet meer +na te rekenen. + +## Wat er nog beslist moet worden + +- **Facturen.** Moet MijnBesluit ze maken, of doe je dat zelf? Met btw erop hoort er een factuur + met een doorlopend nummer bij. +- **Terugbetalen.** Wat als een campagne door een storing niet heeft gedraaid? +- **Algemene voorwaarden.** Daarin hoort in elk geval te staan dat het aantal een maximum is en + dat je een advertentie mag weigeren. + +## Hoe het in de code zit + +| Wat | Waar | +|---|---| +| Tabellen | `db/13_adverteren.sql`: `adverteerders`, `campagnes`, `campagne_uren` | +| Gegevens | `MijnBesluit.Domain/Entities/Adverteren.cs` | +| Regels | `MijnBesluit.Application/Services/AdvertentieService.cs` | +| Opslag | `AdvertentieRepository` in Infrastructure | +| Controles | `tests/MijnBesluit.SelfCheck/AdvertentieChecks.cs` | + +Een adverteerder is een **eigen tabel** en geen regel in `users`. Hij is een bedrijf, geen persoon +met een kluis, familie of nalatenschap. Hem bij de gebruikers zetten zou betekenen dat elke vraag +over gebruikers hem moet uitzonderen, en één vergeten uitzondering is er één te veel in een +applicatie die over nalatenschappen gaat. Inloggen gaat wel op dezelfde manier: een code van zes +cijfers per e-mail, met een eigen doel-nummer in `login_codes`. + +De afbeelding staat als blob bij de campagne en niet bij `vault_files`. Die tabel is voor spullen +van gebruikers en is versleuteld met sleutels per gebruiker; een advertentie is juist openbaar. +Hij wordt bij het opslaan verkleind. + +Wat er bewust **niet** in zit: iets wat een advertentie koppelt aan wie hem ziet. Er wordt per +campagne per uur geteld, meer niet. Geen profielen, geen kenmerken, geen bezoeker die door de +applicatie heen te volgen is. Dit is een applicatie waarin mensen hun wachtwoorden en hun laatste +woorden bewaren; daar hoort geen advertentienetwerk in te zitten dat meekijkt. + +De beheerder krijgt een e-mail zodra er een campagne is ingediend. Vanaf dat moment ligt het bij +hem: betaling afvinken en de inhoud beoordelen. Goedkeuren kán niet voordat de betaling is +afgevinkt — anders draait er iets waarvoor niet betaald is, en dat merk je pas als het voorbij is. + +## In welke volgorde te bouwen + +1. ~~Adverteerder en campagne vastleggen, met een beheerscherm om te betalen-af-te-vinken en goed + te keuren.~~ **Klaar.** `/beheer/adverteren`, bereikbaar vanaf `/beheer`. +2. ~~De balk op het overzicht, met de keuzeregels en de tellers.~~ **Klaar.** Op de website + rechtstreeks vanuit `AdvertentieService`; in de app via de socketactie `home.advertentie`. +3. ~~Het scherm waarin een adverteerder zichzelf aanmeldt en een campagne samenstelt, met de + bunq.me-link erin.~~ **Klaar.** `/adverteren` en `/adverteren/nieuw`. +4. ~~Het scherm waarin hij ziet wat er werkelijk is getoond.~~ **Klaar.** `/adverteren/campagnes`. + +Zo staat het werkende deel er eerst, en kun je met één handmatig ingevoerde campagne al zien of +die balk doet wat je wilt voordat er een adverteerder bij komt. + +## Het adverteerdersgedeelte + +Drie pagina's, allemaal **statisch weergegeven** en niet interactief: + +* `/adverteren` - wat het is, wat het kost, en inloggen met een code per e-mail. De ingang staat + op het inlogscherm, onder "Ik ben nieuw en wil een account aanmaken". +* `/adverteren/nieuw` - de campagne samenstellen. +* `/adverteren/campagnes` - zijn campagnes, de stand, de betaalregel, en wat er is getoond. + +**Een adverteerder logt in met een eigen cookie en een eigen schema** (`Adverteerderslogin`), +los van de gebruikers van MijnBesluit, en dat cookie gaat alleen mee naar `/adverteren`. Dat is +geen netheid. Met één cookie en een claim erin hangt het verschil tussen een adverteerder en +iemand met een kluis af van de vraag of elk scherm aan die ene controle denkt; met twee schema's +kan dat niet misgaan. Om dezelfde reden zijn die schermen niet interactief: het nummer van de +adverteerder komt bij elk verzoek vers uit dat cookie en hoeft dus nergens te blijven staan. + +De code die hij per e-mail krijgt heeft een **eigen doel** (`LoginCodePurpose.Adverteerder`). +Een adverteerderscode geeft dus nooit toegang tot een kluis, en een gewone inlogcode werkt niet +in het adverteerdersgedeelte. Dat ligt vast in de zelfcontrole "een code voor adverteerders is +geen inlogcode". + +Wie nieuw is vult zijn bedrijfsnaam in vóórdat hij de code aanvraagt. Die naam wordt bij de code +bewaard en pas gebruikt zodra de code klopt - net als de voornaam bij het registreren van een +gebruiker. Zo kan niemand zich een bedrijfsnaam aanmeten zonder eerst bij dat e-mailadres te +kunnen. + +De prijs loopt mee terwijl je invult, met een klein stukje javascript in de pagina zelf. Dat is +een gemak en geen bedrag: wat het kost rekent de server bij het indienen zelf uit. + +### Een campagne met de hand invoeren om het te proberen + +Er is nog geen scherm waarin een adverteerder zichzelf aanmeldt, dus voor een proef zet je er één +met de hand in. Daarna zie je hem op `/beheer/adverteren` staan, vink je de betaling af, keur je +hem goed, en staat hij op het overzicht. + +```sql +INSERT INTO adverteerders (company_name, contact_name, email, created_at) +VALUES ('Uitvaartzorg Noord', 'Piet', 'piet@voorbeeld.nl', UTC_TIMESTAMP(6)); + +INSERT INTO campagnes (advertiser_id, text, link, starts_on, ends_on, per_hour, + amount_cents, rate_cents, payment_reference, state, created_at) +VALUES (LAST_INSERT_ID(), 'Persoonlijk afscheid, dichtbij', 'https://voorbeeld.nl', + CURDATE(), CURDATE() + INTERVAL 30 DAY, 5, 3000, 20, 'MB-HAND', 1, UTC_TIMESTAMP(6)); +``` + +`state` 1 is *wacht op betaling*; de rest gaat via het beheerscherm. + +## Het klikpad + +| Adres | Wat het doet | +|---|---| +| `/advertentie/{id}/afbeelding` | De afbeelding. Openbaar, en een jaar in de browsercache | +| `/advertentie/{id}/uit` | Telt de klik en stuurt door naar de adverteerder | + +Allebei zonder inlog: een advertentie is openbaar, en een plaatje dat alleen met een sessie te +laden is werkt niet in de app. + +De tegel linkt naar `/uit` en niet rechtstreeks naar de adverteerder. Met javascript tellen zou +ook kunnen, maar dan telt hij niet mee als er iets misgaat onderweg. Dit is één plek en één +telling. De doorverwijzing is bewust *niet* permanent: die blijft anders in de browser hangen en +dan telt een tweede klik niet meer mee. + +In de app opent de tegel in de browser van de telefoon (`IBuitenlink`), niet in de app zelf. Zou +hij binnen de webview openen, dan wordt MijnBesluit een browser en staat iemand op de site van een +adverteerder zonder weg terug naar zijn eigen kluis. diff --git a/docs/Android-app.md b/docs/Android-app.md new file mode 100644 index 0000000..ab5cc31 --- /dev/null +++ b/docs/Android-app.md @@ -0,0 +1,326 @@ +# De Android-app + +De app is dezelfde applicatie als de website, met dezelfde schermen. Wat verschilt, is de weg +naar de gegevens: de website praat rechtstreeks met MariaDB, de app praat via de SocketServer. + +## Hoe het in elkaar zit + +| Project | Wat het is | Te testen? | +|---|---|---| +| `MijnBesluit.Client` | De aanroepen over de socket, met modellen voor de app. Geen regels, alleen vertalen. | Ja — 10 controles in de zelftest, tegen een echte socketserver | +| `MijnBesluit.AppUi` | De schermen (Blazor). Inloggen, overzicht, kluis, familie, momenten, nalatenschap. | Compileert; het gedrag zit grotendeels in de laag eronder | +| `MijnBesluit.App` | De Android-schil: één webview met de schermen erin. | Nee — dit moet in Visual Studio op een toestel | + +De schermen staan bewust in een eigen project en niet in de app zelf. Zo kan de website ze +later overnemen, en hoeft een wijziging maar op één plek. + +## Wat je nodig hebt in Visual Studio + +De MAUI-werkbelasting (".NET Multi-platform App UI development"). Heb je die al voor +RenHDashboardMaui, dan ben je er al. Het project staat op `net10.0-android`, net als dat project. + +Klopt je versie niet, pas dan `TargetFrameworks` in `src\MijnBesluit.App\MijnBesluit.App.csproj` +aan naar wat je wél hebt staan; de andere projecten hoeven niet mee te veranderen. + +## Eerste keer draaien + +1. Rechtermuis op `MijnBesluit.App` → **Set as Startup Project**. +2. Kies bovenin een Android-emulator of je eigen toestel. +3. F5. + +Verschijnt bij het bouwen een melding dat `Microsoft.AspNetCore.Components.WebView.Maui` +ontbreekt, dan levert jouw werkbelasting die niet vanzelf. Voeg hem dan toe via +**Manage NuGet Packages** op `MijnBesluit.App`. + +## Waar de app verbinding mee zoekt + +In `MauiProgram.cs` staat bovenaan: + +```csharp +public const string Server = "mijnbesluit.nl"; +public const int Poort = 5200; +``` + +- **Op een emulator, met de server op je eigen pc:** zet `Server` op `10.0.2.2` — dat is vanuit + de emulator jouw pc — en zet `tls: true` om naar `tls: false` zolang er geen certificaat is. +- **Op een echt toestel:** laat `mijnbesluit.nl` staan. + +## Een APK maken + +```powershell +D:\MijnBesluit\tools\Apk-maken.ps1 -Snel # snel, om zelf te proberen +D:\MijnBesluit\tools\Apk-maken.ps1 # ondertekend, om weg te geven +D:\MijnBesluit\tools\Apk-maken.ps1 -Aab # bundel voor de Play Store +``` + +De debug-versie is meteen te installeren op je eigen toestel (wel eerst "installeren uit +onbekende bron" toestaan). Die wordt gebouwd met `-p:EmbedAssembliesIntoApk=true`: bij Debug +laat MAUI de .NET-assemblies anders buiten het pakket, omdat Visual Studio ze bij het debuggen +los naar het toestel duwt. Zonder die instelling installeert de APK wel, maar klapt de app bij +het starten meteen dicht. De release-versie vraagt de eerste keer om een sleutelbestand aan te +maken; dat komt in `D:\MijnBesluitSleutels`. + +**Bewaar dat sleutelbestand apart, net als de mastersleutel van de kluis.** Android herkent een +app aan de sleutel waarmee hij is ondertekend. Raak je hem kwijt, dan is er geen nieuwe versie +meer uit te brengen die op de toestellen van je gebruikers over de oude heen gaat — ook niet met +hulp van Google. Dan moet iedereen de app verwijderen en opnieuw installeren, en is alles wat +lokaal op het toestel stond weg. + +Voor de Play Store lever je geen APK aan maar een AAB (Android App Bundle): `-Aab`. Dat is +dezelfde opdracht met een ander pakketformaat, hetzelfde sleutelbestand en hetzelfde wachtwoord. +Google maakt uit die bundel per toestel een APK, zodat niemand de onderdelen voor andere +toestellen meedownloadt. + +Een AAB kun je niet zelf op een telefoon zetten. Voor het uitproberen en voor het rondsturen aan +je eigen testers blijf je dus de APK gebruiken; de AAB is alleen voor de Play Console. + +## De serverkant + +Staat klaar sinds 14 september 2026: + +- De SocketServer draait als Windows-service op mijnbesluit.nl, poort 5200. +- Poort 5200 is open; `Test-NetConnection mijnbesluit.nl -Port 5200` geeft True. +- TLS 1.3, met het certificaat van mijnbesluit.nl uit de opslag WebHosting + (`B1BAFBB8A37A2899A2706FE037DDAB0F1694EFD1`, geldig tot 1 april 2027). +- Database en e-mail werken via de socket: `SocketServer-proef.ps1 -Email ` levert een + inlogcode in het postvak. + +Controleer het met: + +```powershell +D:\MijnBesluit\tools\SocketServer-proef.ps1 +D:\MijnBesluit\tools\SocketServer-proef.ps1 -Email jouw@adres.nl +``` + +Let op de vervaldatum van het certificaat: bij vernieuwing verandert de vingerafdruk en moet +`Socket:CertificateThumbprint` mee, gevolgd door `Restart-Service MijnBesluitSocket`. De service +zet bij het opstarten in het logboek in welke opslag hij het certificaat vond en tot wanneer het +geldig is. + +## Wat de app op het toestel bewaart + +Alleen het sessietoken, in de Keystore van Android (`ToestelTokenopslag`). Geen kluisinhoud, +geen wachtwoorden: die worden opgehaald op het moment dat je ze bekijkt, en zijn weg zodra je +het scherm sluit. Uitloggen wist het token van het toestel. + +## De terugknop van het toestel + +Android heeft een eigen terugknop, en die sloot standaard de hele app — ook midden in een +formulier. Dat loopt nu via `Terugknop` in `MijnBesluit.AppUi`. + +Hoe het werkt: wie iets open heeft staan meldt zich daar aan en weer af zodra het dicht is. De +laatste aanmelding is als eerste aan de beurt, dus in de volgorde waarin de dingen over elkaar +heen liggen. In de praktijk: + +1. Staat er een foto open in een bijlagenvenster, dan gaat die dicht. +2. Staat er een wijzigscherm open, dan gaat dat dicht — zonder op te slaan, net als Annuleren. +3. Zit je in een scherm, dan ga je naar het overzicht. +4. Ben je op het overzicht, dan verschijnt "Nog een keer terug om te sluiten". Pas een tweede + druk binnen twee seconden sluit de app. + +De schermen hoeven hier niets voor te doen: `AppSchil` regelt stap 3 en 4, en `Popup` regelt +stap 1 en 2. Een scherm dat er later bij komt doet automatisch mee. + +`HoofdPagina.OnBackButtonPressed` is het enige stukje MAUI erin: dat geeft de druk door en laat +Android zijn gang gaan zodra niemand hem afhandelt. + +## Vanzelf op slot + +Achter het tandwiel in de balk staat één instelling: na hoeveel minuten stilte de app dichtgaat. +`Uitlogklok` houdt bij wanneer er voor het laatst iets gebeurde; de schil vangt elke aanraking en +elke toets op — die bubbelen naar de buitenste laag, dus dat is één plek — en kijkt elke halve +minuut of de termijn voorbij is. + +**Wat er dan gebeurt, hangt af van het toestel.** + +| Toestel | Wat er gebeurt | Standaard | +|---|---|---| +| Heeft een schermslot, Android 10 of nieuwer | De app gaat op slot. De sessie blijft staan; openen gaat met vingerafdruk, gezicht of de code van de telefoon. | Aan, na 15 minuten — en "uit" bestaat daar niet | +| Geen schermslot, of ouder | De app logt écht uit: het sessietoken gaat van het toestel af en er moet een nieuwe code per e-mail komen. | Uit | + +**Op een toestel met een schermslot kan de klok niet uit.** Die keuze staat niet in de lijst, en +een nul die er van vroeger nog in staat wordt opgetild naar het kwartier - in de klok zelf, niet +in het scherm, zodat ook de schil ervan uitgaat. Het slot kost daar één aanraking; uit zetten +levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis is. Zonder +schermslot ligt dat andersom en mag "uit" er wél staan. + +**Op slot gaat de lijn dicht.** Zodra de app vergrendelt, verbreekt hij de verbinding met de +SocketServer; na het ontgrendelen zet hij hem weer op. Het token blijft staan en de sessie aan de +serverkant ook - die hangt aan het token en niet aan de verbinding - dus je bent gewoon weer waar +je was. + +Waarom dat de moeite is: de server verbreekt zelf een verbinding waarover tien minuten niets +gebeurt, maar dat is een terugval, geen zekerheid. Een telefoon die in een tas verdwijnt of zijn +netwerk kwijtraakt, laat een half-open socket achter tot die tien minuten om zijn. Zelf dichtdoen +maakt er een zekerheid van, scheelt een plek van de vijfhonderd, en zorgt dat je na het +ontgrendelen een verse verbinding hebt in plaats van een die misschien nog leeft. + +**Bij het openen vraagt hij altijd, en hij vraagt het aan de telefoon.** Ligt er een sessie op +dit toestel en kan het om een vingerafdruk vragen, dan komt dat venster - ongeacht de klok. De +klok gaat over wat er gebeurt terwijl de app openstaat; dit gaat over het openen zelf. Zo is de +app afsluiten en opnieuw starten geen manier om langs het slot te komen. + +Let op *waarop* dat gebeurt: op wat er in de beveiligde opslag van de telefoon ligt, niet op een +goedkeuring van de server (`HeeftBewaardeSessieAsync`, en `BijHetOpenenAsync` in de schil). Dat +was eerst andersom, en dat was fout. Het slot ging pas op de app nadat de server het token had +goedgekeurd, dus een telefoon zonder netwerk - of een server die net opnieuw was gestart - stuurde +je naar het inlogscherm met een vraag om je e-mailadres, terwijl je telefoon prima wist wie je +was. Nu komt eerst je vingerafdruk en pas daarna de vraag of de sessie nog loopt. Zolang de app +dat nog niet weet, toont de schil alleen het logo: geen scherm dat een tel later achter een slot +verdwijnt, en geen scherm dat alvast gegevens gaat ophalen. + +Dat de sessie ook echt nog loopt, is de andere helft van hetzelfde verhaal: de SocketServer +bewaart zijn sessies sinds `db/16_sessies.sql` in de database en niet meer alleen in het +geheugen. Zonder dat logde elke uitrol of reboot van de server iedereen uit, en was de +vingerafdruk niets waard. Zie `docs/Socketprotocol.md`, hoofdstuk Sessies. + +Dat verschil zit in de prijs van vergeten. Met een toestelslot kost het je één aanraking, dus mag +het standaard aan. Zonder kost het een e-mail afwachten, en dat leg je niemand ongevraagd op. Wie +zélf "uit" kiest houdt uit: die nul staat dan in de opslag en is iets anders dan niets gekozen +hebben. Er is een zelftest die dat verschil bewaakt. + +`IToestelslot` vraagt het toestel om iemand te controleren; `Toestelslot` in MijnBesluit.App doet +dat met `BiometricPrompt` en `SetDeviceCredentialAllowed(true)`, zodat de code van de telefoon +altijd als terugval werkt. **De app krijgt alleen ja of nee terug.** Er is geen eigen pincode, er +wordt niets bewaard en er valt hier dus ook niets te lekken; de controle gebeurt in een apart +stuk hardware waar geen enkele app bij kan. + +`SetDeviceCredentialAllowed` staat vanaf Android 11 als verouderd gemarkeerd. De opvolger +(`SetAllowedAuthenticators`) bestaat pas vanaf 11, en de app werkt vanaf 10. Zolang dat zo is +hoort deze hier te staan; gaat de ondergrens ooit naar 11, dan mag het om. + +Op slot laat de schil **niets** van de app meer zien — geen balk, geen navigatie, geen inhoud — +en slikt de terugknop van het toestel. Een grendel die je nog door het raam laat kijken is geen +grendel. Op het slotscherm staat ook "toch helemaal uitloggen", voor wie de telefoon uit handen +geeft. + +Twee dingen die verder met opzet zo zijn: + +- **Er wordt op de wandklok gerekend, niet op een teller.** Een telefoon die een uur in een tas + zit telt dus net zo goed mee, ook al heeft de lus in de schil dan niet gedraaid. Bij het + terugkomen uit de achtergrond seint `App.xaml.cs` de klok meteen in, vóór er gegevens worden + opgehaald — dat is juist het geval waar dit voor bedoeld is. Stond de app al op slot, dan komt + het venster van het toestel meteen weer in beeld. +- De termijn staat in de gewone voorkeursopslag van Android (`Toestelinstellingen`), niet in de + Keystore: daar hoort alleen in wat geheim is, en hoe lang de app wacht is dat niet. Het + sessietoken staat er wél in (`ToestelTokenopslag`), en dat is ook precies wat achter dit slot + blijft liggen terwijl de app dicht is. + +### Een sessie gaat er alleen af als de server hem weigert + +Een app die op slot staat, staat op de achtergrond, en Android sluit zo'n app vaak gewoon af. +Terugkomen is dan geen ontwaken maar een koude start: de app pakt het bewaarde token op en vraagt +de server wie hij is. Op dat moment is het netwerk van de telefoon soms nog niet wakker. + +Dat mislukken is **geen** reden om het token weg te gooien. Deed de app dat wel, dan moest je een +code per e-mail aanvragen terwijl er niets mis was met je sessie — precies op het moment dat je +dacht dat je alleen even je vinger hoefde te leggen. Alleen de server mag een sessie ongeldig +verklaren (foutcode `sessie-verlopen`); dan ruimt `SessieKwijtAsync` het token op en gaat de app +naar het inlogscherm. Blijft het token staan, dan blijft de app op zijn scherm met "geen +verbinding" en probeert hij het bij de volgende handeling opnieuw. + +De zelfcontrole "App: geen verbinding gooit het bewaarde token niet weg" houdt beide kanten vast. + +## De tegels in de lijsten + +Momenten, Mijn mensen, Mijn kluis en Nalatenschap tonen allemaal dezelfde tegel: + +* Als er een profielfoto is, staat die vooraan. Anders de beginletters, even groot, zodat een + lijst niet verspringt zodra er ergens een foto bij komt. +* Drie regels ernaast, altijd in dezelfde opbouw: + 1. waar je op zoekt - het onderwerp, de naam, de titel. Loopt door tot de rand. + 2. de toelichting, met rechts eventueel een labeltje (de status bij Mijn mensen). + 3. de rest, met rechts de knoppen. Die regel mag leeg zijn; bij Mijn mensen is dat zo. +* **De tegel zelf aantikken opent hem.** Bij Momenten en Nalatenschap klapt er iets uit, bij + Mijn mensen en Mijn kluis opent het scherm met de gegevens. Daar staat dus geen knop voor. +* Rechtsonder staan alleen de andere acties, als tekentje: de prullenbak, de envelop om iemand + uit te nodigen. Een wijzertje (het driehoekje) laat zien dat er iets uitklapt; dat is geen + knop. Elke knop heeft een `title` en een `aria-label`, dus een schermlezer noemt hem gewoon. + +Het labeltje op regel 2 en de knoppen op regel 3 staan daardoor op elke tegel op dezelfde plek, +pal boven elkaar. Dat is de reden dat de knoppen op de onderste regel staan en er niet overheen +zweven: een knop is 40 pixels hoog, en dat is anderhalve tekstregel - hij kwam dus altijd over +dat labeltje heen. De tegel is daar een pixel of vijftien hoger van (ongeveer 126 in plaats van +110), en niets loopt meer over elkaar. + +Datums staan afgekort: *9 Jun. 2031*. De maandnamen staan met de hand in `Tonen` en komen niet +uit de taalinstelling van het toestel; op een telefoon die op Engels staat las de lijst anders +"9 June 2031" terwijl de rest van het scherm Nederlands is. + +Mijn kluis heeft bovenaan een keuzelijst om op soort te filteren, met de knop Toevoegen ernaast, +net als op de website. Het filteren gebeurt bij de server (`KluisAsync` neemt een categorie mee) +en niet in de lijst op het toestel: dan hoeft de telefoon niet alles op te halen om er drie +dingen uit te laten zien. + +Bij Mijn kluis is bekijken en wijzigen hetzelfde scherm. Er was eerder ook een uitklap onder de +tegel die precies dezelfde gegevens en bijlagen toonde: twee plekken om te onderhouden en twee +manieren om hetzelfde te doen. + +## Het logo + +Het logo staat op vier plekken, allemaal vanuit hetzelfde bestand: + +| Waar | Bestand | +|---|---| +| Inlogscherm van de app, groot | `src/MijnBesluit.AppUi/wwwroot/logo.png` | +| Merkteken in de balk van de app, klein | hetzelfde bestand | +| Kop en inlogpagina van de website | `src/MijnBesluit.Web/wwwroot/logo.png` | +| Pictogram en startscherm van de app | `Resources/AppIcon/appicon.png`, `Resources/Splash/splash.png` | + +Het pictogram is **alleen het witte woordmerk op een doorzichtige achtergrond**, met het +logogroen eronder via `Color="#C1D13F"` in het csproj. Zo maakt Android er zelf een rond, +vierkant of druppelvormig pictogram van. Het woordmerk staat op zeventig procent van het vlak: +alles buiten die cirkel mag een toestel wegsnijden, en op die maat blijft het hele woordmerk +staan, hoe een telefoon het ook maskeert. + +Op 26 pixels - het merkteken in de balk - is het woordmerk niet te lezen. Dat hoeft ook niet: +daar staat de naam in letters ernaast. Groot en leesbaar staat het op het inlogscherm. + +Nog niet gedaan: de melding op de telefoon gebruikt het app-pictogram als klein teken, en Android +maakt daar een silhouet van. Een woordmerk wordt op 24 pixels een vlek. Een eigen, eenvoudig +tekentje in `Platforms/Android/Resources/drawable` zou dat oplossen. + +## Wat er nog niet in zit + +- Meldingen op de telefoon als er een bericht bezorgd wordt — de SocketServer kan uit zichzelf + een melding sturen, maar een push-melding als de app dicht is vraagt om Firebase +- iOS: `net10.0-ios` toevoegen aan `TargetFrameworks` en een Mac om op te bouwen +- Een grens aan hoe lang de app op slot mag blijven staan. Nu blijft de sessie liggen tot iemand + hem opent of uitlogt; na een maand ongebruikt zou hij zichzelf kunnen opruimen. + +## Naar de Google Play Store + +Stand op 16 september 2026. De eisen van Google wijzigen; kijk ze na op +. + +**Wat al klopt:** de app bouwt op `targetSdkVersion 36`, en dat is sinds 31 augustus 2026 de eis +voor nieuwe apps. De rechten zijn zuinig (internet, camera, media, vingerafdruk, meldingen) en de +meldingstoestemming wordt op het moment zelf gevraagd. + +**Wat er is gebouwd omdat Google het eist:** + +* **Account opheffen**, in de app én op `/account-verwijderen` zonder app. Zie + `docs/Beveiliging.md`. +* **Een privacyverklaring** op `/privacy`, met een link erheen vanaf het inlogscherm en vanuit + Instellingen in de app. + +**Wat er nog moet:** + +1. **Een organisatie-account** in de Play Console, op naam van het bedrijf - geen persoonlijk + account. Persoonlijke accounts van na 13 november 2023 moeten eerst een gesloten test draaien + met 12 testers die 14 dagen onafgebroken meedoen; organisatie-accounts niet. Een organisatie + wordt wel geverifieerd (KvK, D-U-N-S), en dat duurt weken. Begin daarmee. +2. **Een AAB in plaats van een APK:** + `dotnet publish -f net10.0-android -c Release -p:AndroidPackageFormat=aab` +3. **Een upload-keystore**, bewaard zoals de masterkey: apart, met back-up, buiten de projectmap. + Zet Play App Signing aan; dan kan Google een nieuwe upload-sleutel geven als hij ooit weg is. + Let op dat de app die gebruikers krijgen door Google opnieuw wordt ondertekend - voor de + pushmeldingen maakt dat niets uit, maar alles wat aan een certificaatvingerafdruk hangt moet + je met de sleutel van Google registreren. +4. **`ApplicationVersion` omhoog bij elke upload.** Staat nu op 1. +5. **Het Data safety-formulier**, de leeftijdsclassificatie en de winkelvermelding: icoon + 512x512, banner 1024x500, minimaal twee schermafbeeldingen, korte en lange beschrijving, en + het adres van de privacyverklaring. +6. **"Bevat advertenties" aanvinken** zodra de advertentiebalk meedraait. De inhoud van die + advertenties valt dan onder het beleid van Google; dat is een reden te meer om ze vooraf zelf + goed te keuren. diff --git a/docs/Architectuur.md b/docs/Architectuur.md new file mode 100644 index 0000000..0876e22 --- /dev/null +++ b/docs/Architectuur.md @@ -0,0 +1,155 @@ +# MijnBesluit — architectuur + +## Wat het is + +Een platform om je beslissingen bij leven vast te leggen in een digitale kluis, en om berichten +te versturen naar familie en vrienden — nu of in de toekomst, en ook pas na je overlijden. + +## De vorm in één plaatje + +``` + Website (Blazor) Android / iOS (MAUI) + | | + | in-proces | TLS-socket, poort 5200 + v v + +---------------------------------------------+ + | MijnBesluit.Server (dispatcher, sessies) | <- alleen voor de socketkant + +---------------------------------------------+ + \ / + v v + +---------------------------------------------+ + | MijnBesluit.Application | + | AccountService, FamilyService, VaultService, | + | MessageService, LegacyService + crypto | + +---------------------------------------------+ + | + +---------------------------------------------+ + | MijnBesluit.Domain (entiteiten + regels) | + +---------------------------------------------+ + | + +---------------------------------------------+ + | MijnBesluit.Infrastructure (EF Core) | + +---------------------------------------------+ + | + MariaDB + versleutelde bestanden op schijf +``` + +De regels staan één keer, in Application en Domain. De website en de app zijn allebei niet meer +dan een manier om diezelfde regels aan te roepen. Wat op de website niet mag, kan dus ook via de +app niet — dat hoeft nergens tweemaal te worden afgedwongen. + +## De projecten + +| Project | Wat het doet | Externe packages | +|---|---|---| +| `MijnBesluit.Domain` | Entiteiten en de regels: eigendom van profielen, overlijdensprocedure, vrijgave van berichten | geen | +| `MijnBesluit.Application` | De handelingen: registreren, uitnodigen, kluis, berichten, nalatenschap. Plus alle versleuteling | geen | +| `MijnBesluit.Protocol` | Het socketprotocol: framing, de server-luisteraar en de client voor de app | geen | +| `MijnBesluit.Server` | Vertaalt socketverzoeken naar Application-aanroepen; sessiebeheer | geen | +| `MijnBesluit.Infrastructure` | EF Core op MariaDB, sleutelbestand, bestandsopslag, e-mail | EF Core, Pomelo | +| `MijnBesluit.ServiceBeheer` | De Windows-service installeren, verwijderen, starten en stoppen | geen | +| `MijnBesluit.SocketServer` | De zelfstandige service voor de app, het beheerschermpje en de achtergrondtaken | Hosting, WinForms | +| `MijnBesluit.Web` | De website in Blazor | geen | +| `MijnBesluit.SelfCheck` | Controleert de regels zonder database of webserver | geen | + +Dat de eerste vier geen enkel extern package nodig hebben is met opzet: daardoor zijn de regels +en het protocol te bouwen en te controleren zonder dat er ook maar iets geïnstalleerd hoeft te +worden, en is `MijnBesluit.Protocol` zonder gedoe te hergebruiken in de MAUI-app. + +## Geen webservice, geen IIS + +De website is Blazor Server en roept de Application-laag rechtstreeks aan, in hetzelfde proces. +Er zit dus geen webservice tussen die serialiseert, over HTTP praat en weer deserialiseert. + +De app praat met `MijnBesluit.SocketServer`: een zelfstandig draaiende Windows-service (64 bits) +met een eigen TCP-luisteraar. Daar komt geen IIS aan te pas. + +Dezelfde exe is ook zijn eigen installatieprogramma: start Windows hem, dan draait de server; +start jij hem met de rechtermuisknop als administrator, dan verschijnt een schermpje met een +poortnummer en een knop **Install** — of **UnInstall** als hij al geïnstalleerd is. Zie +`docs/SocketServer-installeren.md`. + +``` +dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish +``` + +Binnen de service: + +* één thread accepteert verbindingen; +* elke verbinding krijgt een eigen thread met een blokkerende leeslus; +* elk verzoek gaat naar zijn eigen thread, zodat een trage databaseactie de volgende verzoeken + van diezelfde app niet ophoudt. Elk verzoek krijgt daarmee ook zijn eigen databasecontext. + +## Hoe een profiel van eigenaar wisselt + +Dit is de kern van hoe MijnBesluit zich verspreidt. + +1. Jan maakt een account. Hij krijgt een profiel (dat hij meteen zelf bezit) en een familieproject. +2. Jan voegt zijn dochter Eva toe. Er komt een profiel voor Eva, **beheerd door Jan**. Jan kan + alles aan dat profiel wijzigen. +3. Jan stuurt een uitnodiging naar Eva's e-mailadres. In de database staat alleen de SHA-256 van + het uitnodigingstoken; het token zelf bestaat enkel in die ene e-mail. +4. Eva klikt op de link en kiest een wachtwoord. Op dat moment wordt het profiel **geclaimd**: + `claimed_by_user_id` wordt gevuld en `managed_by_user_id` leeggemaakt. +5. Vanaf nu kan Jan niets meer aan Eva's profiel wijzigen. Hij ziet haar nog steeds in zijn + familie staan, met het label "geclaimd". +6. Eva krijgt haar eigen familieproject en nodigt op haar beurt anderen uit. + +De regel staat op één plek: `ProfileOwnership.CanEdit`. Alles wat een profiel wil wijzigen gaat +daarlangs. + +## Overlijden en nalatenschap + +0. Je wijst beheerders aan, op volgorde. Zij zien dat meteen staan onder *Aan mij toevertrouwd*, + met hun volgorde erbij — daar reken je tenslotte op als het zover is. Meer dan de aanwijzing + zien ze niet: geen datum, geen aantallen, geen kluis. + + Weghalen kan ook (`legacy.remove-manager`). Dat hoort erbij: een beheerder kan zelf overlijden, + en aanwijzen zonder kunnen terugnemen is geen keuze. Eén geval kan niet — iemand die je kluis + al heeft gekregen. Dan is de procedure afgerond en heeft hij gezien wat erin stond; die regel + weghalen zou alleen de administratie opschonen en niet wat er gebeurd is. +1. **Iedereen uit je kring** kan melden dat je bent overleden: de mensen die jij hebt toegevoegd + en die hun uitnodiging hebben aangenomen. Eén melding per gebruiker, afgedwongen met een + unieke sleutel in de database. +2. **Minstens één van die meldingen komt van een aangewezen beheerder.** Zolang die er niet is, + begint er niets: geen bericht aan de betrokkene, geen termijn die verloopt. + + Waarom niet "alleen beheerders mogen melden", wat voor de hand lag: dan heb je evenveel + beheerders nodig als er meldingen vereist zijn. Wijs je er twee aan terwijl er drie meldingen + nodig zijn — wat de meeste mensen zullen doen — dan kan de procedure nooit worden afgerond en + gebeurt er bij een overlijden niets. Stilzwijgend, want niemand merkt het. + + Zo werkt het nu dus niet. Het *aantal* haal je met je kring, want die merkt een overlijden als + eerste en is groot genoeg. De *beheerder* is de stem die er in elk geval bij moet zitten. Twee + verschillende soorten mensen moeten het dus eens zijn voordat er iets in beweging komt. + + Blijft die beheerdersmelding uit, dan zegt het nalatenschapsscherm dat met zoveel woorden + (`WachtOpBeheerder`). En wie nog niemand heeft aangewezen, krijgt daar een waarschuwing: zonder + beheerder blijft de kluis ook na een overlijden dicht. +3. Zodra aan allebei is voldaan (standaard drie meldingen, per profiel instelbaar), krijgt de + betrokkene zelf een e-mail: *klopt dit niet, laat het weten*. +4. Reageert die persoon binnen de bezwaartermijn (standaard 14 dagen), dan vervallen alle + meldingen en gebeurt er niets. +5. Reageert die persoon niet, dan wordt het overlijden bevestigd. De aangewezen + nalatenschapsbeheerders krijgen toegang tot precies die kluisitems die zijn gemarkeerd als + "vrijgeven na overlijden" — de rest blijft dicht. Berichten met "pas als ik er niet meer ben" + worden bezorgd. + +Een **lopende** procedure ziet er bij de beheerder precies zo uit als iemand die springlevend is. +Dat is met opzet: anders kon hij aan zijn eigen scherm aflezen dat er meldingen binnen zijn over +iemand die dat nog kan tegenspreken. Pas bij stap 5 verandert de kaart. + +Later kan een koppeling met de BRP (de opvolger van de GBA) stap 1 tot en met 4 vervangen door +één betrouwbare bron. De rest van de code verandert dan niet: alleen wie `DeathConfirmedAt` +vult, verandert. + +## Achtergrondtaken + +`AchtergrondTaken` in de SocketServer draait elk kwartier en doet drie dingen: berichten bezorgen +waarvan de datum is bereikt of de afzender bevestigd is overleden, bezwaartermijnen die zijn +afgelopen afronden, en verlopen sessies opruimen. + +## Tijd + +Alles wordt in UTC opgeslagen en pas op het scherm omgerekend naar lokale tijd. Een bericht dat +op 25 december bezorgd moet worden, verschuift zo nooit door zomertijd. diff --git a/docs/Beveiliging.md b/docs/Beveiliging.md new file mode 100644 index 0000000..88e2a7e --- /dev/null +++ b/docs/Beveiliging.md @@ -0,0 +1,208 @@ +# MijnBesluit — beveiliging + +In een kluis met wachtwoorden, pincodes en uitvaartwensen is beveiliging geen bijzaak. Dit +document beschrijft precies hoe het werkt, zodat je het kunt controleren en er later bewust van +kunt afwijken. + +## Inloggen met een code per e-mail + +Er zijn geen wachtwoorden. Je vult je e-mailadres in, krijgt een code van zes cijfers toegestuurd +en vult die in. Dat is dezelfde gang van zaken als in de WinDev-versie, met drie verschillen. + +**De code staat niet leesbaar opgeslagen.** In de WinDev-versie stond hij in +`mlPeople.ActivationCode` en werd hij bovendien in een paginavariabele (`gsInlogcode`) aan de +browser meegegeven; wie de paginabron bekeek, kon zo inloggen. Hier gaat alleen een hash de +database in (PBKDF2-HMAC-SHA512), en de code verlaat de server nooit anders dan per e-mail. + +**Zes cijfers in plaats van vier.** `Random(1111,9999)` gaf ruim achtduizend mogelijkheden; nu +zijn het er een miljoen. En `Random` is voorspelbaar zodra je een paar uitkomsten kent, dus de +code komt uit `RandomNumberGenerator`. + +**Er zitten remmen op.** Een code is 15 minuten geldig, werkt één keer, en na vijf misgokken +wordt hij weggegooid. Een nieuwe code maakt de vorige ongeldig. Per e-mailadres mag er hooguit +één code per 30 seconden en vijf per uur worden aangevraagd, zodat niemands postvak volloopt. + +Die vijf per uur tellen alleen de codes waarmee **niet** is ingelogd (`signed_in_at` leeg). De +rem is er tegen iemand die andermans postvak volgooit, en wie de code invult zit zelf in dat +postvak. Telden geslaagde aanmeldingen wel mee, dan sloot je jezelf buiten na vijf keer inloggen +in een uur — en dat gebeurt precies wanneer er iets anders aan de hand is en je het opnieuw +probeert. Een code die is verdrongen door een nieuwe aanvraag of die is stukgeraden telt wél +mee: allebei die dingen kan een vreemde doen die alleen het adres kent, en anders was de rem in +twee stappen leeg te trekken. + +Alles staat als constante bovenin `AccountService`, dus aanpassen is één regel. + +## Een account opheffen + +Zelf, op elk moment, in de app bij Mijn profiel en op `/account-verwijderen`. Dat webadres werkt +ook zonder in te loggen en zonder de app - Google Play eist dat apart, en terecht: een +verwijderknop die alleen in de app zit helpt niet als je de app al hebt weggegooid. + +**Er komt een verse code per e-mail aan te pas**, met een eigen doel (`LoginCodePurpose.Opheffen`) +zodat een gewone inlogcode hier niet volstaat. Een openstaande sessie op een telefoon die iemand +anders in handen krijgt, hoort een kluis niet leeg te kunnen gooien. + +De regel waar alles op teruggaat: **weg is wat van jou is, blijven doet wat van een ander is.** +In deze applicatie liggen die twee dichter bij elkaar dan je zou denken, dus staat per soort in +`AccountOpheffenService` waarom het de ene of de andere kant op valt. Kort: + +| Weg | Blijft | +|---|---| +| Je kluis, je bestanden, je sleutel | Berichten die al bezorgd zijn - die zijn van de ontvanger | +| Berichten die nog klaarstonden | Je naam in de lijst van wie jou heeft toegevoegd | +| Je eigen families en je aantekeningen over anderen | Aantekeningen die ook aan iemand anders hangen | +| Je aanwijzingen in nalatenschappen, beide kanten op | Het logboek (daar staat geen inhoud in) | +| Je toestellen, je sessies, je inlogcodes | | + +Dat bezorgde berichten leesbaar blijven is geen toeval: bij het bezorgen krijgt de ontvanger zijn +eigen kopie van de sleutel (zie `MessageService`). Jouw sleutel weggooien maakt ze dus niet stuk. +Zonder die eigenschap zou "bezorgd blijft" een belofte zijn die we niet waarmaken. + +Het scherm laat eerst zien wát er weggaat - aantallen, geen "weet je het zeker?". En wie ergens +als nalatenschapsbeheerder is aangewezen, krijgt dat er apart bij te zien: die aanwijzing vervalt, +en de ander rekent erop. + +> **Wat dit betekent.** Wie bij iemands e-mail kan, kan bij diens kluis. Dat is de prijs van +> inloggen zonder wachtwoord — al gold dat bij wachtwoorden met e-mailherstel net zo goed. Het +> alternatief, wachtwoord én code, is veiliger maar legt een drempel bij precies de groep waar +> MijnBesluit voor bedoeld is. Wil je later toch twee stappen: het staat allemaal in +> `AccountService`, de rest van de code verandert niet. + +Bij inloggen wordt gezegd dat een e-mailadres onbekend is ("Maak dan eerst een account aan"). +Dat verraadt of iemand deelnemer is. Bewust: zonder die melding raken mensen die zich vergissen +in hun adres het spoor bijster. Wil je dat niet prijsgeven, geef dan altijd hetzelfde antwoord +en stuur bij een onbekend adres een mail met de tekst dat er geen account is. + +## De kluis: drie lagen + +``` + inhoud van een item --AES-256-GCM--> cijfertekst in de database + ^ + | contentsleutel (CEK), per item willekeurig + | + CEK --AES-256-GCM--> wrapped_content_key (ingepakt met de datasleutel van de eigenaar) + ^ + | datasleutel (DEK), per gebruiker + | + DEK --AES-256-GCM--> wrapped_data_key (ingepakt met de mastersleutel van de server) +``` + +Er staat dus nergens een sleutel in leesbare vorm in de database. Wie de database kopieert heeft +er niets aan zolang hij het sleutelbestand niet ook heeft. + +Waarom drie lagen en niet twee? Omdat toegang geven aan een ander dan neerkomt op één handeling: +de contentsleutel nóg een keer inpakken, nu met de datasleutel van die ander. Dat is precies wat +er gebeurt als een bericht wordt bezorgd of als een nalatenschapsbeheerder toegang krijgt. De +inhoud zelf hoeft niet opnieuw versleuteld te worden, en de eigenaar houdt zijn eigen sleutel. + +AES-256-GCM is *authenticated encryption*: bij elke ontsleuteling wordt de authenticatietag +gecontroleerd. Één gewijzigde byte in de database en het openen mislukt met een duidelijke fout, +in plaats van dat er stilletjes onzin uit komt. + +## Wat is niet versleuteld? + +Bewust leesbaar, omdat er anders niet te zoeken of te sorteren valt: + +- de titel en de categorie van een kluisitem; +- het onderwerp, de afzender, de ontvanger en de bezorgdatum van een bericht; +- namen, e-mailadressen en relaties. + +Zet dus geen geheimen in een titel. Dat staat ook zo in het scherm waar je een item aanmaakt. + +## De mastersleutel + +Die staat in een sleutelbestand (standaard `keys/masterkey.json`), als een sleutelring: meerdere +genummerde sleutels waarvan er één de actieve is. In een ingepakte datasleutel staat het +versienummer vooraan, zodat je kunt roteren zonder oude gegevens onleesbaar te maken. + +Eenmalig aanmaken: + +```csharp +FileMasterKeyProtector.CreateKeyFile("keys/masterkey.json"); +``` + +Drie dingen om goed te doen: + +1. **Zet het bestand niet in dezelfde back-up als de database.** Anders heeft wie de back-up + steelt alsnog alles. +2. **Beperk de rechten** tot het account waaronder de service draait. +3. **Maak er een aparte back-up van, op een andere plek.** Raak je dit bestand kwijt, dan is + iedere kluis onherstelbaar dicht. Er is geen achterdeur — dat is het punt. + +Voor productie is een sleutelkluis of HSM beter. Vervang dan alleen `FileMasterKeyProtector` +door een eigen implementatie van `IMasterKeyProtector`; de rest van de code verandert niet. + +Merk op: omdat de server de mastersleutel heeft, kan iemand met volledige serverrechten in +theorie bij de kluisinhoud. Dat is de prijs van deze opzet, en dat is bewust gekozen: het maakt +wachtwoordherstel mogelijk en zorgt dat de nalatenschapsbeheerder ook echt toegang krijgt als de +overledene zelf niets meer kan doen. Wil je dat niet, dan is de hybride variant nodig waarbij de +datasleutel ook met het wachtwoord van de gebruiker wordt ingepakt — maar dan is een vergeten +wachtwoord gelijk aan een verloren kluis. + +## Bestanden + +Bijlagen staan versleuteld op schijf, buiten de database, in mappen per jaar en maand. De +oorspronkelijke bestandsnaam komt niet in het pad voor (die kan al iets verraden); wat op schijf +staat heet `.bin`. Van de onversleutelde inhoud wordt een SHA-256 bewaard, +zodat beschadiging vast te stellen is. Paden worden gecontroleerd, zodat een opslagsleutel nooit +buiten de map kan wijzen. + +## Tokens + +Uitnodigingstokens en vernieuwtokens zijn 32 willekeurige bytes uit een cryptografische +generator, URL-veilig gecodeerd. In de database staat alleen de SHA-256 ervan. Een uitnodiging is +30 dagen geldig en eenmalig bruikbaar. + +## Verbindingen + +De website draait achter HTTPS met HSTS. Het sessiecookie is `HttpOnly`, `SameSite=Lax` en +`Secure`. + +De SocketServer gebruikt TLS 1.2 of 1.3, met een servercertificaat uit een pfx-bestand of uit de +certificaatopslag van Windows. Zonder certificaat start de server wel, maar onversleuteld, met +een waarschuwing in het logboek — dat is uitsluitend bedoeld voor lokaal testen. Er gaan +wachtwoorden en pincodes over deze verbinding. + +> **Verschil met de RenH-socketserver.** Daar wordt de verbinding beveiligd met een +> "socketsleutel": een tijdstempel, versleuteld met AES-128-CBC waarvan de sleutel de MD5 is van +> een vast wachtwoord in de broncode. Dat werkt prima als bewijs dat de client bij het systeem +> hoort en dat het bericht vers is, maar het versleutelt de inhoud niet en het wachtwoord staat +> in elke uitgeleverde app. Voor een dashboard met verkoopcijfers is dat een andere afweging dan +> voor een kluis met pincodes. Daarom hier TLS, waarbij de hele verbinding versleuteld is en het +> certificaat aan de serverkant blijft. + +## Het logboek + +`audit_log` legt vast wie wat wanneer deed: inloggen, mislukte pogingen, profielwijzigingen, +uitnodigingen, het openen van een kluisitem, overlijdensmeldingen en het vrijgeven van een +nalatenschap. De applicatiegebruiker in de database heeft op die tabel alleen INSERT-rechten +(zie `db/02_applicatiegebruiker.sql`), zodat regels niet achteraf gewijzigd of verwijderd kunnen +worden. + +## Foutmeldingen + +De SocketServer stuurt bij een onverwachte fout altijd dezelfde neutrale tekst terug. Wat er echt +misging staat in het logboek van de server, niet op het scherm van de gebruiker. Zo lekken er +geen servernamen, tabelnamen of stacktraces naar buiten. + +## Waar de geheimen staan + +Zet de connectiereeks en het SMTP-wachtwoord **niet** in `appsettings.json` in je repository. Bij +ontwikkelen: + +``` +dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Database=mijnbesluit;User Id=...;Password=..." --project src\MijnBesluit.Web +``` + +In productie: omgevingsvariabelen of een sleutelkluis. + +## Wat er nog niet is + +Eerlijk overzicht van wat bewust nog ontbreekt: + +- **Twee-factor-authenticatie.** Het veld staat in de database, de implementatie nog niet. +- **Snelheidsbegrenzing per IP-adres.** Er is wel een blokkade per account na vijf mislukte + pogingen, maar nog geen begrenzing op verzoekniveau. +- **Certificaat-pinning in de app.** Aan te raden zodra de app publiek is. +- **Een onafhankelijke beveiligingsaudit.** Voor een product dat dit soort gegevens bewaart is + dat geen luxe. diff --git a/docs/E-mail.md b/docs/E-mail.md new file mode 100644 index 0000000..921c78d --- /dev/null +++ b/docs/E-mail.md @@ -0,0 +1,228 @@ +# E-mail: hoe MijnBesluit post verstuurt + +Zonder e-mail werkt MijnBesluit niet. Inloggen gaat met een code per mail, en uitnodigingen aan +familie gaan per mail. Elke storing hier is dus een storing in het hart van de applicatie. + +## Hoe het nu loopt + +Sinds september 2026 gaat alle uitgaande post via de **eigen Kerio Connect** op dezelfde server +als de website. Het domein mijnbesluit.nl staat daar als domein ingericht, met de mailboxen +`noreply@mijnbesluit.nl` en `info@mijnbesluit.nl`. + +``` +MijnBesluit (website + SocketServer) + -> 127.0.0.1:25 (Kerio Connect, zelfde machine) + -> poort 25 naar buiten -> Gmail en de rest +``` + +De afzender is `noreply@mijnbesluit.nl`. Dat mag nu, want het domein staat op onze eigen server; +bij Solcon kon het niet (zie "Wat er al een keer misging"). + +## Waar de instellingen staan + +In `C:\MijnBesluitData\appsettings.secrets.json` op de server, in het blok `Smtp`. Dat bestand +wordt gelezen door **zowel de website als de SocketServer**, zodat er één plek is: + +```json +"Smtp": { + "Host": "127.0.0.1", + "Port": 25, + "UseStartTls": false, + "Username": "", + "Password": "", + "FromAddress": "noreply@mijnbesluit.nl", + "FromName": "MijnBesluit", + "WriteToDiskInsteadOfSending": false, + "RedirectAllTo": "" +} +``` + +### Waarom gebruikersnaam en wachtwoord leeg zijn + +Kerio staat relayen toe voor de groep "Local clients", en `127.0.0.1` zit daarin. Post die van +deze server zelf komt hoeft dus niet in te loggen. Dat is niet alleen eenvoudiger maar ook beter: +er staat geen mailwachtwoord meer in dit bestand. + +**Vul hier niet alsnog een wachtwoord in.** Klopt het niet, dan krijg je geen foutmelding maar +traagheid - zie hieronder. + +Na een wijziging moet je herstarten; de instellingen worden één keer bij het opstarten gelezen +en daarna vastgehouden, ondanks `reloadOnChange`: + +```powershell +Restart-Service MijnBesluitSocket +Restart-WebAppPool mijnbesluit +``` + +### Waarom `127.0.0.1` en niet `localhost` + +`localhost` lost Windows eerst op naar het IPv6-adres `::1`. Luistert Kerio daar niet, dan wacht +.NET het verbindingstijdslimiet af voordat hij IPv4 probeert. Het resultaat: de mail komt wél +aan, maar het antwoord aan de app duurt meer dan tien seconden en het proefscript geeft een +time-out. Met een hard IPv4-adres is het binnen een seconde klaar. + +**Dit geldt net zo goed voor de database.** MariaDB luistert standaard alleen op 127.0.0.1, dus +`Server=localhost` in de connectiereeks kost bij elke nieuwe verbinding dezelfde verloren +seconden - merkbaar als de eerste bezoeker na een herstart van de app-pool lang moet wachten. +Zet ook daar `Server=127.0.0.1`. Meet het verschil zo: + +```powershell +Measure-Command { .\tools\SocketServer-proef.ps1 } # raakt de database niet +Measure-Command { .\tools\SocketServer-proef.ps1 -Email ... -Nieuw } # raakt hem wel +``` + +### Waarom geen TLS op die stap + +Kerio presenteert op 587 nog een zelfondertekend certificaat. `System.Net.Mail.SmtpClient` +weigert dat botweg (`The remote certificate is invalid according to the validation procedure`, +met `RemoteCertificateNameMismatch, RemoteCertificateChainErrors`), en de controle uitzetten is +in een applicatie die wachtwoorden bewaart het verkeerde ruilmiddel. + +Over loopback is dat te verdedigen: het pakket verlaat de machine niet. Maar het hoort tijdelijk +te zijn - zie "Wat hier nog niet goed aan is". + +Let op: `SmtpClient` kan geen implicit TLS. Poort 465 werkt dus nooit; dat moet 587 met STARTTLS +zijn zodra er een geldig certificaat is. + +## De DNS voor mijnbesluit.nl + +Bij Strato. Alles hieronder is september 2026 ingericht; daarvóór had het domein helemaal geen +TXT-records. + +| Naam | Type | Waarde | +|---|---|---| +| `mijnbesluit.nl` | A | 85.215.171.61 | +| `mijnbesluit.nl` | MX | `mail.kwik-bit.nl`, prioriteit hoog | +| `mijnbesluit.nl` | TXT | `v=spf1 ip4:85.215.171.61 ~all` | +| `mail._domainkey.mijnbesluit.nl` | TXT | de DKIM-sleutel die Kerio toont | +| `_dmarc.mijnbesluit.nl` | TXT | `v=DMARC1; p=none; rua=mailto:info@mijnbesluit.nl` | + +Aandachtspunten: + +* In het MX-record hoort een **naam**, geen IP-adres. Een MX naar `85.215.171.61.` wordt door + mailservers die zich aan de regels houden geweigerd. +* Strato's eigen SPF- en DMARC-knoppen staan op "geen", zodat er geen tweede record naast het + onze komt. Twee SPF-records betekent dat geen van beide werkt. +* De reverse lookup van 85.215.171.61 geeft `mail.kwik-bit.nl`, en die naam wijst terug naar + hetzelfde IP. Die heen-en-terugcontrole is wat Gmail eist; hij hoeft niet bij het + afzenderdomein te horen. De Internet hostname in Kerio moet gelijk zijn aan die PTR. + +### Bekend probleem: ns3 loopt achter + +De twee nameservers van Strato geven niet hetzelfde antwoord: `ns4.stratoserver.net` heeft het +DKIM-record, `ns3.stratoserver.net` valt terug op een wildcard-CNAME naar `mijnbesluit.nl`. +DKIM slaagt daardoor ongeveer de helft van de keren. SPF staat op het domein zelf en heeft er +geen last van, en SPF alleen is genoeg voor DMARC-uitlijning - de post komt dus gewoon aan. + +Controleren: + +```powershell +$ns = (Resolve-DnsName mijnbesluit.nl -Type NS -Server 8.8.8.8).NameHost +foreach ($s in $ns) { + "--- $s" + Resolve-DnsName mail._domainkey.mijnbesluit.nl -Type TXT -Server $s -ErrorAction SilentlyContinue | + Select-Object Type, @{n='Waarde';e={ if ($_.Strings) { ($_.Strings -join '').Substring(0,40) } else { $_.NameHost } }} +} +``` + +Staat ns3 er over een paar dagen nog steeds anders in, dan is dat een melding aan Strato: +*"`mail._domainkey.mijnbesluit.nl` geeft op ns3 een wildcard-CNAME en op ns4 het TXT-record."* + +## Testen + +Van binnen naar buiten, zodat je weet wáár het misgaat: + +```powershell +# 1. de hele keten: socket, database, mailserver +.\tools\SocketServer-proef.ps1 -Email iemand@example.nl -Nieuw + +# 2. alleen de mailserver +$c = Get-Credential +Send-MailMessage -SmtpServer mail.kwik-bit.nl -Port 587 -UseSsl -Credential $c ` + -From "noreply@mijnbesluit.nl" -To "..." -Subject "Proef" -Body "Proef" +``` + +`-Nieuw` vraagt een registratiecode in plaats van een inlogcode, zodat je naar een adres kunt +testen waar nog geen account bij hoort. Er wordt pas een account aangemaakt als de code wordt +ingevuld. + +Komt de mail aan bij Gmail, open hem dan via *Origineel weergeven*: daar staat of SPF, DKIM en +DMARC geslaagd zijn. Dat is de enige echte controle. + +## Wat er al een keer misging + +### 0. Een voorbeeldtekst als wachtwoord kost negen seconden per inlogcode + +Het langste dat dit project ons heeft gekost, en het stond al die tijd in het instellingenbestand: + +```json +"Password": "het wachtwoord van die mailbox", +``` + +Dat is de voorbeeldtekst uit de handleiding, nooit vervangen. Het gevolg is níet een foutmelding. +Kerio weigert de aanmelding, .NET probeert het opnieuw met een andere methode, en valt daarna +terug op versturen zonder aanmelden - wat mag, want het IP-adres staat in "Local clients". De +mail komt dus gewoon aan. Alleen duurt hij **negen seconden**, elke keer, en die staat de +gebruiker bij het inloggen uit te zitten. + +Wat het zo lastig maakte om te vinden: alles wat je van buitenaf meet is snel. Kerio doet 5 ms, +met en zonder inloggen. Maar dat meet je met Windows PowerShell, en dat is .NET Framework 4.8 - +een andere implementatie van `SmtpClient` dan de .NET 8 waar de applicatie op draait, die andere +keuzes maakt bij het aanmelden. Appels met peren. + +Lessen voor de volgende keer: + +* Controleer het instellingenbestand **als eerste**, niet als laatste. +* Windows PowerShell is geen goede proef voor .NET-gedrag. Gebruik `pwsh` als je het na wilt doen. +* De tijdmeting in `SmtpEmailSender` splitst het wachten in "wachten op een vrije draad" en + "het SMTP-gesprek zelf". Die twee getallen wezen het aan; laat ze staan. + +De applicatie waarschuwt nu bij het opstarten als het SMTP-wachtwoord spaties bevat - een echt +wachtwoord doet dat zelden en een voorbeeldzin altijd. + +### 1. De afzender moet bij het account horen + +Solcon weigerde elk afzenderadres dat niet bij het inlogaccount hoorde: + +``` +Mailbox unavailable. The server response was: Verification failed for +``` + +Dat is met de eigen mailserver verdwenen. + +### 2. Elke mail heeft een Message-ID nodig + +``` +550-5.7.1 Messages missing a valid Message-ID header are not accepted +``` + +.NET zet die koptekst niet zelf, en niet elke mailserver vult hem aan. `SmtpEmailSender` zet hem +er daarom zelf op; zie `Berichtkenmerk` in de Application-laag. + +### 3. Het domein moet zeggen wie er namens hem mag versturen + +``` +550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM +``` + +Vandaar de records hierboven. + +## Wat hier nog niet goed aan is + +**Kerio heeft geen geldig certificaat.** Zelfondertekend, op de verkeerde naam. Daardoor moet de +applicatie over loopback zonder TLS praten, krijgt elke mailclient een waarschuwing, en kunnen +andere mailservers de TLS-verbinding weigeren. De oplossing is een Let's Encrypt-certificaat voor +`mail.kwik-bit.nl` met **win-acme** op die server - die kan via IIS op poort 80 valideren, en na +afloop een script draaien dat het certificaat in Kerio zet. Kerio's eigen Let's Encrypt-knop +werkt hier niet, want die wil poort 80 zelf hebben en die is van IIS. + +Daarna kan het `Smtp`-blok terug naar `mail.kwik-bit.nl` op poort 587 met `UseStartTls: true`. + +**Je ziet niet of een mail aankomt.** De applicatie weet alleen of Kerio hem heeft aangenomen. +Wordt hij daarna alsnog geweigerd, dan komt die melding als bounce terug op `noreply@` en ziet de +gebruiker alleen "de code is verstuurd" terwijl er niets aankomt. De DMARC-rapporten naar +`info@mijnbesluit.nl` geven daar wel zicht op, maar pas achteraf en per dag. + +**`p=none` in DMARC.** Dat is de juiste stand om mee te beginnen: je krijgt rapporten zonder dat +er post geweigerd wordt. Gaat het een paar weken goed, dan kan het naar `p=quarantine` en het +SPF-record van `~all` naar `-all`. diff --git a/docs/Identiteit.md b/docs/Identiteit.md new file mode 100644 index 0000000..3fcef9f --- /dev/null +++ b/docs/Identiteit.md @@ -0,0 +1,275 @@ +# Wie is wie: dubbele profielen, claimen en samenvoegen + +Dit document legt vast hoe MijnBesluit omgaat met de vraag "is dit dezelfde persoon?". Het is +geschreven na een ontwerpgesprek op 15 september 2026, omdat de keuzes hier niet vanzelfsprekend +zijn en de redenen erachter over een paar maanden niet meer uit de code te halen zijn. + +## Het probleem + +Jonathan voegt zijn dochter Ysabeau toe als dochter. Zijn ex-partner voegt dezelfde Ysabeau toe, +ook als dochter. Straks doen haar grootouders dat ook. Uiteindelijk maakt Ysabeau zelf een +account en wil ze haar eigen profiel beheren — en als ze daar een foto zet, horen haar familie en +vrienden die te zien. + +Zonder maatregelen levert dat vier keer dezelfde persoon op. + +## De kern van de oplossing + +**Een niet-geclaimd profiel is een privé-aantekening van degene die hem maakte.** + +Wat Jonathan heeft aangemaakt is niet "Ysabeau", het is *zijn aantekening over zijn dochter*. Dat +zijn ex haar eigen aantekening heeft is normaal en eerlijk. Dubbele profielen zijn vóór het +claimen dus geen fout. + +Het wordt pas een probleem op het moment dat Ysabeau zelf zegt: die ben ik. Daarmee verschuift de +hele vraag — niet "hoe voorkom ik duplicaten bij het aanmaken", maar "hoe smelten ze samen op het +moment van claimen". + +## Wat we bewust NIET doen + +**Niet zoeken op naam en geboortedatum.** MyHeritage doet dat wel: je typt een naam en +geboortedatum en krijgt "bedoelt u deze?". Dat werkt daar, maar het lekt. Wie mag zo'n vraag +stellen? Als iemand "Ysabeau Flietstra, 12 maart 1998" intikt en het systeem zegt "die kennen +we", dan weet die persoon nu dat ze hier zit. Met genoeg pogingen is dat een zoekmachine op +mensen, en dat is precies wat MijnBesluit niet moet zijn. + +**Niet stilzwijgend koppelen op e-mailadres.** (Wél vrágen; zie het hoofdstuk hieronder over de +vraag bij het aanmelden. Het verschil is wie er beslist.) Dit zat er wél in, tot 15 september 2026: +`AddMemberAsync` zocht een bestaand profiel op adres en hing dat aan je familie. Gevolg: wie het +adres van iemand kende, zag meteen wat een ander over die persoon had ingevuld — naam, +geboortedatum, profielfoto. Dat is dezelfde zoekmachine, via een andere deur. + +De regel is nu: **je hergebruikt alleen je eigen aantekeningen, nooit die van een ander.** Voeg +je iemand toe aan een tweede familie van jezelf, dan is dat wél hetzelfde profiel — anders zie je +je eigen dochter twee keer. + +## Hoe het dan wel samenkomt + +**De uitnodiging is de match.** Jonathan nodigt Ysabeau uit op haar adres, zijn ex doet dat ook, +misschien op een ander adres. Ze neemt beide uitnodigingen aan. Daarmee heeft zíj gezegd dat +beide profielen haar zijn — en zij is de enige die dat mag zeggen. + +Concreet: + +| Situatie | Wat er gebeurt | +|---|---| +| Eerste uitnodiging, nog geen account | Er wordt een account gemaakt en het profiel wordt geclaimd. Zoals het altijd al ging. | +| Tweede uitnodiging, **ingelogd** | Het profiel uit de uitnodiging gaat op in het profiel dat je al hebt. | +| Tweede uitnodiging, **niet ingelogd**, adres is bekend | Geweigerd, met de uitleg dat je eerst moet inloggen. Een tweede account zou juist het probleem veroorzaken. | + +Dit werkt ook als beide ouders een ander e-mailadres van haar hadden: het adres speelt geen rol +bij het koppelen, alleen de link die zij aanklikt. + +## De vraag bij het aanmelden (17 september 2026) + +De uitnodiging is de match, maar hij is niet de enige weg. Wat er in de praktijk gebeurde: iemand +voegt een vriend toe met zijn e-mailadres, die vriend maakt zelf een account aan, en dan staan er +twee losse dingen naast elkaar die allebei over dezelfde man gaan. De vriend moet dan wachten tot +er een uitnodiging komt die de ander misschien nooit verstuurt. + +Automatisch koppelen op adres is daar niet het antwoord op, om twee redenen. Het lekt (zie +hierboven), en een adres is geen identiteit: twee mensen kunnen er één delen - een echtpaar, een +ouder die het adres van zijn kind gebruikt, een `info@` - en sinds `db/15` kunnen er ook meerdere +aantekeningen met hetzelfde adres bestaan. Automatisch koppelen moet dan gokken welke, en een +verkeerde gok maakt van twee mensen stilletjes één. Dat krijg je er niet meer uit. + +Daarom vragen we het aan de enige die het antwoord weet. Wie is aangemeld, ziet bovenaan zijn +overzicht staan: *"X heeft een profiel voor je aangemaakt op jouw e-mailadres, als vriend. Ben jij +dat?"* Met twee knoppen, en dat is alles. + +| Antwoord | Wat er gebeurt | +|---|---| +| **Ja, dat ben ik** | Dezelfde samenvoeging als bij een uitnodiging die je ingelogd aanneemt. | +| **Nee, dat ben ik niet** | Er verandert niets, en de vraag komt niet terug (`persons.link_declined_by_user_id`, `db/17`). | +| **Niets** | Er verandert niets. De vraag blijft staan tot hij hem beantwoordt. | + +Waarom dat mag zonder dat het lekt: wie deze vraag krijgt, heeft zojuist met een code in dat +postvak bewezen dat het adres van hem is. De enige die iets te zien krijgt is dus de eigenaar van +het adres - en over zichzelf. Hij ziet de naam van degene die de aantekening bijhoudt en wat hij +van hem zou zijn; dat is precies genoeg om de vraag te kunnen beantwoorden, en niet meer. + +Andersom gebeurt er niets: degene die de aantekening maakte krijgt geen melding dat er iets is +gevraagd, en geen melding dat het antwoord nee was. Anders zou het weigeren zélf een mededeling +worden over wie er wel of niet een account heeft. + +De vraag staat op het overzicht en niet als popup bij het aanmelden. Iemand kan je ook toevoegen +nádat jij je al had aangemeld, en een popup die je wegklikt is een vraag die je nooit meer krijgt. + +Er is hierdoor **geen apart goedkeuringsverzoek nodig** voor het toevoegen van iemand die al een +account heeft. Dat stond eerst in het plan, maar het lost zichzelf op: wie nooit andermans +profiel adopteert, geeft ook niets weg. Wil je die persoon er echt bij, dan nodig je uit — en die +uitnodiging *is* de toestemming. + +## Wat er verhuist bij het samenvoegen + +Zie `SamenvoegService`. Een niet-geclaimd profiel bezit weinig, want kluisitems horen bij de +persoon áchter een account. Wat er wel aan hangt: + +- **lidmaatschappen** — staat het doel al in die familie, dan verdwijnt het dubbele lidmaatschap; + de relatie die er al stond blijft leidend, want die is ingevuld door de eigenaar van die familie +- **berichten** die voor het duplicaat klaarstonden of ervandaan kwamen +- **foto's** waar de persoon op staat; hoogstens één blijft profielfoto +- **uitnodigingen**, ook de gebruikte +- **rollen in een nalatenschap** — een dubbele aanwijzing of een tweede overlijdensmelding van + dezelfde gebruiker verdwijnt, anders vervalst dat het aantal +- **lege velden** worden aangevuld vanuit het duplicaat, nooit overschreven: wat bij het doel + staat komt van de persoon zelf en weegt zwaarder + +Het duplicaat wordt **niet verwijderd**. Hij blijft staan met `merged_into_person_id` naar waar +hij in is opgegaan — er hangt geschiedenis aan, en een verwijderde rij is niet terug te halen. + +Twee grenzen, allebei met een controle erop: + +- Je kunt alleen samenvoegen met **je eigen** profiel. +- Twee **geclaimde** profielen gaan nooit automatisch samen. Dat zijn twee echte accounts met + ieder een eigen kluis; daar hoort een mens naar te kijken. + +## De profielfoto + +Hier lag een echte afweging. Zet Ysabeau zelf een foto, dan is die versleuteld met háár sleutel. +Haar vader zou hem dan niet kunnen zien — precies het tegenovergestelde van de bedoeling. + +**Gekozen: de kleine versie is niet versleuteld, de foto op ware grootte wel.** + +De redenering: een profielfoto is juist het ding dat je aan andere mensen laat zien. Hem +behandelen als kluismateriaal is niet eerlijk over wat het is, en zou betekenen dat je voor elke +kijker een sleutel moet uitdelen en weer intrekken zodra families veranderen. + +De prijs is echt en bewust genomen: wie toegang heeft tot de database, ziet gezichten van 160 +pixels. Alles waar het in MijnBesluit om gaat — wachtwoorden, polissen, uitvaartwensen, berichten +— blijft versleuteld, net als de foto op ware grootte. + +**Wie hem mag zien:** iedereen met wie je in een familie zit. Dat is de kring die mensen zelf +samenstellen door elkaar toe te voegen en uit te nodigen. Die controle staat in `VaultService` en +niet in de schermen: een toegangsregel hoort niet af te hangen van de vraag of elk scherm eraan +denkt. + +Kleine versies uit de allereerste opzet waren nog wél versleuteld. Die worden bij het eerste +gebruik automatisch vervangen; daar is geen migratie voor nodig. + +**Waar hij staat:** overal waar een mens in een lijst staat — Mijn mensen, je beheerders, de +nalatenschappen die aan jou zijn toevertrouwd — en in app en website hetzelfde. Dat zit in één +onderdeel, `Penning.razor`. Dat bestand staat twee keer op schijf (de app en de site delen geen +project) en een zelfcontrole vergelijkt ze letterlijk, zodat ze niet uit elkaar lopen. + +**Jezelf** sta je niet tussen je eigen mensen — je bent geen familielid van jezelf. Bovenaan Mijn +mensen staat daarom een eigen kaart die naar Mijn profiel gaat. Daar wijzig je je naam, je +geboortedatum, je foto en je e-mailadres; dat laatste met een code naar het nieuwe adres. Zonder +die kaart gingen mensen hun eigen gegevens zoeken in de lijst waar ze juist niet in staan. + +## Het adres van een persoon is niet uniek + +Dat lijkt een detail van de database, maar het is de hele regel hierboven in één index. + +Op `persons.email` stond een `UNIQUE`-index. Het gevolg: voegde je iemand toe met een adres dat +al ergens in de database stond - bij wie dan ook, ook bij een wildvreemde - dan liep het opslaan +stuk. Voor de gebruiker was dat een knop die niets deed. Wie een adres het eerst invulde, +bepaalde daarmee dat niemand anders die persoon nog kon toevoegen. In een applicatie waar twee +ouders allebei hun dochter toevoegen is dat precies de verkeerde kant om. + +Sinds `db/15_adres_niet_uniek.sql` is het een gewone index. Wat dat betekent voor de code: + +- **Zoeken op adres kan meer dan één profiel opleveren.** `ListByEmailAsync` geeft ze allemaal; + `FindByEmailAsync` geeft er één, geclaimde eerst, zodat je in elk geval altijd dezelfde + terugkrijgt. +- **Wie zijn eigen profiel zoekt, moet daarop filteren** en niet op de eerste treffer. Met de + eerste de beste kreeg je die van een ander te pakken, viel de eigendomscontrole uit, en maakte + je een tweede aantekening aan voor iemand die je zelf al had staan. +- **Bij het wijzigen is alleen een botsing met je eigen profiel een bezwaar.** Dat een ander + dezelfde persoon kent, is geen fout maar het uitgangspunt. +- **Een account maken koppelt niet meer op adres.** Wie zonder uitnodiging een account maakt, + krijgt zijn eigen profiel; de aantekening van een ander komt bij hem terecht op het moment dat + hij díens uitnodiging aanneemt. Zoeken op adres betekende andermans aantekening adopteren + zonder dat die ander er iets over te zeggen had, en met meerdere aantekeningen was het + bovendien de vraag welke je dan kreeg. + +Bij `users.email` blijft uniek juist wél gelden: dat is een account, en er kan er maar één per +adres zijn. Een zelfcontrole bewaakt allebei die kanten. + +## Wat hier nog niet is opgelost + +- **Mensen zonder e-mailadres** kunnen nooit claimen, dus hun duplicaten blijven bestaan. Voor + een kluis en berichten maakt dat niet uit. Zodra er ooit een gedeelde stamboom komt, wordt het + wel een vraag. +- **Samenvoegingen van vóór 15 september 2026** zijn niet terug te draaien. Daar is geen bonnetje + van (zie hieronder); de gegevens om het mee te doen bestaan eenvoudigweg niet. Dat is geen fout + die nog te repareren valt. + +## Samenvoegen terugdraaien + +Samenvoegen was lange tijd onomkeerbaar, en dat stond hier ook als open punt. Dat is het nu niet +meer. + +Waarom het niet zomaar kon: ná een samenvoeging is niet meer te zien wélke lidmaatschappen, +berichten en bestanden bij het duplicaat hoorden. Ze staan gewoon bij het doel, tussen alles wat +daar al stond. Een gok daarop zou andermans gegevens meesleuren. + +**Daarom schrijft elke samenvoeging nu een bonnetje.** In de tabel `samenvoegingen` komt per +samenvoeging één regel te staan met precies wat er is verhuisd, als lijstje stappen in JSON. +Terugdraaien is die lijst in omgekeerde volgorde aflopen. Regels die bij het samenvoegen zijn +weggegooid — een dubbel lidmaatschap, een dubbele beheerder — staan er als volledige kopie in, +want die moeten opnieuw worden aangemaakt. + +Drie dingen die daarbij met opzet zo zijn: + +- **Wat er ná de samenvoeging is bijgekomen, blijft staan.** Wat Ysabeau daarna zelf in haar kluis + heeft gelegd, is van haar en hoort bij haar. Alleen wat in het bonnetje staat gaat terug. +- **Een veld dat we hebben aangevuld gaat alleen leeg als het nog steeds is wat wij invulden.** + Heeft de persoon het daarna zelf aangepast, dan is het van hem en blijft het staan. +- **Terugdraaien mag alleen een beheerder.** Samenvoegen doet de persoon zelf — alleen hij mag + zeggen dat twee profielen hem zijn — maar het raakt ook de gegevens van degene die het + duplicaat ooit aanmaakte. Gaat het mis, dan hoort er een mens naar te kijken. + +Is het doelprofiel zelf later ergens in opgegaan, dan weigert het terugdraaien: het bonnetje +wijst dan niet meer naar waar de gegevens staan. Die latere samenvoeging gaat eerst terug. + +Het bonnetje blijft na afloop staan, met wanneer en waarom het is teruggedraaid. Dát er ooit is +samengevoegd, is zelf ook geschiedenis. + +## De beheerder + +Er is één beheerpagina, `/beheer`, en die is met opzet bijna leeg. Hij bestaat voor de twee +situaties waar niemand anders uit komt: een overlijden dat ten onrechte is bevestigd terwijl de +bezwaarlink verlopen is — het account staat dan op "overleden", inloggen kan niet meer, en er is +geen weg terug — en een samenvoeging die niet had gemoeten. + +Wat een beheerder wél kan: + +- een profiel opzoeken op het **volledige** e-mailadres +- een bevestigd overlijden terugdraaien, met een verplichte reden die in het logboek komt +- een samenvoeging terugdraaien, ook met een verplichte reden + +Beide handelingen hebben iets gemeen dat ze veilig maakt: ze geven nooit toegang. De ene neemt +toegang weg, de andere zet terug wat er stond. + +Wat hij niet kan, en ook nooit moet kunnen: + +- **een kluis openen.** Zijn enige handeling neemt toegang wég en geeft hem nooit. Wie dat + verandert, haalt de bodem onder het hele product vandaan. +- **op naam zoeken.** Dat zou de zoekmachine op mensen zijn die hierboven wordt afgewezen. Je + moet al weten wie je zoekt, omdat diegene contact met je heeft opgenomen. +- **iemand beheerder maken.** Dat gaat met de hand in de database: + `UPDATE users SET is_beheerder = 1 WHERE email = '...'`. Wat niet via de applicatie kan, kan + ook niet via een lek in de applicatie. + +Wie geen beheerder is, krijgt op die pagina "Niet gevonden" te zien — niet "geen toegang". Een +pagina waarvan je niet weet dat hij bestaat, ga je ook niet proberen. + +### Het menu-item Beheer + +In het menu staat Beheer alleen voor een beheerder. Of dat zo is, komt uit het inlogcookie +(de claim `mijnbesluit:beheer`) en niet uit de database. + +Dat is niet uit zuinigheid. Het menu zit in de layout, en die staat op élke pagina; hij wordt +tegelijk met de pagina zelf opgebouwd. Vroeg de layout het op bij de database, dan liep die +vraag gelijk op met de vraag van de pagina — over dezelfde DbContext, en die kan er maar één +tegelijk aan. Het gevolg was `A second operation was started on this context instance`: een +foutpagina die er soms wel en soms niet was, afhankelijk van welke vraag net eerder klaar was. +Een layout hoort niets op te halen; de zelfcontrole "Een layout haalt zelf niets op" houdt dat +vast. + +De prijs is dat iemand die net beheerder is gemaakt het menu-item pas ziet na opnieuw inloggen. +Dat is te overzien, want de claim is geen sleutel maar een lampje: `/beheer` en +`/beheer/adverteren` kijken zelf nog in de database of je het écht bent, en doen dat bij elke +handeling opnieuw. Wordt iemands beheerderschap ingetrokken, dan is hij dus meteen geweerd — +alleen blijft het menu-item nog staan tot hij uitlogt. diff --git a/docs/Meldingen.md b/docs/Meldingen.md new file mode 100644 index 0000000..8996788 --- /dev/null +++ b/docs/Meldingen.md @@ -0,0 +1,148 @@ +# Meldingen op de telefoon + +Als er een bericht voor iemand wordt bezorgd, hoort zijn telefoon dat te laten weten - ook als +de app dicht is. Daarvoor is Firebase Cloud Messaging van Google nodig; een andere weg is er op +Android niet. + +De applicatie is er klaar voor. Wat er nog moet gebeuren is een Firebase-project aanmaken en +twee bestanden op de juiste plek zetten. Aan de code hoeft niets te veranderen: het csproj gaat +vanzelf aan zodra het eerste bestand er staat. + +## Wat er al werkt + +| Onderdeel | Waar | +|---|---| +| Toestellen bijhouden | tabel `toestellen`, `MeldingService` | +| Aanmelden vanuit de app | bij elk inloggen en elke hervatte sessie | +| Afmelden bij uitloggen | zodat de volgende gebruiker op die telefoon niet jouw meldingen krijgt | +| Versturen bij bezorging | `MessageService.DeliverAsync` | +| Praten met Google | `FirebasePush`, met een zelfgeschreven ondertekening | +| Opruimen van dode kenmerken | een toestel dat Google niet meer kent wordt meteen afgemeld | + +Zonder servicesleutel gebeurt er niets en werkt alles gewoon door - er gaan alleen geen +meldingen uit. + +**Wat er bewust níét in een melding staat:** de inhoud. Een melding verschijnt op een scherm dat +ook zichtbaar is als de telefoon op tafel ligt. Er staat alleen dat er iets klaarstaat en van +wie. Daar is een controle voor in de zelftest. + +## Stap 1: het Firebase-project + +1. Ga naar console.firebase.google.com en maak een project, bijvoorbeeld **MijnBesluit**. + Google Analytics kun je overslaan. +2. Voeg een **Android-app** toe met pakketnaam `nl.mijnbesluit.app` - precies zoals hij in + `MijnBesluit.App.csproj` bij `ApplicationId` staat. +3. Download `google-services.json` en zet hem in `src\MijnBesluit.App\Platforms\Android\`. + +Een Play Console-account is hier niet voor nodig; Firebase is gratis en staat los van de +Play Store. + +## Stap 2: niets + +Dit stond hier vroeger als een handmatige aanpassing van het csproj. Dat is nu niet meer nodig: +in `MijnBesluit.App.csproj` staat een blok dat zichzelf aanzet zodra `google-services.json` er +staat. Het haalt dan het package `Xamarin.Firebase.Messaging` erbij, koppelt het json-bestand, +en zet de schakelaar `FIREBASE` aan die bij de `#if` in `Toestelmeldingen.cs` hoort. + +Staat het bestand er niet, dan gebeurt er niets en bouwt het project gewoon. Dat is met opzet: +er is nu geen stap die je een keer kunt overslaan, en het project blijft te bouwen op een machine +waar dat bestand niet staat. + +Het versienummer in dat blok (`125.1.1.1`, bij `net10.0-android`) moet passen bij de +werkbelasting op je machine. Klaagt de build over versies, kijk dan in NuGet welke bij jouw +.NET-versie hoort en pas alleen dat nummer aan. + +In hetzelfde blok staan vijf AndroidX-packages met een versienummer erbij. Die horen niet bij +Firebase zelf, maar lossen een botsing op: Firebase vraagt om oudere `.Ktx`-packages, terwijl +MAUI de bijbehorende basispackages al nieuwer binnenhaalt. Sinds Fragment 1.9.0 zitten de +Kotlin-uitbreidingen in het hoofdpackage, dus kwam dezelfde klasse twee keer binnen en brak de +build bij het dexen (`Type androidx.fragment.app.FragmentManagerKt is defined multiple times`). + +Komen de NU1608-waarschuwingen ooit terug, dan heeft een nieuwere MAUI de basispackages weer +opgehoogd: zoek in NuGet de bijpassende `.Ktx`-versie op en hoog die nummers mee op. Weghalen +lost het niet op, dan komt de dubbele klasse terug. + +Na het wijzigen van die versies: gooi `src\MijnBesluit.App\obj` en `bin` weg voordat je opnieuw +bouwt. In `obj` staan de uitgepakte Java-bibliotheken van de vorige build, en die worden niet +altijd opgeruimd. + +## Stap 3: de servicesleutel op de server + +1. In Firebase: **Projectinstellingen > Serviceaccounts > Nieuwe persoonlijke sleutel genereren**. + Er wordt een json-bestand gedownload. +2. Zet dat op de server als `C:\MijnBesluitData\keys\firebase.json`. +3. Zet in `C:\MijnBesluitData\appsettings.secrets.json`: + +```json +"Push": { + "ServiceAccountFile": "C:\\MijnBesluitData\\keys\\firebase.json" +} +``` + +4. `Restart-Service MijnBesluitSocket`. + +**Dat bestand is een sleutel tot je Firebase-project.** Bewaar het net zo zorgvuldig als +`masterkey.json` en zet het nooit in de publicatiemap - die wordt bij elke publicatie +overschreven, en erger: dan staat hij in het zip-bestand dat je rondstuurt. + +In het logboek van de service staat bij het opstarten of het gelukt is: + +``` +Meldingen staan aan voor Firebase-project mijnbesluit-... +``` + +## Stap 4: controleren + +1. App opnieuw installeren en inloggen. Android vraagt nu toestemming voor meldingen - die + vraag komt van `Permissions.PostNotifications`, verplicht sinds Android 13. +2. **Ga naar Mijn profiel en druk op "Stuur me een proefmelding".** Dat kan ook op de website, + bij Mijn profiel onder *Meldingen op je telefoon*. + +Die knop loopt de hele keten langs en zegt waar het op vastloopt: + +| Wat er staat | Wat eraan mankeert | +|---|---| +| "Op de server staat geen servicesleutel voor Firebase" | stap 3 is niet gedaan, of het pad klopt niet | +| "Er is geen toestel aangemeld" | de app heeft geen kenmerk; zie *Als er niets komt* hieronder | +| "Verstuurd naar je toestel" en je ziet niets | meldingen staan op de telefoon zelf uit | +| "Google nam de melding niet aan" | het kenmerk is verlopen; opnieuw inloggen in de app | +| Je ziet de melding | klaar | + +Hij gaat alleen naar je eigen toestellen. Iemand anders een melding kunnen sturen zou een manier +zijn om mensen lastig te vallen; daar is een controle voor in de zelftest. + +Wil je het echte pad ook een keer zien: zet vanaf de website een bericht voor jezelf klaar met +bezorging **meteen**. De achtergrondtaak van de SocketServer bezorgt het binnen een kwartier, en +dan hoort de melding vanzelf te verschijnen. + +## Als er niets komt + +* **Geen regel in `toestellen`** - de app krijgt geen kenmerk. Meestal ontbreekt + `google-services.json` in de APK die op het toestel staat (is hij ná het bouwen toegevoegd, + bouw dan opnieuw), of heeft de gebruiker de toestemmingsvraag weggeklikt. + +```sql +SELECT id, user_id, platform, naam, laatst_gezien FROM toestellen; +``` +* **Wel een regel, geen melding** - kijk in het logboek van de service. Staat er "Google gaf + geen toegangstoken", dan klopt de servicesleutel niet. Staat er "Melding niet afgeleverd", + dan zegt Google waarom. +* **"Verstuurd naar je toestel", maar niets te zien terwijl de app openstaat** - Android zet een + melding alleen zelf in de balk als de app op de achtergrond staat. Staat de app open, dan geeft + Android hem door aan `Meldingsdienst` in `Platforms\Android`, en die toont hem. Ontbreekt die + klasse, of is hij niet als dienst aangemeld, dan verdwijnt de melding geruisloos. Daarom gaan + titel en tekst twee keer mee in het bericht: in het `notification`-deel voor Android zelf, en + in het `data`-deel voor de app. Zie `FirebasePush.StuurAsync`. +* **Werkte eerst, nu niet meer** - het kenmerk van Firebase verandert af en toe vanzelf, + bijvoorbeeld na het terugzetten van een back-up. De app meldt zich daarom bij elke start + opnieuw aan; werkt dat niet, dan komt de app niet langs het inloggen. + +## Wat hier nog niet in zit + +* **iOS.** Daar loopt het via APNs, met een sleutel uit het Apple Developer Program (€99 per + jaar). Firebase kan dat doorgeven, dus de serverkant blijft hetzelfde. +* **Meldingen bij andere gebeurtenissen.** Nu alleen bij een bezorgd bericht. Een overlijdens- + melding of een uitnodiging zou net zo goed een melding kunnen zijn; de plek om dat aan te + roepen is `MeldingService.StuurAsync`. +* **Instellen per gebruiker.** Wie geen meldingen wil, kan ze nu alleen op het toestel zelf + uitzetten. diff --git a/docs/SocketServer-installeren.md b/docs/SocketServer-installeren.md new file mode 100644 index 0000000..59cc2c4 --- /dev/null +++ b/docs/SocketServer-installeren.md @@ -0,0 +1,201 @@ +# De SocketServer installeren als Windows-service + +`MijnBesluit.SocketServer.exe` is twee dingen tegelijk, net als je in WinDev gewend bent: + +* start **Windows** hem, dan draait de server; +* start **jij** hem, dan verschijnt een beheerschermpje. + +Eén exe om uit te leveren dus, en geen los installatieprogramma. + +## Bouwen + +```powershell +D:\MijnBesluit\tools\SocketServer-publiceren.ps1 +``` + +Dat levert `D:\publish\mijnbesluit-socketserver.zip`. Of met de hand: + +``` +dotnet publish src\MijnBesluit.SocketServer -c Release -r win-x64 --self-contained false -o D:\MijnBesluit\publish +``` + +Het project staat vast op `x64`; er komt dus geen 32-bits versie uit, ook niet op een machine +waar dat de standaard zou zijn. Wil je dat er op de doelmachine helemaal geen .NET geïnstalleerd +hoeft te zijn, gebruik dan `--self-contained true`; de map wordt dan een stuk groter maar draait +op zichzelf. + +## Op mijnbesluit.nl, naast de website + +De SocketServer draait náást IIS op dezelfde machine en praat met dezelfde database. Drie dingen +moeten daarbij kloppen, anders krijgt de app straks een lege of onleesbare kluis te zien. + +**1. Dezelfde geheimen.** `appsettings.json` wijst met `ExtraConfigFile` naar +`C:\MijnBesluitData\appsettings.secrets.json` — precies het bestand dat de website al gebruikt. +Eén plek dus voor de databasegegevens en de SMTP-instellingen, en ze overleven een nieuwe +publicatie. Ontbreekt dat bestand, dan stopt de service meteen met een duidelijke melding in +plaats van een onbegrijpelijke "Access denied" van de database. + +**2. Hetzelfde sleutelbestand.** `Security:MasterKeyFile` staat op +`C:\MijnBesluitData\keys\masterkey.json`, hetzelfde bestand als de website gebruikt. Met een +ánder sleutelbestand is geen enkel kluisitem te openen dat via de website is opgeslagen. Ook +`Storage:Path` moet dezelfde map zijn. + +Het serviceaccount **LocalSystem** moet dat sleutelbestand kunnen lezen. + +**3. Een certificaat.** Zonder certificaat luistert de server onversleuteld, en daar gaan +wachtwoorden en pincodes overheen. Het certificaat van mijnbesluit.nl uit IIS is te hergebruiken: + +De snelste weg is IIS vragen welk certificaat hij voor de site gebruikt: + +```powershell +Import-Module WebAdministration +Get-ChildItem IIS:\SslBindings | Select-Object IPAddress, Port, Host, Thumbprint, Store +``` + +Staat er geen regel met `mijnbesluit.nl`, dan gebruikt die site de regel met IP-adres +`0.0.0.0` — dat is het certificaat voor alle sites zonder eigen regel. + +Om te zien welke namen zo'n certificaat dekt: + +```powershell +Get-ChildItem Cert:\LocalMachine\WebHosting | + Select-Object Subject, @{n='Namen';e={$_.DnsNameList -join ', '}}, Thumbprint, NotAfter, HasPrivateKey +``` + +Zet de vingerafdruk in `Socket:CertificateThumbprint`. De server kijkt in de opslagen **My** én +**WebHosting**; die laatste is waar hostingpanelen en Let's Encrypt-tools hun certificaten +neerzetten. Spaties maken niet uit. Let op de +vervaldatum: wordt het certificaat vernieuwd, dan verandert de vingerafdruk en moet deze +instelling mee — een herstart van de service is dan nodig. + +**Poort 5200 open.** In de firewall van de server: + +```powershell +New-NetFirewallRule -DisplayName "MijnBesluit SocketServer" -Direction Inbound ` + -Protocol TCP -LocalPort 5200 -Action Allow +``` + +En bij je hoster, als daar nog een firewall voor staat. Controleer het daarna vanaf je eigen pc +met `Test-NetConnection mijnbesluit.nl -Port 5200`. + +## Installeren + +1. Klik met de **rechtermuisknop** op `MijnBesluit.SocketServer.exe` en kies + **Als administrator uitvoeren**. +2. Het schermpje verschijnt met de status *Niet geïnstalleerd*. +3. Vul het poortnummer in (standaard 5200). Onder het veld zie je meteen of die poort vrij is. +4. Klik op **Install**. + +Wat er dan gebeurt: + +* het poortnummer wordt in `appsettings.json` naast de exe gezet — dat bestand leest de service + straks zelf; +* de service wordt aangemaakt onder de naam `MijnBesluitSocket`, met weergavenaam + *MijnBesluit SocketServer*, opstarttype **Automatisch** en account **LocalSystem**; +* Windows krijgt de opdracht de service zelf opnieuw te starten als hij onverwacht stopt: na + 10 seconden, dan na 30, en daarna elke 2 minuten; +* de service wordt meteen gestart. + +## Verwijderen + +Rechtermuisknop → **Als administrator uitvoeren**. Het schermpje laat nu *Draait* of +*Geïnstalleerd, gestopt* zien, en de knop heet **UnInstall**. Er wordt eerst gevraagd of je het +zeker weet; daarna wordt de service gestopt en verwijderd. + +Zolang de service bestaat kun je de poort niet wijzigen — dat staat er ook bij. Wil je een andere +poort: verwijderen, poort aanpassen, opnieuw installeren. + +## Zonder beheerdersrechten + +Start je de exe gewoon met een dubbelklik, dan verschijnt hetzelfde schermpje, maar zijn de +knoppen uitgeschakeld en staat er een knop **Opnieuw starten als administrator**. Die start +dezelfde exe opnieuw met de UAC-vraag. Klik je die weg, dan gebeurt er niets. + +Het programma vraagt dus niet uit zichzelf om rechten bij het starten: alleen als je iets wilt +doen waarvoor ze nodig zijn. + +## Waar de service zijn spullen zoekt + +Windows start een service met `C:\Windows\System32` als werkmap. Alle relatieve paden in +`appsettings.json` worden daarom omgezet naar paden **naast de exe**: + +| Instelling | Standaard | Wordt | +|---|---|---| +| `Security:MasterKeyFile` | `keys/masterkey.json` | `\keys\masterkey.json` | +| `Storage:Path` | `kluisbestanden` | `\kluisbestanden` | +| `Smtp:DropFolder` | `mail-uitgaand` | `\mail-uitgaand` | + +Zet je er een volledig pad neer (`D:\MijnBesluit\keys\masterkey.json`), dan wordt dat gebruikt +zoals het er staat. Voor het sleutelbestand is dat vaak handiger: dan overleeft het een nieuwe +publish. + +Het serviceaccount **LocalSystem** moet het sleutelbestand kunnen lezen en in de map met +kluisbestanden kunnen schrijven. + +## Hoe de server met verbindingen omgaat + +* Eén thread accepteert verbindingen en doet verder niets. +* Elke verbinding krijgt zijn **eigen thread**, met een blokkerende leeslus. Die thread heet + `MijnBesluit-verbinding-`, dus in een dump of in de debugger zie je meteen welke + verbinding waar hangt. +* Elk binnengekomen verzoek krijgt een **eigen thread**. De leeslus gaat meteen door met lezen, + dus een trage databaseactie houdt de volgende verzoeken van diezelfde app niet op. Per + verbinding lopen er maximaal `Socket:MaxVerzoekenPerVerbinding` tegelijk (standaard 8) en over + de hele server `Socket:MaxVerzoekenTotaal` (standaard 200); daarboven wordt er van die + verbinding even niet gelezen, wat vanzelf tegendruk geeft. + + Bewust een echte thread en niet de threadpool: de afhandeling blokkeert op de database en op + bestanden, en dat zou de pool uithongeren — die groeit maar langzaam, waardoor verzoeken + alsnog achter elkaar zouden gaan lopen in plaats van naast elkaar. Een verzoekthread krijgt + 512 kB stapelruimte (`Socket` hoeft daar niets voor te weten; zie `RequestThreadStackBytes`). +* Het wegschrijven van antwoorden is geserialiseerd: er kunnen nooit twee antwoorden door elkaar + over de lijn gaan. Antwoorden kunnen wél in een andere volgorde terugkomen dan de verzoeken + binnenkwamen — de app koppelt ze op `requestId`. +* Bij afsluiten wacht de server tot de lopende verzoeken van een verbinding klaar zijn, zodat er + niet naar een al opgeruimde verbinding wordt geschreven. + +Omdat elk verzoek op zijn eigen thread loopt, krijgt het ook zijn eigen databasecontext: een +EF Core-context is niet bedoeld om door meerdere threads tegelijk gebruikt te worden. + +## Instellingen + +```json +"Socket": { + "Address": "0.0.0.0", + "Port": 5200, + "MaxVerbindingen": 500, + "MaxVerzoekenPerVerbinding": 8, + "MaxVerzoekenTotaal": 200, + "CertificateFile": "D:\\MijnBesluit\\certificaat.pfx", + "CertificatePassword": "...", + "CertificateThumbprint": "" +} +``` + +`CertificateThumbprint` pakt een certificaat uit de machine-certificaatopslag van Windows — +handig als je er al een hebt dat automatisch vernieuwd wordt. Spaties in de vingerafdruk maken +niet uit; die worden eruit gehaald. + +## Als er iets misgaat + +* **Je wilt meekijken terwijl hij draait.** Stop de service en start dezelfde exe zelf met + `--service`: + + ``` + net stop MijnBesluitSocket + cd C:\MijnBesluitSocket + MijnBesluit.SocketServer.exe --service + ``` + + Er verschijnt dan een venster met het logboek erin; daar komt ook langs waarom een verzoek + misging (`Fout bij '': …`). Afsluiten met Ctrl+C, en daarna `net start MijnBesluitSocket` + niet vergeten. Liever een bestand? Zet er `> log.txt 2>&1` achter; dan blijft het venster weg + en vult dat bestand zich terwijl hij draait. + +* **De service start niet.** Kijk in *Logboeken → Windows-logboeken → Toepassing*. De meest + voorkomende oorzaak is dat de poort al bezet is; dat staat er dan letterlijk. +* **"De service is verwijderd maar Windows is er nog niet mee klaar."** Er staat nog een venster + *Services* open met de service geselecteerd. Sluit dat en probeer het over een halve minuut + opnieuw. +* **Het schermpje zegt dat de poort bezet is.** Met `netstat -ano | findstr :5200` zie je welk + proces erop luistert. diff --git a/docs/Socketprotocol.md b/docs/Socketprotocol.md new file mode 100644 index 0000000..61c3000 --- /dev/null +++ b/docs/Socketprotocol.md @@ -0,0 +1,176 @@ +# Het socketprotocol van MijnBesluit + +De Android- en iOS-app praten met `MijnBesluit.SocketServer` over een eigen TCP-verbinding. +Geen IIS, geen webservice. + +## De vorm van een bericht + +``` +[4 bytes lengte, big-endian][UTF-8 JSON] +``` + +De lengte staat er dus vóór. De ontvanger weet daardoor exact wanneer een bericht af is. + +> **Waarom niet de `**BYE**`-markering uit de RenH-socketserver?** +> Omdat TCP geen berichtgrenzen kent. Wie in de datastroom naar een markering zoekt, loopt vast +> zodra die tekencombinatie toevallig in de inhoud voorkomt — precies het probleem dat in +> `RHSocketClient` op 10-09-2026 is gerepareerd, toen een leesgrens midden in een groot antwoord +> op een fragment "BYE" viel. Met een lengte vooraan kan dat niet gebeuren, en het scheelt ook +> het base64-coderen van alles wat er over de lijn gaat. `SelfCheck` bevat een controle die +> precies dit randgeval nabootst: een bericht met `**BYE**` en `BYE` in de inhoud, 400 kB groot, +> dat ongeschonden aankomt. + +## Eén verbinding, meerdere verzoeken + +`RHSocketClient` zet per aanroep een nieuwe verbinding op. Hier blijft één verbinding open: + +- verzoeken mogen elkaar overlappen; antwoorden worden teruggekoppeld op `requestId`; +- de server kan uit zichzelf een bericht sturen (een bericht dat binnenkomt, een + overlijdensmelding over jouw profiel) — de app herkent dat aan het ontbreken van een + bijbehorend openstaand verzoek; +- er is niet bij elk verzoek opnieuw een TCP- en TLS-handdruk nodig. + +## Verzoek + +```json +{ + "requestId": "8f2c...", + "action": "vault.list", + "token": "", + "version": 1, + "device": "Pixel 8", + "payload": { "category": 3 } +} +``` + +## Antwoord + +```json +{ + "requestId": "8f2c...", + "status": "Ok", + "message": null, + "payload": [ { "id": 12, "title": "Bank", "category": 3 } ] +} +``` + +`status` is `"Ok"` of `"Error"` — dezelfde opzet als je RenH-socketserver, zodat het vertrouwd +aanvoelt. Bij `"Error"` staat in `message` een tekst die je zo aan de gebruiker kunt tonen. + +## Acties + +| Actie | Sessie nodig | Wat het doet | +|---|---|---| +| `ping` | nee | Controle of de server leeft; meldt ook of de verbinding versleuteld is | +| `auth.request-code` | nee | Stuurt een inlogcode naar een bestaand account | +| `auth.signin` | nee | Inloggen met e-mailadres en code; geeft een sessietoken | +| `auth.request-register-code` | nee | Stuurt een code om een account aan te maken; naam gaat mee | +| `auth.register` | nee | Maakt het account aan met de ontvangen code | +| `auth.accept-invitation` | nee | Uitnodiging aannemen en het profiel claimen (geen code nodig) | +| `auth.signout` | ja | Sessie beëindigen | +| `family.members` | ja | Familieleden met hun eigendomsstatus | +| `family.add-member` | ja | Iemand toevoegen | +| `family.invite` | ja | Uitnodiging per e-mail versturen | +| `vault.list` | ja | Titels en categorieën (geen inhoud) | +| `vault.open` | ja | Eén item ontsleutelen | +| `vault.save` | ja | Item versleuteld opslaan | +| `message.inbox` / `message.outbox` | ja | Berichten voor en door jou | +| `message.read` | ja | Bericht lezen en als gelezen markeren | +| `message.compose` | ja | Bericht klaarzetten | +| `legacy.declare-death` | ja | Overlijden melden | +| `legacy.object` | ja | Een melding over jezelf tegenspreken | +| `legacy.status` | ja | Stand van de procedure | +| `home.advertentie` | ja | De advertentie voor het overzicht, of leeg. Het opvragen telt de vertoning mee | +| `legacy.appoint-manager` | ja | Nalatenschapsbeheerder aanwijzen | +| `legacy.remove-manager` | ja | Een beheerder weer weghalen | + +Een actie die een sessie vereist en die zonder geldig token binnenkomt, krijgt altijd: +*Je sessie is verlopen. Log opnieuw in.*, met `"code": "sessie-verlopen"` erbij. + +Dat `code`-veld is er voor de gevallen waarin de app iets anders moet doen dan de melding tonen. +Bij `sessie-verlopen` gooit de client het bewaarde token weg en stuurt de schil je naar het +inlogscherm. Op de tekst van de melding sturen zou ook werken, tot iemand er een komma in zet. + +De app krijgt `sessie-verlopen` alleen als de server de sessie echt niet meer kent. Een mislukt +verzoek is iets anders: geen bereik, een server die net opstart. Dan blijft het token staan en +probeert de app het de volgende keer gewoon opnieuw. Dat onderscheid is het verschil tussen een +hik en een e-mail met een code. + +## Gebruik vanuit de app + +`MijnBesluit.Protocol` heeft geen enkel extern package nodig en kan daardoor rechtstreeks in het +MAUI-project worden opgenomen. + +```csharp +var verbinding = new MijnBesluitSocketClient("socket.mijnbesluit.nl", 5200, useTls: true); + +verbinding.PushReceived += melding => + Console.WriteLine($"Van de server: {melding.Message}"); + +// Stap 1: code aanvragen. Die komt per e-mail binnen. +await verbinding.SendAsync(SocketActions.RequestLoginCode, new { email = "jan@example.nl" }); + +// Stap 2: inloggen met de code die de gebruiker heeft ingetypt. +var inloggen = await verbinding.SendAsync(SocketActions.SignIn, + new { email = "jan@example.nl", code = ingevuldeCode }); + +if (inloggen.Status == "Ok") +{ + verbinding.Token = /* token uit inloggen.Payload */; + var kluis = await verbinding.SendAsync(SocketActions.VaultList); +} +``` + +Tijdens ontwikkelen mag `AcceptAnyCertificate = true` om met een zelfondertekend certificaat te +werken. Zet dat in een uitgeleverde app nooit aan: dan is TLS zijn waarde kwijt. + +## Threads + +De server accepteert verbindingen op één thread, geeft elke verbinding een eigen thread met een +blokkerende leeslus, en handelt elk binnengekomen verzoek af op zijn eigen thread. Antwoorden +kunnen daardoor in een andere volgorde terugkomen dan de verzoeken binnenkwamen; daar is +`requestId` voor. Het wegschrijven is geserialiseerd, dus twee antwoorden raken nooit door elkaar. + +Zie `docs/SocketServer-installeren.md` voor de instellingen en de grenzen. + +## Sessies + +Een sessie leeft veertien dagen en schuift op bij elk verzoek. Ze staan in het geheugen én in de +tabel `sessies` (`db/16_sessies.sql`). + +Het geheugen is er voor de snelheid: een gewoon verzoek raakt geen database. De tabel is er om +een herstart te overleven. Dat laatste is geen luxe: de app blijft aangemeld en gaat daarna open +met een vingerafdruk of de code van de telefoon. Stonden de sessies alleen in het geheugen, dan +was elke uitrol, elke Windows-update 's nachts en elke reboot genoeg om iedereen weer een code +per e-mail te laten opvragen - het toestel wist dan nog wie je was, de server niet meer. De +database wordt alleen geraadpleegd bij een token dat niet in het geheugen staat, dus in de +praktijk bij het eerste verzoek van elk toestel na een herstart. + +In de tabel staat de SHA-256 van het token, nooit het token zelf: uit een hash valt geen token +terug te rekenen. Dezelfde afspraak als bij de inlogcodes. + +De vervaltijd schuift op bij gebruik, maar wordt niet bij elk verzoek weggeschreven - zie +`SessionStore.SchuifopDrempel`. Bij een termijn van veertien dagen maakt een uur speling niets +uit, en het scheelt een schrijfactie per aanroep. + +Gaat de database niet, dan komt die fout naar buiten als een gewone fout en niet als +"je sessie is verlopen". Ook dat is het verschil tussen een hik en een e-mail met een code. + +De tabel `refresh_tokens` uit het oorspronkelijke schema wordt nergens gebruikt; `sessies` is +niet hetzelfde (die heeft de persoon erbij, en er is hier geen apart toegangstoken). + +## Poort en certificaat + +Standaard poort 5200. In te stellen in `appsettings.json`: + +```json +"Socket": { + "Address": "0.0.0.0", + "Port": 5200, + "CertificateFile": "D:\\MijnBesluit\\certificaat.pfx", + "CertificatePassword": "..." +} +``` + +Of met de vingerafdruk van een certificaat uit de Windows-certificaatopslag +(`CertificateThumbprint`), bijvoorbeeld een certificaat dat je al met Let's Encrypt vernieuwt. diff --git a/docs/Stappenplan-App-Store-en-Play-Store.pdf b/docs/Stappenplan-App-Store-en-Play-Store.pdf new file mode 100644 index 0000000..a5ec447 --- /dev/null +++ b/docs/Stappenplan-App-Store-en-Play-Store.pdf @@ -0,0 +1,118 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/PageMode /UseNone /Pages 10 0 R /Type /Catalog +>> +endobj +9 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260918054241+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918054241+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Stappenplan: Google Play en Apple App Store \204 MijnBesluit) /Trapped /False +>> +endobj +10 0 obj +<< +/Count 3 /Kids [ 5 0 R 6 0 R 7 0 R ] /Type /Pages +>> +endobj +11 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1726 +>> +stream +Gatm;=``:f&:XAWkXUdC'9-nkc)tlgQ#!^A2Q9==18'I@F:>2P.fT,[k`TuPNP5Zq;CnfIFXa@-1g/8f-&lj3Jl7*YF%A$P%huW%SK$h+Q.]K-)]b<(h,_%83e?`(*3.KOZW\,GZH=(ce("'nHEVIS8c"m4?R-#j>cf]s03oN.HX0DqBA3OM3,S0`E5BE4jW(#4+ESWaVo,(@Tmtp+'^Z2e\UMK60FBPUkk`s%^@*&R]Ap2i]tUtf^;ZVn!8J*53uC*9aKjRt?V(Dke>69iH&_\RjD9RkeFC'E()3enTg\#UZU1#g29f[+MDo(C>([Z9pTd8,a8_17N3C8jPNT7,Y4$KNd9>keqk]f9l=qWg9']q&";\=kQ\W)mL.9W(p0FqAD#/b2>>\<+%5otqi4(6+MuTfO6H:S?cFqUERIs;h=g`D1eAeoJV%?4FJ[hZ`=%oGAc/![dXB%6>c3-P(rEK0W^pU+Ub4H%A[4F7ORF+p\>*^ngfZ5hMg&3W,+$:aRJj-N"KDWk2%HDRl\h)i=X*FL#W"O[#6JprY3O\Z9,trjCO\=?q+bi":]a2oYT.fY]bOGdT\aIAliipf*ZFFC/6<4pei[*2>oTI`C\8tgin1DT4G5rD%60<+E_0g]U[(CC$$X[1'7u3]YZMZ;-nm0,f?F]$6Vq9FASHj\"r=I>cS`1u=OBRJlmNfDAE:hE9V9'oe-DF>%W0J?\q-5i^TL:AeONZjCOp2ou$7D_UHPT5>1VG]>AL'uN%Pi[-t4k4Suq3H*(B+&EFZ_iOU4(c(W*3mfTGL!p_eQQV9FF(Jia/qMXWWF/FMV3QDf1'X16s?)4h?o.P88h'2D,g4d<'`$5F"1@gcG_lR1!4T/\IupeP%2EM:Nc;p/u(X'-2lD*U8#AQj>/*BX/K=@Hm$LYdPNRj%/_AYOMQ1q.!a%$r?+luCW+D\r-1RlcC,)n*R7MjOFV8Sa>R(/5XVX0fn_WLcD_`"SPMH_iWL2Y].T>9F$)ZL)XSH.6WMJBE"pkFD$om9:^`BhV[L3OtfESF699ddC0*&g!=nMm2b>Xr9o?`Kh]F57)k:k3cbqVT7EqJF.Y[iL#,quFZ&r->tB[gDnUOE71BX!"ahqB(2`=f\q)Pd6_#@gj:_3h%Z^nX[/t-dfgTmRhbY<^.mqX'k1)Vqg+OmI*9TS7B1:;k&\)5EL7m^VD_jiTd5LFFVrk)lEj^\E#JetkQhd^-Xut/KhZ@gI;o(m!ncDNY;MiZDG=@4I=iPaO6IjT0)c*pfgmL~>endstream +endobj +12 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2270 +>> +stream +GatmOcjNTq#qrc)E(0+#YI_eom7UQ-tsqe^;MKo"EA4IDO`n@97;1mq]069YG_$jX\5fedlFsi'@?@RSPDu[3H:OS6dHX)?KfgN0QPrf(nr+P*T-jYj,a(dk`O=9HiCgY1-*1RX\@oR%)nC%@_>CF5d0age10lcEFMS1_6f9Z7uBV.?@E0iVuHVm<%F73UWd`WG0-;0BhALS81pepF@alBf#`S$/\]6jNXP-1$#B6oF.G)PjMD8m+nV+aq1lO%$).)ff0QsD9>2>A$7$_=E[@bmk:<<2cEIWY`IBMl)[]1/BG@k_nTb/iFeS54Bo:08'Wq\X]NgT&5ZX.".0%/\riB1`Z$fudN\6>U)Uk;]PSA],r:Ojq4JCs<:mm\B);J(`_>m[QuPXpT=)^H_m'ZJ7CtVn,LV,2W)a1Z6fT2r2C_q,!_iB@iN1R+j(ai,4D"W6XD#C/e_4_#\4JQR8E[+2,b;Vo51PST$4e)pfib;=G<$US.H:a:AgNPgDA>[Nei.,,.!Wd"k=TUnP$bEB>i"L&2fPDS]"u9ik7)oAZMU_*Gg:P0*;7`^T'@S8'HY0/)kq]#;>0)\Pcs?>\VN=+::^hXnB&g]pg>c\LP3R?W:M!\*B:9$:kE"ZHo+[`$pf1J>UEZmf/'C2TV7)/@UZF$l7,2.KZpB9du-U;Ah$g2pH"e[AP6T2`VTak3EWCT?#AMlk*;rERGeE\S0Ct1Ep.GbGnbmR'.N*F`V\-<:YFHJbNcZ&G2s#f>]FfGSRJ0ub!kk6OYO*cNX,;gP'ojcu<@G#NpcPi^`Po7m[VT$=\?60^X9`Pcr]G?Ki.[X>J\qqO_sNZH0i.2JH3d@$Y]B*\"YA`8Mm(J6[1h0n9OMW&iS"N$^;Ou7Dc.'L"NTSZNNj/.*fLptJa9QO>^P`SN8%EmcAq;N@kL)#>o^nRR,#W&k>`41KHX!gQ+E!X-;s/(I,La7Sn699_t'hm2HKs-4DK&LNUgSCJJTq2[1q$_@m^@ek%;N`>X-gd7"MWW(j[DV0uFS)'UUT9Z%q1MSYYgfXZc((0^g>',r_dI;_8sVge'E#5l-$F96%#U@lG'F/aDt4!CJee*a2]Zs%;?g3oTBM,L&`LT72u,;4ZiHq7U15h]DqEG3]tO*;RUS:)7t5DA$j"fUQ`Ee8m&jO"4^cVA;'_^91&jVC5U?6EFo+fCBA?imF0N)YruR3k^o2rFb4Yh,HVYA"jST67Z&AeJm`Q+u%'OmBAbHdjD)`PmU8]C#%u-[SO#*#\V`Bo;(PM;Tp9B?FFY,\pnL4D[q7Y$KcS:g?ds0Zpd6%npftTN[4~>endstream +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 969 +>> +stream +Gas1]bEC=.&A70Vjqt$bCKZ+,K7]qm[Yi+o>;!3EnLCsn,:hH!Y@hAN-OU<%-'"dhmN-(G*!1H]oNUtgD[O"bjhO53p:qmg6#d.uMUhG95=_RObE@NKOJIKu4NT%5Q=Kjat!k*Pg&b[kZ_1YEH^PRT"3;e]#(:#_p*-%Ps^dK1)&I6=0s1m7\`'cC4M_bi0Y_\&hoH8D$dNfUOFGB"U"fepR2nOE-;'V8X3mNR2'IO/a4k_+mu@:?`)HS1cMj+8"EoTi7``$QFtiYhlL#AVKX4m05&nUQle^Xn+R,Z#>4h#4(h4T)@1Z6^mbPO2l\9S1rWfWbq^p>UfAoTOVH1sY\QbhDk]husqs=],WO/jM"!.KWID,69/YXQNWHLN)"Kkeh>CgO01GfCe*HWQpPYmF,ndnD=]epfk-2MI'Y`k?Dh[Jjq+;s,G4l-Shl$N2t"]u\\j'oRu@QHaGSZes"^KLo79F"XM=!>liI9At_5"\/-jC(f5P0:i6NrXWOF)]Rn$_nYnWq&)=lE:hH$SKl6YKrZ$S7NSo0#=:?3VY;AJq)d%55SU/53J#'-BBQ9a=_Cn-;ho5PJ=@lCXfO?)$OZYs7E$PnngO8~>endstream +endobj +xref +0 14 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000331 00000 n +0000000436 00000 n +0000000641 00000 n +0000000846 00000 n +0000001051 00000 n +0000001120 00000 n +0000001447 00000 n +0000001519 00000 n +0000003337 00000 n +0000005699 00000 n +trailer +<< +/ID +[<8caba89788e8ba064a7d9a534fafdf0e><8caba89788e8ba064a7d9a534fafdf0e>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 9 0 R +/Root 8 0 R +/Size 14 +>> +startxref +6759 +%%EOF diff --git a/docs/Testscenario-overlijden-melden.pdf b/docs/Testscenario-overlijden-melden.pdf new file mode 100644 index 0000000..01a5532 --- /dev/null +++ b/docs/Testscenario-overlijden-melden.pdf @@ -0,0 +1,105 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +6 0 obj +<< +/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/PageMode /UseNone /Pages 10 0 R /Type /Catalog +>> +endobj +9 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260918051107+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918051107+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Testscenario: overlijden melden \204 MijnBesluit) /Trapped /False +>> +endobj +10 0 obj +<< +/Count 2 /Kids [ 6 0 R 7 0 R ] /Type /Pages +>> +endobj +11 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2101 +>> +stream +Gb!;d?$"^h'Rf.GgddkXZ;t=goV#dHD'tl"P;Gu)_TRNH=5lJ`kinJWVfH7I]UefTp*2Ed.`Y,!9Z%04S34g-?qa$fR'3d*-u>fHpUP\itKat=bHqpG*451\>lm>p'"ZL)taaS+dpV@iA:d@PCa9V6[u;O5K$;Pp]cA?dnhdcmJ^aVAn/rPIVoiP2AGmpMkhq3l/BVZec5AET8$)FE/W(eNQ*fs5.MAQ^'S>mk%YaJ5-M[FZmV%%M_>`OH"Hg2%Z75;P@-XY:$b`#Cng..c@.j2AES0VtGoG`sYH^ZsKaccWGk#c7aMfgrHk(J!?Da5'S)3Y]sUEJ3Ze7s8j==eZ,Yl%QGLh4p`27sfQ+aO#3+H[aACJu3i_2/=0mC8k/BBFq-/%#K_9g>Ji@!?&t$hl8OVaB!J1[0N?Fa*ZZ\YuX:6`K`kNi*K\#fQ*0h5ktlV1Boq]=*#t[0%EQ+eD,6qRZ>'rKTarcVb=Na:UA$:\^^YgH7?2qY\!jLF2^#I(`7;NWB;eoj$9M6[U@JQ0:j[rXdrp\$+2dIDOOY^:99/.>Yu'\"#)D/%K.A;9Ea6K9^ik$L1(S98TM`3UIcQOYh^b^A3:MQ(YoA0aYFDYMJU"2&6)u=a[OV%@NNWb^4=EKTK1USF?=IEQ_3CjMA?B)-EJgNC+Gn@`XR!%[*;bP-THcGfjO2j_&PE7c-;DgU".pdVQj7BnNkeCM:t;=RP,%a,:A'JHS&f^0bEN*\^K[G%RX:NL-$qf"$0tk5$k\2_EfBh)KiDsf8jWSmPUoh-\4I#)GGYG;=B!bfI/o~>endstream +endobj +12 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 420 +>> +stream +GasbV;,;fu'SYH?(&*h.dnseOokhn%`,'G)5457jQ$#E]V/r'>hL[^'jM?r+h"7,?pY6HMj?MH!AI1'P0YYGTA)OP@#pR1JE6En.!-cRB7Oa65L+HmAZ]o`Bn2)];c@Y3Rkgr1P-mKR-5W#5]_1#1!OsTUF+./?j!qBen)>-E!.h<`Aa3b(I2ic3eOpH:=3,+h"iija=,$l;;h`](ZP$tRTKG1n=b3$,fRi<1Fhg$1:[Y5+&1K.9i#OTlK^Fur.:0.Y%Pl8s,eS)+++\uCsRCbOJ=GK&07o\3PKeO]PG#Frkm!5bgRU9?fAkEiUWgOK;4)!k;rV3ZsdLAdg4loXk*egg+j`pHqbeN@7>P[4M;ONM&q2f/N!bMAn\=DZ^.YTeEq,$i([`M_>k$\'R6pDgo&=^]?_&%PWZ1e~>endstream +endobj +xref +0 13 +0000000000 65535 f +0000000061 00000 n +0000000122 00000 n +0000000229 00000 n +0000000341 00000 n +0000000418 00000 n +0000000523 00000 n +0000000728 00000 n +0000000933 00000 n +0000001002 00000 n +0000001317 00000 n +0000001383 00000 n +0000003576 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 9 0 R +/Root 8 0 R +/Size 13 +>> +startxref +4087 +%%EOF diff --git a/docs/Uitrollen-met-het-venster.md b/docs/Uitrollen-met-het-venster.md new file mode 100644 index 0000000..869065a --- /dev/null +++ b/docs/Uitrollen-met-het-venster.md @@ -0,0 +1,253 @@ +# Uitrollen met een venster + +Twee programma's: eentje op je ontwikkelmachine met vinkjes en een knop, en eentje op de server +die het pakket aanneemt en installeert. + +Ze vervangen de PowerShell-scripts niet — ze roepen ze aan. `Publiceren.ps1`, `Uitrollen.ps1` en +`SocketServer-uitrollen.ps1` doen dit werk al maanden, inclusief het bewaren van de vorige versie +en het terugzetten daarvan als er iets misgaat. Die logica nog een keer in C# schrijven zou +betekenen dat er twee versies zijn van iets dat maar op één manier goed is. + +## Drie werkwijzen + +De agent kan het op drie manieren, en welke je nodig hebt hangt af van hoe je bij de server kunt. + +**Wachten op een opdracht — dit is meestal wat je wilt.** Je start de agent op de server, kiest +"wachten", en laat dat venster staan. Vanaf dat moment doe je alles op je eigen pc: vinkjes +aanzetten, op **Bouwen en uitrollen** drukken, en meekijken. Het uitrolvenster zet de pakketten in +je publicatiemap en legt er een briefje bij (`uitrol-opdracht.json`); de agent ziet dat briefje +via de doorgegeven schijf van je externe-bureaubladverbinding, controleert per pakket de hash, en +rolt uit in de vaste volgorde. Wat hij doet schrijft hij terug in `uitrol-log.txt` in diezelfde +map, en het venster op je pc leest dat regel voor regel mee. + +Geen poort, geen certificaat, geen gedeeld geheim — en op de server hoef je niets in te tikken. +Met een VPN werkt dezelfde weg, alleen wijst `Opdrachtmap` dan naar een gedeelde map op je pc in +plaats van naar `\\tsclient`. + +Het blijft met opzet een apart briefje en niet "er staat een nieuwe zip, ga je gang": anders rolt +de agent uit zodra je om een heel andere reden een keer bouwt. Een opdracht hoort te ontstaan +doordat iemand op een knop drukt. + +**Zelf uitrollen, zonder netwerk.** Je staat al op de server — via RDP of aan de console — en het +pakket staat ergens waar die server bij kan, bijvoorbeeld op de schijf die je met je +externe-bureaubladverbinding hebt doorgegeven. Start de agent, kies "een pakket uitrollen dat op +deze server staat", en geef het pad op: + +``` +\\tsclient\D\publish\mijnbesluit.zip +``` + +Hier komt geen poort aan te pas, geen certificaat en geen gedeeld geheim. Dat is geen gat in de +beveiliging: dat je op deze server bent ingelogd ís de toegangscontrole. Wie zover komt, heeft +geen pakket nodig om schade aan te richten. + +**Op afstand, met een druk op de knop.** Je pc stuurt het pakket naar de agent en die rolt het +uit. Dat vraagt wél een poort, en daarmee alles hieronder: TLS, een handtekening en een firewall. + +> **Let op: RDP is geen tunnel.** Je ziet het bureaublad van de server, maar je pc kan daarmee +> niet bij een poort op die server. De luisterstand heeft dus alleen zin als je de server echt +> over een netwerk kunt bereiken — een VPN. Zolang dat er niet is, gebruik je de eerste werkwijze: +> bouwen op je pc, het pakket over de doorgegeven schijf, en uitrollen op de server. + +Het uitrolvenster op je pc is in beide gevallen nuttig: bouwen en inpakken doet het altijd. Alleen +het versturen werkt pas als de luisterstand kan. + +## Waar je je bewust van moet zijn + +Dit gaat over de luisterstand. Rol je uit terwijl je zelf op de server staat, dan is er niets van +dit alles nodig. + +**Dit is een kanaal waarmee code op je server terechtkomt.** Dat is niet een bijwerking maar het +doel, en het is dezelfde server waar de kluizen en de masterkey staan. Wie bij dit kanaal kan, +kan alles wat de server kan. + +Daarom zitten er vier grendels op, en die zijn geen van alle optioneel: + +1. **De poort staat alleen open op het besloten netwerk.** De agent luistert wel op alles, maar de + firewall van de server hoort alles te weigeren behalve het VPN-bereik. Dit is geen poort voor + het open internet. +2. **TLS**, met hetzelfde certificaat als de SocketServer. Zonder certificaat weigert de agent te + starten. +3. **Een handtekening per opdracht**, met een gedeeld geheim. TLS zegt dat niemand meeleest, niet + wie er aanbelt — en op een besloten netwerk zit meer dan alleen jouw pc. In de handtekening + zitten de soort, de hash van het pakket, een tijdstip en een eenmalig nummer. Een opgevangen + opdracht is dus na twee minuten niets meer waard, kan geen tweede keer, en past niet op een + ander pakket. +4. **Een vaste lijst met wat de agent mag**: de site uitrollen, de SocketServer uitrollen, de + databasescripts draaien. Er zit geen "voer dit uit" bij. Een agent die opdrachten van buiten + uitvoert is een achterdeur met een nette naam. + +En hij draait alleen als jij hem start. Geen Windows-service: een poort die dag en nacht openstaat +voor iets wat je een paar keer per week doet, staat vooral open als je er niet bent. + +Vijf zelfcontroles bewaken de handtekening: verkeerd geheim, te kort geheim, een ander pakket +onder dezelfde handtekening, dezelfde opdracht twee keer, en een opdracht die te oud is. + +## Eenmalig inrichten + +Stap 1 en de firewallregel zijn alleen nodig voor de luisterstand. Rol je uit terwijl je zelf op +de server staat, dan volstaan stap 2 (zonder `Geheim` en zonder certificaat) en stap 3. + +### 1. Een geheim maken — alleen voor de luisterstand + +Op de server, in de map waar de agent staat: + +``` +MijnBesluit.Uitrolagent.exe --nieuwe-sleutel +``` + +Zet de uitkomst in `uitrolagent.json` **op de server** en in `uitroller.json` **op je pc**. Niet in +de projectmap, niet in een e-mail, niet in een schermafbeelding. + +### 2. De agent neerzetten + +Publiceer `src\MijnBesluit.Agentvenster` en zet de uitkomst op de server, bijvoorbeeld in +`C:\Beheer\uitrolagent`. Dat is het venster; de agent zelf komt als projectverwijzing mee, dus in +die ene map staan daarna allebei: + +| Bestand | Waarvoor | +|---|---| +| `MijnBesluit.Agentvenster.exe` | Het venster. Dubbelklikken, hij staat te wachten. Dit is wat je dagelijks gebruikt. | +| `MijnBesluit.Uitrolagent.exe` | Hetzelfde werk zonder venster, plus `--nieuwe-sleutel`, `--zip` en `--luister` | + +Vul `uitrolagent.json` in: + +| Instelling | Wat erin hoort | +|---|---| +| `Geheim` | De sleutel uit stap 1 | +| `Poort` | 5210, of iets anders dat vrij is | +| `CertificateThumbprint` | Hetzelfde certificaat als de SocketServer gebruikt | +| `Scriptmap` | Waar `Uitrollen.ps1` en `SocketServer-uitrollen.ps1` staan, bijvoorbeeld `C:\Beheer` | +| `Werkmap` | Waar binnengekomen pakketten landen | +| `Opdrachtmap` | Waar de agent naar een briefje van je pc kijkt als hij staat te wachten. Over RDP: `\\tsclient\D\publish` | +| `DbGebruiker` / `DbWachtwoord` | Alleen nodig voor databasescripts. Neem een eigen account dat mag wijzigen; niet root als het niet hoeft | + +> **Let op bij opnieuw publiceren.** Een publicatie zet `uitrolagent.json` terug op de versie uit de +> projectmap — met lege velden. Alles wat je niet elke keer opnieuw wilt intikken hoort daarom in +> `C:\MijnBesluitData\uitrolagent.secrets.json`. Dat bestand gaat **per sleutel** over +> `uitrolagent.json` heen, dus je zet er alleen in wat afwijkt: `Geheim`, `MysqlExe`, `DbGebruiker`, +> `DbWachtwoord`, en `Opdrachtmap` of `Scriptmap` als die bij jou anders zijn. Het agentvenster zegt +> bij het opstarten in zijn logboek welke twee bestanden hij heeft gelezen. + +Zet de firewall goed: poort 5210 alleen vanaf het VPN-bereik. + +### 3. Het venster neerzetten + +Publiceer `src\MijnBesluit.Uitroller` en vul `uitroller.json` in: de server, de poort, hetzelfde +geheim, en waar je projectmap staat. + +## De vinkjes staan er al + +Bij het openen kijkt het venster wat er sinds de vorige uitrol is gewijzigd, en zet de vinkjes +daarop. Het zegt er ook bij waarom: *"De website: Home.razor is gewijzigd."* + +Waarom dat de moeite is: na een middag werken weet niemand meer precies wat hij heeft aangeraakt. +Dan vink je te veel aan — kost tijd — of te weinig, en dan draait er een website tegen een +SocketServer die iets anders verwacht. Dat laatste kost een avond zoeken naar een fout die er niet +is. + +Wat waarin terechtkomt staat in `Wijzigingen.cs` en wordt door een zelfcontrole bewaakt. De +gedeelde projecten tellen mee bij allebei: een wijziging in Domain of Application zit zowel in de +website als in de SocketServer. Protocol zit in de SocketServer en in de app, maar niet in de +website — die praat niet over de socket. + +Twee dingen die met opzet zo zijn: + +- **Bij twijfel staat hij aan.** Is er nog nooit vanuit dit venster uitgerold, dan staat alles + aan. Te veel uitrollen kost een minuut. +- **Onthouden gebeurt op het moment van het nieuwste bestand**, niet op de klok van het moment + waarop het lukte. Wijzig je iets terwijl hij bouwt, dan zou dat laatste die wijziging als + uitgerold aanmerken terwijl hij er niet in zit — en dan staat het vinkje de volgende keer ten + onrechte uit. + +Het blijft een voorstel: je kunt elk vinkje zelf aan- en uitzetten. Wat er onthouden wordt staat +in `laatste-uitrol.json` naast de exe. + +## Uitrollen met een wachtende agent + +1. Op de server, één keer: dubbelklik `MijnBesluit.Agentvenster.exe`. Hij staat meteen te + wachten — er is geen knop om eerst in te drukken. Laat dat venster staan zolang je werkt. + Controleer dat `Opdrachtmap` in `uitrolagent.json` naar je publicatiemap wijst; over een + externe-bureaubladverbinding is dat `\\tsclient\D\publish`. +2. Op je pc: start `MijnBesluit.Uitroller.exe`, zet de vinkjes en druk op **Bouwen en uitrollen**. + +Meer is het niet. Je ziet in het venster op je eigen scherm wat er op de server gebeurt, inclusief +de regels van `Uitrollen.ps1` zelf. + +### Het agentvenster + +Twee vensters die op elkaar lijken, en dat is de bedoeling: op je pc staat het uitrolvenster, op de +server het agentvenster. Dezelfde regels komen op allebei de schermen voorbij. + +Wat je op de server ziet: + +- **Een balk bovenin** met wat hij doet: *Wacht op een opdracht van je pc* of *Bezig met uitrollen*. + Die regel is van een meter afstand te lezen, want dit venster staat op een machine waar je niet + naar zit te kijken. +- **Het logboek**, regel voor regel, inclusief de uitvoer van de PowerShell-scripts. Dat is niet + anders dan vroeger — het gaat alleen niet meer naar een consolescherm. +- **Een voortgangsbalk** die loopt zolang er iets gebeurt. Uitrollen duurt minuten; zonder teken + van leven ga je je afvragen of hij nog wel iets doet. +- **Stoppen met wachten**, als je hem wilt laten staan zonder dat hij opdrachten oppakt. + +Sluiten terwijl er een uitrol loopt vraagt hij na. Een uitrol halverwege afbreken laat een website +achter die niet meer bij zijn database past, en dat merk je pas als er iemand op zit. + +Er draait geen PowerShell-venster meer waar je omheen moet werken: het venster start PowerShell +zelf, vangt elke regel op en zet die in het logboek. De console-agent bestaat nog wel, en die heb +je ook nodig — voor `--nieuwe-sleutel`, voor `--zip` als je een pakket uitrolt dat al op de server +staat, en voor `--luister` zodra er een VPN is. + +Valt de verbinding weg terwijl de agent bezig is, dan maakt hij zijn werk gewoon af — alleen het +meekijken stopt. Op het scherm van de server staat hetzelfde. + +## Uitrollen terwijl je zelf op de server staat + +1. Op je pc: start `MijnBesluit.Uitroller.exe`, zet de vinkjes en druk op **Bouwen en uitrollen**. + Kan hij de agent niet bereiken, dan staan de pakketten alsnog klaar in je publicatiemap — + bouwen en inpakken gebeurt namelijk hier. +2. Op de server: start `MijnBesluit.Uitrolagent.exe`, kies **2** en geef het pad op naar de zip op + je doorgegeven schijf. + +Of, als je liever niets aanwijst, in één regel: + +``` +MijnBesluit.Uitrolagent.exe --zip \\tsclient\D\publish\mijnbesluit.zip --soort site +``` + +`--soort` is `site`, `socketserver` of `database`. + +## Uitrollen op afstand (vraagt een VPN) + +1. Op de server: start `MijnBesluit.Uitrolagent.exe` en kies **3**, of start hem met `--luister`. + Er verschijnt een venster dat zegt dat hij luistert. Laat het openstaan. +2. Op je pc: start `MijnBesluit.Uitroller.exe`, druk eerst op **Verbinding proberen**. Dat kost + een seconde en scheelt de teleurstelling van een kwartier bouwen voor niets. +3. Zet de vinkjes en druk op **Bouwen en uitrollen**. + +**De volgorde ligt vast en is niet die van de vinkjes:** eerst de databasescripts, dan de website, +dan de SocketServer, en de APK helemaal achteraan. Een nieuwe kolom moet er zijn voordat de code +erom vraagt; andersom staat de site stuk in de tussentijd. De SocketServer is als laatste omdat +het herstarten daarvan iedereen met de app uitlogt. + +Wat er op de server gebeurt lees je mee in het venster, regel voor regel. Loopt de verbinding weg +halverwege, dan gaat het uitrollen op de server gewoon door — daar staat hetzelfde in beeld. + +## De databasescripts + +De agent draait de scripts uit `db\` **vanaf nummer 03**, in volgorde. Nummer 01 maakt het schema +aan en 02 maakt de applicatiegebruiker met zijn wachtwoord; dat is inrichten, geen bijwerken, en +dat hoort een mens één keer met de hand te doen. Een agent die accounts kan aanmaken is een agent +die meer kan dan hij moet. + +Alle scripts gaan er elke keer overheen. Dat mag: ze zijn zo geschreven dat opnieuw draaien niets +doet — kijk maar in de kop van elk script. Dat is eerlijker dan bijhouden wat er al gedraaid is; +zo'n administratie kan ernaast zitten, en dan mis je er precies één. + +## Als er iets misgaat + +Het uitrolscript zet bij een fout de vorige versie zelf terug en zegt dat in het logboek. Gebeurt +dat niet, dan staan de vorige versies op de server in `_vorige-versies` naast de sitemap. + +De agent doet één ding tegelijk. Krijg je "er loopt al een uitrol", dan is er een eerdere poging +nog bezig of vastgelopen; kijk op het scherm van de agent. diff --git a/docs/Uitrollen-naar-IIS.md b/docs/Uitrollen-naar-IIS.md new file mode 100644 index 0000000..3d1ee7a --- /dev/null +++ b/docs/Uitrollen-naar-IIS.md @@ -0,0 +1,332 @@ +# MijnBesluit uitrollen op een Windows-server met IIS + +Uitgangspunt: `mijnbesluit.nl` wijst al naar de server en daar draait IIS. + +> **Dit is geen webservice zoals in WinDev.** IIS doet hier alleen de voordeur: hij neemt het +> verzoek aan en geeft het door aan het proces van de applicatie. Er zit geen SOAP, geen extra +> serialisatie en geen tweede laag tussen. De website praat vanuit dat proces rechtstreeks met +> MariaDB. + +## 1. Wat er op de server moet staan + +**MariaDB 10.6 of nieuwer.** Haal de MSI van mariadb.org, installeer met een sterk +root-wachtwoord en laat "Enable networking" uit staan als de website op dezelfde server draait — +dan is de database alleen via localhost te bereiken en kan niemand er van buitenaf bij. Staat de +database op een andere machine, zet `bind-address` dan op het interne adres en open poort 3306 +alleen voor de webserver. + +**Het .NET 8 Hosting Bundle** — dus niet de SDK. Zoek op "ASP.NET Core Hosting Bundle" en neem +de 8.0-versie. Die installeert de runtime én de IIS-module die het verzoek doorgeeft. Daarna: + +``` +net stop was /y +net start w3svc +``` + +**WebSocket Protocol in IIS.** Dit wordt vaak vergeten en dan werkt de site half: pagina's laden +wel, maar knoppen doen niets. Blazor Server houdt namelijk een websocket open. Aanzetten via +Serverbeheer → Rollen en onderdelen toevoegen → Webserver (IIS) → Webserver → +Toepassingsontwikkeling → **WebSocket Protocol**. + +## 2. Mappen op de server + +Zet de gegevens **buiten** de map waar je publiceert. Alles in de publicatiemap wordt bij een +volgende publicatie overschreven, en juist deze drie dingen mag je nooit kwijtraken: + +``` +D:\MijnBesluitData\ + keys\masterkey.json de sleutel van alle kluizen + dataprotection\ cookiesleutels van ASP.NET + kluisbestanden\ versleutelde bijlagen + appsettings.secrets.json connectiereeks en e-mailinstellingen + +D:\Sites\mijnbesluit\ hier komt de gepubliceerde applicatie +``` + +## 3. Database inrichten + +### 3a. MariaDB installeren (als hij er nog niet staat) + +Haal de Windows-MSI van — neem de "Stable"-versie, 64 bits. +Tijdens het installeren zijn er drie keuzes die er echt toe doen: + +**Root-wachtwoord.** Bedenk een lang, willekeurig wachtwoord en zet het in je wachtwoordbeheer. +Je hebt het hierna twee keer nodig en daarna bijna nooit meer. + +**"Enable networking".** Draait de website op dezelfde server, **haal dit vinkje dan weg**, of +zet daaronder het adres op `127.0.0.1`. De database is dan alleen vanaf de server zelf te +bereiken en er is van buitenaf simpelweg geen deur om op te kloppen. Dat scheelt je de hele +categorie problemen van een database die per ongeluk aan het internet hangt. + +**"Use UTF8 as default server's character set".** Aanvinken. Het schema zet utf8mb4 overal zelf +al, maar dit voorkomt verrassingen bij alles wat je er later handmatig bij maakt. + +De installatie zet een Windows-service neer die `MariaDB` heet en automatisch meestart. Controleer +dat even in `services.msc`. + +Installeer op de server meteen ook **HeidiSQL** (zit bij de MariaDB-installatie, of los te +downloaden). Dat werkt prettiger dan de opdrachtregel, en omdat je toch op de server bent +ingelogd hoef je de database niet open te zetten. + +### 3b. Database en gebruiker aanmaken + +Kopieer de map `db` uit het project naar de server, bijvoorbeeld naar `D:\db`. + +Pas eerst in `02_applicatiegebruiker.sql` de regel met `VERVANG_DIT_WACHTWOORD` aan. Kies een +ander wachtwoord dan op je ontwikkelmachine: raakt die ene uit, dan staat de andere er niet mee +open. + +**Met HeidiSQL op de server:** verbinden als `root` op `127.0.0.1`, dan *Bestand → +SQL-bestand uitvoeren* (Ctrl+Shift+O), eerst `01_schema.sql`, daarna `02_applicatiegebruiker.sql`. + +**Of vanaf de opdrachtregel**, in een **cmd**-venster (niet PowerShell, die kent `<` niet): + +``` +"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u root -p < D:\db\01_schema.sql +"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u root -p < D:\db\02_applicatiegebruiker.sql +``` + +Het versienummer in dat pad verschilt per installatie; kijk even in `C:\Program Files`. + +Beide scripts mogen meerdere keren gedraaid worden; bestaande tabellen en rechten blijven staan. + +### 3c. Controleren + +```sql +SHOW DATABASES; +USE mijnbesluit; +SHOW TABLES; -- hoort 14 tabellen te geven +SHOW GRANTS FOR 'mijnbesluit_app'@'localhost'; +``` + +Bij de rechten hoort `audit_log` alleen SELECT en INSERT te hebben, de andere tabellen ook UPDATE +en DELETE, en nergens CREATE, ALTER of DROP. Zo kan de applicatie het logboek wel vullen maar +niet opschonen, en kan een fout in de code nooit een tabel weggooien. + +Test daarna of het applicatieaccount echt werkt, want daar loopt het straks op vast: + +``` +"C:\Program Files\MariaDB 11.4\bin\mysql.exe" -u mijnbesluit_app -p -D mijnbesluit -e "SELECT COUNT(*) FROM users;" +``` + +Komt daar `0` uit, dan klopt zowel het wachtwoord als de rechten. Dat wachtwoord zet je straks in +`appsettings.secrets.json` (stap 6). + +### 3d. Vanaf je eigen pc meekijken + +Zet MariaDB daarvoor **niet** open naar buiten. Twee betere manieren: werk in HeidiSQL op de +server zelf via de externe-bureaubladverbinding, of maak in HeidiSQL op je eigen pc een +verbinding van het type *SSH-tunnel* als er een SSH-server op de machine draait. Een database die +rechtstreeks aan het internet hangt wordt binnen een dag gevonden en geprobeerd. + +## 4. Publiceren — in het vervolg met twee opdrachten + +> Liever met vinkjes en een knop dan met twee PowerShell-vensters? Zie +> [Uitrollen met een venster](Uitrollen-met-het-venster.md). Die twee programma's roepen dezelfde +> scripts aan als hieronder staan; de scripts blijven dus de waarheid. + +Net als de "publish site" uit WinDev, maar dan in twee stappen omdat de server niet bij jouw +schijven kan. + +**Op je eigen pc:** + +```powershell +cd D:\MijnBesluit +.\tools\Publiceren.ps1 +``` + +Dat bouwt de Release-versie, gooit `appsettings.Development.json` eruit (die hoort niet op een +server) en maakt er `D:\publish\mijnbesluit.zip` van. + +**Op de server**, in een PowerShell-venster als administrator: + +```powershell +.\Uitrollen.ps1 -Zip \\tsclient\D\publish\mijnbesluit.zip +``` + +Dat script: + +* haalt het pad en de toepassingsgroep uit IIS, dus je hoeft niets in te typen; +* waarschuwt als de toepassingsgroep niet op "Geen beheerde code" staat; +* pakt eerst helemaal uit en zet de site pas daarna een paar seconden stil; +* bewaart de vorige versie in `_vorige-versies` naast de sitemap; +* zet bij een fout de vorige versie automatisch terug; +* controleert daarna of `https://mijnbesluit.nl/inloggen` antwoordt — het leidt dat adres af uit + de bindingen van de site; +* ruimt alles op behalve de laatste vijf versies. + +Kopieer `Uitrollen.ps1` één keer naar de server, bijvoorbeeld naar `C:\Beheer`. Werkt de +stationsdoorverwijzing van je externe-bureaubladverbinding niet, kopieer dan eerst het +zip-bestand naar de server en geef dat pad op: + +```powershell +.\Uitrollen.ps1 -Zip D:\mijnbesluit.zip +``` + +Terugvallen op de vorige versie kan altijd; het script laat de precieze opdrachten zien als de +controle mislukt. + +### Handmatig publiceren + +Liever zelf, of eenmalig: + +## 4b. Handmatig publiceren + +Op je ontwikkelmachine: + +``` +dotnet publish src\MijnBesluit.Web -c Release -o D:\publish\mijnbesluit +``` + +Kopieer de inhoud van `D:\publish\mijnbesluit` naar `D:\Sites\mijnbesluit` op de server. In +Visual Studio kan het ook met Publiceren → Map, of met Web Deploy als dat op de server aanstaat. + +`dotnet publish` maakt zelf een `web.config` aan met de juiste verwijzing naar de IIS-module; +daar hoef je niets aan te doen. + +## 5. De site in IIS + +**Toepassingsgroep** aanmaken, naam `MijnBesluit`: + +* .NET CLR-versie: **Geen beheerde code**. Dat lijkt vreemd, maar klopt: de applicatie draait in + een eigen proces en IIS hoeft er geen .NET voor te laden. +* Identiteit: `ApplicationPoolIdentity` (heet dan `IIS AppPool\MijnBesluit`). +* Bij Geavanceerde instellingen: **Leegloop-time-out op 0** en **Vaste interval voor recyclen op + 0**. Anders wordt de applicatie na twintig minuten zonder bezoek afgesloten en duurt het + eerste bezoek daarna onnodig lang. + +**Website** aanmaken: + +* Fysiek pad: `D:\Sites\mijnbesluit` +* Toepassingsgroep: `MijnBesluit` +* Binding: https op poort 443, hostnaam `mijnbesluit.nl`, met je certificaat. Doe hetzelfde voor + `www.mijnbesluit.nl` als je die gebruikt. +* Een binding op poort 80 mag, maar laat die alleen doorsturen naar https — de applicatie doet + dat zelf ook. + +**Rechten.** Geef `IIS AppPool\MijnBesluit`: + +* Lezen en uitvoeren op `D:\Sites\mijnbesluit` +* **Wijzigen** op `D:\MijnBesluitData` (hij moet in `dataprotection` en `kluisbestanden` kunnen + schrijven, en `keys\masterkey.json` kunnen lezen) + +## 6. Instellingen op de server + +In `appsettings.Production.json` (die is meegepubliceerd) staan alleen paden en de site-URL. +De geheimen komen uit het bestand waar `ExtraConfigFile` naar wijst. Maak +`D:\MijnBesluitData\appsettings.secrets.json`: + +```json +{ + "ConnectionStrings": { + "MijnBesluit": "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=HET-WACHTWOORD;" + }, + "Smtp": { + "Host": "smtp.solcon.nl", + "Port": 587, + "UseStartTls": true, + "Username": "flietstra03", + "Password": "HET-MAILWACHTWOORD", + "FromAddress": "jonathan@flietstra.nl", + "FromName": "MijnBesluit", + "WriteToDiskInsteadOfSending": false + } +} +``` + +Let op het afzenderadres. Een mailserver van een provider accepteert vaak alleen een afzender +die bij het account hoort: Solcon weigert `noreply@mijnbesluit.nl` met *"Verification failed"*. +Gebruik daarom voorlopig je eigen adres. Zodra mijnbesluit.nl een eigen mailbox of verzenddienst +heeft, zet je `Host`, `Username`, `Password` en `FromAddress` daarop om — dat is ook nodig voor +SPF en DKIM, anders komen uitnodigingen bij Gmail en Outlook in de spam. + +Zo test je een afzenderadres los van de applicatie, op de server zelf: + +```powershell +$cred = Get-Credential +Send-MailMessage -SmtpServer smtp.solcon.nl -Port 587 -UseSsl -Credential $cred ` + -From "jonathan@flietstra.nl" -To "jonathan@flietstra.nl" -Subject "test" -Body "test" +``` + +Let op: **geen** `RedirectAllTo` hier. Die hoort alleen op je testmachine; op de server moet post +gewoon naar de echte ontvanger. + +Geef dit bestand alleen leesrecht aan `IIS AppPool\MijnBesluit` en aan beheerders. + +## 7. Het sleutelbestand aanmaken + +Eenmalig, op de server, in een opdrachtvenster: + +``` +cd D:\Sites\mijnbesluit +MijnBesluit.Web.exe --maak-sleutel D:\MijnBesluitData\keys\masterkey.json +``` + +Hij weigert een bestaand bestand te overschrijven — met opzet, want dat zou elke bestaande kluis +in één klap onleesbaar maken. + +**Maak hier meteen een back-up van, op een andere plek dan je database-back-up.** Zonder dit +bestand is niets meer te openen; mét dit bestand én een databasekopie is alles leesbaar. Die twee +horen dus niet bij elkaar te liggen. + +## 8. Controleren + +Ga naar `https://mijnbesluit.nl/inloggen`. Werkt dat, controleer dan ook: + +* Vraag een code aan. Komt de e-mail aan, dan klopt de SMTP-instelling. +* Log in en klik door naar Kluis. Werken de knoppen, dan doet de websocket het. +* Herstart de toepassingsgroep in IIS en ververs de pagina. Blijf je ingelogd, dan staat de + sleutelring van ASP.NET goed. Word je uitgelogd, kijk dan naar `Security:DataProtectionPath` + en de schrijfrechten op die map. + +## 9. Back-up + +Drie dingen, en het liefst niet op dezelfde plek: + +1. De database. Bijvoorbeeld dagelijks met een taak: + `mysqldump -u root -p --single-transaction mijnbesluit > D:\Backup\mijnbesluit-%DATE%.sql` +2. `D:\MijnBesluitData\kluisbestanden` — de versleutelde bijlagen. +3. `D:\MijnBesluitData\keys\masterkey.json` — **apart**, en niet in dezelfde back-upset als 1 en 2. + +Beproef ook eens een keer of terugzetten werkt. Een back-up die je nooit hebt teruggezet is een +aanname, geen back-up. + +## 10. Als er iets misgaat + +**Kijk eerst in de log.** De applicatie schrijft zelf naar `C:\MijnBesluitData\logs`, één +bestand per dag. Daar staat de échte fout in; de bezoeker krijgt alleen een nette melding. + +```powershell +Get-ChildItem C:\MijnBesluitData\logs | Sort-Object LastWriteTime -Descending | + Select-Object -First 1 | Get-Content -Tail 60 +``` + +**"We konden de e-mail met je code niet versturen"** — de SMTP-instellingen. Ontbreekt het +blok `Smtp` in `appsettings.secrets.json`, dan probeert de applicatie `localhost:587` en is er +niets om verbinding mee te maken. Staat het blok er wel, dan zegt de log wat de mailserver +antwoordde: een time-out betekent dat de poort dichtstaat op de server, en een 5xx-antwoord dat +de mailserver de server niet als afzender accepteert. + + +**HTTP 500.19** — IIS kan `web.config` niet lezen. Meestal is het Hosting Bundle niet +geïnstalleerd, of is het na IIS geïnstalleerd. Installeer het (opnieuw) en herstart IIS. + +**HTTP 500.30** — de applicatie start niet. Zet in `web.config` tijdelijk +`stdoutLogEnabled="true"` en kijk in de map `logs`. Negen van de tien keer is het de +connectiereeks of een ontbrekend sleutelbestand. + +**HTTP 502.5** — verkeerde runtime-versie, of het Hosting Bundle ontbreekt. + +**De pagina laadt maar knoppen doen niets** — WebSocket Protocol staat niet aan in IIS. Zie stap 1. + +**HTTP 400 bij het insturen van een formulier** — de sleutelring van ASP.NET is weg. Zie stap 8. + +**"Access denied for user"** — het wachtwoord in `appsettings.secrets.json` komt niet overeen met +dat in de database. Test het eerst met de MariaDB-client op de server zelf. + +## 11. En de SocketServer? + +Die heb je pas nodig zodra de Android-app er is; de website werkt er los van. Wil je hem alvast +draaien: publiceer `MijnBesluit.SocketServer`, zet hem op dezelfde server neer, gebruik hetzelfde +`D:\MijnBesluitData` en installeer hem als service met het beheerschermpje. Open dan poort 5200 +in de firewall. Zie `docs/SocketServer-installeren.md`. diff --git a/docs/Volgende-stappen.md b/docs/Volgende-stappen.md new file mode 100644 index 0000000..072a873 --- /dev/null +++ b/docs/Volgende-stappen.md @@ -0,0 +1,72 @@ +# Volgende stappen + +Wat er staat, en wat er nog moet. Eerlijk overzicht. + +## Werkt en is gecontroleerd + +- Domein en regels: eigendom van profielen, overlijdensprocedure, vrijgave van berichten. +- Versleuteling: AES-256-GCM in drie lagen, wachtwoordhashing, tokens. +- De handelingen: registreren, inloggen, familie, uitnodigen, claimen, kluis, berichten, + nalatenschap. +- Het socketprotocol: framing, server, client, sessies, dispatcher. +- De website in Blazor: inloggen, registreren, uitnodiging aannemen, overzicht, familie, kluis, + momenten, nalatenschap. +- Databaseschema voor MariaDB. +- De SocketServer als 64-bits Windows-service, met een beheerschermpje voor Install/UnInstall, + een eigen thread per verbinding en elk verzoek op zijn eigen thread. +- Inloggen en registreren met een code per e-mail, zonder wachtwoorden. +- De app-kant: `MijnBesluit.Client` (aanroepen over de socket) en `MijnBesluit.AppUi` (de + schermen). Zie `docs/Android-app.md`. +- Zesenvijftig controles in `MijnBesluit.SelfCheck`, alle groen. + +## Nog te bouwen + +### 1. De Android-app afmaken + +De drie projecten staan er: `MijnBesluit.Client`, `MijnBesluit.AppUi` en de schil +`MijnBesluit.App`. De eerste twee bouwen en zijn getest; de schil is alleen op een toestel te +beproeven. Hoe je hem draait staat in `docs/Android-app.md`. + +De serverkant staat: de SocketServer draait op mijnbesluit.nl, poort 5200 is open, en de +verbinding loopt over TLS 1.3 met het certificaat van het domein. Database en e-mail werken er +ook overheen. + +Wat nu nog moet: de app op een echt toestel beproeven (`tools\Apk-maken.ps1 -Debug`), en daarna: opnieuw verbinden als het toestel uit de slaapstand komt, biometrisch ontgrendelen +voordat de kluis opengaat, en push-meldingen (Firebase) voor een bericht dat bezorgd wordt +terwijl de app dicht is. + +### 2. Bijlagen in de schermen + +`VaultService.StoreFileAsync` werkt, maar er is nog geen scherm om een document, foto of video te +uploaden en terug te bekijken. Denk aan een maximumgrootte en aan streamen in plaats van het hele +bestand in het geheugen laden. + +### 3. Videoberichten + +`MessageKind.Video` bestaat in het model. Opnemen in de app, versleuteld opslaan als +`VaultFile`, en tonen bij het lezen van het bericht. + +### 5. Twee-factor-authenticatie + +Het veld staat in de database. TOTP is de logische keuze; het geheim hoort versleuteld te worden +met dezelfde envelope-opzet als de kluis. + +### 6. Koppeling met de BRP + +Dit vervangt op termijn de drie meldingen. Alleen de plek waar `DeathConfirmedAt` gevuld wordt +verandert; de rest van de code niet. Houd de handmatige procedure ernaast: niet elke deelnemer +komt uit de BRP. + +### 7. iOS + +Zodra Android draait, is dat vooral een kwestie van een tweede doelplatform in de MAUI-csproj en +een Mac om mee te bouwen. + +### 8. Voordat het live gaat + +- Een onafhankelijke beveiligingsaudit. +- Snelheidsbegrenzing per IP-adres op inloggen en registreren. +- Beproef het terugzetten van een back-up — inclusief het sleutelbestand. +- Verwerkersovereenkomst en privacyverklaring: dit zijn bijzondere persoonsgegevens. +- Bedenk wat er gebeurt als MijnBesluit zelf ooit stopt. Mensen vertrouwen je hun + uitvaartwensen toe; daar hoort een antwoord bij. diff --git a/docs/Website-testen.md b/docs/Website-testen.md new file mode 100644 index 0000000..d14c2c9 --- /dev/null +++ b/docs/Website-testen.md @@ -0,0 +1,215 @@ +# De website lokaal testen + +Drie stappen, daarna draait hij. + +> **Draaide de website al met een oudere versie?** Draai dan eerst +> `db\03_inloggen_met_code.sql`: die voegt de tabel voor de inlogcodes toe en haalt de +> wachtwoordkolom weg. Op een verse database is dat niet nodig. + +## 1. Database aanmaken + +Het makkelijkst gaat dit **in HeidiSQL**: maak verbinding, dan *Bestand → SQL-bestand uitvoeren* +(Ctrl+Shift+O) en kies `D:\MijnBesluit\db\01_schema.sql`. Geen gedoe met paden of shells. + +Wil je het toch vanaf de opdrachtregel, let dan op: **dit werkt niet in PowerShell.** PowerShell +kent het teken `<` niet en geeft *"The '<' operator is reserved for future use."* Gebruik een +**cmd**-venster: + +``` +mysql -u root -p < D:\MijnBesluit\db\01_schema.sql +``` + +Moet het per se in PowerShell, dan kan het zo: + +```powershell +Get-Content D:\MijnBesluit\db\01_schema.sql | mysql -u root -p +``` + +Staat `mysql` niet in je pad, gebruik dan het volledige pad — bij MariaDB is dat meestal +`"C:\Program Files\MariaDB 11.4\bin\mysql.exe"`. Bij nieuwere versies heet het programma +`mariadb.exe`; dat werkt precies hetzelfde. + +Daarna hetzelfde voor `02_applicatiegebruiker.sql`, nadat je daarin `VERVANG_DIT_WACHTWOORD` hebt +aangepast. Wil je voor het testen gewoon je eigen MariaDB-account gebruiken, dan kun je dat tweede +script overslaan. + +Beide scripts mogen meerdere keren gedraaid worden; bestaande tabellen en rechten blijven staan. + +Controleer achteraf in HeidiSQL of de database `mijnbesluit` er staat met dertien tabellen. + +## 2. Wachtwoord en connectiereeks + +Bedenk eerst één wachtwoord voor de databasegebruiker. Dat moet op **twee** plekken hetzelfde +staan, en dat is waar het vaak misgaat. + +**In de database.** Heb je `02_applicatiegebruiker.sql` gedraaid zonder +`VERVANG_DIT_WACHTWOORD` aan te passen, dan is dat nu letterlijk het wachtwoord. Zet het goed: + +```sql +ALTER USER 'mijnbesluit_app'@'localhost' IDENTIFIED BY 'HIER-JOUW-WACHTWOORD'; +FLUSH PRIVILEGES; +``` + +**In de applicatie.** Niet in `appsettings.json` (dat bestand gaat mee de broncodebeheer in), +maar in user secrets: + +``` +cd D:\MijnBesluit +dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=HIER-JOUW-WACHTWOORD;" --project src\MijnBesluit.Web +``` + +Controleer met `dotnet user-secrets list --project src\MijnBesluit.Web` wat er echt staat. +In Visual Studio kan het ook: rechtsklik op het project → *Gebruikersgeheimen beheren*. + +Wil je eerst alleen zien dát alles draait, zet dan tijdelijk je eigen `root`-account in de +reeks. Weet je dan dat het werkt, schakel daarna pas over op `mijnbesluit_app`. + +## 3. Starten + +Zorg dat het ontwikkelcertificaat van .NET vertrouwd is (eenmalig): + +``` +dotnet dev-certs https --trust +``` + +Start dan het project: + +``` +dotnet run --project src\MijnBesluit.Web --launch-profile https +``` + +Of in Visual Studio: zet `MijnBesluit.Web` als startproject en druk op F5. Kies het profiel +**https**; het inlogcookie is met opzet als *Secure* gemarkeerd en werkt dus niet over gewoon http. + +De site komt op **https://localhost:7014**. + +Het sleutelbestand hoef je niet zelf aan te maken: bij de eerste start zet de website +`keys/masterkey.json` neer in de uitvoermap, omdat dat in `appsettings.Development.json` aanstaat. +Gooi die map weg en alles wat je tijdens het testen in de kluis hebt gezet, is onleesbaar — dat is +precies de bedoeling. + +## Een rondje door de applicatie + +1. **Registreren** op `/registreren`. Vul je naam en e-mailadres in en klik op *Code aanvragen*. + Er is geen wachtwoord: je krijgt een code van zes cijfers toegestuurd. Tijdens ontwikkelen + wordt die niet verstuurd maar als html-bestand neergezet in + `src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand` — open het nieuwste bestand, lees de + code en vul hem in. Daarna heb je meteen een eigen profiel en familieproject. +2. **Kluis** → *Toevoegen*. Kies bijvoorbeeld *Uitvaartwens* en zet de vrijgave op + "Na mijn overlijden". Sla op en klik daarna op *Bekijken*: de inhoud wordt ter plekke + ontsleuteld. Kijk in HeidiSQL in de tabel `vault_items`: in de kolom `ciphertext` staat + onleesbare rommel, alleen `title` en `category` zijn leesbaar. +3. **Familie** → iemand toevoegen met een e-mailadres, daarna *Uitnodiging sturen*. + Er wordt niets verstuurd tijdens ontwikkelen: de mail komt als html-bestand in + `src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`. Open het nieuwste bestand in je browser + en klik op de link — of kopieer de `/uitnodiging/...`-link naar een **privévenster**. +4. **De uitnodiging aannemen** in dat privévenster (anders log je jezelf uit). Er is niets in te + vullen: de link uit de e-mail is zelf al het bewijs dat je bij dat adres kunt. Ga daarna terug + naar je eerste venster, ververs *Familie*: het profiel staat nu op **Geclaimd** en je kunt er + niets meer aan wijzigen. +5. **Momenten** → *Nieuw bericht* voor die persoon, met "Pas als ik er niet meer ben". In het + privévenster zie je bij Momenten nog niets staan — zo hoort het. +6. **Nalatenschap** → wijs die persoon aan als beheerder. Zet het aantal meldingen op 1 en de + bezwaartermijn op 0 dagen om de procedure te kunnen doorlopen zonder drie accounts. + Meld jezelf vanuit het privévenster als overleden, en kijk wat er gebeurt. + +Voor stap 6 heb je geen achtergrondtaak nodig zolang het aantal meldingen gehaald wordt en de +bezwaartermijn 0 is; de bevestiging gebeurt dan meteen. Staat er wél een termijn, dan is het de +achtergrondtaak in de SocketServer die dat later afrondt. + +## Als er iets niet werkt + +**"The '<' operator is reserved for future use"** — je zit in PowerShell. Zie stap 1. + +**"'mysql' is not recognized as an internal or external command"** — het programma staat niet in +je pad. Gebruik het volledige pad naar `mysql.exe` of `mariadb.exe`, of draai het script in +HeidiSQL. + +**Je krijgt geen e-mail met de code** — tijdens ontwikkelen wordt er niets verstuurd. Kijk in +`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`; daar staat de mail als html-bestand. +Wil je wel echt versturen, zet dan `Smtp:WriteToDiskInsteadOfSending` op `false` en vul de +SMTP-gegevens in (zie hieronder). + +**"Deze code is verlopen of al gebruikt"** — een code werkt één keer en 15 minuten. Vraag een +nieuwe aan met de knop onder het codeveld. + +**"Access denied for user 'root'@'localhost'"** — verkeerd wachtwoord. Let op: achter `-p` komt +géén spatie als je het wachtwoord meteen meegeeft; laat `-p` liever leeg, dan wordt er om +gevraagd. + +**Je krijgt geen e-mail met de code** — tijdens ontwikkelen wordt er niets verstuurd. Kijk in +`src\MijnBesluit.Web\bin\Debug\net8.0\mail-uitgaand`; daar staat de mail als html-bestand. +Wil je wel echt versturen, zet dan `Smtp:WriteToDiskInsteadOfSending` op `false` en vul de +SMTP-gegevens in (zie hieronder). + +**"Deze code is verlopen of al gebruikt"** — een code werkt één keer en 15 minuten. Vraag een +nieuwe aan met de knop onder het codeveld. + +**"Access denied for user 'mijnbesluit_app'@'localhost' (using password: YES)"** — het +wachtwoord in user secrets is niet hetzelfde als dat in de database. Zie stap 2. Het ligt niet +aan `localhost` versus `127.0.0.1`: een account dat als `@localhost` is aangemaakt, werkt ook +over TCP. Test het account eerst in HeidiSQL met precies hetzelfde wachtwoord. + +**"Unable to connect to any of the specified MySQL hosts"** — MariaDB draait niet, of de +connectiereeks klopt niet. Test de reeks eerst in HeidiSQL met hetzelfde account. + +**"Geen connectiereeks 'MijnBesluit' gevonden"** — de user secret staat op het verkeerde project. +Controleer met `dotnet user-secrets list --project src\MijnBesluit.Web`. + +**Je wordt na het inloggen meteen weer uitgelogd** — je draait over http in plaats van https. +Gebruik het profiel *https*. + +**Waarschuwing over het certificaat in de browser** — `dotnet dev-certs https --trust` nog niet +gedraaid, of de browser moet opnieuw opgestart worden. + +**Je ziet je eigen wijziging niet** — Blazor Server houdt een verbinding open. Ververs met +Ctrl+F5 als een scherm blijft hangen. + +## De app erbij + +De website heeft de SocketServer niet nodig; die is alleen voor de Android/iOS-app. Wil je beide +tegelijk draaien, start het SocketServer-project dan met het argument `--service`, anders krijg je +het installatieschermpje in plaats van de server. Zie `docs/SocketServer-installeren.md`. + +## Echte e-mail versturen + +Tijdens ontwikkelen worden mails weggeschreven naar een map. Wil je ze echt versturen, zet dan de +SMTP-gegevens in user secrets — niet in `appsettings.json`, want dat bestand gaat mee de +broncodebeheer in: + +``` +cd D:\MijnBesluit +dotnet user-secrets set "Smtp:Host" "mail.jouwprovider.nl" --project src\MijnBesluit.Web +dotnet user-secrets set "Smtp:Port" "587" --project src\MijnBesluit.Web +dotnet user-secrets set "Smtp:UseStartTls" "true" --project src\MijnBesluit.Web +dotnet user-secrets set "Smtp:Username" "noreply@mijnbesluit.nl" --project src\MijnBesluit.Web +dotnet user-secrets set "Smtp:Password" "HIER-HET-WACHTWOORD" --project src\MijnBesluit.Web +dotnet user-secrets set "Smtp:FromAddress" "noreply@mijnbesluit.nl" --project src\MijnBesluit.Web +dotnet user-secrets set "Smtp:FromName" "MijnBesluit" --project src\MijnBesluit.Web +dotnet user-secrets set "Smtp:WriteToDiskInsteadOfSending" "false" --project src\MijnBesluit.Web +``` + +### Veilig oefenen met echte adressen + +Zodra je echt verstuurt, komen uitnodigingen ook echt aan. Wil je met de namen en adressen van je +eigen familie oefenen zonder ze lastig te vallen, zet dan: + +``` +dotnet user-secrets set "Smtp:RedirectAllTo" "jouw@eigenadres.nl" --project src\MijnBesluit.Web +``` + +Alle uitgaande post gaat dan naar dat ene adres. Het oorspronkelijke adres staat in het onderwerp +(`[TEST aan eva@example.nl] ...`) en bovenaan de mail, dus je ziet precies wie hem gekregen zou +hebben. Leeglaten — of de instelling verwijderen — en het gaat weer gewoon naar de +echte ontvanger. + +Dezelfde regels gelden voor `--project src\MijnBesluit.SocketServer`; die verstuurt ook mail +(uitnodigingen, bezorgde berichten, meldingen over de nalatenschap). + +Voor de SocketServer als Windows-service werkt user secrets niet — die draait onder +LocalSystem. Zet de instellingen daar in `appsettings.json` naast de exe, of beter: in +omgevingsvariabelen (`Smtp__Password`), zodat het wachtwoord niet in een bestand staat dat +meegekopieerd wordt. + +Wil je later van provider wisselen, dan is dit de enige plek die verandert; in de code staat +nergens een mailserver. diff --git a/docs/iOS-app.md b/docs/iOS-app.md new file mode 100644 index 0000000..70e1541 --- /dev/null +++ b/docs/iOS-app.md @@ -0,0 +1,72 @@ +# De iOS-app + +De app voor iPhone en iPad is dezelfde app als die voor Android. `MijnBesluit.AppUi` (alle +schermen), `MijnBesluit.Client` en het socketprotocol zijn gedeeld; op iOS draait diezelfde HTML +in een WKWebView in plaats van een Android WebView. Wat verschilt is alleen wat het toestel zelf +doet. + +Dit document gaat over dat verschil, en over wat Apple wil. + +## Wat er klaarstaat + +- `net10.0-ios` staat in `MijnBesluit.App.csproj`, maar **standaard uit**. Aanzetten met + `-p:MetIos=true`, of door `MetIos` blijvend te zetten op de machine die aan een Mac hangt. + Dat moet wel zo: staat de iOS-doelomgeving er onvoorwaardelijk in, dan wil ook een build van + alleen de Android-APK de iOS-werkbelasting en een Mac hebben, en struikelt het maken van de + APK over iets wat er niets mee te maken heeft. +- `Platforms/iOS/` heeft `AppDelegate.cs`, `Program.cs` en `Info.plist`. +- `Toestelslot` heeft een iOS-tak: Face ID of Touch ID via LocalAuthentication, met de code van + de telefoon als terugval. +- De gebruikstoelichtingen staan in `Info.plist`. + +## Wat er nog moet + +**Een Mac.** Apple's bouwgereedschap draait alleen op macOS. Visual Studio op Windows verbindt +er over het netwerk mee ("Pair to Mac", over SSH) en laat de Mac compileren en ondertekenen. Op +die Mac moet Xcode staan, één keer met de hand geopend zijn, en Remote Login aan. Staat er geen +Mac, haal `net10.0-ios` dan tijdelijk uit `TargetFrameworks`; Android blijft dan gewoon bouwen. + +**Het Apple Developer Program**, $99 per jaar. Als organisatie heb je nodig: een echte +rechtspersoon, een D-U-N-S-nummer daarvoor, een e-mailadres op je eigen domein en een werkende +website op dat domein. Dat D-U-N-S-nummer heb je voor Google Play ook nodig - vraag het één keer +aan. + +**Meldingen via APNs.** `Toestelmeldingen` geeft op iOS nu null terug: geen kenmerk, dus geen +meldingen, en verder werkt alles. Dat komt erbij zodra het account er is, want het hangt eraan +vast: een APNs-sleutel, de Push Notifications-capability, een provisioning profile en een +`Entitlements.plist`. + +## Twee vallen waar je vroeg in loopt + +**Ontbrekende gebruikstoelichting = de app sluit.** Vraag je om Face ID zonder +`NSFaceIDUsageDescription` in `Info.plist`, dan sluit iOS de app ter plekke af. Geen foutmelding, +geen crashmelding die ergens op wijst - hij is gewoon weg. Hetzelfde geldt voor camera, microfoon +en fotobibliotheek. Ze staan er nu allemaal in; komt er ooit iets bij wat het toestel moet +vrijgeven, dan hoort de tekst er in dezelfde wijziging bij te staan. Een zelfcontrole bewaakt dat. + +**De socket op poort 5200.** Dat is een eigen TLS-verbinding en geen URL-verkeer, dus App +Transport Security gaat er niet over en je hebt er géén ATS-uitzondering voor nodig. Een +uitzondering toevoegen die je niet nodig hebt levert alleen vragen op bij de beoordeling. Maar +probeer die verbinding wel vroeg op een echt toestel, en ook over 4G en niet alleen over wifi. + +## Wat Apple bij het inzenden vraagt + +- **Account verwijderen in de app.** Verplicht. Staat er al. +- **App Privacy** - hetzelfde soort formulier als Databeveiliging bij Google. De + privacyverklaring staat in `Privacytekst.razor`. +- **Exportverklaring** (`ITSAppUsesNonExemptEncryption`). Deze app versleutelt een kluis, dus dat + antwoord is niet vanzelfsprekend "nee" zoals bij een app die alleen HTTPS gebruikt. De sleutel + staat bewust NIET in `Info.plist`: dan vraagt App Store Connect het bij elke inzending en + beantwoord je het bewust. Zie de toelichting in dat bestand. +- **Sign in with Apple** is NIET verplicht. Dat geldt alleen als je inloggen via Google of + Facebook aanbiedt; MijnBesluit doet e-mail met een code. + +## De volgorde + +1. D-U-N-S aanvragen voor de B.V. (kan weken duren, en Google Play wacht er ook op) +2. Apple Developer Program, als organisatie +3. Mac regelen, Xcode erop, Pair to Mac aan de praat +4. Bouwen en op een echt toestel zetten +5. Uitproberen: inloggen met een code, de socket, de kluis, het slot met Face ID +6. Meldingen via APNs +7. Inzenden diff --git a/src/MijnBesluit.Agentvenster/App.xaml b/src/MijnBesluit.Agentvenster/App.xaml new file mode 100644 index 0000000..1394107 --- /dev/null +++ b/src/MijnBesluit.Agentvenster/App.xaml @@ -0,0 +1,5 @@ + + diff --git a/src/MijnBesluit.Agentvenster/App.xaml.cs b/src/MijnBesluit.Agentvenster/App.xaml.cs new file mode 100644 index 0000000..168b63e --- /dev/null +++ b/src/MijnBesluit.Agentvenster/App.xaml.cs @@ -0,0 +1,7 @@ +using System.Windows; + +namespace MijnBesluit.Agentvenster; + +public partial class App : Application +{ +} diff --git a/src/MijnBesluit.Agentvenster/Hoofdvenster.xaml b/src/MijnBesluit.Agentvenster/Hoofdvenster.xaml new file mode 100644 index 0000000..e66b386 --- /dev/null +++ b/src/MijnBesluit.Agentvenster/Hoofdvenster.xaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + +

+ +

+ +} +else +{ +
+
+ @if (OpOverzicht) + { + MijnBesluit + } + else + { + + @Schermnaam + } + +
+ @* Een tekentje en niet het woord "Geen verbinding". Op een telefoon van 360 pixels + duwde dat labeltje de schermnaam tot een paar pixels in elkaar, en verbinding + verliezen is hier normaal: Android verbreekt hem zodra de app even op de + achtergrond staat, en de eerstvolgende aanroep zet hem weer op. Iets wat komt en + gaat hoort de rest van de balk niet overhoop te gooien. *@ + @if (!Toestand.Verbonden) + { + ⚠ + } + @* Geen poppetje meer naar Mijn profiel. Je eigen kaart staat bovenaan Mijn mensen en + brengt je daar ook heen; twee ingangen naar hetzelfde scherm kostten hier de + breedte die de schermnaam nodig had - "Mijn mensen" viel over twee regels. *@ + @if (Toestand.IsAangemeld) + { + ⚙ + Uitloggen + } +
+
+ +
+ @Body +
+ + @* Op het overzicht is er niets meer om naar terug te gaan. Eén druk sluit de app daar dan + ook niet meteen: dat gebeurde te vaak per ongeluk, midden in het invullen. *@ + @if (Afsluiten) + { +
Nog een keer terug om te sluiten
+ } + + @if (Toestand.IsAangemeld) + { + + } +
+} + +@code { + private string Pad = "/"; + + /// Er is één keer op terug gedrukt op het overzicht; nog een keer sluit de app. + private bool Afsluiten; + + /// Zolang de schil er staat vangt hij de terugknop van het toestel af. + private IDisposable? Terugmelding; + + private bool OpOverzicht => Pad is "/" or ""; + + /// De app is dicht tot iemand zich bij het toestel meldt. De sessie blijft staan. + private bool Vergrendeld; + + /// + /// De app is net geopend en kijkt nog of er een sessie op dit toestel ligt. Zolang dit waar + /// is komt er geen scherm in beeld; zie de uitleg boven in dit bestand. + /// + private bool Bezigmetstarten = true; + + /// Er staat een venster van het toestel open; niet er nog een bovenop zetten. + private bool Ontgrendelen; + + /// + /// Ging het slot erop omdat er een tijd niets gebeurde, of omdat de app net openging? Alleen + /// voor de zin op het slotscherm; het slot zelf is in beide gevallen hetzelfde. + /// + private bool DoorStilte; + + /// De naam van het scherm, voor in de balk. + private string Schermnaam => Pad switch + { + "/familie" => "Mijn mensen", + "/kluis" => "Mijn kluis", + "/momenten" => "Momenten", + "/nalatenschap" => "Nalatenschap", + "/profiel" => "Mijn profiel", + "/instellingen" => "Instellingen", + "/inloggen" => "Inloggen", + _ => "MijnBesluit" + }; + + /// Loopt zolang de schil er staat en kijkt of de stilte lang genoeg heeft geduurd. + private readonly CancellationTokenSource _stoppen = new(); + + /// + /// Op welke schermen het trek-naar-beneden-gebaar iets doet. De schil staat om elk scherm + /// heen - ook het overzicht, de instellingen, Mijn profiel - maar alleen deze vier hebben + /// een lijst die het waard is om handmatig te verversen zonder eerst weg te navigeren. + /// + private static readonly string[] TeVerversenPaden = { "/kluis", "/familie", "/momenten", "/nalatenschap" }; + + private DotNetObjectReference? _vernieuwverwijzing; + + protected override async Task OnInitializedAsync() + { + Toestand.Veranderd += Herteken; + Toestand.SessieVerlopen += NaEenVerlopenSessie; + Navigatie.LocationChanged += Verplaatst; + Klok.Hervat += NaHetHervatten; + _ = WaaktAsync(_stoppen.Token); + + // Deze meldt zich als eerste aan en ligt dus onderop: popups die later opengaan zijn + // eerder aan de beurt, en pas als er niets meer openstaat gaat het over navigeren. + Terugmelding = Terugknoppen.Aanmelden(Terug); + + Bijwerken(); + + await BijHetOpenenAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Niet alleen bij firstRender: die allereerste tekening is bijna altijd nog het + // opstart- of slotscherm (zie hierboven), en dáár staat geen #app-inhoud in de pagina - + // dus het koppelen zou dan stil mislukken en, omdat firstRender maar één keer waar is, + // nooit meer opnieuw worden geprobeerd. In plaats daarvan proberen we het na elke + // tekening; validatie.js zet zelf een vlaggetje zodra het is gelukt, dus zodra + // #app-inhoud er eenmaal wél staat, gebeurt het precies één keer en daarna niets meer. + _vernieuwverwijzing ??= DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("mijnbesluit.pullToVernieuwen", "app-inhoud", _vernieuwverwijzing, TeVerversenPaden); + } + + /// + /// Aangeroepen vanuit validatie.js zodra er ver genoeg naar beneden is getrokken op een van + /// de schermen uit . Het scherm dat op dat moment openstaat + /// luistert zelf mee via VernieuwSignaal; de schil hoeft niet te weten welk scherm dat is. + /// + [JSInvokable] + public Task OpVernieuwenAsync() => Vernieuwen.VraagAanAsync(); + + /// + /// Wat er gebeurt zodra de app opengaat, vóór er een scherm in beeld komt. + /// + /// De afspraak is: na de eerste keer inloggen met een code kom je erin met je vingerafdruk + /// of de code van je telefoon, tot je zelf uitlogt. Daarom wordt hier maar één ding gevraagd, + /// en wel aan het toestel: ligt hier een sessie? De server komt er niet aan te pas. + /// + /// Dat verschil is waar het eerder misging. Het slot ging pas op de app nadat de server het + /// token had goedgekeurd. Een telefoon die net wakker wordt heeft nog geen netwerk, en na + /// een herstart van de service kende de server de sessie niet meer - en dan stond je op het + /// inlogscherm met een vraag om je e-mailadres, terwijl je telefoon prima wist wie je was. + /// Nu is het andersom: eerst je vingerafdruk, daarna pas de vraag of de sessie nog loopt. + /// + private async Task BijHetOpenenAsync() + { + var opSlot = false; + + try + { + // Zonder toestelslot heeft dit geen zin: daar is uitloggen de enige grendel, en dat + // doet de uitlogklok al. Zie Uitlogklok. + opSlot = Slot.Beschikbaar && await Toestand.Client.HeeftBewaardeSessieAsync(); + } + catch (Exception) + { + // Kan de beveiligde opslag niet gelezen worden, dan is er geen sessie om te + // beschermen en komt iemand vanzelf op het inlogscherm uit. + } + + Bezigmetstarten = false; + StateHasChanged(); + + if (opSlot) await OpSlotAsync(doorStilte: false); + } + + /// + /// De terugknop van het toestel. Overal behalve op het overzicht brengt hij je daarheen - + /// hetzelfde als het pijltje linksboven. Op het overzicht zelf is er niets meer achter, + /// en pas een tweede druk binnen twee seconden laat de app sluiten. + /// + private bool Terug() + { + // Op slot komt de terugknop nergens: die zou je anders zo de app uit sturen, en dan + // staat er bij de volgende start weer een open kluis. + if (Vergrendeld) return true; + + // Het inlogscherm telt hier als eindpunt. Daar teruggaan naar het overzicht stuurt je + // meteen weer naar het inlogscherm; dan kom je met de terugknop nergens meer. + if (!OpOverzicht && Pad != "/inloggen") + { + InvokeAsync(() => Navigatie.NavigateTo("/")); + return true; + } + + if (Afsluiten) return false; // tweede keer: het toestel mag zijn gang gaan + + Afsluiten = true; + InvokeAsync(StateHasChanged); + _ = VergeetAfsluitenAsync(); + return true; + } + + private async Task VergeetAfsluitenAsync() + { + await Task.Delay(TimeSpan.FromSeconds(2)); + Afsluiten = false; + await InvokeAsync(StateHasChanged); + } + + private void Verplaatst(object? afzender, LocationChangedEventArgs e) + { + Klok.Aangeraakt(); // van scherm wisselen is ook iets doen + Afsluiten = false; // je bent ergens anders; de waarschuwing hoort niet mee te reizen + Bijwerken(); + InvokeAsync(StateHasChanged); + } + + private void Bijwerken() + { + var relatief = Navigatie.ToBaseRelativePath(Navigatie.Uri); + var vraagteken = relatief.IndexOf('?'); + if (vraagteken >= 0) relatief = relatief[..vraagteken]; + Pad = "/" + relatief.TrimStart('/'); + } + + private void Aangeraakt() => Klok.Aangeraakt(); + + /// + /// De server kent ons token niet meer - bijvoorbeeld omdat de SocketServer opnieuw is + /// gestart; die bewaart zijn sessies in het geheugen. Dan hoort de app naar het inlogscherm + /// te gaan in plaats van te blijven staan met een knop die nooit meer kan lukken. + /// + /// Ook het slot gaat eraf: er valt niets meer te beschermen, en een slotscherm dat je na het + /// ontgrendelen op een dode sessie zet is alleen maar verwarrend. + /// + private void NaEenVerlopenSessie() => InvokeAsync(() => + { + Vergrendeld = false; + Navigatie.NavigateTo("/inloggen"); + }); + + /// De app komt terug uit de achtergrond: meteen kijken, niet pas bij de volgende tik. + private void NaHetHervatten() => InvokeAsync(async () => + { + // Stond hij al op slot, dan hoort het venster meteen weer in beeld te komen: daar is + // de gebruiker voor teruggekomen. + if (Vergrendeld) { await OntgrendelenAsync(); return; } + + if (Toestand.IsAangemeld && Klok.IsVerlopen(DateTime.UtcNow)) await TijdIsOmAsync(); + }); + + /// + /// De termijn is voorbij. Kan het toestel om een vingerafdruk of code vragen, dan gaat de + /// app op slot en blijft de sessie staan. Kan dat niet, dan is uitloggen de enige grendel + /// die er is. + /// + private async Task TijdIsOmAsync() + { + if (!Slot.Beschikbaar) { await UitloggenAsync(); return; } + + await OpSlotAsync(doorStilte: true); + } + + /// + /// Het slot erop, en de lijn naar de server dicht. + /// + /// Dat laatste is geen zuinigheid maar netheid: wat dicht is hoeft niet bewaakt te worden, en + /// een app op slot heeft de server niets te vragen. Het token blijft staan en de sessie aan de + /// serverkant ook - die hangt aan het token, niet aan de verbinding - dus na het ontgrendelen + /// ben je gewoon weer waar je was. + /// + /// In de praktijk is de server ons meestal voor: die verbreekt een verbinding waarover tien + /// minuten niets gebeurt, en de app gaat pas na een kwartier op slot. Dit is dus vooral het + /// geval waarin iemand zelf het slot erop zet, of de termijn korter heeft gezet. + /// + /// + /// Waar als de klok afliep, onwaar als de app net openging. Alleen voor de tekst op het + /// slotscherm. + /// + private async Task OpSlotAsync(bool doorStilte) + { + DoorStilte = doorStilte; + Vergrendeld = true; + StateHasChanged(); + + await Toestand.Client.VerbindingSluitenAsync(); + + await OntgrendelenAsync(); + } + + private async Task OntgrendelenAsync() + { + if (Ontgrendelen) return; + + Ontgrendelen = true; + StateHasChanged(); + + try + { + var open = await Slot.OntgrendelAsync( + "MijnBesluit openen", + "De app is vergrendeld omdat er een tijd niets gebeurde."); + + if (!open) return; // afgebroken of mislukt: het slot blijft dicht + + Vergrendeld = false; + Klok.Aangeraakt(); + + // De lijn alvast weer opzetten. Hoeft niet - het eerste verzoek doet het ook - maar + // zo staat hij er al terwijl iemand nog naar zijn scherm kijkt, in plaats van dat + // zijn eerste handeling op een TLS-handdruk wacht. + await Toestand.Client.VerbindingOpenenAsync(); + } + finally + { + Ontgrendelen = false; + StateHasChanged(); + } + } + + private async Task UitloggenAsync() + { + Vergrendeld = false; + await Toestand.UitloggenAsync(); + Navigatie.NavigateTo("/inloggen"); + } + + /// + /// Kijkt elke halve minuut of de app zichzelf moet uitloggen. + /// + /// Elke halve minuut en niet vaker: de termijn staat in minuten, dus nauwkeuriger hoeft + /// niet, en op een telefoon is elke wakkere seconde accu. Er wordt op de wandklok gerekend, + /// dus een uur in een broekzak telt net zo goed mee als een uur kijken - ook al heeft deze + /// lus dan niet gedraaid. + /// + private async Task WaaktAsync(CancellationToken ct) + { + using var tik = new PeriodicTimer(TimeSpan.FromSeconds(30)); + + try + { + while (await tik.WaitForNextTickAsync(ct)) + { + // Staat hij al op slot, dan is er niets meer te bewaken tot hij weer opengaat. + if (!Toestand.IsAangemeld || Vergrendeld || !Klok.IsVerlopen(DateTime.UtcNow)) continue; + + await InvokeAsync(TijdIsOmAsync); + + if (!Toestand.IsAangemeld) return; // uitgelogd; er valt niets meer te bewaken + } + } + catch (OperationCanceledException) + { + // De schil is weg. Niets aan de hand. + } + } + + private void Herteken() => InvokeAsync(StateHasChanged); + + public void Dispose() + { + Toestand.Veranderd -= Herteken; + Toestand.SessieVerlopen -= NaEenVerlopenSessie; + Navigatie.LocationChanged -= Verplaatst; + Terugmelding?.Dispose(); + Klok.Hervat -= NaHetHervatten; + _stoppen.Cancel(); + _stoppen.Dispose(); + _vernieuwverwijzing?.Dispose(); + } +} diff --git a/src/MijnBesluit.AppUi/Meldingen.cs b/src/MijnBesluit.AppUi/Meldingen.cs new file mode 100644 index 0000000..9fc533d --- /dev/null +++ b/src/MijnBesluit.AppUi/Meldingen.cs @@ -0,0 +1,32 @@ +namespace MijnBesluit.AppUi; + +/// +/// Meldingen op het toestel zelf. +/// +/// Net als bij het kiezen van bestanden: de schermen weten hier niets van Android of iOS, ze +/// vragen alleen om het kenmerk waarmee de server een melding kan sturen. Firebase levert dat +/// kenmerk, en het verandert af en toe vanzelf - daarom wordt het bij elke start opnieuw +/// opgehaald en doorgegeven. +/// +public interface IMeldingen +{ + /// + /// Vraagt toestemming (Android 13 en hoger vraagt dat apart) en haalt het kenmerk op. + /// Geeft null als de gebruiker geen meldingen wil of als Firebase niet is ingesteld. + /// + Task KenmerkAsync(); + + /// Waar dit op draait: "android" of "ios". + string Platform { get; } + + /// De naam van het toestel, om in het overzicht te kunnen tonen. + string? Toestelnaam { get; } +} + +/// Voor als er geen meldingen zijn ingericht; alles werkt dan gewoon, zonder push. +public sealed class GeenMeldingen : IMeldingen +{ + public Task KenmerkAsync() => Task.FromResult(null); + public string Platform => "onbekend"; + public string? Toestelnaam => null; +} diff --git a/src/MijnBesluit.AppUi/MijnBesluit.AppUi.csproj b/src/MijnBesluit.AppUi/MijnBesluit.AppUi.csproj new file mode 100644 index 0000000..81a6b08 --- /dev/null +++ b/src/MijnBesluit.AppUi/MijnBesluit.AppUi.csproj @@ -0,0 +1,17 @@ + + + net8.0 + enable + enable + false + + + + + + + + diff --git a/src/MijnBesluit.AppUi/Onderdelen/Bijlagenblok.razor b/src/MijnBesluit.AppUi/Onderdelen/Bijlagenblok.razor new file mode 100644 index 0000000..6ee2a17 --- /dev/null +++ b/src/MijnBesluit.AppUi/Onderdelen/Bijlagenblok.razor @@ -0,0 +1,236 @@ +@* Foto's, documenten en opnamen bij een kluisitem, een persoon of een bericht. + + Eén component voor alle drie, zodat ze er hetzelfde uitzien en zich hetzelfde gedragen. + Wat er per plek verschilt zijn de knoppen die je meegeeft: bij een persoon kun je een foto + aanwijzen als profielfoto, bij een kluisitem niet. + + Het bekijken zit hier ingebouwd en gaat niet via de schermen eromheen. Dat scheelt vier keer + dezelfde code, en belangrijker: het werkt daardoor overal hetzelfde. *@ + +@inject AppToestand Toestand + +
+ + + @if (Bijlagen.Count > 0) + { +
+ @foreach (var bijlage in Bijlagen) + { +
+ @* De naam en het tekentje samen zijn de knop om hem te bekijken. Een aparte + oogknop erbij zou de rij op een telefoon te vol maken. *@ + + + @if (KanProfielfoto && bijlage.IsAfbeelding && !bijlage.IsProfilePhoto + && !Bijlagewachtrij.Wacht(bijlage)) + { + + } + + +
+ + @* Verwijderen is onomkeerbaar - de bijlage is daarna echt weg - dus eerst + bevestigen, net als overal elders waar iets wordt weggehaald. *@ + @if (Weghalen == bijlage) + { +
+ @bijlage.FileName verwijderen? +
+ + +
+
+ } + } +
+ } + + @if (Bezig && Overzetten is not null) + { +
@Overzetten
+
+ } + + @* Maken en kiezen staan los van elkaar, want het zijn twee verschillende handelingen: een + foto van je paspoort maak je ter plekke, die van vorige zomer zoek je op. Zonder camera + blijven alleen de kiesknoppen over. *@ +
+ @if (KanCamera) + { + + } + + + @if (KanVideo) + { + @if (KanCamera) + { + + } + + } + + +
+ + @if (Uitleg is not null) + { +
@Uitleg
+ } +
+ +@* ---------------------------------------------------------------- bekijken *@ +@if (Bekeken is not null) +{ + + + @if (Ophalen) + { +

Bezig met ophalen…

+
+ } + else if (Kijkfout is not null) + { +
@Kijkfout
+ } + else if (Bron is not null && Bekeken.IsAfbeelding) + { + @Bekeken.FileName + } + else if (Bron is not null && Bekeken.IsVideo) + { + + } + else + { +

+ Dit soort bestand is hier niet te tonen. Op de website kun je het downloaden. +

+ } +
+} + +@code { + [Parameter] public string Titel { get; set; } = "Foto's en documenten"; + [Parameter] public string? Uitleg { get; set; } + + [Parameter] public IReadOnlyList Bijlagen { get; set; } = Array.Empty(); + [Parameter] public bool Bezig { get; set; } + + /// Mogen hier video's bij? Bij een persoon niet, bij een bericht en een kluisitem wel. + [Parameter] public bool KanVideo { get; set; } + + /// + /// Heeft dit toestel een camera die we mogen gebruiken? Zo niet, dan blijven alleen de + /// knoppen over waarmee je iets opzoekt dat er al staat. + /// + [Parameter] public bool KanCamera { get; set; } + + /// Mag hier een foto worden aangewezen als profielfoto? Alleen bij een persoon. + [Parameter] public bool KanProfielfoto { get; set; } + + /// Tekst en percentage van een lopende overdracht; null als er niets loopt. + [Parameter] public string? Overzetten { get; set; } + [Parameter] public int Percentage { get; set; } + + [Parameter] public EventCallback OpFotoMaken { get; set; } + [Parameter] public EventCallback OpFoto { get; set; } + [Parameter] public EventCallback OpDocument { get; set; } + [Parameter] public EventCallback OpVideo { get; set; } + [Parameter] public EventCallback OpVideoKiezen { get; set; } + [Parameter] public EventCallback OpVerwijderen { get; set; } + [Parameter] public EventCallback OpProfielfoto { get; set; } + + /// De bijlage die op een "weet je het zeker?" staat te wachten om verwijderd te worden. + private Bijlage? Weghalen; + + /// De bijlage die openstaat om te bekijken; null als er niets openstaat. + private Bijlage? Bekeken; + + /// De inhoud als verwijzing die een img- of video-element rechtstreeks aankan. + private string? Bron; + + private bool Ophalen; + private int Vordering; + private string? Kijkfout; + + private async Task BekijkAsync(Bijlage bijlage) + { + Bekeken = bijlage; + Bron = null; + Kijkfout = null; + + // Een bijlage die nog wacht staat nergens, dus valt er ook niets op te halen. Dat is geen + // fout; de popup zegt gewoon wat er aan de hand is. + if (Bijlagewachtrij.Wacht(bijlage)) + { + Kijkfout = "Deze staat er nog niet. Sla eerst op, dan kun je hem bekijken."; + return; + } + + // Een document halen we niet op: we kunnen het toch niet tonen, en het kan tientallen + // megabytes zijn. De popup zegt dan meteen waar je het wél kunt openen. + if (!bijlage.IsAfbeelding && !bijlage.IsVideo) return; + + Ophalen = true; + Vordering = 0; + StateHasChanged(); + + var uitkomst = await Toestand.Client.BestandOphalenAsync(bijlage.Id, + (gedaan, totaal) => + { + Vordering = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }); + + Ophalen = false; + + if (!uitkomst.Gelukt) { Kijkfout = uitkomst.Melding; return; } + + // De webview haalt zijn plaatjes niet van een server, dus geven we de inhoud mee in de + // verwijzing zelf. Dat kan omdat er een grens op de bestandsgrootte staat. + Bron = $"data:{uitkomst.Waarde!.Soort};base64,{Convert.ToBase64String(uitkomst.Waarde.Inhoud)}"; + } + + private void Dichtdoen() + { + Bekeken = null; + Bron = null; + Kijkfout = null; + Ophalen = false; + } + + private async Task VerwijderBevestigdAsync(Bijlage bijlage) + { + Weghalen = null; + await OpVerwijderen.InvokeAsync(bijlage); + } + + private static string Teken(Bijlage bijlage) => + bijlage.IsAfbeelding ? "\U0001F4F7" : bijlage.IsVideo ? "\U0001F3AC" : "\U0001F4C4"; +} diff --git a/src/MijnBesluit.AppUi/Onderdelen/Datumkiezer.razor b/src/MijnBesluit.AppUi/Onderdelen/Datumkiezer.razor new file mode 100644 index 0000000..f4ef961 --- /dev/null +++ b/src/MijnBesluit.AppUi/Onderdelen/Datumkiezer.razor @@ -0,0 +1,130 @@ +@* Een datum kiezen met drie keuzelijsten in plaats van . + + Reden: in de webview waar de app in draait opent de agendaknop van een datumveld niet - je + ziet dan een veld waar niets mee gebeurt. Bovendien is kiezen op een telefoon makkelijker + dan typen, zeker voor wie niet zo handig is met een toetsenbord. + + Werkt zowel vooruit (een bezorgmoment) als achteruit (een geboortedatum); zet daarvoor + VanJaar, TotJaar en Aflopend. Met Optioneel mag het veld leeg blijven. *@ + +
+ + + + + +
+ +@code { + [Parameter] public DateTime? Waarde { get; set; } + [Parameter] public EventCallback WaardeChanged { get; set; } + + /// Hoeveel jaren er te kiezen zijn vanaf dit jaar. Genegeerd als TotJaar is gezet. + [Parameter] public int JarenVooruit { get; set; } = 60; + + [Parameter] public int? VanJaar { get; set; } + [Parameter] public int? TotJaar { get; set; } + + /// Nieuwste jaar bovenaan. Voor een geboortedatum scheelt dat veel scrollen. + [Parameter] public bool Aflopend { get; set; } + + /// Mag het veld leeg blijven? Dan komt er een streepje bij de jaren. + [Parameter] public bool Optioneel { get; set; } + + // Afgekort ("Feb.", "Mrt.") en niet voluit: in de drie keuzelijsten naast elkaar duwde + // "september" op een smalle telefoon de jaarkolom uit beeld. De lijst staat in + // MijnBesluit.Client, samen met de website-versie in het domein; een zelfcontrole houdt + // die twee gelijk. + private static readonly string[] Maanden = MijnBesluit.Client.Datums.KorteMaanden; + + private int Eerste => VanJaar ?? DateTime.Today.Year; + private int Laatste => TotJaar ?? (DateTime.Today.Year + JarenVooruit); + + private IEnumerable Jaren + { + get + { + var reeks = Enumerable.Range(Eerste, Math.Max(1, Laatste - Eerste + 1)); + return Aflopend ? reeks.Reverse() : reeks; + } + } + + /// Jaar 0 betekent: geen datum gekozen. Alleen mogelijk als Optioneel aan staat. + private bool Leeg => _jaar == 0; + + /// Het jaar dat bovenaan de lijst staat: bij Aflopend het nieuwste, anders het oudste. + private int StandaardJaar => Aflopend ? Laatste : Eerste; + + private int _dag = DateTime.Today.Day; + private int _maand = DateTime.Today.Month; + private int _jaar = DateTime.Today.Year; + + // Dag en maand staan nooit op slot, ook niet als het jaar nog op "-" staat: je moet een + // datum in elke volgorde kunnen invullen, niet per se bij het jaar beginnen. Kies je dag of + // maand terwijl er nog geen jaar gekozen is, dan springt het jaar vanzelf naar wat er + // bovenaan de lijst staat - daarmee telt de datum meteen als gekozen, zonder dat je apart + // nog het jaar hoeft aan te wijzen als de standaardwaarde al klopt. + private int Dag + { + get => _dag; + set { _dag = value; ActiveerAlsLeeg(); _ = Doorgeven(); } + } + + private int Maand + { + get => _maand; + set { _maand = value; ActiveerAlsLeeg(); _ = Doorgeven(); } + } + + private int Jaar { get => _jaar; set { _jaar = value; _ = Doorgeven(); } } + + private void ActiveerAlsLeeg() + { + if (Optioneel && _jaar == 0) _jaar = StandaardJaar; + } + + protected override void OnParametersSet() + { + if (Waarde is { } d) + { + _dag = d.Day; + _maand = d.Month; + _jaar = d.Year; + } + else if (Optioneel) + { + _jaar = 0; + } + } + + private Task Doorgeven() + { + if (Leeg) return WaardeChanged.InvokeAsync(null); + + // 31 februari bestaat niet; dan pakken we de laatste dag van die maand. Zo kan de + // gebruiker de drie lijstjes in elke volgorde instellen zonder foutmelding. + var dagen = DateTime.DaysInMonth(_jaar, _maand); + var dag = Math.Min(_dag, dagen); + + return WaardeChanged.InvokeAsync(new DateTime(_jaar, _maand, dag, 12, 0, 0, DateTimeKind.Local)); + } +} diff --git a/src/MijnBesluit.AppUi/Onderdelen/Penning.razor b/src/MijnBesluit.AppUi/Onderdelen/Penning.razor new file mode 100644 index 0000000..b47c62f --- /dev/null +++ b/src/MijnBesluit.AppUi/Onderdelen/Penning.razor @@ -0,0 +1,42 @@ +@* Het ronde penningetje voor een persoon: zijn profielfoto, en anders zijn beginletters. + + Eén component voor alle lijsten waar mensen in staan - Mijn mensen, je beheerders, de + nalatenschappen die aan jou zijn toevertrouwd. Ze horen er hetzelfde uit te zien, en dat + blijft alleen zo als het op één plek staat. + + Meegeven doe je alleen een naam en waar het plaatje vandaan komt; hoe dat plaatje eruit + ziet is aan het stijlblad. Zonder bron valt hij terug op de letters, even groot, zodat de + lijst niet verspringt zodra er ergens een foto bij komt. *@ + +@if (!string.IsNullOrEmpty(Bron)) +{ + @Naam +} +else +{ + @Beginletters(Naam) +} + +@code { + /// De naam van de persoon; alleen gebruikt voor de letters en de tekstballon. + [Parameter] public string Naam { get; set; } = ""; + + /// + /// Waar het plaatje vandaan komt. Op de website een adres van de server, in de app de + /// inhoud zelf. Leeg of null betekent: geen foto, dus de letters. + /// + [Parameter] public string? Bron { get; set; } + + /// + /// De eerste letter van de voornaam en van de achternaam. Bij één woord alleen de eerste, + /// bij een lege naam een vraagteken - er moet altijd iets in de penning staan. + /// + public static string Beginletters(string? naam) + { + var delen = (naam ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (delen.Length == 0) return "?"; + return delen.Length == 1 + ? delen[0][..1].ToUpperInvariant() + : (delen[0][..1] + delen[^1][..1]).ToUpperInvariant(); + } +} diff --git a/src/MijnBesluit.AppUi/Onderdelen/Popup.razor b/src/MijnBesluit.AppUi/Onderdelen/Popup.razor new file mode 100644 index 0000000..e3c6191 --- /dev/null +++ b/src/MijnBesluit.AppUi/Onderdelen/Popup.razor @@ -0,0 +1,75 @@ +@* Een venster over het scherm heen, voor wijzigen en bevestigen. + + Bewust geen JavaScript-dialoog: die ziet er op elk toestel anders uit en is niet te + vormgeven. Dit is gewoon een laag over het scherm, met de knoppen onderaan waar een duim + ze kan bereiken. *@ + +@implements IDisposable +@inject Terugknop Terugknoppen + + + +@code { + [Parameter] public string Titel { get; set; } = ""; + [Parameter] public string Bevestigtekst { get; set; } = "Opslaan"; + + /// Kleurt de knop als waarschuwing; voor verwijderen. + [Parameter] public bool Gevaarlijk { get; set; } + + [Parameter] public bool Bezig { get; set; } + + /// + /// Er valt niets te bevestigen of af te breken, alleen dicht te doen. Dan is "Annuleren" + /// naast "Sluiten" twee knoppen voor hetzelfde. Voor een popup die alleen iets laat zien. + /// + [Parameter] public bool AlleenSluiten { get; set; } + + /// + /// Waarom Bevestigen niet is gelukt (bijvoorbeeld een verplicht veld dat leeg is). Staat + /// IN de popup, niet op de pagina erachter - die is aan het oog onttrokken zolang de popup + /// openstaat, dus een melding die daar verschijnt ziet niemand. + /// + [Parameter] public string? Foutmelding { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public EventCallback OpBevestigen { get; set; } + [Parameter] public EventCallback OpSluiten { get; set; } + + /// Zolang dit venster openstaat vangt het de terugknop van het toestel af. + private IDisposable? Terugmelding; + + protected override void OnInitialized() => + Terugmelding = Terugknoppen.Aanmelden(() => + { + // Terug betekent hier hetzelfde als Annuleren: dicht, zonder op te slaan. Dat is wat + // iemand verwacht, en het is de veilige kant - per ongeluk opslaan kun je niet + // terugdraaien, per ongeluk annuleren wel. + InvokeAsync(Sluiten); + return true; + }); + + public void Dispose() => Terugmelding?.Dispose(); + + private Task Bevestigen() => OpBevestigen.InvokeAsync(); + private Task Sluiten() => OpSluiten.InvokeAsync(); +} diff --git a/src/MijnBesluit.AppUi/Onderdelen/Privacytekst.razor b/src/MijnBesluit.AppUi/Onderdelen/Privacytekst.razor new file mode 100644 index 0000000..e9aae0b --- /dev/null +++ b/src/MijnBesluit.AppUi/Onderdelen/Privacytekst.razor @@ -0,0 +1,146 @@ +@* De privacyverklaring zelf. Staat hier als onderdeel en niet in een pagina, omdat de app hem + ook laat zien - daar in een popup vanaf het overzicht, hier als pagina op /privacy. + + Dit bestand staat twee keer op schijf: de app en de site delen geen project. Een zelfcontrole + vergelijkt ze letterlijk, want twee versies van je privacyverklaring is erger dan twee versies + van een knop: het is het document waar iemand zich op beroept. + + Verandert er iets aan wat er wordt opgeslagen, wie het kan zien of wat er naar buiten gaat, dan + verandert deze tekst mee - en dan hoort de datum hieronder mee te veranderen. *@ + +

Laatst bijgewerkt op 19 september 2026.

+ +

+ MijnBesluit is een digitale kluis. Je legt er vast wat er geregeld moet worden, en je zet + berichten klaar voor de mensen om je heen. Dat zijn je meest persoonlijke gegevens, en + daarom staat hier zo precies mogelijk wat ermee gebeurt. +

+ +

Wie is verantwoordelijk

+

+ MijnBesluit is een dienst van Kwik-Bit Automatisering B.V.
+ Boulevard 1945 nr. 216, 7511 AG Enschede
+ KvK-nummer 97571792 +

+

+ Vragen over je gegevens kun je stellen via + privacy@@mijnbesluit.nl. +

+ +

Wat we vastleggen

+
    +
  • Je e-mailadres. Daarmee log je in; we sturen er een code naartoe.
  • +
  • Je naam en geboortedatum, als je die invult.
  • +
  • Wat je in je kluis zet: titels, notities, wachtwoorden, polissen, + wensen, en de bestanden die je toevoegt.
  • +
  • De mensen die je toevoegt: hun naam, en het e-mailadres dat je + invult om ze uit te nodigen.
  • +
  • De berichten die je voor iemand klaarzet, en wanneer ze bezorgd zijn.
  • +
  • Een logboek van handelingen: wie wanneer inlogde, wat er werd + gewijzigd, wanneer een bericht is bezorgd. Daar staat geen inhoud in.
  • +
+ +

Wat er versleuteld is

+

+ Alles wat in je kluis staat en alles wat in je berichten staat, is versleuteld met een + sleutel die bij jouw account hoort. Ook de bestanden die je toevoegt: die staan + versleuteld op schijf. Wie de database in handen krijgt zonder de sleutel, ziet er niets + van. +

+

+ Eén uitzondering, en die noemen we liever zelf: de kleine versie van een + profielfoto - het rondje van veertig pixels in een lijst - is niet versleuteld. Dat is een + afweging geweest: een profielfoto is juist bedoeld om aan anderen te laten zien, en hem + als kluismateriaal behandelen zou betekenen dat we voor elke kijker sleutels moeten + uitdelen en weer intrekken. De foto op ware grootte is wél versleuteld. +

+

+ Het verkeer tussen de app en onze server gaat over een versleutelde verbinding, net als + de website. +

+ +

Wie kan wat zien

+
    +
  • + Jij ziet je eigen kluis. Niemand anders, ook wij niet: wij kunnen je + kluisitems niet lezen zonder jouw account. +
  • +
  • + Mensen met wie je in een familie zit zien elkaars naam en + profielfoto. Niet elkaars kluis. +
  • +
  • + Iemand die je hebt aangewezen als nalatenschapsbeheerder krijgt + toegang tot wat je daarvoor hebt bestemd, en pas nadat je overlijden is bevestigd + volgens de procedure die in de app staat. +
  • +
  • + Iemand met wie je een kluisitem alvast "bespreekt". Dat kies je zelf, + per item: die persoon ziet het dan meteen, inclusief een eventuele foto of bijlage, + zonder dat daar een overlijden voor nodig is. Zonder die keuze ziet niemand het. +
  • +
  • + Een beheerder van MijnBesluit kan een profiel opzoeken op volledig + e-mailadres en een ten onrechte bevestigd overlijden terugdraaien. Hij kan géén kluis + openen. Elke handeling van een beheerder komt in het logboek. +
  • +
+ +

Wat er naar buiten gaat

+

+ Wij verkopen geen gegevens en er staan geen meetscripts of volgcookies op deze site. + Er zijn twee partijen die noodzakelijkerwijs iets te zien krijgen: +

+
    +
  • + Google (Firebase Cloud Messaging) bezorgt de meldingen op je + telefoon. Daarvoor gaat een kenmerk van je toestel naar Google, met de titel en de + korte tekst van de melding. De inhoud van een bericht of een kluisitem gaat daar + nooit in mee. +
  • +
  • + De verzender van onze e-mail, voor de inlogcodes en uitnodigingen. +
  • +
+

+ Adverteerders krijgen niets over jou te zien. Er wordt per advertentie geteld hoe vaak + hij is getoond en aangeklikt, per uur - meer niet. Geen profielen, geen kenmerken, geen + bezoeker die door de applicatie heen te volgen is. +

+ +

Hoe lang we het bewaren

+

+ Zolang je account bestaat. Hef je het op, dan wordt het verwijderd - zie hieronder. Het + logboek blijft staan, omdat je achteraf moet kunnen nagaan wat er is gebeurd; daar staat + geen inhoud in. Van gegevens die bij onze administratie horen, zoals facturen voor + adverteerders, houden we de wettelijke bewaartermijn aan. +

+ +

Je account opheffen

+

+ Dat kan zelf, op elk moment, en het hoeft niet via ons te lopen: + mijnbesluit.nl/account-verwijderen. Dat adres werkt ook + als je de app niet meer hebt. In de app staat dezelfde knop bij Mijn profiel. +

+

+ Wat er dan weggaat: je kluis, je bestanden, de berichten die nog klaarstonden, je lijst + met mensen, en je account zelf. Wat blijft: berichten die al bezorgd zijn - die zijn van + degene die ze heeft gekregen - en je naam in de lijst van mensen die jou hebben + toegevoegd. Dat laatste is hun aantekening, niet jouw gegeven; alles wat er aan jou + vastzat, gaat eraf. +

+ +

Je rechten

+

+ Je mag inzien wat we van je hebben, het laten corrigeren, en het laten verwijderen. Het + meeste doe je zelf in de app of op de website. Lukt iets niet, mail dan + privacy@@mijnbesluit.nl. Ben je het oneens + met hoe wij met je gegevens omgaan, dan kun je een klacht indienen bij de Autoriteit + Persoonsgegevens. +

+ +

Als er iets misgaat

+

+ Bij een datalek melden wij dat bij de Autoriteit Persoonsgegevens, en bij jou als het + jouw gegevens raakt. +

diff --git a/src/MijnBesluit.AppUi/Onderdelen/Productuitleg.razor b/src/MijnBesluit.AppUi/Onderdelen/Productuitleg.razor new file mode 100644 index 0000000..b3fc106 --- /dev/null +++ b/src/MijnBesluit.AppUi/Onderdelen/Productuitleg.razor @@ -0,0 +1,95 @@ +@* De uitleg over MijnBesluit, zonder kop en zonder knoppen eromheen. + + Dit staat als los onderdeel zodat de website en de app dezelfde tekst tonen: op de website als + pagina met knoppen om aan te melden, in de app als popup vanaf het overzicht. De app en de site + delen geen project, dus dit bestand staat twee keer op schijf; een zelfcontrole vergelijkt ze + letterlijk, zodat ze niet uit elkaar gaan lopen. + + Kop en knoppen staan er bewust niet in: op de website hoort er "Een account aanmaken" onder, en + in de app ben je al aangemeld. *@ + +

+ Er zit van alles in je hoofd waar niemand anders bij kan. Het wachtwoord van je e-mail, + waar de polissen liggen, hoe je je uitvaart voor je ziet, en de dingen die je nog tegen + iemand had willen zeggen. Zolang alles goed gaat maakt dat niet uit. Gaat het niet goed, + dan staan de mensen die van je houden met lege handen — op het slechtst denkbare moment. +

+

+ MijnBesluit is de plek waar dat wél ergens staat. Veilig opgeborgen zolang het van jou + alleen is, en beschikbaar op het moment dat jij hebt bepaald. +

+ +

Vier dingen

+
    +
  • + Je kluis. Wachtwoorden voor websites en apparaten, de pincode van + je telefoon, je sociale media, polissen, documenten, je uitvaartwensen. Alles gaat + versleuteld de opslag in. Per onderdeel kies je zelf of het alleen voor jou is, + pas na je overlijden vrijkomt, of op een datum die jij bepaalt. Je kunt een item ook + meteen — nog gewoon terwijl je er bent — met je nalatenschapsbeheerder + bespreken; die ziet het dan al, inclusief een eventuele foto of bijlage, zonder dat + daar een overlijden voor nodig is. +
  • +
  • + Je mensen. De familie en vrienden die erbij horen. Je maakt hun + profiel aan en nodigt ze uit. Tot ze die uitnodiging aannemen beheer jij dat + profiel; daarna is het van hen en vullen ze hun eigen MijnBesluit in. Zo groeit + het vanzelf verder. +
  • +
  • + Momenten. Een bericht of een video die je klaarzet voor iemand, + te bezorgen op een datum die jij kiest — of pas als jij er niet meer bent. + Een achttiende verjaardag, een huwelijk, of gewoon iets wat je nog wilde zeggen. + En je kunt zelf ook berichten ontvangen die een ander voor jou heeft klaargezet. +
  • +
  • + Je nalatenschap. Wie er straks mag regelen wat er geregeld moet + worden. Die persoon krijgt niet zomaar toegang: er moeten meerdere mensen + onafhankelijk melden dat je bent overleden — waaronder iemand die jij zelf hebt + aangewezen — en daarna heb jij nog een termijn om te laten weten dat je er gewoon + nog bent. Pas als die voorbij is, gaat het open. +
  • +
+ +

Hoe veilig is het?

+

+ Je legt hier je wachtwoorden en je laatste wensen vast. Dan moet je weten wat daarmee + gebeurt. +

+
    +
  • + Alles gaat versleuteld de opslag in. Elk onderdeel krijgt zijn eigen + sleutel, en die sleutel wordt zelf ook weer versleuteld. Wie de database in handen + zou krijgen, heeft daar niets aan: er staat geen leesbare regel in. +
  • +
  • + Bijlagen net zo. Een foto van je polis of een scan van je testament + wordt versleuteld opgeslagen en pas ontsleuteld op het moment dat jij hem opent. +
  • +
  • + Er is geen wachtwoord. Je vult je e-mailadres in en krijgt een code + die tien minuten geldig is en één keer werkt. Geen wachtwoord betekent: niets om te + stelen, te raden of te hergebruiken. De code zelf staat ook versleuteld opgeslagen, + dus zelfs wij kunnen hem niet teruglezen. +
  • +
  • + Niemand komt bij je kluis, ook wij niet. Wat je op "alleen voor + mijzelf" zet, blijft dat. Je nalatenschapsbeheerder krijgt alleen wat jij hebt + vrijgegeven, en pas op het moment dat jij hebt bepaald. +
  • +
  • + Er wordt bijgehouden wie wat doet. Elke keer dat er iets wordt + geopend, gewijzigd of vrijgegeven, wordt dat vastgelegd. Zo is achteraf na te gaan + wat er is gebeurd. +
  • +
+

+ Volledige zekerheid bestaat niet, en dat beweren we ook niet. Wat we wel doen is elke + keuze zo maken dat een fout of een inbraak zo min mogelijk oplevert. +

+ +

Wat kost het?

+

+ Een eigen MijnBesluit is gratis. Wat je vastlegt blijft van jou, en je kunt alles wat je + hebt ingevoerd altijd zelf wijzigen of verwijderen. +

diff --git a/src/MijnBesluit.AppUi/Onderdelen/Versiehistorie.razor b/src/MijnBesluit.AppUi/Onderdelen/Versiehistorie.razor new file mode 100644 index 0000000..d4bfd8d --- /dev/null +++ b/src/MijnBesluit.AppUi/Onderdelen/Versiehistorie.razor @@ -0,0 +1,52 @@ +@* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. + + Zelfde opzet als Productuitleg.razor en Privacytekst.razor: op de website een popup vanaf + het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site + delen geen project - en een zelfcontrole vergelijkt ze letterlijk. + + Wat hier NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor + "wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@ + +

+ Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. +

+ +

20 september 2026

+
    +
  • + Rijbewijs en paspoort in de kluis. Twee nieuwe categorieën, met een + knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken + of te kiezen. +
  • +
  • + Mijn profiel als pop-up. Je eigen profiel bewerk je nu in dezelfde + pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de + app en op de website. +
  • +
  • + Duidelijker voor de nalatenschapsbeheerder. Bij "Aan mij toevertrouwd" + zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is. +
  • +
  • + Muziek en verdere wensen groeien mee. Die velden bij een kluisitem + passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website. +
  • +
+ +

19 september 2026

+
    +
  • + Met mij gedeeld, ook in de app. Heeft iemand een kluisitem rechtstreeks + met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je + dat voortaan ook op je telefoon, niet meer alleen op de website. +
  • +
  • + Een gedeeld of nagelaten kluisitem bekijken. Dat opent nu als pop-up in + dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij + horen. +
  • +
  • + Met de vinger naar beneden trekken ververst het scherm. Werkt op Kluis, + Mijn mensen, Momenten en Nalatenschap. +
  • +
diff --git a/src/MijnBesluit.AppUi/SchermBasis.cs b/src/MijnBesluit.AppUi/SchermBasis.cs new file mode 100644 index 0000000..fbc320f --- /dev/null +++ b/src/MijnBesluit.AppUi/SchermBasis.cs @@ -0,0 +1,184 @@ +using MijnBesluit.Client; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace MijnBesluit.AppUi; + +/// +/// Gedeelde basis voor de schermen achter het inloggen: haalt de gegevens op, houdt één +/// foutmelding bij en stuurt wie niet is aangemeld naar het inlogscherm. +/// +public abstract class SchermBasis : ComponentBase, IDisposable +{ + [Inject] protected AppToestand Toestand { get; set; } = default!; + [Inject] protected NavigationManager Navigatie { get; set; } = default!; + [Inject] private VernieuwSignaal Vernieuwen { get; set; } = default!; + + /// Wordt getoond in een rode balk bovenaan het scherm. + protected string? Fout { get; set; } + + /// Er loopt iets; knoppen zijn dan uit zodat er niet twee keer wordt gedrukt. + protected bool Bezig { get; set; } + + protected override async Task OnInitializedAsync() + { + Toestand.Veranderd += OpVerandering; + + // Op PersoonId en niet op IsAangemeld: een bewaard token zegt alleen dat we ooit zijn + // ingelogd, niet dat de app al weet wie hij is. Lukte het oppakken van de sessie de + // vorige keer niet omdat er even geen verbinding was, dan staat het token er nog en + // proberen we het hier gewoon opnieuw. + // + // En naar het inlogscherm alleen als er daarna echt geen token meer is. Dat betekent + // dat de server de sessie niet meer kent - de enige goede reden om iemand opnieuw te + // laten inloggen. Een haperende verbinding hoort dat niet te doen: dan komt er een rode + // balk "geen verbinding" en blijft de sessie staan. + if (Toestand.PersoonId == 0 && !await Toestand.HervatAsync() && !Toestand.IsAangemeld) + { + Navigatie.NavigateTo("/inloggen"); + return; + } + + await LaadAsync(); + + // Alleen het scherm dat nu open staat luistert naar het trek-naar-beneden-gebaar; zie + // VernieuwSignaal. Er wordt niet naar Bezig gekeken - dat gebruiken de schermen zelf al + // voor hun eigen knoppen en zou hier de verversing kunnen blokkeren zonder reden. + Vernieuwen.Gevraagd += OpVernieuwenGevraagdAsync; + } + + /// Hier haalt het scherm zijn eigen gegevens op. + protected virtual Task LaadAsync() => Task.CompletedTask; + + private async Task OpVernieuwenGevraagdAsync() + { + await LaadAsync(); + await InvokeAsync(StateHasChanged); + } + + /// + /// Voert iets uit dat kan mislukken en zet de melding klaar. Zo staat in elk scherm maar + /// één keer de afhandeling van fouten, en blijft de knop niet hangen als het misgaat. + /// + protected async Task ProbeerAsync(Func> werk) + { + Bezig = true; + Fout = null; + StateHasChanged(); + try + { + var uitkomst = await werk(); + if (!uitkomst.Gelukt) Fout = uitkomst.Melding; + return uitkomst.Gelukt; + } + finally + { + Bezig = false; + StateHasChanged(); + } + } + + /// + /// Enter of spatie op een tegel die je normaal aantikt. Zo'n tegel is geen knop voor de + /// browser, dus zonder dit is hij alleen met een vinger of muis te bedienen - en niet met + /// een toetsenbord of een schermlezer. + /// + protected static Task ToetsAsync(KeyboardEventArgs toets, Func doen) => + toets.Key is "Enter" or " " ? doen() : Task.CompletedTask; + + private void OpVerandering() => InvokeAsync(StateHasChanged); + + public virtual void Dispose() + { + Toestand.Veranderd -= OpVerandering; + Vernieuwen.Gevraagd -= OpVernieuwenGevraagdAsync; + } +} + +/// Kleine hulpmiddelen om waarden leesbaar op het scherm te zetten. +public static class Tonen +{ + // De maandnamen komen niet uit de taalinstelling van het toestel. Die bepaalde ze eerst, en + // op een telefoon die op Engels staat las de lijst "9 June 2031" terwijl de rest van het + // scherm Nederlands is. Een app die maar in één taal bestaat hoort ook maar in één taal te + // rekenen. + // + // De lijst zelf staat in MijnBesluit.Client, naast de website-versie in het domein; een + // zelfcontrole houdt die twee gelijk. De website liep tegen precies hetzelfde aan. + private static string[] Maanden => MijnBesluit.Client.Datums.Maanden; + private static string[] KorteMaanden => MijnBesluit.Client.Datums.KorteMaanden; + + public static string Datum(DateTime? moment) + { + if (moment is null) return ""; + var d = moment.Value.ToLocalTime(); + return $"{d.Day} {Maanden[d.Month - 1]} {d.Year}"; + } + + public static string DatumTijd(DateTime? moment) + { + if (moment is null) return ""; + var d = moment.Value.ToLocalTime(); + return $"{Datum(d)} om {d:HH:mm}"; + } + + /// + /// De datum zoals hij op een tegel staat: "9 Jun. 2031". Daar telt elke pixel, en de maand + /// voluit duwde het jaartal van de regel af - "22 Septem...". + /// + public static string Kort(DateTime? moment) + { + if (moment is null) return ""; + var d = moment.Value.ToLocalTime(); + return $"{d.Day} {KorteMaanden[d.Month - 1]} {d.Year}"; + } + + /// + /// Hetzelfde voor een kale datum zonder tijd - een geboortedag, een sterfdag. Die hoort + /// niet omgerekend te worden naar een tijdzone: 9 juni is 9 juni, waar je ook bent. + /// + public static string Kort(DateOnly? dag) => + dag is null ? "" : $"{dag.Value.Day} {KorteMaanden[dag.Value.Month - 1]} {dag.Value.Year}"; + + /// De nummers komen overeen met VaultCategory in het domein. + public static string Categorie(int categorie) => categorie switch + { + 1 => "Uitvaartwens", + 2 => "Polis", + 3 => "Wachtwoord", + 4 => "Apparaat", + 5 => "Pincode", + 6 => "Document", + 7 => "Notitie", + 8 => "Sociale media", + _ => "Overig" + }; + + /// + /// Hoe een relatie op het scherm heet. De lijst staat in MijnBesluit.Client, zodat de app en + /// de website er niet ieder een eigen versie van hebben. + /// + public static string Relatie(int relatie) => MijnBesluit.Client.Relaties.Naam(relatie); + + public static string Vrijgave(int beleid) => beleid switch + { + 0 => "Alleen voor mijzelf", + 1 => "Na mijn overlijden", + 2 => "Op een datum", + _ => "" + }; + + /// + /// Het tekentje bij de vrijgave. Het takje is hetzelfde als dat van Nalatenschap onderin, + /// en de kalender staat voor een datum - zo hoeft niemand een nieuwe taal te leren. + /// + /// Voor "Alleen voor mijzelf" staat hier niets: dat ben jij, en daar hoort je eigen + /// profielfoto, geen plaatje. Het scherm zet daar een penning neer. + /// + public static string Vrijgaveteken(int beleid) => beleid switch + { + 1 => "\U0001F33F", // takje, net als bij Nalatenschap + 2 => "\U0001F4C5", // kalender + _ => "" + }; +} diff --git a/src/MijnBesluit.AppUi/Schermen/Familie.razor b/src/MijnBesluit.AppUi/Schermen/Familie.razor new file mode 100644 index 0000000..b856dc5 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Familie.razor @@ -0,0 +1,568 @@ +@page "/familie" +@inherits SchermBasis +@using Microsoft.JSInterop +@inject IBestandskiezer Bestandskiezer +@inject IJSRuntime JS + +@* Familie samenstellen en uitnodigen. Dit is het hart van MijnBesluit: zodra iemand de + uitnodiging aanneemt en zelf zijn familie invult, wordt het profiel van hem. Jij ziet hem + dan nog wel staan, maar kunt er niets meer in wijzigen. Tot dat moment mag je alles + aanpassen - ook een e-mailadres waar een tikfout in zit. + + De kop staat in de balk erboven, dus hier niet nog een keer. *@ + +@if (Fout is not null) +{ +
@Fout
+} + +@if (Gelukt is not null) +{ +
@Gelukt
+} + + + +@* Jezelf bovenaan. Je staat verderop niet tussen de anderen - je bent geen familielid van + jezelf - maar dit is wel de plek waar mensen je zoeken als ze hun eigen gegevens willen + wijzigen. De kaart doet zelf niets; hij brengt je naar Mijn profiel. *@ + +
+ +
+ @Mijnnaam +
+ Je eigen gegevens en foto + Jijzelf +
+
+
+
+ +
+ @foreach (var lid in Leden.Where(l => l.PersonId != Toestand.PersoonId)) + { + @* Dezelfde tegel als bij Momenten: foto voorop, knoppen als tekentjes rechtsonder. + Aantikken opent het wijzigscherm. Een profiel dat iemand zelf beheert mag je niet + wijzigen en heeft geen knoppen: dat is dus geen tegel en ook niet aan te tikken. *@ +
+
+ @* De profielfoto als die er is, anders de beginletters. Even groot, zodat de + lijst niet verspringt zodra er ergens een foto bij komt. *@ + + + @* Drie regels, net als bij Momenten en Mijn kluis: naam, relatie met de status + rechts, en daaronder de regel met de knoppen. Op Mijn mensen staat op die + onderste regel verder niets - dat mag. Zo staat de status op elke tegel + pal boven de knoppen, en op elke tegel op dezelfde plek. *@ +
+ @lid.DisplayName + +
+ @Omschrijving(lid) + + @switch (lid.State) + { + case ProfielStand.Geclaimd: + Geclaimd + break; + case ProfielStand.Uitgenodigd: + Uitgenodigd + break; + default: + Door jou + break; + } +
+
+
+ + @* Een eigen regel, buiten .groei en dus uitgelijnd met de linkerkant van de foto - + niet met de naam ernaast. Wijzigen staat er niet bij: daarvoor tik je de tegel + zelf aan. stopPropagation, want anders opent een druk op de prullenbak ook meteen + het wijzigscherm. Wat elke knop doet staat in title en aria-label, dus een + schermlezer hoort het gewoon. *@ +
+ @* Bij een geclaimd profiel staat hier wanneer dat gebeurde, op dezelfde regel + als de prullenbak. Kort: "Geclaimd op 15 Sep. 2026" past ernaast, zonder dat + de tegel hoger wordt. *@ + + @if (lid.IsGeclaimd) + { + @:Geclaimd@(lid.ClaimedAt is { } sinds ? $" op {Tonen.Kort(sinds)}" : ""). + } + + + @* De prullenbak staat op ELKE tegel, ook bij een profiel dat iemand zelf + beheert. Dat je Karin uit je lijst haalt zegt niets over haar eigen + MijnBesluit: haar profiel blijft van haar, het verdwijnt alleen hier. + Dat dit eerder niet kon was geen regel maar een gevolg - de knoppen stonden + samen in één "alleen als je mag wijzigen"-blok, en weghalen is iets anders + dan wijzigen. + + Jezelf staat hier niet tussen; daarvoor hef je je account op. *@ + + @if (!lid.IsGeclaimd && !string.IsNullOrWhiteSpace(lid.Email)) + { + + } + + + +
+
+ } +
+ +@if (Leden.Count <= 1) +{ +
+ 👥 + Er staat hier nog niemand. Voeg je partner of je kinderen toe; + zij krijgen een uitnodiging en kunnen daarna hun eigen MijnBesluit invullen. +
+} + +@* ---------------------------------------------------------------- toevoegen en wijzigen *@ +@if (Bewerken is not null) +{ + + +
+
+
+
+ +
+ + +
+ +
+ + +
Niet verplicht. Met een datum staat je familie bovenaan op leeftijd.
+
+ +
+ + +
Met een adres kun je hem uitnodigen. Zonder adres beheer je het profiel zelf.
+
+ + @if (Bewerken.PersoonId == 0) + { +
+ +
+ Die kun je toevoegen zodra deze persoon is opgeslagen; ze horen bij hem en + hij moet er eerst zijn. +
+
+ } + else + { + + } + + @if (BewerkLid is { } lid) + { +
+
Profiel aangemaakt op @Tonen.Datum(lid.CreatedAt)
+ + @if (lid.InvitedAt is { } verstuurd) + { +
+ Uitgenodigd op @Tonen.Datum(verstuurd)@(lid.InvitationResends > 0 + ? $" ({lid.InvitationResends + 1}× verstuurd)" : "") +
+ + @if (lid.InvitationExpiresAt is { } tot) + { +
+ @(tot > DateTime.UtcNow + ? $"Uitnodiging geldig tot {Tonen.Datum(tot)}" + : $"Uitnodiging verlopen op {Tonen.Datum(tot)}") +
+ } + } + else + { +
Nog niet uitgenodigd.
+ } +
+ } +
+} + +@* ---------------------------------------------------------------- verwijderen *@ +@if (Weghalen is not null) +{ + @* Drie gevallen, en ze verdienen alle drie een ander scherm. + + 1. Hij beheert je nalatenschap. Dan kan het niet, en zegt dit scherm waar je dat weghaalt. + De server weigert het ook; dit is er zodat je niet eerst op "verwijderen" hoeft te + drukken om erachter te komen. + 2. Er staan berichten voor hem klaar. Die verdwijnen mee, en dat is het soort ding dat je + wilt weten vóór je ja zegt - niet erna. + 3. Gewoon weghalen. *@ + @if (Weghalen.IsBeheerder) + { + + +

+ @Weghalen.DisplayName is aangewezen om je nalatenschap te beheren. Zolang dat zo + is, hoort hij bij je mensen te staan. +

+

+ Haal hem eerst weg bij Nalatenschap. Dat is met opzet een aparte + handeling: wie je nalatenschap beheert, is een besluit dat je niet per ongeluk + terugdraait door iemand uit een lijst te vegen. +

+
+ } + else + { + + +

+ @Weghalen.DisplayName verdwijnt uit je lijst. Een uitnodiging die nog openstaat + vervalt daarmee. +

+ + @if (Weghalen.MomentenKlaar > 0) + { +
+ Er @(Weghalen.MomentenKlaar == 1 ? "staat nog 1 moment" : $"staan nog {Weghalen.MomentenKlaar} momenten") + voor @Weghalen.DisplayName klaar. @(Weghalen.MomentenKlaar == 1 ? "Dat verdwijnt" : "Die verdwijnen") mee + en @(Weghalen.MomentenKlaar == 1 ? "wordt" : "worden") dus nooit bezorgd. + Wat al bezorgd is blijft staan. +
+ } + +

+ @if (Weghalen.IsGeclaimd) + { + + @Weghalen.DisplayName beheert dit profiel zelf. Het verdwijnt alleen bij + jou; zijn eigen MijnBesluit blijft zoals het is. + + } + else + { + + Het profiel zelf blijft bestaan; hij kan in andere families staan of zijn + eigen MijnBesluit hebben. + + } +

+
+ } +} + +@code { + private IReadOnlyList Leden = Array.Empty(); + + /// Jouw eigen regel uit de ledenlijst; daar staat je profielfoto in. + private Familielid? Ikzelf => Leden.FirstOrDefault(l => l.PersonId == Toestand.PersoonId); + + private string Mijnnaam => Ikzelf?.DisplayName is { Length: > 0 } naam ? naam : "Mijn profiel"; + private Formulier? Bewerken; + + /// Het lid dat bij hoort; null bij toevoegen. + /// Nodig omdat het formulier alleen de te wijzigen velden bevat, niet de geschiedenis. + private Familielid? BewerkLid; + + private string? Gelukt; + + /// Wie er op verwijderen staat te wachten; null als er niemand weg hoeft. + private Familielid? Weghalen; + + /// De foto's en documenten bij de persoon die in het wijzigscherm staat. + private IReadOnlyList Bijlagen = Array.Empty(); + + /// Tekst bij de voortgangsbalk tijdens een overdracht; null als er niets loopt. + private string? Overzetten; + private int Percentage; + + private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen } + + /// + /// Wat je kunt kiezen. De lijst zelf staat in MijnBesluit.Client, naast de website-versie in + /// het domein; een zelfcontrole houdt die twee gelijk. + /// + private static (int Waarde, string Naam)[] Relaties => MijnBesluit.Client.Relaties.Keuzes; + + protected override async Task LaadAsync() + { + if (Toestand.Familie is null) + { + var ververst = await Toestand.VerversAsync(); + if (!ververst.Gelukt) { Fout = ververst.Melding; return; } + } + + if (Toestand.Familie is null) return; + + var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id); + if (leden.Gelukt) Leden = leden.Waarde!; + else Fout = leden.Melding; + } + + private void Nieuw() + { + Gelukt = null; + Fout = null; + Weghalen = null; + BewerkLid = null; + Bijlagen = Array.Empty(); + Bewerken = new Formulier(); + } + + private async Task WijzigenAsync(Familielid lid) + { + Gelukt = null; + Fout = null; + Weghalen = null; + BewerkLid = lid; + Bewerken = new Formulier + { + PersoonId = lid.PersonId, + Voornaam = lid.FirstName, + Achternaam = lid.LastName, + Email = lid.Email ?? "", + Relatie = lid.Relation, + Geboortedatum = lid.BirthDate?.ToDateTime(TimeOnly.MinValue) + }; + + await BijlagenLadenAsync(); + } + + private async Task OpslaanAsync() + { + var formulier = Bewerken!; + if (string.IsNullOrWhiteSpace(formulier.Voornaam) && string.IsNullOrWhiteSpace(formulier.Achternaam)) + { + Fout = "Vul minstens een voor- of achternaam in."; + await JS.InvokeVoidAsync("mijnbesluit.springNaarVeld", "vn"); + return; + } + + var email = string.IsNullOrWhiteSpace(formulier.Email) ? null : formulier.Email.Trim(); + + if (await ProbeerAsync(async () => + { + var geboren = formulier.Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null; + + var r = formulier.PersoonId == 0 + ? await Toestand.Client.VoegFamilielidToeAsync( + Toestand.Familie!.Id, formulier.Voornaam.Trim(), formulier.Achternaam.Trim(), + email, formulier.Relatie, geboren) + : await Toestand.Client.WijzigFamilielidAsync( + Toestand.Familie!.Id, formulier.PersoonId, formulier.Voornaam.Trim(), + formulier.Achternaam.Trim(), email, formulier.Relatie, null, geboren); + + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = formulier.PersoonId == 0 ? "Toegevoegd." : "De wijziging is opgeslagen."; + Sluiten(); + await LaadAsync(); + } + } + + private async Task VerwijderenAsync() + { + var lid = Weghalen!; + Gelukt = null; + + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.VerwijderFamilielidAsync(Toestand.Familie!.Id, lid.PersonId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = $"{lid.DisplayName} staat hier niet meer bij."; + Weghalen = null; + await LaadAsync(); + } + } + + private async Task UitnodigenAsync(Familielid lid) + { + Gelukt = null; + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.NodigUitAsync(Toestand.Familie!.Id, lid.PersonId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = $"De uitnodiging is naar {lid.Email} gestuurd."; + await LaadAsync(); + } + } + + // ------------------------------------------------------------------ foto's en documenten + + private async Task BijlagenLadenAsync() + { + if (Bewerken is not { PersoonId: > 0 } formulier) { Bijlagen = Array.Empty(); return; } + + var lijst = await Toestand.Client.PersoonsfotosAsync(formulier.PersoonId); + Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty(); + if (!lijst.Gelukt) Fout = lijst.Melding; + } + + private async Task ToevoegenAsync(Soort soort) + { + if (Bewerken is not { PersoonId: > 0 } formulier) return; + + Fout = null; + var gekozen = soort switch + { + Soort.Document => await Bestandskiezer.KiesDocumentAsync(), + Soort.Foto => await Bestandskiezer.KiesFotoAsync(), + Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(), + Soort.Video => await Bestandskiezer.KiesVideoAsync(), + _ => await Bestandskiezer.NeemVideoOpAsync() + }; + + // Niets gekozen of afgebroken: gewoon niets doen, geen foutmelding. + if (gekozen is null) return; + + Overzetten = $"{gekozen.Naam} wordt opgeslagen…"; + Percentage = 0; + StateHasChanged(); + + var uitkomst = await Toestand.Client.BestandVersturenAsync( + gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null, + (gedaan, totaal) => + { + Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }, + persoonId: formulier.PersoonId); + + Overzetten = null; + if (!uitkomst.Gelukt) Fout = uitkomst.Melding; + else await BijlagenLadenAsync(); + } + + private async Task BijlageWegAsync(Bijlage bijlage) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await BijlagenLadenAsync(); + } + } + + /// Wijst deze foto aan als profielfoto; een eerdere profielfoto blijft gewoon staan. + private async Task ProfielfotoAsync(Bijlage bijlage) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.ProfielfotoAsync(bijlage.Id); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await BijlagenLadenAsync(); + } + } + + private void Sluiten() + { + Bewerken = null; + BewerkLid = null; + Bijlagen = Array.Empty(); + Overzetten = null; + } + + /// Eén formulier voor toevoegen en wijzigen; PersoonId 0 betekent nieuw. + private class Formulier + { + public long PersoonId { get; set; } + public string Voornaam { get; set; } = ""; + public string Achternaam { get; set; } = ""; + public string Email { get; set; } = ""; + public int Relatie { get; set; } = 1; + + /// Null als er geen geboortedatum is ingevuld; dat mag. + public DateTime? Geboortedatum { get; set; } + } + + /// + /// De regel onder de naam: "Partner (59)". Kort gehouden, want de status staat ernaast en + /// die heeft ook ruimte nodig. Is iemand overleden, dan is dat belangrijker dan zijn leeftijd. + /// + /// + /// Welke tegelklasse deze kaart krijgt. Bij een profiel dat iemand zelf beheert staan er + /// geen knoppen en valt er niets te wijzigen: dan is het geen tegel en ook niet aan te + /// tikken. + /// + private static string Tegelklasse(Familielid lid) => + lid.IsGeclaimd ? "" : "tegel klikbaar"; + + /// + /// De tegel is aangetikt. Een profiel dat iemand zelf beheert mag je niet wijzigen, dus + /// dan gebeurt er niets. + /// + private Task TegelWijzigenAsync(Familielid lid) => + lid.IsGeclaimd ? Task.CompletedTask : WijzigenAsync(lid); + + private static string Omschrijving(Familielid lid) + { + var relatie = Tonen.Relatie(lid.Relation); + + if (lid.DateOfDeath is { } overleden) + return $"{relatie} · overleden {Tonen.Kort(overleden)}"; + + return lid.BirthDate is { } geboren ? $"{relatie} ({Leeftijd(geboren)})" : relatie; + } + + /// Leeftijd in hele jaren, zoals je hem zelf zou uitrekenen. + private static int Leeftijd(DateOnly geboren) + { + var vandaag = DateOnly.FromDateTime(DateTime.Today); + var jaren = vandaag.Year - geboren.Year; + if (geboren.AddYears(jaren) > vandaag) jaren--; + return jaren; + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Inloggen.razor b/src/MijnBesluit.AppUi/Schermen/Inloggen.razor new file mode 100644 index 0000000..0f61228 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Inloggen.razor @@ -0,0 +1,157 @@ +@page "/inloggen" +@inject AppToestand Toestand +@inject NavigationManager Navigatie + +@* Aanmelden gaat in twee stappen: e-mailadres, dan de code uit de mail. Geen wachtwoorden; + er valt dus ook niets te vergeten of te laten uitlekken. *@ + +
+ @* Hier stond het logo groot. Weggehaald: de balk erboven zegt al "Inloggen", en het logo + duwde "Welkom terug" en het invulveld zo ver naar beneden dat je op een telefoon eerst + naar een leeg scherm keek. Op de website staat hij wel: daar is de pagina hoog genoeg en + is het het eerste wat iemand van MijnBesluit ziet. *@ +

@(NieuwAccount ? "Begin je eigen MijnBesluit" : "Welkom terug")

+

+ @(NieuwAccount + ? "Je legt vast wat je wilt regelen, en zet berichten klaar voor de mensen om je heen." + : "Vul je e-mailadres in; je krijgt een code van zes cijfers om mee binnen te komen.") +

+ + @if (Fout is not null) + { +
@Fout
+ } + +
+ @if (!CodeVerstuurd) + { + @if (NieuwAccount) + { +
+
+ + +
+
+ + +
+
+ } + +
+ + +
+ + + } + else + { +

We hebben een code gestuurd naar @Afgeschermd.

+ +
+ + +
De code blijft een kwartier geldig.
+
+ + + + + } +
+ +

+ @if (NieuwAccount) + { + Heb je al een account? Inloggen. + } + else + { + Nog geen account? Begin hier. + } +

+
+ +@code { + [Parameter, SupplyParameterFromQuery(Name = "nieuw")] + public bool NieuwAccount { get; set; } + + private string Email = ""; + private string Voornaam = ""; + private string Achternaam = ""; + private string Code = ""; + private string? Afgeschermd; + private bool CodeVerstuurd; + private bool Bezig; + private string? Fout; + + protected override async Task OnInitializedAsync() + { + // Wie nog een geldige sessie op het toestel heeft, hoeft hier niet te zijn. + if (await Toestand.HervatAsync()) Navigatie.NavigateTo("/"); + } + + private async Task CodeAanvragenAsync() + { + Bezig = true; + Fout = null; + try + { + var uitkomst = NieuwAccount + ? await Toestand.Client.VraagRegistratiecodeAanAsync(Email.Trim(), Voornaam.Trim(), Achternaam.Trim()) + : await Toestand.Client.VraagInlogcodeAanAsync(Email.Trim()); + + if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; return; } + + Afgeschermd = uitkomst.Waarde!.VerstuurdNaar; + CodeVerstuurd = true; + Code = ""; + } + finally { Bezig = false; } + } + + private async Task AanmeldenAsync() + { + Bezig = true; + Fout = null; + try + { + var uitkomst = NieuwAccount + ? await Toestand.Client.RegistreerAsync(Email.Trim(), Code) + : await Toestand.Client.LogInAsync(Email.Trim(), Code); + + if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; Code = ""; return; } + + await Toestand.VerversAsync(); + await Toestand.MeldToestelAanAsync(); + Navigatie.NavigateTo("/"); + } + finally { Bezig = false; } + } + + private async Task OpnieuwAsync() + { + CodeVerstuurd = false; + await CodeAanvragenAsync(); + } + + private void NaarInloggen() { NieuwAccount = false; Opnieuw(); } + private void NaarRegistreren() { NieuwAccount = true; Opnieuw(); } + + private void Opnieuw() + { + CodeVerstuurd = false; + Code = ""; + Fout = null; + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Instellingen.razor b/src/MijnBesluit.AppUi/Schermen/Instellingen.razor new file mode 100644 index 0000000..261c185 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Instellingen.razor @@ -0,0 +1,117 @@ +@page "/instellingen" +@inject AppToestand Toestand +@inject Uitlogklok Klok +@inject IToestelslot Slot +@inject NavigationManager Navigatie + +@* Wat je op dít toestel kunt instellen. Alles hier blijft op de telefoon: de server weet er + niets van, en op een andere telefoon staat het dus apart. + + De kop staat in de balk erboven, dus hier niet nog een keer. *@ + +@if (Gelukt is not null) +{ +
@Gelukt
+} + +
+

@(Slot.Beschikbaar ? "Automatisch op slot" : "Automatisch uitloggen")

+

+ De app blijft normaal aangemeld, zodat je niet elke keer een code uit je mail hoeft te + halen. Maar op een telefoon die op tafel ligt of kwijtraakt, staat je kluis dan open. + Hier zet je een termijn: gebeurt er zo lang niets, dan + @(Slot.Beschikbaar ? "gaat de app op slot" : "logt de app zichzelf uit"). +

+ +
+ + @* Met een toestelslot staat "uit" er niet bij. Het slot kost je één aanraking; die keuze + levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis is. + Zonder toestelslot mag hij er wél staan - daar is de prijs van de termijn een nieuwe + code per e-mail, en dat is te zwaar om iemand ongevraagd op te leggen. *@ + + + @* Dezelfde instelling, maar wat hij kost verschilt per toestel. Dat hoort erbij te + staan: het is het verschil tussen één aanraking en een e-mail afwachten. *@ +
+ @if (Slot.Beschikbaar) + { + + Je blijft aangemeld. Openen gaat met je vingerafdruk of de code van je + telefoon - dezelfde waarmee je het toestel zelf ontgrendelt. MijnBesluit + krijgt die nooit te zien; de telefoon zegt alleen ja of nee. + + } + else + { + + Let op: op dit toestel is dit echt uitloggen. Je moet daarna een nieuwe code + per e-mail aanvragen, net als de eerste keer. Dat komt doordat deze telefoon + geen schermslot heeft, of te oud is om erom te kunnen vragen. Daarom staat + het hier standaard uit. + + } +
+
+
+ +
+

Meldingen

+

+ Of je telefoon iets laat weten als er een bericht klaarstaat, regel je bij + Mijn profiel. Daar staat ook de knop om een proefmelding te sturen. +

+
+ +@* Dezelfde tekst als op mijnbesluit.nl/privacy, uit hetzelfde bestand - zie Privacytekst.razor. + In een popup en niet in de browser: een verklaring die de app uit stuurt, is er een die + niemand leest. *@ +
+

Privacy

+

+ Wat we vastleggen, wat versleuteld is, wie wat kan zien, en hoe je alles laat verwijderen. +

+ +
+ +@if (Privacy) +{ + + + +} + +@code { + /// Staat de privacyverklaring open? + private bool Privacy; + + private int Minuten; + private string? Gelukt; + + // De klok tilt een nul zelf op naar de standaard zodra dit toestel om een vingerafdruk kan + // vragen, dus wat hier binnenkomt staat altijd in de lijst. + protected override void OnInitialized() => Minuten = Klok.Minuten; + + private Task OpslaanAsync() + { + Klok.Minuten = Minuten; + + var termijn = Uitlogklok.Naam(Minuten).ToLowerInvariant().Replace("na ", ""); + + Gelukt = Minuten == 0 + ? "De app blijft open staan." + : Slot.Beschikbaar + ? $"De app gaat op slot als er {termijn} niets gebeurt." + : $"De app logt zichzelf uit als er {termijn} niets gebeurt."; + + return Task.CompletedTask; + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Kluis.razor b/src/MijnBesluit.AppUi/Schermen/Kluis.razor new file mode 100644 index 0000000..f5c9527 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Kluis.razor @@ -0,0 +1,609 @@ +@page "/kluis" +@inherits SchermBasis +@using Microsoft.JSInterop +@inject IBestandskiezer Bestandskiezer +@inject IJSRuntime JS + +@* De kluis: wachtwoorden, pincodes, polissen en uitvaartwensen. Alles wat hier binnenkomt + wordt op de server versleuteld; de app ziet de inhoud alleen als hij erom vraagt. *@ + +@* De kop staat in de balk erboven, dus hier niet nog een keer. + + Bovenaan filteren en toevoegen op één regel, net als op de website: het soort links, de knop + ernaast. Het filteren gebeurt bij de server en niet hier in de lijst - dan hoeft de telefoon + niet alles op te halen om er drie dingen uit te laten zien. *@ +@* Geen label boven de keuzelijst: "Alles" of "Wachtwoord" zegt zelf al genoeg, en het scheelt + een regel bovenaan een scherm waar de lijst de hoofdzaak is. Voor een schermlezer staat het er + nog wel, in aria-label. + + De keuzelijst en de knop zijn even hoog doordat de rij op stretch staat: dan worden ze allebei + zo hoog als de rij. Een vaste hoogte meegeven zou ook kunnen, maar dan loopt het uit elkaar + zodra iemand zijn lettergrootte verandert. *@ +
+ + + +
+ +@if (Fout is not null) +{ +
@Fout
+} + +@if (Bewerken is not null) +{ + + +
+ + @* De nummers komen overeen met VaultCategory in het domein. Overig is 99, niet 9: + dat stond hier eerder verkeerd en leverde een categorie op die nergens bij hoort. *@ + +
+ +
+ + +
+ + @switch (Categorie) + { + case 3: +
+
+
+ break; + + case 8: +
+ + +
+ @if (Bewerken.Platform == SocialeMedia.Anders) + { +
+ + +
+ } +
+
+
+ + +
De codes die je krijgt bij tweestapsverificatie. Zonder die codes komt niemand er nog in.
+
+ break; + + case 4: +
+
+
+ break; + + case 5: +
+
+ break; + + case 2: +
+
+
+ break; + + case 1: +
+ + +
+
+
+
+ break; + + case 9: + break; + + case 10: + case 11: + // Geen eigen velden: alleen de titel, de foto's hieronder en de gewone + // toelichting. Zie de gerichte maak-knoppen vlak boven de bijlagenlijst. + break; + } + + @if (Categorie is 1 or 9) + { +
+ + +
+ Kies je hier iemand, dan kan diegene dit item meteen lezen en met je bespreken, + nog vóór je overlijden. Kies je niemand, dan blijft dit item zoals altijd alleen + voor jezelf. +
+
+ } + +
+ + +
+ + @* Rijbewijs en paspoort vragen om specifieke kanten, niet om "een foto, welke dan + ook" - dus staan hier twee (of één) gerichte knoppen boven de gewone bijlagenlijst. + De naam waarmee de foto wordt opgeslagen zegt meteen welke kant het is; dat scheelt + een apart veld erbij, en de bijlagenlijst hieronder laat ze meteen zien. *@ + @if (Bestandskiezer.KanCamera && Categorie == 10) + { +
+ + +
+ } + else if (Bestandskiezer.KanCamera && Categorie == 11) + { +
+ +
+ } + + @* Ook bij een nieuw item. Hier was geen nummer om een bestand aan te hangen, dus stond + er een zinnetje "dat kan zodra dit is opgeslagen" - een uitleg van onze boekhouding + waar iemand die een foto van zijn polis wil toevoegen niets aan heeft. Nu wachten ze + in Wachtrij en gaan ze mee zodra het opslaan klaar is. *@ + + +
+ + +
+ + @if (Vrijgave == 2) + { +
+ + +
+ } + +
+} + +@if (Items.Count == 0 && Bewerken is null) +{ + @* Met een filter aan betekent "niets te zien" iets anders dan met een lege kluis: dan is er + wél iets, alleen niet van deze soort. *@ +
+ 🔒 + @if (Filter.Length > 0) + { + Van deze soort staat er nog niets in je kluis. + } + else + { + + Je kluis is nog leeg. Begin met wat je nu al kwijt zou raken: + het wachtwoord van je e-mail, of de pincode van je telefoon. + + } +
+} + +
+ @foreach (var item in Items) + { + @* Dezelfde tegel als bij Momenten en Mijn mensen: drie regels over de volle breedte, + de knoppen als tekentjes rechtsonder. Een kluisitem heeft geen foto, dus begint de + tegel meteen met de titel. + + Aantikken opent het scherm met de inhoud. Dat is hetzelfde scherm als wijzigen: het + liet toch al alles zien wat er in het item staat, inclusief de bijlagen. Er stond hier + eerder een uitklap ónder de tegel die precies hetzelfde toonde - twee plekken om te + onderhouden, en twee manieren om hetzelfde te doen. *@ +
+
+
+ @item.Title +
@Tonen.Categorie(item.Category)
+ + @* stopPropagation, want anders opent een druk op de prullenbak ook meteen + het scherm met de inhoud. *@ +
+ @* Een tekentje voor de regel, even hoog als de tekst ernaast. Het zegt + in een oogopslag wat er met dit item gebeurt; de tekst blijft staan, + want een takje raadt niemand. Bij "Alleen voor mijzelf" is dat jij, + dus staat daar je eigen profielfoto - en heb je die niet, dan je + initialen, net als overal waar mensen in een lijst staan. *@ + + @if (item.ReleasePolicy == 0) + { + + } + else + { + + } + @Tonen.Vrijgave(item.ReleasePolicy) + + + @if (Weghalen != item.Id) + { + + + + } +
+
+
+ + @if (Weghalen == item.Id) + { + @* Alles wat hieronder uitklapt zit ín de tegel, en een tik op de tegel opent of sluit hem. + Zonder stopPropagation klapt hij dus weer dicht zodra je de video aanraakt, een + bijlage aantikt of op "nee" drukt. *@ +
+ @item.Title verwijderen? Wat erin staat is daarna weg. +
+ + +
+
+ } + +
+ } +
+ +@code { + private IReadOnlyList Items = Array.Empty(); + private KluisInhoud? Bewerken; + + /// Op welk soort de lijst gefilterd staat; leeg is alles. Zelfde opzet als op de website. + private string Filter = ""; + + private int Categorie = 3; + private IReadOnlyList Beheerders = Array.Empty(); + private string BesprekenMetTekst = ""; + private long? BesprekenMetPersonId => long.TryParse(BesprekenMetTekst, out var p) ? p : null; + private int Vrijgave = 1; + private DateTime? VrijgaveDatum = DateTime.Today.AddYears(1); + private string Titel = ""; + + /// 0 als het om een nieuw item gaat. + private long ItemId; + + /// Welk item er op verwijderen staat te wachten; null als er niets weg hoeft. + private long? Weghalen; + + /// De bijlagen bij het item dat openstaat. + private IReadOnlyList Bijlagen = Array.Empty(); + + /// Tekst bij de voortgangsbalk tijdens het versturen of ophalen; null als er niets loopt. + private string? Overzetten; + private int Percentage; + + private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen } + + /// Bijlagen die zijn gekozen voor een item dat nog niet bestaat. + private readonly Bijlagewachtrij Wachtrij = new(); + + /// Wat er opgeslagen staat plus wat er nog wacht; samen wat het scherm laat zien. + private IReadOnlyList AlleBijlagen => + Wachtrij.Leeg ? Bijlagen + : Bijlagen.Concat(Wachtrij.AlsBijlagen(DateTime.UtcNow)).ToList(); + + protected override async Task LaadAsync() + { + // Leeg is "alles". Een nummer dat niet te lezen valt telt ook als alles: liever de hele + // lijst dan een leeg scherm zonder uitleg. + int? categorie = int.TryParse(Filter, out var nummer) ? nummer : null; + + var lijst = await Toestand.Client.KluisAsync(categorie); + if (lijst.Gelukt) Items = lijst.Waarde!; + else Fout = lijst.Melding; + + var beheerders = await Toestand.Client.BeheerdersAsync(); + if (beheerders.Gelukt) Beheerders = beheerders.Waarde!; + + // De vraag "verwijderen?" hoorde bij een item dat er na het filteren misschien niet + // meer staat. + Weghalen = null; + } + + /// Het wijzigscherm dichtdoen zonder op te slaan; wat wachtte gaat dan ook weg. + private void Afbreken() + { + Wachtrij.Leegmaken(); + Bewerken = null; + } + + private void Nieuw() + { + Wachtrij.Leegmaken(); // wat er van een vorige keer nog wachtte hoort hier niet bij + Bijlagen = Array.Empty(); + Bewerken = new KluisInhoud(); + ItemId = 0; + Titel = ""; + Categorie = 3; + Vrijgave = 1; + VrijgaveDatum = DateTime.Today.AddYears(1); + BesprekenMetTekst = ""; + Weghalen = null; + Fout = null; + } + + private async Task WijzigenAsync(KluisItem item) + { + Fout = null; + Weghalen = null; + Wachtrij.Leegmaken(); + + // De inhoud staat versleuteld op de server; die halen we op om het formulier te vullen. + var inhoud = await Toestand.Client.KluisOpenenAsync(item.Id); + if (!inhoud.Gelukt) { Fout = inhoud.Melding; return; } + + Bewerken = inhoud.Waarde; + ItemId = item.Id; + Titel = item.Title; + Categorie = item.Category; + Vrijgave = item.ReleasePolicy; + VrijgaveDatum = item.ReleaseAt?.ToLocalTime() ?? DateTime.Today.AddYears(1); + BesprekenMetTekst = item.SharedManagerPersonId?.ToString() ?? ""; + + // De bijlagen horen bij het item, dus die halen we er meteen bij. + await BijlagenLadenAsync(); + } + + private async Task VerwijderenAsync(long itemId) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.KluisVerwijderenAsync(itemId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Weghalen = null; + Bijlagen = Array.Empty(); + await LaadAsync(); + } + } + + private async Task OpslaanAsync() + { + if (string.IsNullOrWhiteSpace(Titel)) { Fout = "Geef het item een naam, anders weet niemand later waar het over gaat."; return; } + + var datum = Vrijgave == 2 ? VrijgaveDatum : null; + if (Vrijgave == 2 && datum is null) { Fout = "Kies een datum waarop dit vrijkomt."; return; } + + long nieuwItemId = 0; + + if (await ProbeerAsync(async () => + { + var r = ItemId == 0 + ? await Toestand.Client.KluisOpslaanAsync(Categorie, Titel.Trim(), Bewerken!, Vrijgave, datum, BesprekenMetPersonId) + : await Toestand.Client.KluisWijzigenAsync(ItemId, Categorie, Titel.Trim(), Bewerken!, Vrijgave, datum, BesprekenMetPersonId); + + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + if (ItemId == 0 && r.Waarde.TryGetProperty("itemId", out var nummer)) + nieuwItemId = nummer.GetInt64(); + + return Uitkomst.Ok(); + })) + { + // Nu pas kunnen de bijlagen mee: het item bestaat en heeft een nummer. + var doel = ItemId == 0 ? nieuwItemId : ItemId; + if (doel != 0 && !Wachtrij.Leeg) + { + var misging = await Wachtrij.VerstuurAsync(Toestand.Client, kluisItemId: doel, + voortgang: (naam, procent) => + { + Overzetten = $"{naam} wordt opgeslagen…"; + Percentage = procent; + InvokeAsync(StateHasChanged); + }); + + Overzetten = null; + if (misging is not null) Fout = misging; + } + + Bewerken = null; + await LaadAsync(); + } + } + + /// + /// De tegel is aangetikt: dat opent het scherm met de inhoud. Dat is hetzelfde scherm als + /// wijzigen - het toont alles wat er in het item staat en je kunt het meteen aanpassen. + /// Staat de vraag "verwijderen?" open, dan gebeurt er niets: dan hoort de aandacht bij die + /// vraag te blijven. + /// + private Task TegelOpenenAsync(KluisItem item) => + Weghalen == item.Id ? Task.CompletedTask : WijzigenAsync(item); + + private async Task BijlagenLadenAsync() + { + if (BijlageDoel is not { } id) { Bijlagen = Array.Empty(); return; } + + var lijst = await Toestand.Client.BijlagenAsync(id); + Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty(); + if (!lijst.Gelukt) Fout = lijst.Melding; + } + + /// + /// Bij welk item een bijlage hoort: het item dat in het scherm staat. Bij een nieuw item is + /// er nog geen nummer; dan wachten de bijlagen in de wachtrij tot het opslaan klaar is. + /// + private long? BijlageDoel => Bewerken is not null && ItemId != 0 ? ItemId : null; + + private async Task ToevoegenAsync(Soort soort) + { + Fout = null; + var gekozen = soort switch + { + Soort.Document => await Bestandskiezer.KiesDocumentAsync(), + Soort.Foto => await Bestandskiezer.KiesFotoAsync(), + Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(), + Soort.Video => await Bestandskiezer.KiesVideoAsync(), + _ => await Bestandskiezer.NeemVideoOpAsync() + }; + + // Niets gekozen of afgebroken: gewoon niets doen, geen foutmelding. + if (gekozen is null) return; + + await VerwerkGekozenAsync(gekozen); + } + + /// + /// Voor Rijbewijs en Paspoort: meteen de camera openen voor één specifieke kant, met een + /// vaste, herkenbare naam - zo zie je in de bijlagenlijst meteen welke kant het is, zonder + /// dat daar een apart veld voor nodig is. + /// + private async Task MaakGerichteFotoAsync(string label) + { + Fout = null; + var gekozen = await Bestandskiezer.MaakFotoAsync(); + if (gekozen is null) return; + + var punt = gekozen.Naam.LastIndexOf('.'); + var extensie = punt >= 0 ? gekozen.Naam[punt..] : ".jpg"; + await VerwerkGekozenAsync(gekozen with { Naam = $"{label}{extensie}" }); + } + + private async Task VerwerkGekozenAsync(GekozenBestand gekozen) + { + // Nog geen nummer om het aan te hangen: dan wacht het tot na het opslaan. + if (BijlageDoel is not { } itemId) + { + Wachtrij.VoegToe(gekozen); + return; + } + + Overzetten = $"{gekozen.Naam} wordt opgeslagen…"; + Percentage = 0; + StateHasChanged(); + + var uitkomst = await Toestand.Client.BestandVersturenAsync( + gekozen.Naam, gekozen.Soort, gekozen.Inhoud, itemId, + (gedaan, totaal) => + { + Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }); + + Overzetten = null; + if (!uitkomst.Gelukt) Fout = uitkomst.Melding; + else await BijlagenLadenAsync(); + } + + private async Task BijlageWegAsync(Bijlage bijlage) + { + // Wat nog wacht staat nergens; dat halen we hier weg en niet bij de server. + if (Bijlagewachtrij.Wacht(bijlage)) { Wachtrij.Verwijder(bijlage); return; } + + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await BijlagenLadenAsync(); + } + } + + /// + /// Muziek en Wensen mogen meerdere regels worden; dan is een vast aantal regels niet + /// genoeg en een schuifbalk niet fijn op een telefoon. Deze twee velden groeien daarom + /// mee met wat erin staat - alleen hier in de app, op de website hebben ze de ruimte + /// al staan. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (Bewerken is not null) + { + await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "muziek"); + await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "wensen"); + } + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Momenten.razor b/src/MijnBesluit.AppUi/Schermen/Momenten.razor new file mode 100644 index 0000000..1ea5ac4 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Momenten.razor @@ -0,0 +1,854 @@ +@page "/momenten" +@inherits SchermBasis +@inject IBestandskiezer Bestandskiezer + +@* Berichten die je klaarzet voor later: op een datum, of pas na je overlijden. + Zolang een bericht niet bezorgd is, mag je het wijzigen of terugtrekken. *@ + +@* De kop staat in de balk erboven, dus hier niet nog een keer. *@ + + +@if (Fout is not null) +{ +
@Fout
+} + +@if (Gelukt is not null) +{ +
@Gelukt
+} + +@* Twee tegels die zeggen wat er is, en waarmee je kiest wat je ziet. + + Waarom niet allebei de lijsten onder elkaar, zoals het was: "Voor mij" is bij bijna iedereen + leeg - berichten komen pas na een datum of na een overlijden - en dat betekende dat je elke + keer langs een lege doos moest scrollen om bij je eigen berichten te komen. Het getal zegt + nu wat die lijst bevat, zonder dat je ernaartoe hoeft. + + Welke tegel openstaat bij binnenkomst wordt niet vastgezet maar afgeleid: staat er iets + nieuws voor je klaar, dan is dát wat je wilt zien. Zie Standaardkant. *@ +
+ + + +
+ +@if (Schrijven is not null) +{ + + + @if (Schrijven.BerichtId == 0) + { +
+ + +
+ } + else + { +

Voor @Naam(Schrijven.AanPersoonId)

+ } + +
+
+ + @* "Een opname erbij" is iets anders dan een bijlage: daarmee ís het bericht een video, + in plaats van een tekst met iets eronder. Die opname gaat als los bestand mee en wordt + bij het opslaan aan het bericht geknoopt; bijlagen staan daaronder. *@ + @if (Schrijven.BerichtId == 0) + { + @if (Bestandskiezer.KanCamera) + { +
+ + + @if (Schrijven.BestandId is not null) + { +
+ @Schrijven.Bestandsnaam + +
+ } + else if (Overzetten is null) + { + +
+ Een kort filmpje zegt meer dan een brief. Maximaal + @(MaxMegabyte) MB, dus houd het bij een minuut of wat. +
+ } +
+ } + + @if (Overzetten is not null) + { +
+
@Overzetten
+
+
+ } + + } + + @* Ook bij een nieuw bericht. Hier was geen nummer om een bestand aan te hangen, dus + stond er een zinnetje "dat kan zodra het bericht klaarstaat" - een uitleg van onze + boekhouding waar iemand die een foto wil meesturen niets aan heeft. Nu wachten ze in + Wachtrij en gaan ze mee zodra het bericht is klaargezet. *@ + + +
+ + +
+ + @if (Schrijven.Wanneer == "datum") + { +
+ + +
+ } + +
+} + +@if (Kant == "voor") +{ +@if (Inkomend.Count == 0) +{ +
🎬 Er is nog niets voor je bezorgd.
+} +
+ @foreach (var bericht in Inkomend) + { + @* Drie regels naast de foto: onderwerp, van wie, wanneer. In die volgorde, want zo lees + je een lijst: eerst waar het over gaat, dan van wie. Wat niet past wordt afgekapt in + plaats van afgebroken - een tegel van vijf regels hoog zet de rest buiten beeld. *@ +
+
+ +
+ @bericht.Subject +
@Naam(bericht.FromPersonId)
+ +
+ + @* Bij een nieuw bericht valt "bezorgd" weg. Anders duwde die regel de + datum van het scherm af - en juist de datum wil je hier lezen. *@ + @if (bericht.IsNieuw) { Nieuw · } else { Bezorgd } + @Tonen.Kort(bericht.DeliveredAt) + @if (bericht.HeeftBestand) { · met opname } + + + @if (Verwijderen != bericht.Id) + { + + + + } +
+
+
+ + @if (Verwijderen == bericht.Id) + { + @* Zie de toelichting bij dezelfde constructie onder "Van mij": stopPropagation + overal hieronder, anders klapt dit weer dicht - of erger, opent het de + leespopup - zodra je "ja" of "nee" aantikt. *@ +
+ Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @Naam(bericht.FromPersonId) + houdt het eigen exemplaar gewoon. +
+ + +
+
+ } +
+ } +
+ +@* Het bericht dat openstaat, in een popup - net als overal elders in de app. De tegel zelf + klapt niet meer uit: dat gaf op een tegel met veel tekst een rommelig, wisselend lange + lijst, en overal elders in de app hoort lezen in een popup te zitten. *@ +@if (Gelezen is { } gelezenId && Inkomend.FirstOrDefault(b => b.Id == gelezenId) is { } open) +{ + +

@Naam(open.FromPersonId) · bezorgd @Tonen.Kort(open.DeliveredAt)

+

@Berichttekst

+ + @if (Opname is not null) + { + + } + else if (open.HeeftBestand && Overzetten is not null) + { +
+
@Overzetten
+
+
+ } +
+} + +} + +@if (Kant == "van") +{ +@if (Uitgaand.Count == 0) +{ +
+ 💬 + Zet iets klaar voor iemand. Een brief voor later komt vaak harder aan dan je denkt. +
+} +
+ @foreach (var bericht in Uitgaand) + { + @* Aantikken is wijzigen; zolang het bericht nog niet bezorgd is tenminste. Daarna staat + het vast en doet de tegel niets meer. *@ +
+
+ +
+ @bericht.Subject +
@Naam(bericht.ToPersonId)
+ + @* Alleen terugtrekken staat er nog als knop; wijzigen doe je door de tegel + aan te tikken. stopPropagation, want anders opent een druk op de + prullenbak ook meteen het wijzigscherm. *@ +
+ @* Hetzelfde tekentje als in de kluis, even hoog als de tekst: het takje + voor na je overlijden, de kalender voor een datum. Het staat vóór de + tekst en dus links uitgelijnd met de naam erboven. De tekst blijft + staan; een takje raadt niemand. *@ + + + @Bezorging(bericht) + + + @if (!Bezorgd(bericht) && Weghalen != bericht.Id) + { + + + + } + else if (Bezorgd(bericht) && Verwijderen != bericht.Id) + { + + + + } +
+
+
+ + @if (Weghalen == bericht.Id) + { + @* Alles wat hieronder uitklapt zit ín de tegel, en een tik op de tegel opent of sluit hem. + Zonder stopPropagation klapt hij dus weer dicht zodra je de video aanraakt, een + bijlage aantikt of op "nee" drukt. *@ +
+ Dit bericht terugtrekken? Het wordt dan nooit bezorgd. +
+ + +
+
+ } + else if (Verwijderen == bericht.Id) + { +
+ Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @Naam(bericht.ToPersonId) + houdt het eigen exemplaar gewoon. +
+ + +
+
+ } +
+ } +
+ +@* Je eigen verzonden bericht ter inzage, in dezelfde opmaak als "Voor mij" hierboven. *@ +@if (GelezenUit is { } gelezenUitId && Uitgaand.FirstOrDefault(b => b.Id == gelezenUitId) is { } openUit) +{ + +

Voor @Naam(openUit.ToPersonId) · @Bezorging(openUit)

+ + @* Alleen zolang het bericht al bezorgd is: daarvoor staat er nog niets om te tonen. *@ + @if (openUit.DeliveredAt is not null) + { +

+ Verzonden: @Tonen.Kort(openUit.DeliveredAt)
+ Gelezen: @(openUit.ReadAt is { } gelezenOp ? Tonen.Kort(gelezenOp) : "nog niet") +

+ } + +

@BerichttekstUit

+ + @if (Opname is not null) + { + + } + else if (openUit.HeeftBestand && Overzetten is not null) + { +
+
@Overzetten
+
+
+ } +
+} + +} + +@code { + private IReadOnlyList Inkomend = Array.Empty(); + private IReadOnlyList Uitgaand = Array.Empty(); + private IReadOnlyList Leden = Array.Empty(); + + /// Welke tegel openstaat: "van" of "voor". Zie Standaardkant. + private string Kant = "van"; + + /// Of de kant al een keer door de gebruiker of door het laden is gekozen. + private bool KantGekozen; + + /// Hoeveel binnengekomen berichten nog niet gelezen zijn. + private int AantalNieuw => Inkomend.Count(b => b.IsNieuw); + + private Formulier? Schrijven; + private long? Gelezen; + private long? Weghalen; + + /// Welke tegel om bevestiging vraagt om te verwijderen - bij "Voor mij" én bij + /// "Van mij" (ook als het bericht al bezorgd is, in tegenstelling tot Weghalen hierboven, + /// dat alleen voor terugtrekken vóór bezorging is). + private long? Verwijderen; + private string? Berichttekst; + + /// Welk eigen verzonden bericht ter inzage openstaat; los van Gelezen hierboven, + /// dat is voor "Voor mij". + private long? GelezenUit; + private string? BerichttekstUit; + private string? Gelukt; + + /// De opname bij het bericht dat openstaat, als data-URL. + private string? Opname; + + /// Tekst bij de voortgangsbalk; null als er niets loopt. + private string? Overzetten; + private int Percentage; + + /// De bijlagen bij het bericht dat in het wijzigscherm staat. + private IReadOnlyList Bijlagen = Array.Empty(); + + private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen } + + private static int MaxMegabyte => 50; + + /// + /// Kiest één keer, bij het openen, welke tegel openstaat. + /// + /// "Van mij" is de gewone stand: dat is waar je komt om iets klaar te zetten of na te kijken. + /// Maar staat er iets nieuws vóór jou, dan is dat het enige wat op dat moment telt - een + /// bericht van iemand die er niet meer is, laat je niet achter een tegel staan. + /// + /// Eén keer, want daarna heeft de gebruiker zelf gekozen. Bij elk herladen opnieuw beslissen + /// zou betekenen dat het scherm onder je handen omspringt zodra je een bericht wegklikt. + /// + private void Standaardkant() + { + if (KantGekozen) return; + + Kant = AantalNieuw > 0 ? "voor" : "van"; + KantGekozen = true; + } + + protected override async Task LaadAsync() + { + var inkomend = await Toestand.Client.InkomendAsync(); + if (inkomend.Gelukt) Inkomend = inkomend.Waarde!; else Fout = inkomend.Melding; + + var uitgaand = await Toestand.Client.UitgaandAsync(); + if (uitgaand.Gelukt) Uitgaand = uitgaand.Waarde!; else Fout ??= uitgaand.Melding; + + Standaardkant(); + + if (Toestand.Familie is not null) + { + var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id); + if (leden.Gelukt) Leden = leden.Waarde!; + } + } + + private void Nieuw() + { + Gelukt = null; + Fout = null; + Weghalen = null; + Bijlagen = Array.Empty(); + Schrijven = new Formulier { BezorgOp = DateTime.Today.AddYears(1) }; + } + + private async Task WijzigenAsync(Bericht bericht) + { + Gelukt = null; + Fout = null; + Weghalen = null; + + // De tekst staat versleuteld op de server; die halen we op om het scherm te vullen. + var tekst = await Toestand.Client.BerichtConceptAsync(bericht.Id); + if (!tekst.Gelukt) { Fout = tekst.Melding; return; } + + Schrijven = new Formulier + { + BerichtId = bericht.Id, + AanPersoonId = bericht.ToPersonId, + Onderwerp = bericht.Subject, + Tekst = tekst.Waarde!, + Wanneer = bericht.DeliverAfterDeath ? "overlijden" : bericht.DeliverAt is null ? "nu" : "datum", + BezorgOp = bericht.DeliverAt?.ToLocalTime() ?? DateTime.Today.AddYears(1) + }; + + await BijlagenLadenAsync(); + } + + // ------------------------------------------------------------------ foto's en documenten + + private async Task BijlagenLadenAsync() + { + if (Schrijven is not { BerichtId: > 0 } formulier) { Bijlagen = Array.Empty(); return; } + + var lijst = await Toestand.Client.BerichtbijlagenAsync(formulier.BerichtId); + Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty(); + if (!lijst.Gelukt) Fout = lijst.Melding; + } + + private async Task ToevoegenAsync(Soort soort) + { + if (Schrijven is null) return; + + Fout = null; + var gekozen = soort switch + { + Soort.Document => await Bestandskiezer.KiesDocumentAsync(), + Soort.Foto => await Bestandskiezer.KiesFotoAsync(), + Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(), + Soort.Video => await Bestandskiezer.KiesVideoAsync(), + _ => await Bestandskiezer.NeemVideoOpAsync() + }; + + if (gekozen is null) return; + + // Nog geen nummer om het aan te hangen: dan wacht het tot het bericht is klaargezet. + if (Schrijven is not { BerichtId: > 0 } formulier) + { + Wachtrij.VoegToe(gekozen); + return; + } + + Overzetten = $"{gekozen.Naam} wordt opgeslagen…"; + Percentage = 0; + StateHasChanged(); + + var uitkomst = await Toestand.Client.BestandVersturenAsync( + gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null, + (gedaan, totaal) => + { + Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }, + berichtId: formulier.BerichtId); + + Overzetten = null; + if (!uitkomst.Gelukt) Fout = uitkomst.Melding; + else await BijlagenLadenAsync(); + } + + private async Task BijlageWegAsync(Bijlage bijlage) + { + // Wat nog wacht staat nergens; dat halen we hier weg en niet bij de server. + if (Bijlagewachtrij.Wacht(bijlage)) { Wachtrij.Verwijder(bijlage); return; } + + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await BijlagenLadenAsync(); + } + } + + private async Task VideoOpnemenAsync() + { + Fout = null; + var opname = await Bestandskiezer.NeemVideoOpAsync(); + if (opname is null) return; + + Overzetten = "De opname wordt opgeslagen…"; + Percentage = 0; + StateHasChanged(); + + // De opname gaat als los bestand naar de kluis; het bericht verwijst er straks naar. + var uitkomst = await Toestand.Client.BestandVersturenAsync( + opname.Naam, opname.Soort, opname.Inhoud, null, + (gedaan, totaal) => + { + Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }); + + Overzetten = null; + + if (!uitkomst.Gelukt) { Fout = uitkomst.Melding; return; } + + Schrijven!.BestandId = uitkomst.Waarde; + Schrijven.Bestandsnaam = opname.Naam; + } + + private void Sluiten() + { + Wachtrij.Leegmaken(); + Schrijven = null; + Bijlagen = Array.Empty(); + Overzetten = null; + } + + /// Bijlagen die zijn gekozen voor een bericht dat nog niet bestaat. + private readonly Bijlagewachtrij Wachtrij = new(); + + /// Wat er opgeslagen staat plus wat er nog wacht; samen wat het scherm laat zien. + private IReadOnlyList AlleBijlagen => + Wachtrij.Leeg ? Bijlagen + : Bijlagen.Concat(Wachtrij.AlsBijlagen(DateTime.UtcNow)).ToList(); + + private void OpnameWeghalen() + { + if (Schrijven is null) return; + Schrijven.BestandId = null; + Schrijven.Bestandsnaam = null; + } + + private async Task OpslaanAsync() + { + var f = Schrijven!; + if (f.BerichtId == 0 && f.AanPersoonId == 0) { Fout = "Kies eerst voor wie het bericht is."; return; } + if (string.IsNullOrWhiteSpace(f.Onderwerp)) { Fout = "Geef het bericht een onderwerp."; return; } + if (string.IsNullOrWhiteSpace(f.Tekst) && f.BestandId is null) + { + Fout = "Er staat nog niets in je bericht. Schrijf iets, of neem een video op."; + return; + } + + var naOverlijden = f.Wanneer == "overlijden"; + var bezorgOp = f.Wanneer == "datum" ? f.BezorgOp : null; + + if (f.Wanneer == "datum" && bezorgOp is null) { Fout = "Kies een datum."; return; } + + long nieuwBerichtId = 0; + + if (await ProbeerAsync(async () => + { + var r = f.BerichtId == 0 + ? await Toestand.Client.BerichtKlaarzettenAsync( + f.AanPersoonId, f.Onderwerp.Trim(), f.Tekst, bezorgOp, naOverlijden, + soort: f.BestandId is null ? 0 : 1, bestandId: f.BestandId) + : await Toestand.Client.BerichtWijzigenAsync( + f.BerichtId, f.Onderwerp.Trim(), f.Tekst, bezorgOp, naOverlijden); + + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + if (f.BerichtId == 0 && r.Waarde.TryGetProperty("berichtId", out var nummer)) + nieuwBerichtId = nummer.GetInt64(); + + return Uitkomst.Ok(); + })) + { + // Nu pas kunnen de bijlagen mee: het bericht bestaat en heeft een nummer. + var doel = f.BerichtId == 0 ? nieuwBerichtId : f.BerichtId; + if (doel != 0 && !Wachtrij.Leeg) + { + var misging = await Wachtrij.VerstuurAsync(Toestand.Client, berichtId: doel, + voortgang: (naam, procent) => + { + Overzetten = $"{naam} wordt opgeslagen…"; + Percentage = procent; + InvokeAsync(StateHasChanged); + }); + + Overzetten = null; + if (misging is not null) Fout = misging; + } + + Gelukt = f.BerichtId == 0 ? "Het bericht staat klaar." : "De wijziging is opgeslagen."; + Schrijven = null; + Bijlagen = Array.Empty(); + await LaadAsync(); + } + } + + private async Task IntrekkenAsync(long berichtId) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.BerichtIntrekkenAsync(berichtId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = "Het bericht is teruggetrokken."; + Weghalen = null; + await LaadAsync(); + } + } + + /// + /// Een bericht uit je eigen lijst halen - bij "Voor mij" of bij "Van mij", bezorgd of niet. + /// Dit is iets anders dan IntrekkenAsync hierboven: dat trekt een bericht terug vóór het is + /// bezorgd (de ander krijgt het dan nooit), dit ruimt alleen je eigen exemplaar op nadat het + /// er al was - de ander houdt het zijne gewoon. + /// + private async Task VerwijderenAsync(long berichtId) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.BerichtVerwijderenAsync(berichtId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = "Het bericht is verwijderd."; + Verwijderen = null; + await LaadAsync(); + } + } + + /// Doet de popup met het gelezen bericht weer dicht. + private void Dichtdoen() + { + Gelezen = null; + Berichttekst = null; + Opname = null; + } + + /// Doet de popup met het eigen verzonden bericht weer dicht. + private void DichtdoenUit() + { + GelezenUit = null; + BerichttekstUit = null; + Opname = null; + } + + /// + /// Je eigen verzonden bericht nog eens bekijken, ook als het al bezorgd is. Anders dan + /// LezenAsync hierboven (voor "Voor mij", dat meteen ook als gelezen markeert en de lijst + /// herlaadt): hier verandert er niets aan de status, dus is er ook niets om te herladen. + /// + private async Task BekijkenUitAsync(long id) + { + Opname = null; + + var gelukt = await ProbeerAsync(async () => + { + var r = await Toestand.Client.BerichtVerzondenLezenAsync(id); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + BerichttekstUit = r.Waarde; + GelezenUit = id; + return Uitkomst.Ok(); + }); + + if (gelukt && Uitgaand.FirstOrDefault(b => b.Id == id) is { VaultFileId: { } bestandId }) + { + Overzetten = "De opname wordt opgehaald…"; + Percentage = 0; + StateHasChanged(); + + var bestand = await Toestand.Client.BestandOphalenAsync(bestandId, + (gedaan, totaal) => + { + Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }); + + Overzetten = null; + + if (bestand.Gelukt) + { + Opname = $"data:{bestand.Waarde!.Soort};base64,{Convert.ToBase64String(bestand.Waarde.Inhoud)}"; + } + else + { + Fout = bestand.Melding; + } + } + } + + private async Task LezenAsync(long id) + { + Opname = null; + + var gelukt = await ProbeerAsync(async () => + { + var r = await Toestand.Client.BerichtLezenAsync(id); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + Berichttekst = r.Waarde; + Gelezen = id; + return Uitkomst.Ok(); + }); + + if (gelukt && Inkomend.FirstOrDefault(b => b.Id == id) is { VaultFileId: { } bestandId }) + { + Overzetten = "De opname wordt opgehaald…"; + Percentage = 0; + StateHasChanged(); + + var bestand = await Toestand.Client.BestandOphalenAsync(bestandId, + (gedaan, totaal) => + { + Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }); + + Overzetten = null; + + if (bestand.Gelukt) + { + Opname = $"data:{bestand.Waarde!.Soort};base64,{Convert.ToBase64String(bestand.Waarde.Inhoud)}"; + } + else + { + Fout = bestand.Melding; + } + } + + await LaadAsync(); + } + + /// + /// De tegel is aangetikt. Staat er al een bevestiging open (terugtrekken of verwijderen), + /// dan gebeurt er niets - de aandacht hoort daarbij. Is het bericht al bezorgd, dan opent + /// het ter inzage (BekijkenUitAsync); anders open je het om te wijzigen. + /// + private Task TegelWijzigenAsync(Bericht bericht) => + Weghalen == bericht.Id || Verwijderen == bericht.Id ? Task.CompletedTask + : Bezorgd(bericht) ? BekijkenUitAsync(bericht.Id) + : WijzigenAsync(bericht); + + /// 2 = bezorgd, 3 = gelezen. + private static bool Bezorgd(Bericht b) => b.Status >= 2; + + /// + /// Zolang het bericht nog klaarstaat, hoort hier te staan wannéér het weggaat. Is het + /// eenmaal onderweg, dan is dat niet meer interessant - dan wil je weten of het is + /// aangekomen, en of het al gelezen is. Zonder deze voorrang bleef hier voor altijd "zo + /// snel mogelijk" staan, ook nadat het allang bezorgd en gelezen was. + /// + private static string Bezorging(Bericht b) => + b.Status == 3 ? "gelezen" + : b.Status == 2 ? "verzonden" + : b.DeliverAfterDeath ? "na mijn overlijden" + : b.DeliverAt is { } op ? Tonen.Kort(op) + : "zo snel mogelijk"; + + /// + /// Het tekentje bij die tekst. Staat hier vlak naast , zodat de twee + /// niet uit elkaar kunnen lopen: elk geval daar hoort er hier ook een te hebben. + /// + /// Takje en kalender zijn dezelfde als in de kluis. De envelop is voor een bericht dat nog + /// klaarstaat om meteen weg te gaan; het vinkje voor verzonden, het oog voor gelezen. + /// + private static string Bezorgteken(Bericht b) => + b.Status == 3 ? "\U0001F441\uFE0F" + : b.Status == 2 ? "\u2705" + : b.DeliverAfterDeath ? "\U0001F33F" + : b.DeliverAt is not null ? "\U0001F4C5" + : "\u2709"; + + private string Naam(long persoonId) => + Leden.FirstOrDefault(l => l.PersonId == persoonId)?.DisplayName ?? "iemand"; + + /// De profielfoto van deze persoon; null als hij er geen heeft of niet in je lijst staat. + private string? Foto(long persoonId) => + Leden.FirstOrDefault(l => l.PersonId == persoonId)?.Profielfotobron; + + private class Formulier + { + public long BerichtId { get; set; } + public long AanPersoonId { get; set; } + public string Onderwerp { get; set; } = ""; + public string Tekst { get; set; } = ""; + public string Wanneer { get; set; } = "overlijden"; + public DateTime? BezorgOp { get; set; } + + /// De opname die bij dit bericht hoort, als die er is. + public long? BestandId { get; set; } + public string? Bestandsnaam { get; set; } + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Nalatenschap.razor b/src/MijnBesluit.AppUi/Schermen/Nalatenschap.razor new file mode 100644 index 0000000..4092231 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Nalatenschap.razor @@ -0,0 +1,745 @@ +@page "/nalatenschap" +@inherits SchermBasis + +@* Wie mag er straks bij, hoe wordt vastgesteld dat iemand is overleden, en hoe geef je door + dat iemand anders is overleden. Dezelfde onderdelen als op de website; de kop staat in de + balk erboven, dus hier niet nog een keer. *@ + +@if (Fout is not null) +{ +
@Fout
+} + +@if (Gelukt is not null) +{ +
@Gelukt
+} + +@* ---------------------------------------------------------------- loopt er iets *@ +@if (Stand is { LooptEenProcedure: true }) +{ +
+ @if (Stand.WachtOpBeheerder) + { + + Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen") + binnengekomen over jouw profiel — genoeg in aantal, maar er zit nog geen melding bij + van iemand die jij als beheerder hebt aangewezen. Zolang die er niet is gebeurt er niets. + + } + else if (Stand.Missing == 0) + { + Er is gemeld dat je zou zijn overleden. + Klopt het niet, laat het dan weten + @if (Stand.ObjectionDeadline is { } tot) { vóór @Tonen.Datum(tot) } + . + } + else + { + + Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen") + binnengekomen over jouw profiel. Er + @(Stand.Missing == 1 ? "is er nog één" : $"zijn er nog {Stand.Missing}") nodig + voordat de procedure begint. + + } + +
+ + +
+} +else if (Stand is { IsBevestigd: true }) +{ +
Het overlijden is bevestigd. De beheerder heeft toegang gekregen.
+} + +@* ---------------------------------------------------------------- aan mij toevertrouwd *@ +@if (Toevertrouwd.Count > 0) +{ +
+

Aan mij toevertrouwd

+

+ Deze mensen hebben jou aangewezen om hun nalatenschap te regelen. Zolang zij er zijn + valt er niets in te zien; dat verandert pas als de procedure is afgerond. +

+ +
+ @foreach (var nalatenschap in Toevertrouwd) + { + @* Dezelfde tegel als bij Momenten en Mijn mensen: foto voorop, de tekst over de + volle breedte, de knop als tekentje rechtsonder in de hoek van de rij. Naast + de naam bleef er op een telefoon zo weinig over dat "Overleden 3 Mrt. 2026" + over vier regels ging en de naam werd afgekapt. *@ +
+
+ + + @* Drie regels, net als bij Momenten. Twee regels kon ook, maar dan botst + het driehoekje rechtsonder tegen de naam aan zodra die lang is. *@ +
+ @nalatenschap.DisplayName + +
+ @if (!nalatenschap.Afgerond) + { + Je bent aangewezen + } + else if (nalatenschap.DateOfDeath is { } overleden) + { + Overleden @Tonen.Kort(overleden) + } +
+ + @* Wat @nalatenschap.DisplayName zelf heeft ingesteld: zo weet je bij + leven al waar je op moet rekenen, en kun je het er nog met diegene + over hebben in plaats van er pas achter te komen zodra het zover + is. Alleen zolang de procedure nog niet is afgerond - daarna is + het geen vooruitzicht meer maar geschiedenis. *@ + @if (!nalatenschap.Afgerond) + { +
+ @nalatenschap.RequiredDeclarations + meldingen nodig · @nalatenschap.ObjectionDays dagen bezwaartermijn +
+ } + + @* De onderste regel draagt rechts het wijzertje. Geen knop: je klapt + de kluis open door de tegel aan te tikken. *@ +
+ + @if (!nalatenschap.Afgerond) + { + Volgorde @nalatenschap.Volgorde + } + else + { + @nalatenschap.AantalItems item@(nalatenschap.AantalItems == 1 ? "" : "s") + } + + + @if (nalatenschap.Afgerond) + { + + + + } +
+
+
+ + @if (nalatenschap.Afgerond && Bekeken == nalatenschap.PersonId) + { + @* Alles hieronder zit ín de tegel, en een tik op de tegel klapt hem open + of dicht. Zonder stopPropagation klapt hij dus weer dicht zodra je een + item eronder aantikt. *@ +
+ @if (Nagelaten.Count == 0) + { +

+ Er is niets vrijgegeven. @nalatenschap.DisplayName heeft alles op + "alleen voor mijzelf" laten staan; dat blijft ook nu dicht. +

+ } + +
+ @foreach (var item in Nagelaten) + { +
+
+ @item.Title +
@Tonen.Categorie(item.Category)
+
+ +
+ } +
+
+ } +
+ } +
+
+} + +@* ---------------------------------------------------------------- met mij gedeeld *@ +@if (GedeeldMetMij.Count > 0) +{ +
+

Met mij gedeeld

+

+ Dit is met jou gedeeld om alvast te bespreken - je hoeft niet te wachten op een + overlijden om het te lezen. +

+ +
+ @foreach (var item in GedeeldMetMij) + { +
+
+ @item.Title +
@Tonen.Categorie(item.Category)
+
+ +
+ } +
+
+} + +@* ---------------------------------------------------------------- item bekijken *@ +@* Dezelfde opmaak als het invulformulier in de kluis (velden onder elkaar), maar zonder + invoervelden: dit is een ander item dan het jouwe, dus er valt hier niets te wijzigen - + alleen te lezen en, als er iets bij hangt, te bekijken. *@ +@if (GeopendItem is not null && Inhoud is not null) +{ + +
+ @foreach (var regel in Regels(Inhoud)) + { +
+ +

@regel.Waarde

+
+ } +
+ + @if (GeopendBijlagen.Count > 0) + { +
+

Bijlagen

+
+ @foreach (var bijlage in GeopendBijlagen) + { +
+ +
+ } +
+ } +
+} + +@if (BekekenBijlage is not null) +{ + + + @if (BijlageOphalen) + { +

Bezig met ophalen…

+
+ } + else if (BijlageKijkfout is not null) + { +
@BijlageKijkfout
+ } + else if (BijlageBron is not null && BekekenBijlage.IsAfbeelding) + { + @BekekenBijlage.FileName + } + else if (BijlageBron is not null && BekekenBijlage.IsVideo) + { + + } + else + { +

+ Dit soort bestand is hier niet te tonen. Op de website kun je het downloaden. +

+ } +
+} + +@* ---------------------------------------------------------------- mijn beheerders *@ +
+

Mijn beheerders

+ + @if (Beheerders.Count == 0) + { +

Je hebt nog niemand aangewezen. Zonder beheerder blijft je kluis dicht.

+ } + else + { +
+ @foreach (var beheerder in Beheerders) + { +
+ +
+ @beheerder.DisplayName +
+ Volgorde @beheerder.Priority + @if (beheerder.HeeftToegang) { · heeft toegang gekregen } +
+
+ + @* Wie je aanwijst moet je ook weer kunnen weghalen - als hij zelf overlijdt, + of als je het anders wilt regelen. Behalve als hij je kluis al heeft + gekregen: dan valt er niets meer terug te nemen, en dat zegt de server + ook zo als je het toch probeert. *@ + @if (!beheerder.HeeftToegang) + { + + + + } +
+ + @if (Weghalen?.PersonId == beheerder.PersonId) + { +
+ @beheerder.DisplayName niet meer laten beheren? Je kluis + gaat dan niet meer naar hem of haar. Wijs wel iemand anders aan: zonder + beheerder blijft je kluis ook na je overlijden dicht. +
+ + +
+
+ } + } +
+ } + + +
+ +@* ---------------------------------------------------------------- de procedure *@ +
+

Hoeveel meldingen zijn er nodig?

+

+ Er moeten meerdere verschillende mensen melden dat je bent overleden. Daarna krijg je + zelf nog een termijn om dat tegen te spreken. Pas als die verstrijkt, krijgt je beheerder + toegang. +

+ +
+ + +
+ +
+ + @* Een vrij getal, net als op de website. Hier stond een keuzelijstje met zes vaste + waarden; dat is een beperking die nergens op sloeg - de server heeft altijd alles + tussen 1 en 90 toegestaan. *@ + +
Tussen 1 en 90 dagen. Zoveel tijd krijg je om een melding tegen te spreken.
+
+ + @* Zonder beheerder komt de procedure niet op gang, hoeveel mensen er ook melden. Dat hoort + hier te staan en niet pas als het zover is. *@ + @if (Beheerders.Count == 0) + { +
+ Je hebt nog niemand aangewezen. Melden mag iedereen uit Mijn mensen, maar de procedure + begint pas te lopen als er een melding van een van jouw beheerders bij zit. Zonder + beheerder gebeurt er dus niets. +
+ } + + +
+ +@* ---------------------------------------------------------------- iemand melden *@ +@* Melden mag over iedereen uit je eigen mensen. Staat daar niemand in, dan staat dit blok er + niet: een knop met een lege lijst is geen knop. *@ +@if (Meldbaar.Count > 0) +{ +
+

Iemand als overleden melden

+

+ Is iemand uit Mijn mensen overleden? Geef het hier door. Er zijn meerdere + onafhankelijke meldingen nodig, waaronder één van iemand die hij zelf als beheerder + heeft aangewezen. Daarna krijgt de betrokkene eerst bericht. +

+ +
+} + +@* ---------------------------------------------------------------- popups *@ +@if (Aanwijzen) +{ + + +
+ + +
+ +
+ + +
1 is de eerste keus. Wijs je er meer aan, dan is dit de volgorde.
+
+
+} + +@if (Melden) +{ + + +
+ + +
+ +
+ + +
Niet verplicht als je het niet precies weet.
+
+ +

+ Deze melding is niet zomaar iets: de betrokkene krijgt er bericht van. Doe hem alleen + als je het zeker weet. +

+
+} + +@code { + private IReadOnlyList Leden = Array.Empty(); + private IReadOnlyList Beheerders = Array.Empty(); + private NalatenschapStand? Stand; + private string? Gelukt; + + private bool Aanwijzen, Melden; + private long BeheerderId, OverledeneId; + private int Volgorde = 1; + private DateTime? Overlijdensdatum; + + /// + /// Over wie jij een overlijdensmelding mag doen: iedereen uit je eigen mensen die nog niet + /// als overleden staat. Dezelfde kring die de server aanhoudt, zodat het scherm en de regel + /// het over dezelfde mensen hebben. + /// + private IReadOnlyList Meldbaar => + Leden.Where(l => l.PersonId != Toestand.PersoonId && l.DateOfDeath is null).ToList(); + + private int AantalMeldingen = 3; + private int Bezwaardagen = 14; + + /// De nalatenschappen die aan mij zijn toevertrouwd en waarvan de procedure rond is. + private IReadOnlyList Toevertrouwd = Array.Empty(); + + /// Kluisitems die iemand rechtstreeks met mij heeft gedeeld (nog geen overlijden nodig). + private IReadOnlyList GedeeldMetMij = Array.Empty(); + + /// Welke nalatenschap openstaat, en welk item daarbinnen. + private long? Bekeken, GeopendItem; + private IReadOnlyList Nagelaten = Array.Empty(); + private KluisInhoud? Inhoud; + private string? GeopendTitel; + private IReadOnlyList GeopendBijlagen = Array.Empty(); + + /// De bijlage die openstaat om te bekijken; null als er niets openstaat. + private Bijlage? BekekenBijlage; + private string? BijlageBron; + private bool BijlageOphalen; + private int BijlageVordering; + private string? BijlageKijkfout; + + /// Wie er nog aangewezen kan worden: iedereen behalve jezelf en wie er al staat. + private IEnumerable Kandidaten => + Leden.Where(l => l.PersonId != Toestand.PersoonId + && Beheerders.All(b => b.PersonId != l.PersonId)); + + private int Breedte => Stand is null || Stand.RequiredDeclarations == 0 + ? 0 + : Math.Min(100, Stand.ActiveDeclarations * 100 / Stand.RequiredDeclarations); + + protected override async Task LaadAsync() + { + if (Toestand.Familie is null) await Toestand.VerversAsync(); + + if (Toestand.Familie is not null) + { + var leden = await Toestand.Client.FamilieledenAsync(Toestand.Familie.Id); + if (leden.Gelukt) Leden = leden.Waarde!; + } + + var beheerders = await Toestand.Client.BeheerdersAsync(); + if (beheerders.Gelukt) Beheerders = beheerders.Waarde!; + + var toevertrouwd = await Toestand.Client.ToevertrouwdAsync(); + if (toevertrouwd.Gelukt) Toevertrouwd = toevertrouwd.Waarde!; + + var gedeeld = await Toestand.Client.GedeeldMetMijAsync(); + if (gedeeld.Gelukt) GedeeldMetMij = gedeeld.Waarde!; + + var stand = await Toestand.Client.NalatenschapStandAsync(); + if (stand.Gelukt) + { + Stand = stand.Waarde; + + // Allebei de velden terugzetten op wat er is opgeslagen. Het aantal dagen ontbrak + // hier: dat liet zich wijzigen en opslaan, maar sprong bij het herladen terug naar + // veertien - waardoor het leek alsof het niet werd bewaard. + AantalMeldingen = Stand!.RequiredDeclarations; + Bezwaardagen = Stand.ObjectionDays; + } + else + { + Fout = stand.Melding; + } + } + + // ------------------------------------------------------------------ aan mij toevertrouwd + + /// De beheerder waarvan de vraag "weghalen?" openstaat. + private Beheerder? Weghalen; + + private async Task BeheerderWegAsync(Beheerder beheerder) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.VerwijderBeheerderAsync(beheerder.PersonId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = $"{beheerder.DisplayName} beheert je nalatenschap niet meer."; + Weghalen = null; + await LaadAsync(); + } + } + + /// De tegel is aangetikt. Zolang de procedure loopt valt er niets in te zien. + private Task TegelOpenenAsync(Toevertrouwd nalatenschap) => + nalatenschap.Afgerond ? OpenenAsync(nalatenschap.PersonId) : Task.CompletedTask; + + private async Task OpenenAsync(long persoonId) + { + if (Bekeken == persoonId) { Dichtdoen(); return; } + + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.NalatenschapItemsAsync(persoonId); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + Bekeken = persoonId; + Nagelaten = r.Waarde!; + ItemDichtdoen(); + return Uitkomst.Ok(); + })) + { + Gelukt = null; + } + } + + private void Dichtdoen() + { + Bekeken = null; + Nagelaten = Array.Empty(); + ItemDichtdoen(); + } + + /// + /// Opent één nagelaten of gedeeld item. De sleutel daarvoor is bij het bevestigen van het + /// overlijden, of bij het delen zelf, voor jou klaargezet; de server gebruikt die vanzelf + /// als het item niet van jou is. + /// + private async Task ItemOpenenAsync(KluisItem item) + { + if (GeopendItem == item.Id) { ItemDichtdoen(); return; } + + await ProbeerAsync(async () => + { + var r = await Toestand.Client.KluisOpenenAsync(item.Id); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + GeopendItem = item.Id; + Inhoud = r.Waarde; + GeopendTitel = item.Title; + + // Bijlagen zijn hier optioneel: als ophalen mislukt tonen we het item gewoon + // zonder bijlagenlijst, in plaats van de hele popup te blokkeren. + var bijlagen = await Toestand.Client.BijlagenAsync(item.Id); + GeopendBijlagen = bijlagen.Gelukt ? bijlagen.Waarde! : Array.Empty(); + return Uitkomst.Ok(); + }); + } + + private void ItemDichtdoen() + { + GeopendItem = null; + Inhoud = null; + GeopendTitel = null; + GeopendBijlagen = Array.Empty(); + BijlageDichtdoen(); + } + + private async Task BijlageBekijkenAsync(Bijlage bijlage) + { + BekekenBijlage = bijlage; + BijlageBron = null; + BijlageKijkfout = null; + + // Een document halen we niet op: we kunnen het toch niet tonen, en het kan tientallen + // megabytes zijn. De popup zegt dan meteen waar je het wél kunt openen. + if (!bijlage.IsAfbeelding && !bijlage.IsVideo) return; + + BijlageOphalen = true; + BijlageVordering = 0; + StateHasChanged(); + + var uitkomst = await Toestand.Client.BestandOphalenAsync(bijlage.Id, + (gedaan, totaal) => + { + BijlageVordering = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }); + + BijlageOphalen = false; + + if (!uitkomst.Gelukt) { BijlageKijkfout = uitkomst.Melding; return; } + + // De webview haalt zijn plaatjes niet van een server, dus geven we de inhoud mee in de + // verwijzing zelf. Dat kan omdat er een grens op de bestandsgrootte staat. + BijlageBron = $"data:{uitkomst.Waarde!.Soort};base64,{Convert.ToBase64String(uitkomst.Waarde.Inhoud)}"; + } + + private void BijlageDichtdoen() + { + BekekenBijlage = null; + BijlageBron = null; + BijlageKijkfout = null; + BijlageOphalen = false; + } + + /// Dezelfde velden als in Mijn kluis; wat de eigenaar zag, ziet de beheerder ook. + private static IEnumerable<(string Naam, string Waarde)> Regels(KluisInhoud i) + { + (string, string?)[] alles = + { + ("Website", i.Website), ("Gebruikersnaam", i.Username), ("Wachtwoord", i.Secret), + ("Apparaat", i.DeviceName), ("Pincode", i.Pincode), ("Herstelcodes", i.RecoveryCodes), + ("Medium", i.Platform), ("Welk medium", i.PlatformAnders), + ("Verzekeraar", i.Insurer), ("Polisnummer", i.PolicyNumber), ("Telefoon", i.ContactPhone), + ("Begraven of cremeren", i.BurialOrCremation), ("Plek", i.Location), ("Muziek", i.Music), + ("Sprekers", i.Speakers), ("Wensen", i.Wishes), ("Toelichting", i.Notes) + }; + + return alles.Where(r => !string.IsNullOrWhiteSpace(r.Item2)).Select(r => (r.Item1, r.Item2!)); + } + + private async Task AanwijzenAsync() + { + Gelukt = null; + if (BeheerderId == 0) { Fout = "Kies eerst iemand."; return; } + + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.WijsBeheerderAanAsync(BeheerderId, Volgorde); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = "De beheerder is vastgelegd."; + Aanwijzen = false; + BeheerderId = 0; + Volgorde = 1; + await LaadAsync(); + } + } + + private async Task InstellingenOpslaanAsync() + { + Gelukt = null; + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.NalatenschapInstellingenAsync(AantalMeldingen, Bezwaardagen); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = "Je instellingen zijn opgeslagen."; + await LaadAsync(); + } + } + + private async Task MeldenAsync() + { + Gelukt = null; + if (OverledeneId == 0) { Fout = "Kies eerst iemand."; return; } + + var datum = Overlijdensdatum is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null; + + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.MeldOverlijdenAsync(OverledeneId, datum); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = "Je melding is genoteerd."; + Melden = false; + OverledeneId = 0; + Overlijdensdatum = null; + await LaadAsync(); + } + } + + private async Task IkBenErNogAsync() + { + Gelukt = null; + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.IkBenErNogAsync(); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Gelukt = "We hebben doorgegeven dat je er nog bent."; + await LaadAsync(); + } + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Profiel.razor b/src/MijnBesluit.AppUi/Schermen/Profiel.razor new file mode 100644 index 0000000..19f2a09 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Profiel.razor @@ -0,0 +1,472 @@ +@page "/profiel" +@inherits SchermBasis +@inject IBestandskiezer Bestandskiezer + +@* Je eigen gegevens. Dit is het enige profiel dat niet bij Mijn mensen staat - daar staat + iedereen behalve jij - dus zonder dit scherm kon je je eigen naam of adres nergens kwijt. + + Het e-mailadres staat bewust apart van de rest. Dat is je inlog: wie dat zomaar kan + verzetten, kan zichzelf buitensluiten of een ander account overnemen. Daarom gaat er eerst + een code naar het nieuwe adres. De kop staat in de balk erboven, dus hier niet nog een keer. *@ + +@* Net als overal elders: aantikken van je eigen kaart bij Mijn mensen opent dit als popup, + niet als eigen pagina met een knop onderaan. Die knop stond hier ooit vast onderin gezet + (.knop-vast-onder), maar "vast onderin" is precies wat een Popup toch al gratis geeft - zie + .popup-knoppen. De route /profiel blijft bestaan (de kop, Instellingen en het menu linken er + nog naar), maar toont nu meteen deze popup in plaats van een gewone pagina. *@ +@if (Mijn is null) +{ + @if (Fout is not null) + { +
@Fout
+ } +

Bezig met laden…

+} +else +{ + + + @if (Fout is not null) + { +
@Fout
+ } + + @if (Gelukt is not null) + { +
@Gelukt
+ } + +
+
+
+
+ +
+ + +
Niet verplicht.
+
+ +

+ Je bent hier sinds @Tonen.Datum(Mijn.LidSinds). +

+ + @* ---------------------------------------------------------------- meldingen *@ +
+

Meldingen

+

+ Als er een bericht voor je klaarstaat, laat je telefoon dat weten. Wat erin staat + komt er nooit bij te staan — alleen dát er iets is, en van wie. +

+ + @if (Proef is not null) + { +
@Proef.Uitleg
+ } + + +
+ Komt er niets, dan zegt deze knop waar het op vastloopt. +
+
+ +
+

E-mailadres

+

+ Hiermee log je in. Je huidige adres is @Mijn.Email. +

+ + +
+ +
+ +
+ + @* Onderaan, en met een rustige knop: dit is niets om per ongeluk aan te tikken. Dat het er + moet zijn is een eis van Google Play, maar het is ook gewoon eerlijk - wie een kluis + sluit, hoort erop te kunnen rekenen dat hij leeg is. *@ +
+

Je MijnBesluit opheffen

+

+ Je kluis, je bestanden en de berichten die nog klaarstaan worden verwijderd. Dat is + niet terug te draaien. +

+ + +
+
+} + +@* ---------------------------------------------------------------- account opheffen *@ +@if (Opheffen is not null) +{ + + + @if (!Opheffen.CodeGevraagd) + { +

Dit verdwijnt:

+
    +
  • @Opheffen.Telling.Kluisitems in je kluis
  • +
  • @Opheffen.Telling.Bestanden foto's en documenten
  • +
  • @Opheffen.Telling.BerichtenKlaar berichten die nog klaarstonden
  • +
  • je lijst met @Opheffen.Telling.Mensen mensen
  • +
+ +

Dit blijft:

+
    +
  • + @Opheffen.Telling.BerichtenBezorgd berichten die al bezorgd zijn - die zijn + van degene die ze heeft gekregen +
  • +
  • je naam in de lijst van mensen die jou hebben toegevoegd
  • +
+ + @if (Opheffen.Telling.Nalatenschappen > 0) + { +
+ Je bent aangewezen als nalatenschapsbeheerder bij + @Opheffen.Telling.Nalatenschappen @(Opheffen.Telling.Nalatenschappen == 1 ? "persoon" : "mensen"). + Die aanwijzing vervalt. Zeg het ze even, anders rekenen ze op iemand die er niet meer is. +
+ } + +

+ We sturen eerst een code naar je e-mailadres. Zo kan niemand die je telefoon in + handen krijgt je kluis leeggooien. +

+ } + else + { +

Er is een code gestuurd naar @Mijn?.Email.

+ +
+ + +
+ +
+ Hierna is het weg. Er is geen prullenbak en wij kunnen het niet terughalen. +
+ } +
+} + +@* ---------------------------------------------------------------- e-mailadres wijzigen *@ +@if (Adres is not null) +{ + + + @if (!Adres.CodeGevraagd) + { +
+ + +
+ We sturen er een code naartoe. Pas als je die invult verandert je adres, + dus een tikfout kan je hier niet buitensluiten. +
+
+ } + else + { +

Er is een code gestuurd naar @Adres.Nieuw.

+ +
+ + +
+ +

+ Zodra dit klaar is word je uitgelogd. Log daarna weer in met je nieuwe adres. +

+ } +
+} + +@code { + private EigenProfiel? Mijn; + + private string Voornaam = ""; + private string Achternaam = ""; + private DateTime? Geboortedatum; + + private string? Gelukt; + + /// Het adreswijzigingsformulier; null als het niet openstaat. + private Adreswissel? Adres; + + /// Het opheffen van het account; null als het niet openstaat. + private Opheffing? Opheffen; + + private IReadOnlyList Bijlagen = Array.Empty(); + + /// Tekst bij de voortgangsbalk tijdens een overdracht; null als er niets loopt. + private string? Overzetten; + private int Percentage; + + private enum Soort { Document, Foto, FotoMaken, Video, VideoOpnemen } + + /// De uitkomst van de laatste proefmelding; null als er nog niet is geprobeerd. + private Proefmelding? Proef; + + /// + /// Vraagt de server een proefmelding naar dit toestel te sturen. + /// + /// Dit is de knop waarmee je staand naast je telefoon kunt zien of de hele keten werkt: de + /// app heeft een kenmerk, de server kent het, en Google levert af. Blijft er een melding uit, + /// dan zegt het antwoord welke van die drie het is. + /// + private async Task ProefmeldingAsync() + { + Gelukt = null; + Proef = null; + + await ProbeerAsync(async () => + { + var r = await Toestand.Client.ProefmeldingAsync(); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + Proef = r.Waarde; + return Uitkomst.Ok(); + }); + } + + protected override async Task LaadAsync() + { + var profiel = await Toestand.Client.EigenProfielAsync(); + if (!profiel.Gelukt) { Fout = profiel.Melding; return; } + + Mijn = profiel.Waarde; + Voornaam = Mijn!.Voornaam; + Achternaam = Mijn.Achternaam; + Geboortedatum = Mijn.Geboortedatum?.ToDateTime(TimeOnly.MinValue); + + await BijlagenLadenAsync(); + } + + private async Task OpslaanAsync() + { + Gelukt = null; + + if (await ProbeerAsync(async () => + { + var geboren = Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null; + var r = await Toestand.Client.EigenProfielOpslaanAsync( + Voornaam.Trim(), Achternaam.Trim(), geboren); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + // Dit scherm is bereikt via de eigen tegel bovenaan Mijn mensen; wie op Opslaan + // drukt verwacht daar weer terug te komen, niet op ditzelfde formulier te blijven + // staan met alleen een tekstje erboven. + Navigatie.NavigateTo("/familie"); + } + } + + /// Annuleren, of de popup gewoon dichttikken: terug naar waar de tegel vandaan kwam. + private void Sluiten() => Navigatie.NavigateTo("/familie"); + + // ------------------------------------------------------------------ e-mailadres + + private void AdresWijzigen() + { + Gelukt = null; + Fout = null; + Adres = new Adreswissel(); + } + + /// Eén knop, twee stappen: eerst de code aanvragen, daarna hem bevestigen. + private async Task AdresStapAsync() + { + var wissel = Adres!; + + if (!wissel.CodeGevraagd) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.AdreswijzigingAanvragenAsync(wissel.Nieuw.Trim()); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + wissel.Nieuw = wissel.Nieuw.Trim(); + wissel.CodeGevraagd = true; + } + return; + } + + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.AdreswijzigingBevestigenAsync(wissel.Nieuw, wissel.Code.Trim()); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + // De sessie is aan de serverkant ongeldig gemaakt, dus hier ook opruimen: anders + // loopt het volgende scherm tegen "je sessie is verlopen" aan zonder uitleg. + Adres = null; + await Toestand.UitloggenAsync(); + Navigatie.NavigateTo("/inloggen"); + } + } + + // ------------------------------------------------------------------ foto's en documenten + + private async Task BijlagenLadenAsync() + { + if (Mijn is null) { Bijlagen = Array.Empty(); return; } + + var lijst = await Toestand.Client.PersoonsfotosAsync(Mijn.PersoonId); + Bijlagen = lijst.Gelukt ? lijst.Waarde! : Array.Empty(); + if (!lijst.Gelukt) Fout = lijst.Melding; + } + + private async Task ToevoegenAsync(Soort soort) + { + if (Mijn is null) return; + + Fout = null; + var gekozen = soort switch + { + Soort.Document => await Bestandskiezer.KiesDocumentAsync(), + Soort.Foto => await Bestandskiezer.KiesFotoAsync(), + Soort.FotoMaken => await Bestandskiezer.MaakFotoAsync(), + Soort.Video => await Bestandskiezer.KiesVideoAsync(), + _ => await Bestandskiezer.NeemVideoOpAsync() + }; + + if (gekozen is null) return; + + Overzetten = $"{gekozen.Naam} wordt opgeslagen…"; + Percentage = 0; + StateHasChanged(); + + var uitkomst = await Toestand.Client.BestandVersturenAsync( + gekozen.Naam, gekozen.Soort, gekozen.Inhoud, null, + (gedaan, totaal) => + { + Percentage = totaal == 0 ? 100 : (int)(gedaan * 100 / totaal); + InvokeAsync(StateHasChanged); + }, + persoonId: Mijn.PersoonId); + + Overzetten = null; + if (!uitkomst.Gelukt) Fout = uitkomst.Melding; + else await BijlagenLadenAsync(); + } + + private async Task BijlageWegAsync(Bijlage bijlage) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.BijlageVerwijderenAsync(bijlage.Id); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await BijlagenLadenAsync(); + } + } + + private async Task ProfielfotoAsync(Bijlage bijlage) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.ProfielfotoAsync(bijlage.Id); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await BijlagenLadenAsync(); + + // Het profiel nog een keer ophalen, want daar zit je kleine foto in - en die staat + // ook op andere schermen. Zonder dit zou je in de kluis je oude foto blijven zien + // tot de app opnieuw was gestart. + await Toestand.Client.EigenProfielAsync(); + } + } + + // ------------------------------------------------------------------ account opheffen + + /// + /// Haalt eerst op wát er weggaat. "Weet je het zeker?" is een lege vraag; "je kluis met 23 + /// items en 4 berichten die nog klaarstonden" is er een waar iemand over nadenkt. + /// + private async Task OpheffenBeginnenAsync() + { + await ProbeerAsync(async () => + { + var r = await Toestand.Client.OpheffenTellingAsync(); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + Opheffen = new Opheffing { Telling = r.Waarde! }; + return Uitkomst.Ok(); + }); + } + + private async Task OpheffenStapAsync() + { + if (Opheffen is null) return; + + if (!Opheffen.CodeGevraagd) + { + await ProbeerAsync(async () => + { + var r = await Toestand.Client.OpheffenCodeAanvragenAsync(); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + Opheffen.CodeGevraagd = true; + return Uitkomst.Ok(); + }); + return; + } + + await ProbeerAsync(async () => + { + var r = await Toestand.Client.OpheffenBevestigenAsync(Opheffen.Code.Trim()); + if (!r.Gelukt) return Uitkomst.Fout(r.Melding!); + + // Het account bestaat niet meer, dus er valt niets meer te tonen. De client heeft + // zijn bewaarde sessie al opgeruimd; wij sturen alleen het scherm nog naar de deur. + Opheffen = null; + Navigatie.NavigateTo("/inloggen"); + return Uitkomst.Ok(); + }); + } + + /// Het opheffen: eerst zien wat er weggaat, daarna de code die dat bevestigt. + private class Opheffing + { + public MijnBesluit.Client.Opheffing Telling { get; set; } = new(0, 0, 0, 0, 0, 0); + public bool CodeGevraagd { get; set; } + public string Code { get; set; } = ""; + } + + /// Het adreswijzigingsformulier: eerst een adres, daarna de code die daarheen ging. + private class Adreswissel + { + public string Nieuw { get; set; } = ""; + public string Code { get; set; } = ""; + public bool CodeGevraagd { get; set; } + } +} diff --git a/src/MijnBesluit.AppUi/Schermen/Start.razor b/src/MijnBesluit.AppUi/Schermen/Start.razor new file mode 100644 index 0000000..6413d80 --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Start.razor @@ -0,0 +1,300 @@ +@page "/" +@inherits SchermBasis +@inject IBuitenlink Buitenlink + +@* Het beginscherm. Geen kop: die staat in de balk erboven. + + Bovenaan wat er speelt - wie jouw nalatenschap beheert, wat er aankomt - en daaronder de + tegels. Staat er niets, dan beginnen de tegels bovenaan. *@ + +@if (Fout is not null) +{ +
+ @Fout +
+ +
+
+} + +@if (Toestand.LaatsteMelding is not null) +{ +
@Toestand.LaatsteMelding
+} + +@* Heeft iemand een profiel voor je aangemaakt? Dan staat die vraag hier, bovenaan, tot je hem + beantwoordt. Bewust hier en niet als popup bij het aanmelden: iemand kan je ook toevoegen + nadat jij je al had aangemeld, en een popup die je wegklikt is een vraag die je nooit meer + krijgt. Zie docs/Identiteit.md. *@ +@foreach (var aantekening in Aantekeningen) +{ +
+

Ben jij dit?

+

+ @aantekening.Van heeft een profiel voor je aangemaakt op jouw + e-mailadres@(aantekening.Relatie is { Length: > 0 } r ? $", als {r.ToLowerInvariant()}" : ""). +

+

+ Ben jij dat, dan voegen we het bij je eigen profiel: wat hij over je heeft ingevuld + komt bij jou te staan, en vanaf dat moment bepaal jij wat er staat. Ben jij het niet, + dan blijft alles zoals het is en vragen we het niet nog eens. +

+
+ + +
+
+} + +@if (Gegevens is { Regels.Count: > 0 }) +{ +
+ @foreach (var regel in Gegevens.Regels) + { +
+ @Teken(regel.Soort) +
+
@regel.Tekst
+ @if (regel.Datum is { } datum) + { +
@Tonen.Datum(datum)
+ } +
+
+ } +
+ + @if (Gegevens.Regels.Any(r => r.IsWaarschuwing)) + { + + } +} + +@* De advertentie staat hier en niet bovenaan: "wat er speelt" gaat voor. Iemand die net leest + dat er een overlijden op zijn naam is gemeld, hoort daar geen reclame boven te zien. Zie + docs/Adverteren.md. + + Een knop en geen link: hij hoort in de browser van de telefoon te openen en niet in de app. + Zonder dat zou MijnBesluit zelf een browser worden, en sta je op de site van een adverteerder + zonder weg terug naar je eigen kluis. *@ +@if (Reclame is not null) +{ + +} + +
+ + Mijn kluis @(Gegevens?.AantalKluis ?? 0) + + + Mijn mensen @(Gegevens?.AantalFamilie ?? 0) + + @* Twee richtingen, dus twee aantallen; die staan achter de titel in hetzelfde rondje als + de andere tegels, met hun naam erbij zodat je niet hoeft te raden welke welke is. Zo + past de tegel op één regel, even hoog als de andere drie. *@ + + + Momenten + + ↓@(Gegevens?.AantalVoorMij ?? 0) · ↑@(Gegevens?.AantalVanMij ?? 0) + + @if (Gegevens is { AantalNieuweBerichten: > 0 }) + { + @Gegevens.AantalNieuweBerichten + } + + + + Nalatenschap @(Gegevens?.AantalBeheerders ?? 0) + +
+ +@* Drie links onderaan. De eerste twee openen een popup: uitklappen ín het scherm kon ook, maar + dan schuift de rest weg terwijl je iets aan het lezen of instellen bent; in een popup blijf je + waar je was. + + De derde ook. De tekst staat als onderdeel op schijf (Privacytekst.razor) en niet alleen op de + website: een privacyverklaring die de app uit stuurt naar een browser is er een die niemand + leest. Dat onderdeel staat twee keer op schijf - de app en de site delen geen project - en een + zelfcontrole vergelijkt ze letterlijk. *@ +

+ @if (Gegevens is not null) + { + +
+ } + +
+ +
+ +

+ +@if (Vooruitblik) +{ + +

+ Op dit scherm zie je wat er de komende tijd voor je klaarstaat. Zet het op 0 als je + liever verrast wordt. +

+
+ + +
+
+} + +@* Dezelfde tekst als op mijnbesluit.nl/privacy - letterlijk hetzelfde bestand. *@ +@if (Privacy) +{ + + + +} + +@* Zelfde tekst als op de website - Versiehistorie.razor staat twee keer op schijf en een + zelfcontrole vergelijkt ze letterlijk. *@ +@if (VersiehistorieOpen) +{ + + + +} + +@* Dezelfde uitleg als op de website. *@ +@if (Uitleg) +{ + + + +} + +@code { + /// Staat de uitleg open? + private bool Uitleg; + + /// Staat de privacyverklaring open? + private bool Privacy; + + /// Staat de versiehistorie open? + private bool VersiehistorieOpen; + + /// Staat het schermpje voor de vooruitblik open? + private bool Vooruitblik; + + /// Profielen die anderen op mijn adres hebben aangemaakt; alleen ik kan zeggen of ik dat ben. + private IReadOnlyList Aantekeningen = Array.Empty(); + + private Overzicht? Gegevens; + private int Dagen = 30; + + /// De advertentie voor deze keer, of null als er geen is. + private Advertentie? Reclame; + + protected override async Task LaadAsync() + { + Fout = null; + + var overzicht = await Toestand.Client.OverzichtAsync(); + if (!overzicht.Gelukt) { Fout = overzicht.Melding; return; } + + Gegevens = overzicht.Waarde; + Dagen = Gegevens!.VooruitblikDagen; + + // Apart opgevraagd en niet meegestuurd met het overzicht: het opvragen telt de vertoning + // mee, en dat hoort alleen te gebeuren als de tegel ook echt in beeld komt. Gaat het mis, + // dan staat het overzicht er gewoon - reclame is het minst belangrijke op dit scherm. + var reclame = await Toestand.Client.AdvertentieAsync(); + Reclame = reclame.Gelukt ? reclame.Waarde : null; + + // Ook apart, en ook zonder de rest te laten vallen als het misgaat: dit is een vraag die + // best een keer later mag komen, en het overzicht hoort er hoe dan ook te staan. + var aantekeningen = await Toestand.Client.AantekeningenOverMijAsync(); + Aantekeningen = aantekeningen.Gelukt ? aantekeningen.Waarde! : Array.Empty(); + } + + /// "Ja, dat ben ik." Daarna staat alles wat die ander over je had bij jou. + private async Task OvernemenAsync(long persoonId) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.AantekeningOvernemenAsync(persoonId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + // De familie is veranderd; die staat in het geheugen van de app. + await Toestand.VerversAsync(); + await LaadAsync(); + } + } + + /// "Nee, dat ben ik niet." De vraag komt niet terug. + private async Task AfwijzenAsync(long persoonId) + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.AantekeningAfwijzenAsync(persoonId); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + Aantekeningen = Aantekeningen.Where(a => a.PersoonId != persoonId).ToList(); + } + } + + private async Task VooruitblikOpslaanAsync() + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.VooruitblikAsync(Dagen); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await LaadAsync(); + } + } + + private async Task IkBenErNogAsync() + { + if (await ProbeerAsync(async () => + { + var r = await Toestand.Client.IkBenErNogAsync(); + return r.Gelukt ? Uitkomst.Ok() : Uitkomst.Fout(r.Melding!); + })) + { + await LaadAsync(); + } + } + + private static string Teken(string soort) => soort switch + { + "beheerder" => "\U0001F33F", // blad: iemand beheert jouw nalatenschap + "beheer-voor" => "\U0001F91D", // handdruk: jij beheert die van een ander + "moment" => "✉", // envelop + "overlijden" => "⚠", // waarschuwing + _ => "•" + }; +} diff --git a/src/MijnBesluit.AppUi/Schermen/Uitloggen.razor b/src/MijnBesluit.AppUi/Schermen/Uitloggen.razor new file mode 100644 index 0000000..16a627c --- /dev/null +++ b/src/MijnBesluit.AppUi/Schermen/Uitloggen.razor @@ -0,0 +1,15 @@ +@page "/uitloggen" +@inject AppToestand Toestand +@inject NavigationManager Navigatie + +
+

Bezig met uitloggen…

+
+ +@code { + protected override async Task OnInitializedAsync() + { + await Toestand.UitloggenAsync(); + Navigatie.NavigateTo("/inloggen"); + } +} diff --git a/src/MijnBesluit.AppUi/Terugknop.cs b/src/MijnBesluit.AppUi/Terugknop.cs new file mode 100644 index 0000000..ed9e191 --- /dev/null +++ b/src/MijnBesluit.AppUi/Terugknop.cs @@ -0,0 +1,70 @@ +namespace MijnBesluit.AppUi; + +/// +/// De terugknop van het toestel. Android heeft er een, en zonder hulp sluit die de hele app — +/// ook als er net een popup openstaat of je drie schermen diep zit. Dat is precies wat je niet +/// wilt bij een app waar je gegevens in zit te typen. +/// +/// Wie iets open heeft staan meldt zich hier aan, en meldt zich weer af zodra het dicht is. De +/// laatste aanmelding is als eerste aan de beurt: dat is dezelfde volgorde waarin de dingen over +/// elkaar heen liggen — een foto in een popup, in een wijzigscherm, in een scherm. +/// +/// Zegt een afhandelaar nee, dan gaat de knop door naar wie eronder ligt. Zegt niemand ja, dan +/// mag het toestel zijn gang gaan en gaat de app alsnog dicht. +/// +/// De schermen zelf hoeven hier niets van te weten: AppSchil regelt "terug naar het +/// overzicht" en Popup regelt "dit venster dicht". Zo werkt de terugknop overal, ook in +/// schermen die later pas worden gemaakt. +/// +public class Terugknop +{ + private readonly List> _stapel = new(); + + /// + /// Meldt een afhandelaar aan. Geef het teruggegeven voorwerp weer vrij zodra het scherm of + /// het venster dicht gaat; dat haalt hem van de stapel af. + /// + /// + /// Geeft true als hij de druk op de knop heeft afgehandeld, en false om hem door te geven. + /// Wordt aangeroepen vanaf de draad van het toestel, dus werk aan het scherm hoort daarbinnen + /// via InvokeAsync te lopen. + /// + public IDisposable Aanmelden(Func afhandelaar) + { + lock (_stapel) _stapel.Add(afhandelaar); + return new Afmelding(this, afhandelaar); + } + + /// + /// Er is op de terugknop gedrukt. True betekent: afgehandeld, de app blijft open. + /// + public bool Ingedrukt() + { + Func[] stapel; + lock (_stapel) stapel = _stapel.ToArray(); + + for (var i = stapel.Length - 1; i >= 0; i--) + { + if (stapel[i]()) return true; + } + + return false; + } + + private sealed class Afmelding : IDisposable + { + private readonly Terugknop _knop; + private readonly Func _afhandelaar; + + public Afmelding(Terugknop knop, Func afhandelaar) + { + _knop = knop; + _afhandelaar = afhandelaar; + } + + public void Dispose() + { + lock (_knop._stapel) _knop._stapel.Remove(_afhandelaar); + } + } +} diff --git a/src/MijnBesluit.AppUi/Toestelslot.cs b/src/MijnBesluit.AppUi/Toestelslot.cs new file mode 100644 index 0000000..f96d8af --- /dev/null +++ b/src/MijnBesluit.AppUi/Toestelslot.cs @@ -0,0 +1,39 @@ +namespace MijnBesluit.AppUi; + +/// +/// Het slot van het toestel zelf: vingerafdruk, gezicht, of de pincode of het patroon waarmee +/// je je telefoon ontgrendelt. +/// +/// De app ziet die vingerafdruk of code nooit. Hij vraagt het toestel om iemand te controleren +/// en krijgt alleen ja of nee terug; het echte werk gebeurt in een deel van de telefoon waar +/// geen enkele app bij kan. Er valt hier dus ook niets te bewaren of te lekken. +/// +/// Net als de bestandskiezer staat dit als afspraak in de schermen en niet als code: een +/// webview kan hier niet bij, dat moet de app eromheen doen. Zonder implementatie is +/// onwaar en gedraagt de app zich als vroeger - dan logt hij uit in +/// plaats van op slot te gaan. +/// +public interface IToestelslot +{ + /// + /// Kan dit toestel iemand controleren? Onwaar als de telefoon geen schermslot heeft, of + /// als de Android-versie te oud is. Dan heeft vergrendelen geen zin: iedereen die de + /// telefoon in handen heeft komt er toch in. + /// + bool Beschikbaar { get; } + + /// + /// Laat het toestel om een vingerafdruk of code vragen. Waar als het gelukt is, onwaar bij + /// afbreken of mislukken. Gooit niets: een slot dat stukgaat hoort de app niet te laten + /// vallen, het hoort dicht te blijven. + /// + Task OntgrendelAsync(string titel, string uitleg); +} + +/// Wordt gebruikt als er geen toestelslot is. Zegt netjes nee. +public sealed class Geenslot : IToestelslot +{ + public bool Beschikbaar => false; + + public Task OntgrendelAsync(string titel, string uitleg) => Task.FromResult(false); +} diff --git a/src/MijnBesluit.AppUi/Uitlogklok.cs b/src/MijnBesluit.AppUi/Uitlogklok.cs new file mode 100644 index 0000000..19fa208 --- /dev/null +++ b/src/MijnBesluit.AppUi/Uitlogklok.cs @@ -0,0 +1,147 @@ +namespace MijnBesluit.AppUi; + +/// +/// Waar de app instellingen bewaart die alleen op dit toestel gelden. +/// +/// Dit staat als afspraak in de schermen en niet als code, net als de bestandskiezer: een +/// webview kan niet bij de opslag van het toestel, dat moet de app eromheen doen. Zonder +/// implementatie werkt alles gewoon, alleen onthoudt het toestel niets tussen twee starts. +/// +public interface IAppinstellingen +{ + int Lees(string sleutel, int standaard); + void Schrijf(string sleutel, int waarde); +} + +/// Wordt gebruikt als er geen toestelopslag is; onthoudt alleen binnen deze start. +public sealed class Losseinstellingen : IAppinstellingen +{ + private readonly Dictionary _waarden = new(); + + public int Lees(string sleutel, int standaard) => + _waarden.TryGetValue(sleutel, out var waarde) ? waarde : standaard; + + public void Schrijf(string sleutel, int waarde) => _waarden[sleutel] = waarde; +} + +/// +/// Logt de app uit als er een tijd niets is gebeurd. +/// +/// Waarom dit er is: de app blijft aangemeld, en dat is prettig — je hoeft niet elke keer een +/// code uit je mail te halen. Maar op een telefoon die op tafel ligt of kwijtraakt, staat de +/// kluis dan open. Wie dat niet wil, zet hier een termijn. +/// +/// Wat er gebeurt als de tijd om is, hangt af van het toestel. Kan de telefoon om een +/// vingerafdruk of zijn eigen code vragen, dan gaat de app op slot: je kluis is dicht, maar je +/// blijft aangemeld en bent er met één aanraking weer in. Kan dat niet, dan logt de app écht +/// uit - het sessietoken gaat van het toestel af en je moet een nieuwe code per e-mail +/// aanvragen. +/// +/// Daarom hangt ook de standaardwaarde van het toestel af, en staat die niet hier vast. Op een +/// telefoon met een schermslot staat de klok standaard aan: dat kost je bijna niets en zet je +/// kluis dicht zodra je de telefoon wegleg. Zonder schermslot staat hij standaard uit, want +/// dan is de prijs van vergeten een e-mail met een code, en dat is te zwaar om iemand +/// ongevraagd op te leggen. +/// +/// De tijd loopt op de wandklok en niet op een teller, zodat een telefoon die een uur in een +/// broekzak zit net zo goed meetelt als een uur waarin je ernaar kijkt. +/// +public class Uitlogklok +{ + /// Onder deze naam staat de termijn in de opslag van het toestel. + public const string Sleutel = "uitlogminuten"; + + /// Wat je kunt kiezen. Nul is uit. + public static readonly int[] Keuzes = { 0, 1, 2, 5, 10, 15, 30, 60 }; + + /// + /// Wat je kunt kiezen op een toestel dat om een vingerafdruk of code kan vragen: hetzelfde, + /// maar zonder "uit". + /// + /// Waarom die keuze daar verdwijnt: op zo'n toestel kost het slot je één aanraking. "Uit" + /// zetten levert dus bijna niets op en betekent wel dat een gevonden telefoon een open kluis + /// is. Zonder toestelslot ligt dat anders - daar betekent de termijn écht uitloggen, en dan + /// is de prijs van vergeten een e-mail met een code. Die keuze mag iemand daar wél zelf + /// maken. + /// + public static readonly int[] KeuzesMetSlot = { 1, 2, 5, 10, 15, 30, 60 }; + + private readonly IAppinstellingen _opslag; + private readonly int _standaard; + private readonly bool _slotBeschikbaar; + private DateTime _laatsteActie = DateTime.UtcNow; + + /// + /// Wat er geldt zolang niemand iets heeft gekozen. Wie zelf "uit" kiest, houdt uit: die nul + /// staat dan in de opslag en is iets anders dan niets. + /// + /// + /// Kan dit toestel om een vingerafdruk of zijn eigen code vragen? Dan geldt er een + /// ondergrens: nul is hier geen geldige stand. Zie . + /// + public Uitlogklok(IAppinstellingen? opslag = null, int standaard = 0, bool slotBeschikbaar = false) + { + _opslag = opslag ?? new Losseinstellingen(); + _standaard = standaard; + _slotBeschikbaar = slotBeschikbaar; + } + + /// + /// Na hoeveel minuten stilte de app op slot gaat of uitlogt. Nul betekent: niet. + /// + /// Op een toestel dat om een vingerafdruk kan vragen kán hier geen nul uitkomen. Het slot + /// kost daar één aanraking; uit zetten levert dus bijna niets op en betekent wel dat een + /// gevonden telefoon een open kluis is. Er kan toch een nul in de opslag staan - van vroeger, + /// of van een toestel waar het schermslot pas later is aangezet - en die tillen we hier op + /// in plaats van in het instellingenscherm, zodat ook de klok zelf en de schil ervan uitgaan. + /// + public int Minuten + { + get + { + var opgeslagen = _opslag.Lees(Sleutel, _standaard); + return _slotBeschikbaar && opgeslagen <= 0 ? StandaardMetSlot : opgeslagen; + } + set + { + _opslag.Schrijf(Sleutel, value); + Aangeraakt(); // een net gewijzigde termijn hoort niet meteen af te lopen + } + } + + public bool StaatAan => Minuten > 0; + + /// + /// De app is teruggekomen uit de achtergrond en er hoort nu gekeken te worden. + /// + /// Dit is het geval waar het echt om gaat: een telefoon die een uur in een tas zit. De lus + /// in de schil draait dan niet, en zonder dit sein zou iemand na het openen nog een halve + /// minuut in zijn kluis kunnen kijken voordat de app doorheeft dat de tijd om is. + /// + public event Action? Hervat; + + public void Hervatten() => Hervat?.Invoke(); + + /// Er is iets gebeurd: de teller begint opnieuw. + public void Aangeraakt() => _laatsteActie = DateTime.UtcNow; + + /// Hoe lang het al stil is. + public TimeSpan Stil(DateTime nu) => nu - _laatsteActie; + + /// Is de termijn voorbij? Staat de klok uit, dan nooit. + public bool IsVerlopen(DateTime nu) => + StaatAan && Stil(nu) >= TimeSpan.FromMinutes(Minuten); + + /// Wat er standaard geldt op een toestel dat om een vingerafdruk of code kan vragen. + public const int StandaardMetSlot = 15; + + + /// Hoe de termijn op het scherm heet. + public static string Naam(int minuten) => minuten switch + { + 0 => "Uit — blijf open staan", + 1 => "Na 1 minuut", + 60 => "Na een uur", + _ => $"Na {minuten} minuten" + }; +} diff --git a/src/MijnBesluit.AppUi/VernieuwSignaal.cs b/src/MijnBesluit.AppUi/VernieuwSignaal.cs new file mode 100644 index 0000000..6a794cf --- /dev/null +++ b/src/MijnBesluit.AppUi/VernieuwSignaal.cs @@ -0,0 +1,19 @@ +namespace MijnBesluit.AppUi; + +/// +/// Het "trek naar beneden om te verversen"-gebaar zit in de schil (AppSchil), die om het +/// hele scherm heen staat en niets weet van wat het actieve scherm daaronder eigenlijk is of hoe +/// het zijn gegevens ophaalt. Dit is de verbinding daartussen: de schil meldt hier dat er is +/// getrokken, en het scherm dat op dat moment open staat - en dus als enige luistert - haalt +/// zijn eigen gegevens opnieuw op. Zie . +/// +public class VernieuwSignaal +{ + public event Func? Gevraagd; + + /// Wordt aangeroepen door de schil zodra er ver genoeg naar beneden is getrokken. + public async Task VraagAanAsync() + { + if (Gevraagd is { } luisteraar) await luisteraar(); + } +} diff --git a/src/MijnBesluit.AppUi/_Imports.razor b/src/MijnBesluit.AppUi/_Imports.razor new file mode 100644 index 0000000..c666be4 --- /dev/null +++ b/src/MijnBesluit.AppUi/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using MijnBesluit.AppUi +@using MijnBesluit.AppUi.Layout +@using MijnBesluit.AppUi.Onderdelen +@using MijnBesluit.AppUi.Schermen +@using MijnBesluit.Client diff --git a/src/MijnBesluit.AppUi/wwwroot/app.css b/src/MijnBesluit.AppUi/wwwroot/app.css new file mode 100644 index 0000000..89878c5 --- /dev/null +++ b/src/MijnBesluit.AppUi/wwwroot/app.css @@ -0,0 +1,774 @@ +/* MijnBesluit - rustig, warm en groot genoeg om makkelijk te bedienen. + Het onderwerp is zwaar; het scherm hoeft dat niet ook te zijn. */ + +:root { + --achtergrond: #faf7f2; + --vlak: #ffffff; + --vlak-zacht: #f3efe8; + --inkt: #2a2724; + --inkt-zacht: #6b645c; + /* Huisstijl: het groen uit het logo van mijnbesluit.nl. */ + --merk: #C1D13F; + --merk-donker: #AFC02F; + /* Op dat groen hoort donkere tekst; wit is er nauwelijks op te lezen. */ + --merk-tekst: #2a2724; + /* Donkerder groen voor tekst en randen, want het logogroen is te licht om op te lezen. */ + --accent: #6B7A1C; + --accent-donker: #556115; + --accent-zacht: #F1F5DC; + --rand: #e6e0d7; + --waarschuwing: #8a5a22; + --waarschuwing-zacht: #fbf1e3; + --fout: #9b3b32; + --straal: 14px; + --schaduw: 0 1px 2px rgba(42, 39, 36, .05), 0 6px 20px rgba(42, 39, 36, .06); + /* De hoogte van de bovenbalk op de site. Staat hier omdat de navigatie er precies + onder moet blijven plakken; twee losse getallen lopen vroeg of laat uit elkaar. + Hoog genoeg voor de uitlogknop, die zelf 36px is. */ + --kop-hoogte: 66px; +} + +@media (prefers-color-scheme: dark) { + :root { + --achtergrond: #1b1917; + --vlak: #242120; + --vlak-zacht: #2c2927; + --inkt: #f0ece6; + --inkt-zacht: #a8a099; + --merk: #C1D13F; + --merk-donker: #CEDD5B; + --merk-tekst: #1f1d1a; + --accent: #C1D13F; + --accent-donker: #D2E062; + --accent-zacht: #2b3017; + --rand: #38342f; + --waarschuwing: #d9a55f; + --waarschuwing-zacht: #33291a; + --fout: #e08e84; + --schaduw: 0 1px 2px rgba(0, 0, 0, .3), 0 6px 20px rgba(0, 0, 0, .25); + } +} + +* { box-sizing: border-box; } + +html { font-size: 17px; } + +body { + margin: 0; + background: var(--achtergrond); + color: var(--inkt); + font-family: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { line-height: 1.25; font-weight: 600; letter-spacing: -.01em; margin: 0 0 .4em; } +h1 { font-size: 1.9rem; } +h2 { font-size: 1.35rem; } +h3 { font-size: 1.1rem; } +p { margin: 0 0 1em; } +a { color: var(--accent-donker); } + +/* ------------------------------------------------------------------ opbouw */ +.schil { display: flex; flex-direction: column; min-height: 100vh; } + +.kop { + display: flex; align-items: center; gap: 1rem; + padding: .9rem 1.25rem; + background: var(--vlak); + border-bottom: 1px solid var(--rand); + position: sticky; top: 0; z-index: 20; +} + +/* Op de site een vaste hoogte, zodat de navigatie er precies onder kan blijven staan. + In de app niet: daar zit een terugknop in de balk die zijn eigen ruimte nodig heeft. */ +.schil:not(.app) .kop { box-sizing: border-box; height: var(--kop-hoogte); } + +/* In de app staan de balken echt vast: de inhoud schuift eronderdoor. Sticky is niet genoeg + zodra er binnen een scherm wordt gescrold. */ +.schil.app { min-height: 100dvh; } + +/* De bovenbalk staat vast op top:0, en dat is onder de statusbalk van de telefoon - de klok, + het bereik en het accupercentage. Door viewport-fit=cover loopt de webview daar tot onderaan + doorheen, dus moet de balk zelf die ruimte vrijhouden. De inhoud deed dat al wel; de balk + niet, en daardoor liep "Mijn mensen" en "Uitloggen" door de tijd en het batterijsymbool heen. */ +.schil.app .kop.vast { + position: fixed; top: 0; left: 0; right: 0; + padding-top: calc(.9rem + env(safe-area-inset-top)); +} + +.schil.app .nav.vast { position: fixed; bottom: 0; left: 0; right: 0; } +.schil.app .inhoud { + /* Ruimte voor de twee balken, plus wat de telefoon zelf bovenaan en onderaan inneemt. + + De balk is gemeten 60 pixels hoog (een merkteken van 26 met 0,9rem eromheen, plus een + randje). Met 4,2rem = 71 blijft er elf pixels lucht over: genoeg om niet tegen de balk te + plakken, en niet de gapende ruimte die er eerst was - dat waren er zeventien. Gaat de balk + ooit hoger worden, dan hoort dit getal mee te verhuizen. */ + padding-top: calc(4.2rem + env(safe-area-inset-top)); + padding-bottom: calc(5.5rem + env(safe-area-inset-bottom)); +} + +/* Het "trek naar beneden om te verversen"-vaantje (zie validatie.js). Hoogte en doorzichtigheid + komen uit JS mee met de vinger; hier staat alleen hoe het er dan uitziet. De transition is + voor het terugveren als iemand loslaat vóór de drempel, of nadat het verversen klaar is - bij + het trekken zelf zou een transition juist achter de vinger aan hobbelen. + + validatie.js zet hem als buur vlak vóór #app-inhoud, niet erbinnen - anders zou Blazor hem bij + de eerstvolgende tekening weer weghalen. Die plek ligt in de kolom "schil.app", naast de vaste + kopbalk (position: fixed neemt geen ruimte in de kolom in) - zonder iets erbij stond hij dus + altijd achter die balk verstopt. Een marge om hem eronder te krijgen loste dat wel op, maar + telde in de flex-kolom gewoon mee als ruimte (marges van flex-items vouwen niet samen), en dat + bovenop de ruimte die .schil.app .inhoud zelf al voor de balk vrijhoudt gaf een dubbele, + veel te grote lege strook boven elk scherm. In plaats daarvan staat hij hier los van de + kolom-layout: absoluut gepositioneerd, op dezelfde hoogte als waar .inhoud begint, zodat hij + groeiend over die (toch al lege) ruimte heen verschijnt zonder er zelf plek voor te claimen. */ +.schil.app { position: relative; } +.schil.app .ptr-indicator { + /* De top komt uit validatie.js: de kopbalk is niet overal even hoog (een scherm met + terugknop heeft een extra rij), dus die hoogte wordt bij elke aanraking gemeten in plaats + van hier als vaste waarde te staan - anders liep de tekst op zo'n scherm door de balk heen. */ + position: absolute; left: 0; right: 0; z-index: 4; +} + +.ptr-indicator { + /* Ondoorzichtige achtergrond, en geen opacity-vervaging: het vaantje groeit bovenop de inhoud + (zie de uitleg bij .schil.app .ptr-indicator hierboven) in plaats van eronder te schuiven, + dus zonder een volledig dekkende achtergrond op elk moment - ook halverwege het trekken - + schemerde wat eronder stond, zoals de knop "Toevoegen persoon" bij Mijn mensen, dwars door + de tekst heen. De hoogte alleen al onthult het vaantje netjes, een opacity-overgang erbij + hoefde niet. */ + height: 0; overflow: hidden; + display: flex; align-items: center; justify-content: center; gap: .5rem; + font-size: 1.05rem; color: var(--inkt-zacht); text-align: center; + background: var(--achtergrond); + transition: height .2s ease; +} +.ptr-icoon { + display: inline-block; font-size: 1.3rem; line-height: 1; + transition: transform .2s ease; +} +.ptr-icoon.omgekeerd { transform: rotate(180deg); } +.ptr-icoon.bezig { animation: ptr-draaien .8s linear infinite; } +@keyframes ptr-draaien { to { transform: rotate(360deg); } } + +/* De waarschuwing bij het afsluiten: kort zichtbaar, net boven de onderbalk, en niet aan te + raken - hij is een mededeling, geen knop. */ +.afsluithint { + position: fixed; + left: 50%; transform: translateX(-50%); + bottom: calc(5.2rem + env(safe-area-inset-bottom)); + z-index: 40; + /* Zonder breedte kroop hij samen tot een smalle koker van vier regels hoog. max-content laat + hem zo breed worden als de zin is, en pas daarna afbreken als het scherm te smal is. */ + width: max-content; + max-width: calc(100% - 2rem); + padding: .55rem 1rem; + border-radius: 1rem; + background: var(--inkt); + color: var(--vlak); + font-size: .9rem; + text-align: center; + box-shadow: var(--schaduw); + pointer-events: none; +} + +/* De terugknop in de bovenbalk. */ +.terug { + border: none; background: transparent; cursor: pointer; + font-size: 1.8rem; line-height: 1; color: var(--inkt-zacht); + padding: 0 .2rem 0 0; margin: 0; +} + +.kop .merk { + font-weight: 600; font-size: 1.05rem; color: var(--inkt); + text-decoration: none; display: flex; align-items: center; gap: .55rem; + + /* De schermnaam hoort op één regel te staan. Valt hij over twee, dan wordt de balk hoger dan + op de andere schermen en springt de hele inhoud een stukje omlaag. Past hij echt niet, dan + liever afgekapt dan een balk die per scherm van hoogte verschilt. */ + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; +} + +/* Het merkteken in de balk. Klein, want de balk hoort op elk scherm even hoog te zijn en op de + andere schermen staat hier de schermnaam. Op deze maat is het woordmerk niet te lezen; dat + hoeft ook niet, want de naam staat er in letters naast. Groot en leesbaar staat het logo op + het inlogscherm. */ +.kop .merk .stip { width: 26px; height: 26px; flex: none; display: block; } + +.kop .rechts { margin-left: auto; display: flex; align-items: center; gap: .9rem; font-size: .92rem; } + +/* Het tekentje voor "geen verbinding". Het woord stond hier eerst voluit, en dat duwde de + schermnaam op een telefoon van 360 pixels tot zeven pixels in elkaar - of, zonder nowrap, viel + het over twee regels en werd de balk 81 pixels in plaats van 62. Iets wat komt en gaat hoort de + balk niet overhoop te gooien. De uitleg staat in title en aria-label. */ +.kop .geenverbinding { color: var(--waarschuwing); font-size: 1.05rem; flex: none; line-height: 1; } + +.inhoud { flex: 1; width: 100%; max-width: 900px; margin: 0 auto; padding: 1.6rem 1.25rem 5.5rem; } + +/* Navigatie: onderaan op de telefoon, bovenaan op een groot scherm. */ +.nav { + position: fixed; bottom: 0; left: 0; right: 0; z-index: 20; + display: flex; background: var(--vlak); + border-top: 1px solid var(--rand); + padding: .35rem .25rem calc(.35rem + env(safe-area-inset-bottom)); +} + +.nav a { + flex: 1; text-align: center; text-decoration: none; + color: var(--inkt-zacht); font-size: .72rem; + padding: .45rem .2rem; border-radius: 10px; + display: flex; flex-direction: column; align-items: center; gap: .2rem; +} + +.nav a .teken { font-size: 1.2rem; line-height: 1; } +.nav a.actief { color: var(--accent-donker); background: var(--accent-zacht); } + +@media (min-width: 720px) { + html { font-size: 16px; } + .nav { + /* Vastzetten net onder de bovenbalk: samen vormen ze een balk die blijft staan. */ + position: sticky; top: var(--kop-hoogte); z-index: 19; + border-top: none; border-bottom: 1px solid var(--rand); + justify-content: center; gap: .3rem; padding: .3rem 1rem; + } + .nav a { flex: none; flex-direction: row; font-size: .92rem; padding: .5rem .9rem; } + .nav a .teken { font-size: 1rem; } + .inhoud { padding-bottom: 3rem; } +} + +/* ------------------------------------------------------------------ blokken */ +.kaart { + background: var(--vlak); + border: 1px solid var(--rand); + border-radius: var(--straal); + padding: 1.25rem; + box-shadow: var(--schaduw); + margin-bottom: 1rem; +} + +/* Een compacte kaart: voor lijsten waar je er veel van onder elkaar ziet. */ +.kaart.compact { padding: .9rem 1rem; } +.kaart.klikbaar { display: block; text-decoration: none; color: inherit; } +.kaart.klikbaar:hover { border-color: var(--accent); } + +/* Je eigen kaart bovenaan Mijn mensen. Hij hoort er wel bij te horen en er toch net uit te + springen: een randje in de accentkleur, want dit is de enige kaart die ergens heen gaat. */ +.kaart.zelf { border-color: var(--accent); } + +/* De knoppen rechtsonder op een tegel: kleine knoppen naast elkaar, met minder ruimte ertussen + dan een gewone rij. Hier gemeten: met "Wijzigen" en "Terugtrekken" voluit hield het onderwerp + op een telefoon van 360 pixels nog 54 pixels over - "Ee...". Als tekentjes is dat 231, de + volle breedte. Daarom staan er hier iconen en geen woorden. */ +.tegelknoppen { display: flex; gap: .35rem; flex: none; } + +/* Een vaste breedte in plaats van "zo breed als het tekentje". Dat laatste hing af van het + lettertype waarmee het toestel emoji tekent, en dat verschilt per telefoon: dan staan de + knoppen op de ene telefoon net iets anders dan op de andere. Nu is een knop altijd even + breed, wat er ook in staat. */ +.tegelknoppen .knop { width: 2.6rem; padding-left: 0; padding-right: 0; } + +/* De knoppen onder "Foto's en documenten": Foto maken, Foto kiezen, Video opnemen, Video + kiezen, Document - vijf stuks op zijn breedst. Voluit met tekst erbij liepen ze op een + telefoon van 360 pixels op twee, soms drie regels uit; dat kostte veel ruimte voor iets wat + je maar af en toe gebruikt. Nu alleen het tekentje, net als bij .tegelknoppen, en altijd op + één regel - flex-wrap staat hier bewust uit, in tegenstelling tot een gewone .rij die wél + omslaat. Wat elk tekentje betekent staat in title (bij een muis) en aria-label (voor een + schermlezer), want zonder tekst valt er verder niets te lezen. */ +.bijlage-acties { display: flex; gap: .35rem; flex-wrap: nowrap; overflow-x: auto; } +.bijlage-acties .knop { + flex: 1 1 0; min-width: 0; width: auto; + padding-left: 0; padding-right: 0; font-size: 1.15rem; +} + +/* Geen knop maar een wijzertje: het driehoekje dat laat zien dat de tegel uitklapt. Openen doe + je door de tegel zelf aan te tikken, dus hier hoort geen tweede knop te staan die hetzelfde + doet. De regelhoogte is die van een knop, zodat de onderste regel even hoog blijft als op een + tegel waar wel een knop staat. */ +.tegelteken { + width: 1.8rem; flex: none; text-align: center; + color: var(--inkt-zacht); font-size: .9rem; line-height: 40px; +} + +/* Een tegel die je aantikt. Bij een regelt de browser dit zelf; dit is voor de tegels die + geen andere pagina openen maar iets uitklappen of een scherm oproepen. */ +.kaart.klikbaar { cursor: pointer; } + +/* Twee tegels naast elkaar waarmee je kiest wat je eronder ziet: Van mij / Voor mij bij + Momenten. Geen tabbladen met een streepje eronder, maar dezelfde kaart als in de lijst + eronder - zodat je ziet dat het bij elkaar hoort. + + Ze delen de breedte gelijk (flex: 1 1 0 met een basis van nul), want anders wordt de tegel + met "12 · 3 nieuw" erin breder dan die met "0", en dan springt de rij van plek zodra er een + bericht bij komt. + + margin-bottom: 0 omdat .kaart die van zichzelf heeft; in een rij levert dat een gat op + waar niemand om vroeg. */ +.keuzetegels { gap: .7rem; flex-wrap: nowrap; } + +.keuzetegel { + flex: 1 1 0; + min-width: 0; + margin-bottom: 0; + display: flex; flex-direction: column; align-items: flex-start; gap: .15rem; + font: inherit; text-align: left; cursor: pointer; +} + +.keuzetegel > .groot { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* De tegel die openstaat. Vlak en randje in de accentkleur: hetzelfde gebaar als de gekozen + knop onderin de navigatiebalk, zodat "dit kijk je nu naar" er overal eender uitziet. */ +.keuzetegel.aan { border-color: var(--accent); background: var(--accent-zacht); } + +.keuzetegel:hover { border-color: var(--accent); } + +/* Een bericht dat nog niet gelezen is. Het randje in de accentkleur vervangt het labeltje + "Nieuw" dat eerst naast de knop stond: dat kostte breedte die het onderwerp harder nodig had. */ +.kaart.nieuw { border-color: var(--accent); } + +/* Een tegel in een lijst: links de foto als die er is, en ernaast drie regels tekst. Op de + onderste regel staan rechts de knoppen, en op de regel daarboven kan rechts een labeltje + staan. Zo staan die twee op elke tegel op dezelfde plek, ook als de onderste regel leeg is. + + De knoppen lagen hier eerder overheen geschoven, zodat de tegel niet hoger werd dan zijn + tekst. Dat ging goed tot er ook rechts op de tweede regel iets moest staan: een knop is 40 + pixels hoog en dat is anderhalve tekstregel, dus hij kwam altijd over dat labeltje heen. Nu + staan de knoppen gewoon naast de tekst op de onderste regel. De tegel wordt daar een pixel + of vijftien hoger van, en er kan niets meer over elkaar heen lopen. + + De bovenste regel - het onderwerp, de naam, de titel - loopt door tot de rand. Wat niet past + wordt afgekapt in plaats van afgebroken; een tegel van vijf regels hoog zet de rest van de + lijst buiten beeld. */ +.kaart.tegel > .rij { flex-wrap: nowrap; } +.kaart.tegel > .rij > .groei > .groot, +.kaart.tegel > .rij > .groei > .zacht { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Breekt af naar een volgende regel zodra het niet meer past. Zonder flex-wrap lopen drie + knoppen naast elkaar het scherm van een telefoon uit, en is de laatste niet te bereiken. */ +.rij { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; } +.rij .groei { flex: 1; min-width: 0; } + +/* Een kop in zo'n rij mag krimpen, maar zijn tekst hoort dan af te breken in plaats van over + de knop ernaast heen te lopen. Dat gebeurde op de telefoon bij Momenten en Mijn kluis. */ +.rij > h1, .rij > h2, .rij > h3 { overflow-wrap: anywhere; } + +/* De rij met een persoon erin: penning, naam, en rechts de status. + + Deze rij breekt bewust NIET af. De status hoort altijd op dezelfde plek te staan - rechtsboven + op de tegel - zodat je hem in een lijst met één blik afleest. Zakt hij bij de een wel en bij + de ander niet naar een eigen regel, dan moet je elke kaart apart bekijken. + + De naam krijgt de ruimte die overblijft en wordt afgekapt als het niet past. Dat is de goede + afweging: je herkent iemand aan het begin van zijn naam, en de status is een vaste ankerplek. */ +.rij.persoon { flex-wrap: nowrap; } +.rij.persoon > .groei { min-width: 0; } + +/* De regels onder de naam op een persoonsrij: naam van de ander, datum. Net als de naam zelf + worden ze afgekapt in plaats van afgebroken. Zonder dit groeide een tegel bij Momenten naar + vijf regels zodra er een lange bezorgtekst onder stond, en dan staat de rest van de lijst + buiten beeld. */ +.rij.persoon > .groei > .zacht { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Een regel met tekst links en iets vasts rechts: "Partner (59)" met de status ernaast, of de + onderste regel met de knoppen erachter. De tekst krimpt en wordt afgekapt, wat rechts staat + houdt zijn breedte. + + Dit is wat de tegels op elkaar laat lijken: het labeltje staat op elke tegel op dezelfde + plek, en de knoppen eronder ook - ook als er op die onderste regel verder niets staat. */ +.onderregel { display: flex; align-items: center; gap: .6rem; } + +/* Twee van die regels onder elkaar - het labeltje boven, de knoppen eronder - plakten tegen + elkaar aan: de knop begint precies waar het labeltje ophoudt. Een beetje lucht ertussen. */ +.onderregel + .onderregel { margin-top: .35rem; } +.onderregel > .zacht, +.onderregel > .groot { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.onderregel > .label { flex: none; } + +/* Een tekentje voor de tekst van een regel, even hoog als die tekst - geen plaatje dat de + regel hoger maakt. Inline en niet flex: de regel zelf kapt zijn tekst af met puntjes, en dat + werkt alleen zolang de tekst gewone tekst in een gewone regel blijft. */ +.regelicoon > .teken { font-size: 1em; margin-right: .35em; } + +/* Bij "Alleen voor mijzelf" staat er geen tekentje maar de eigen profielfoto. De penning is + elders 44 bij 44; hier krimpt hij mee met de tekst. De letters erin (voor wie geen foto + heeft) mogen kleiner, anders passen twee initialen niet in een rondje van een regel hoog. + De -.2em zet hem op de tekstregel in plaats van op de onderkant van de letters. */ +.regelicoon .penningetje { + display: inline-block; width: 1.15em; height: 1.15em; + vertical-align: -.2em; margin-right: .35em; +} + +/* De maat staat op het omhulsel hierboven en niet op de penning zelf. Een em rekent namelijk + met de lettergrootte van het element waar hij op staat: zet je font-size en width samen op de + penning, dan krimpt de em mee met de letters en werd het rondje 9 pixels in plaats van 18. */ +.regelicoon .penningetje .penning { + width: 100%; height: 100%; font-size: .55em; font-weight: 700; + display: inline-flex; +} + +/* Een naam die niet past wordt afgekapt met puntjes, en breekt niet over drie regels. Een + kaart van drie regels hoog zet de rest van de lijst buiten beeld, en je herkent iemand + toch aan het begin van zijn naam. De volledige naam staat in het wijzigscherm, en op een + muis verschijnt hij als zwevende tekst. */ +.rij.persoon .groot { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Knoppen mogen krimpen tot hun tekst, maar nooit onleesbaar smal worden. */ +.rij > .knop { flex: 0 1 auto; } +.stapel { display: flex; flex-direction: column; gap: .75rem; } +.zacht { color: var(--inkt-zacht); font-size: .9rem; } +.groot { font-size: 1.05rem; font-weight: 600; } + +/* Het aantal achter de titel van een tegel, in plaats van op een regel eronder. Zo blijven + de tegels laag en passen er meer op een scherm. */ +.aantal { + display: inline-block; min-width: 1.9rem; text-align: center; + margin-left: .4rem; padding: .05rem .5rem; + border-radius: 999px; background: var(--vlak-zacht); color: var(--inkt-zacht); + font-size: .85rem; font-weight: 600; vertical-align: middle; +} +.aantal.nieuw { background: var(--merk); color: var(--merk-tekst); } + +/* Wat er speelt, boven de tegels. */ +.speelt { margin-bottom: 1.2rem; } +.speelt .regel { + display: flex; align-items: flex-start; gap: .8rem; + padding: .8rem 1rem; + background: var(--vlak); border: 1px solid var(--rand); border-radius: var(--straal); +} +.speelt .regel.let-op { border-color: var(--waarschuwing); background: var(--waarschuwing-zacht); } +.speelt .regel .teken { font-size: 1.2rem; line-height: 1.4; } + +.raster { display: grid; gap: 1rem; grid-template-columns: 1fr; } +@media (min-width: 620px) { .raster.twee { grid-template-columns: 1fr 1fr; } } + +/* ------------------------------------------------------------------ knoppen */ +.knop { + display: inline-flex; align-items: center; justify-content: center; gap: .5rem; + min-height: 48px; padding: .7rem 1.3rem; + border: 1px solid transparent; border-radius: 10px; + background: var(--merk); color: var(--merk-tekst); + font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; +} +/* Een knop mag nooit breder worden dan zijn omgeving; de tekst breekt dan maar af. */ +.knop { max-width: 100%; } +@media (max-width: 420px) { + .knop { padding-left: .9rem; padding-right: .9rem; } +} +.knop:hover { background: var(--merk-donker); } +.knop:disabled { opacity: .55; cursor: default; } +.knop.vol { width: 100%; } +/* Kleine knop voor in een rij, zoals bij een familielid. Nog steeds groot genoeg om met een + duim te raken: 40 pixels is de ondergrens die je wilt aanhouden. */ +.knop.klein { min-height: 40px; padding: .35rem .8rem; font-size: .9rem; font-weight: 500; } +.knop.rustig { background: transparent; color: var(--accent-donker); border-color: var(--rand); } +.knop.rustig:hover { border-color: var(--accent); background: var(--accent-zacht); } +.knop.gevaar { background: transparent; color: var(--fout); border-color: var(--rand); } + +/* ------------------------------------------------------------------ formulieren */ +label { display: block; font-weight: 500; margin-bottom: .3rem; font-size: .93rem; } + +/* Alle invoervelden, ook die zonder expliciete type: een zonder type is een + tekstveld, maar matcht input[type=text] niet. Zonder deze regel valt zo'n veld terug op + zijn standaardbreedte en loopt het op een telefoon buiten het scherm. */ +input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=submit]), +select, textarea { + width: 100%; max-width: 100%; min-height: 48px; + padding: .65rem .8rem; + border: 1px solid var(--rand); border-radius: 10px; + background: var(--vlak); color: var(--inkt); + font: inherit; +} +textarea { min-height: 130px; resize: vertical; line-height: 1.55; } + +/* Meegroeiend tekstvak (zie validatie.js): de hoogte komt uit de inhoud, niet uit min-height. */ +textarea[data-meegroeit] { min-height: unset; overflow: hidden; resize: none; } +input:focus, select:focus, textarea:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-zacht); +} +.veld { margin-bottom: 1rem; } + +/* Een veld dat niet goed is ingevuld: springNaarVeld() in validatie.js zet deze klasse erop, + scrollt het in beeld en geeft het de focus. Verdwijnt vanzelf zodra iemand er iets aan + verandert - zie validatie.js. */ +.veld-fout { border-color: var(--fout) !important; box-shadow: 0 0 0 3px rgba(155, 59, 50, .15); } + +/* Drie keuzelijsten naast elkaar voor een datum. Dag en jaar krijgen een vaste breedte, want + met 'flex' erop kromp het jaar op een telefoon tot "196" - het laatste cijfer viel weg. + De maand neemt wat er overblijft. */ +.datumkiezer { display: flex; gap: .4rem; align-items: center; } +.datumkiezer select { min-width: 0; padding-left: .5rem; padding-right: .3rem; } +.datumkiezer .dag { flex: 0 0 4.2rem; } +.datumkiezer .maand { flex: 1 1 5rem; } +.datumkiezer .jaar { flex: 0 0 5.4rem; } +.veld .uitleg { font-size: .85rem; color: var(--inkt-zacht); margin-top: .3rem; } + +.keuze { display: flex; align-items: flex-start; gap: .6rem; margin-bottom: .6rem; } +.keuze input { width: 20px; height: 20px; min-height: 0; margin-top: .25rem; flex: none; } +.keuze label { margin: 0; font-weight: 400; } + +/* De geschiedenis van een profiel, onderaan het wijzigscherm: wanneer aangemaakt, + wanneer uitgenodigd, wanneer overgenomen. */ +.geschiedenis { border-top: 1px solid var(--rand); margin-top: 1rem; padding-top: .8rem; } +.geschiedenis > div { margin-bottom: .2rem; } + +/* Eén bijlage in het rijtje onder een kluisitem, persoon of bericht. */ +.bijlage { border: 1px solid var(--rand); border-radius: 10px; padding: .5rem .6rem; } +.bijlage .teken { font-size: 1.2rem; line-height: 1; } + +/* De naam en het tekentje samen zijn de knop om een bijlage te bekijken. Ziet eruit als tekst, + gedraagt zich als een knop - dat laatste is wat een schermlezer nodig heeft. */ +.bijlage-openen { + display: flex; align-items: center; gap: .6rem; + border: none; background: none; padding: 0; margin: 0; + font: inherit; color: inherit; text-align: left; cursor: pointer; + min-width: 0; +} +.bijlage-openen:disabled { cursor: default; opacity: .6; } +.bijlage-openen > .groei { display: flex; flex-direction: column; min-width: 0; } + +/* Een naam die niet past hoort af te breken, niet de knoppen ernaast weg te duwen. Een foto van + de camera heette eerder 824c9d46a1b749ab9f92986b - die past nergens in. */ +.bijlage-openen .naam { overflow-wrap: anywhere; } + +/* Het plaatje of filmpje in de kijkpopup. Hoogte begrensd, anders moet je op een telefoon + scrollen om de sluitknop terug te vinden. */ +.bijlage-groot { + display: block; max-width: 100%; max-height: 60vh; + margin: 0 auto; border-radius: 10px; +} + +/* Het voorbeeldje in de rij zelf, even groot als het tekentje ernaast zou zijn. Alleen de + website gebruikt dit; in de app zit het plaatje in de kijkpopup. */ +.bijlage-klein { + width: 40px; height: 40px; flex: none; display: block; + border-radius: 8px; object-fit: cover; +} +.bijlage a { overflow-wrap: anywhere; } + +/* ------------------------------------------------------------------ popup */ +.popup-achtergrond { + position: fixed; inset: 0; z-index: 50; + background: rgba(20, 18, 16, .45); + display: flex; align-items: flex-end; justify-content: center; + padding: 1rem; +} + +.popup { + width: 100%; max-width: 520px; max-height: 85dvh; overflow-y: auto; + background: var(--vlak); border-radius: var(--straal); + padding: 1.2rem 1.25rem 0; + box-shadow: var(--schaduw); +} +.popup h2 { margin-top: 0; } +/* Blijft onderin beeld staan terwijl de inhoud erboven scrolt - bij een lang formulier + (bijvoorbeeld een uitvaartwens met veel velden) zag je Opslaan en Annuleren anders pas na + helemaal naar beneden scrollen. sticky werkt hier omdat .popup zelf de scrollende ouder is + (overflow-y: auto hierboven); de negatieve margin trekt de balk weer tot de randen van de + popup, en de eigen achtergrond zorgt dat tekst er niet doorheen scrolt. */ +.popup-knoppen { + position: sticky; bottom: 0; z-index: 1; + margin: 1rem -1.25rem 0; padding: .8rem 1.25rem calc(.8rem + env(safe-area-inset-bottom)); + background: var(--vlak); + box-shadow: 0 -6px 10px -8px rgba(20, 18, 16, .35); +} +.popup-knoppen .knop { flex: 1 1 auto; } + +/* De vaste Opslaan-knop op een volledig scherm (zoals Mijn profiel), niet in een popup. Hij + hoort net als bij een popup altijd in beeld te blijven, ook als het formulier eronder lang + is - anders scrol je een heel scherm omhoog om op te slaan. Op de telefoon zweeft hij boven + de onderbalk; op een groot scherm is er geen onderbalk meer, dus dan zakt hij naar onderen. */ +.knop-vast-onder { + position: sticky; bottom: calc(4.7rem + env(safe-area-inset-bottom)); z-index: 5; + margin: 1rem -1.25rem 0; padding: .8rem 1.25rem; + background: var(--vlak); + box-shadow: 0 -6px 10px -8px rgba(20, 18, 16, .35); +} +@media (min-width: 720px) { + .knop-vast-onder { bottom: 0; } +} + +/* Op een groter scherm hoort hij midden in beeld, niet onderaan. */ +@media (min-width: 620px) { + .popup-achtergrond { align-items: center; } +} + +/* ------------------------------------------------------------------ meldingen */ +.melding { + border-radius: 10px; padding: .8rem 1rem; margin-bottom: 1rem; + border: 1px solid var(--rand); background: var(--vlak-zacht); font-size: .95rem; +} +.melding.fout { border-color: var(--fout); color: var(--fout); background: transparent; } +.melding.let-op { border-color: var(--waarschuwing); background: var(--waarschuwing-zacht); color: var(--waarschuwing); } +.melding.goed { border-color: var(--accent); background: var(--accent-zacht); color: var(--accent-donker); } + +.label { + display: inline-block; font-size: .76rem; font-weight: 600; + padding: .18rem .6rem; border-radius: 999px; + background: var(--vlak-zacht); color: var(--inkt-zacht); border: 1px solid var(--rand); +} +.label.geclaimd { background: var(--accent-zacht); color: var(--accent-donker); border-color: var(--accent); } +.label.uitgenodigd { background: var(--waarschuwing-zacht); color: var(--waarschuwing); border-color: var(--waarschuwing); } + +/* De ronde penning bij een persoon: zijn profielfoto, of anders zijn beginletters. Beide + precies even groot, zodat een lijst niet verspringt zodra er ergens een foto bij komt. */ +.penning { + width: 44px; height: 44px; flex: none; border-radius: 50%; + background: var(--accent-zacht); color: var(--accent-donker); + display: flex; align-items: center; justify-content: center; + font-weight: 600; font-size: .95rem; + overflow: hidden; +} + +/* object-fit: cover snijdt bij in plaats van uit te rekken - een portret dat staand is + gemaakt hoort niet breed getrokken te worden. + + Let op de twee gevallen apart. Een img DIE de penning is houdt de 44 bij 44 hierboven; hem + hier op 100% zetten ging mis, want "img.penning" weegt zwaarder dan ".penning" en dan werd + de foto zo groot als de kaart. Een img BINNEN een penning vult hem juist wel. */ +img.penning { object-fit: cover; display: block; } +.penning img { width: 100%; height: 100%; object-fit: cover; display: block; } + +.leeg { text-align: center; padding: 2.5rem 1rem; color: var(--inkt-zacht); } +.leeg .teken { font-size: 2.2rem; display: block; margin-bottom: .5rem; opacity: .55; } + +.voortgang { height: 8px; border-radius: 999px; background: var(--vlak-zacht); overflow: hidden; } +.voortgang > span { display: block; height: 100%; background: var(--merk); } + +hr { border: none; border-top: 1px solid var(--rand); margin: 1.25rem 0; } + +.middenblok { max-width: 420px; margin: 3rem auto; } + +/* In de app hoort de kop bovenaan te beginnen. Die 3rem is voor een webpagina die zelf de hele + hoogte heeft; in de app zit er al een vaste balk boven, en dan keek je op een telefoon eerst + naar een leeg scherm voordat "Welkom terug" in beeld kwam. */ +.schil.app .middenblok { margin-top: .5rem; } + +#blazor-error-ui { + position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; + background: var(--waarschuwing-zacht); color: var(--waarschuwing); + padding: .8rem 1.2rem; display: none; box-shadow: var(--schaduw); +} +#blazor-error-ui .dismiss { cursor: pointer; float: right; } + +/* --------------------------------------------------- inloggen met een code */ +.codeveld { + font-size: 1.7rem; letter-spacing: .5rem; text-align: center; + font-family: "Segoe UI", system-ui, monospace; font-variant-numeric: tabular-nums; + width: 100%; +} + +/* Op een smalle telefoon past zes cijfers met een halve rem ertussen net niet. */ +@media (max-width: 420px) { + .codeveld { font-size: 1.45rem; letter-spacing: .3rem; padding-left: .4rem; padding-right: .4rem; } +} + +/* De uitleg achter het vraagteken; werkt zonder javascript. */ +.uitklap { margin: .4rem 0 1rem; } +.uitklap summary { + cursor: pointer; color: var(--accent-donker); font-size: .9rem; + list-style: none; display: inline-flex; align-items: center; gap: .4rem; +} +.uitklap summary::-webkit-details-marker { display: none; } +.uitklap summary::before { + content: "?"; width: 20px; height: 20px; border-radius: 50%; + background: var(--accent-zacht); color: var(--accent-donker); + display: inline-flex; align-items: center; justify-content: center; + font-weight: 700; font-size: .78rem; flex: none; +} +.uitklap p { margin: .6rem 0 0; font-size: .9rem; color: var(--inkt-zacht); } + +/* Een knop die eruitziet als de uitklapper erboven. "Wat is MijnBesluit?" hoort daar visueel + bij - het zijn allebei vragen onderaan het overzicht - maar het is een knop en geen link, + want hij opent een popup en gaat nergens heen. */ +.alsLink { + border: none; background: none; padding: 0; margin: 0; cursor: pointer; + font: inherit; font-size: .9rem; color: var(--accent-donker); + display: inline-flex; align-items: center; gap: .4rem; +} +.alsLink::before { + content: "?"; width: 20px; height: 20px; border-radius: 50%; + background: var(--accent-zacht); color: var(--accent-donker); + display: inline-flex; align-items: center; justify-content: center; + font-weight: 700; font-size: .78rem; flex: none; +} + +/* Zonder het vraagtekentje, voor een knop die geen vraag is maar een uitweg - "toch helemaal + uitloggen" op het slotscherm. Onderstreept, zodat hij nog wel als iets aanwijsbaars leest. */ +.alsLink.kaal::before { content: none; } +.alsLink.kaal { text-decoration: underline; } + + +.naar-adres { font-weight: 600; } + +.voet { text-align: center; margin-top: 2rem; font-size: .9rem; } +.voet a { color: var(--inkt-zacht); } + +/* Het logo boven het inloggen. Groot genoeg om het woordmerk te lezen, en gecentreerd omdat + het middenblok dat ook is. */ +.merkgroot { + display: block; margin: 0 auto 1.2rem; width: 132px; height: auto; +} + +/* Het scherm dat er staat als de app zichzelf heeft vergrendeld. Geen balk, geen navigatie, + niets van de app eronder: een grendel die je nog door het raam laat kijken is geen grendel. */ +.slotscherm { + display: flex; flex-direction: column; justify-content: center; + min-height: 100dvh; max-width: 340px; margin: 0 auto; + padding: 2rem 1.25rem; text-align: center; +} +.slotscherm h1 { margin: 0 0 .4rem; } +.slotscherm p { margin: 0; } + +/* De regel bovenaan Mijn kluis: de keuzelijst met daarnaast de knop Toevoegen. + + align-items op stretch, zodat de twee even hoog worden zonder dat er ergens een vaste hoogte + staat. Met een vast getal loopt het uit elkaar zodra iemand zijn lettergrootte verandert; zo + volgen ze elkaar altijd. */ +.rij.kluiskop { align-items: stretch; margin-bottom: 1rem; } +.rij.kluiskop > select { min-height: 0; } +.rij.kluiskop > .knop { min-height: 0; } + +/* De advertentietegel op het overzicht. + + Laag, met een lichte achtergrond, en zichtbaar het woord "Advertentie" erop. Dat laatste is + geen wettelijke formaliteit maar het punt zelf: een advertentie die zich voordoet als onderdeel + van de applicatie kost je meer vertrouwen dan hij oplevert. Zie docs/Adverteren.md. + + Hij staat boven de tegels maar onder "wat er speelt". Een waarschuwing dat er een overlijden op + jouw naam is gemeld gaat altijd voor. */ +.advertentie { + display: flex; align-items: center; gap: .9rem; + padding: .7rem .9rem; margin-bottom: 1rem; + background: var(--vlak-zacht); border: 1px solid var(--rand); border-radius: var(--straal); + text-decoration: none; color: inherit; +} +.advertentie:hover { border-color: var(--accent); } +.advertentie img { + width: 56px; height: 56px; flex: none; object-fit: cover; border-radius: 10px; display: block; +} +.advertentie .groei { min-width: 0; } + +/* Twee regels en dan afgekapt: een advertentie mag het overzicht niet overnemen. */ +.advertentie .tekst { + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; line-height: 1.35; +} +.advertentie .merkje { + font-size: .7rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; + color: var(--inkt-zacht); display: block; margin-bottom: .15rem; +} diff --git a/src/MijnBesluit.AppUi/wwwroot/logo.png b/src/MijnBesluit.AppUi/wwwroot/logo.png new file mode 100644 index 0000000..709b571 Binary files /dev/null and b/src/MijnBesluit.AppUi/wwwroot/logo.png differ diff --git a/src/MijnBesluit.AppUi/wwwroot/validatie.js b/src/MijnBesluit.AppUi/wwwroot/validatie.js new file mode 100644 index 0000000..8571fb0 --- /dev/null +++ b/src/MijnBesluit.AppUi/wwwroot/validatie.js @@ -0,0 +1,127 @@ +// Springt naar een veld dat niet goed is ingevuld: scrollt het in beeld, geeft het de focus en +// zet er een rode rand omheen tot iemand er iets aan verandert. Eén plek voor dit gedrag in +// plaats van dat elk scherm het zelf opnieuw uitvindt - zie .veld-fout in app.css. +window.mijnbesluit = window.mijnbesluit || {}; + +window.mijnbesluit.springNaarVeld = function (id) { + const veld = document.getElementById(id); + if (!veld) return; + + veld.scrollIntoView({ behavior: "smooth", block: "center" }); + veld.classList.add("veld-fout"); + veld.focus({ preventScroll: true }); + + const weghalen = () => veld.classList.remove("veld-fout"); + veld.addEventListener("input", weghalen, { once: true }); + veld.addEventListener("change", weghalen, { once: true }); +}; + +// Laat een tekstvak meegroeien met de inhoud - alleen nodig in de app: op de website is er +// al genoeg ruimte. Wordt telkens bij het renderen aangeroepen; het dataset-vlaggetje zorgt +// dat de listener maar één keer wordt gekoppeld. +window.mijnbesluit.laatMeegroeien = function (id) { + const veld = document.getElementById(id); + if (!veld || veld.dataset.meegroeit) return; + veld.dataset.meegroeit = "1"; + + const passenOpInhoud = () => { + veld.style.height = "auto"; + veld.style.height = veld.scrollHeight + "px"; + }; + veld.addEventListener("input", passenOpInhoud); + passenOpInhoud(); +}; + +// Trek naar beneden om te verversen - alleen op de schermen die de schil doorgeeft. Wordt één +// keer aangeroepen door AppSchil, voor de hele levensduur van de app; welk scherm er op dat +// moment binnen staat wordt bij elke aanraking opnieuw bekeken via de URL, niet onthouden. +window.mijnbesluit.pullToVernieuwen = function (containerId, dotNetRef, paden) { + const vak = document.getElementById(containerId); + if (!vak || vak.dataset.pullInit) return; + vak.dataset.pullInit = "1"; + + const drempel = 70; + let start = null, actief = false, bezig = false; + + const indicator = document.createElement("div"); + indicator.className = "ptr-indicator"; + + const icoon = document.createElement("span"); + icoon.className = "ptr-icoon"; + icoon.textContent = "↓"; // pijl naar beneden + indicator.appendChild(icoon); + + const tekst = document.createElement("span"); + tekst.textContent = "Trek naar beneden om te verversen"; + indicator.appendChild(tekst); + + vak.parentNode.insertBefore(indicator, vak); + + const hierActief = () => paden.some(p => window.location.pathname.indexOf(p) >= 0); + + // Niet vak.scrollTop: de schermen zelf scrollen niet, de pagina als geheel doet dat (de + // kop- en navigatiebalk staan vast met position: fixed erbovenop). "Bovenaan" betekent dus + // dat de pagina, niet dit ene element, niets heeft weggescrold. + const bovenaan = () => (document.scrollingElement || document.documentElement).scrollTop === 0; + + // De kopbalk is niet overal even hoog - een scherm met een terugknop heeft een extra rij. + // Het vaantje hoort er altijd net onder te beginnen, dus die hoogte wordt bij elke aanraking + // opnieuw gemeten in plaats van uit één vaste waarde in de CSS te komen. Zonder dit stond het + // vaantje op een scherm met een hogere balk er middenin verstopt: de tekst liep dan door de + // balk heen in plaats van eronder te verschijnen. + const zetBovenkant = () => { + const kop = document.querySelector(".schil.app .kop.vast"); + if (kop) indicator.style.top = kop.offsetHeight + "px"; + }; + + const terug = () => { + indicator.style.height = "0px"; + icoon.classList.remove("omgekeerd", "bezig"); + start = null; + actief = false; + }; + + vak.addEventListener("touchstart", e => { + if (bezig || !bovenaan() || !hierActief()) { start = null; return; } + zetBovenkant(); + start = e.touches[0].clientY; + }, { passive: true }); + + vak.addEventListener("touchmove", e => { + if (start === null || bezig) return; + const afstand = e.touches[0].clientY - start; + if (afstand > 0 && bovenaan()) { + actief = true; + const hoogte = Math.min(afstand, drempel * 1.5); + indicator.style.height = hoogte + "px"; + const overDrempel = hoogte >= drempel; + icoon.classList.toggle("omgekeerd", overDrempel); + tekst.textContent = overDrempel ? "Laat los om te verversen" + : "Trek naar beneden om te verversen"; + } + }, { passive: true }); + + vak.addEventListener("touchend", () => { + if (!actief) { terug(); return; } + + const hoogte = parseInt(indicator.style.height || "0", 10); + if (hoogte >= drempel) + { + bezig = true; + indicator.style.height = drempel + "px"; + icoon.classList.remove("omgekeerd"); + icoon.classList.add("bezig"); + tekst.textContent = "Bezig met verversen…"; + + dotNetRef.invokeMethodAsync("OpVernieuwenAsync").finally(() => + { + bezig = false; + terug(); + }); + } + else + { + terug(); + } + }); +}; diff --git a/src/MijnBesluit.Application/Abstractions/IAfbeeldingVerkleiner.cs b/src/MijnBesluit.Application/Abstractions/IAfbeeldingVerkleiner.cs new file mode 100644 index 0000000..d8184a6 --- /dev/null +++ b/src/MijnBesluit.Application/Abstractions/IAfbeeldingVerkleiner.cs @@ -0,0 +1,31 @@ +namespace MijnBesluit.Application.Abstractions; + +/// +/// Maakt een kleine versie van een foto, voor in lijstjes. +/// +/// Dit staat hier als afspraak en niet als code, om twee redenen. De eerste is de gewone: de +/// Application-laag hoort niet te weten met welke bibliotheek er geschaald wordt. De tweede is +/// praktischer - een afbeeldingsbibliotheek brengt platformafhankelijke onderdelen mee, en die +/// horen in de buitenste laag thuis. +/// +/// Lukt het verkleinen niet, dan komt er null terug en gaat alles gewoon door: het scherm valt +/// dan terug op de beginletters. Een ontbrekend plaatje is nooit een reden om een foto niet op +/// te slaan. +/// +public interface IAfbeeldingVerkleiner +{ + /// + /// Verkleint een afbeelding tot hoogstens pixels op de langste + /// zijde en geeft hem terug als JPEG. Null als het geen afbeelding is of als het niet lukt. + /// + byte[]? Verklein(byte[] inhoud, string soort, int maxZijde); +} + +/// +/// Doet niets. Wordt gebruikt als er geen verkleiner is aangemeld - in de zelfcontroles +/// bijvoorbeeld, waar geen echte afbeeldingen langskomen. +/// +public sealed class GeenAfbeeldingVerkleiner : IAfbeeldingVerkleiner +{ + public byte[]? Verklein(byte[] inhoud, string soort, int maxZijde) => null; +} diff --git a/src/MijnBesluit.Application/Abstractions/IClock.cs b/src/MijnBesluit.Application/Abstractions/IClock.cs new file mode 100644 index 0000000..42c7f90 --- /dev/null +++ b/src/MijnBesluit.Application/Abstractions/IClock.cs @@ -0,0 +1,12 @@ +namespace MijnBesluit.Application.Abstractions; + +/// Tijd als afhankelijkheid, zodat termijnen en bezorgmomenten te testen zijn. +public interface IClock +{ + DateTime UtcNow { get; } +} + +public sealed class SystemClock : IClock +{ + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/src/MijnBesluit.Application/Abstractions/IMasterKeyProtector.cs b/src/MijnBesluit.Application/Abstractions/IMasterKeyProtector.cs new file mode 100644 index 0000000..acc01a3 --- /dev/null +++ b/src/MijnBesluit.Application/Abstractions/IMasterKeyProtector.cs @@ -0,0 +1,17 @@ +namespace MijnBesluit.Application.Abstractions; + +/// +/// Beschermt de datasleutels van gebruikers met de mastersleutel van de server. +/// De implementatie bepaalt waar die mastersleutel staat: een sleutelbestand met +/// DPAPI-bescherming, een HSM of een sleutelkluis. Rotatie gebeurt via . +/// +public interface IMasterKeyProtector +{ + int CurrentKeyVersion { get; } + + /// Versleutelt een datasleutel met de huidige mastersleutel. + byte[] Protect(byte[] dataKey); + + /// Ontsleutelt een datasleutel; herkent zelf met welke masterversie hij is ingepakt. + byte[] Unprotect(byte[] protectedDataKey); +} diff --git a/src/MijnBesluit.Application/Abstractions/Repositories.cs b/src/MijnBesluit.Application/Abstractions/Repositories.cs new file mode 100644 index 0000000..a89be45 --- /dev/null +++ b/src/MijnBesluit.Application/Abstractions/Repositories.cs @@ -0,0 +1,371 @@ +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Application.Abstractions; + +/// Slaat alle wijzigingen van deze bewerking in één transactie op. +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken ct = default); +} + +public interface IUserRepository +{ + Task FindByIdAsync(long id, CancellationToken ct = default); + Task FindByEmailAsync(string email, CancellationToken ct = default); + Task FindByPersonIdAsync(long personId, CancellationToken ct = default); + Task EmailExistsAsync(string email, CancellationToken ct = default); + + /// De beheerders. Nodig om ze te laten weten dat er iets op hen ligt te wachten. + Task> ListBeheerdersAsync(CancellationToken ct = default); + + void Add(User user); + + /// Verwijdert een account. Alleen bij het opheffen ervan; zie AccountOpheffenService. + void Remove(User user); +} + +public interface ILoginCodeRepository +{ + /// De nieuwste code die nog open staat voor dit adres en dit doel. + Task FindOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, CancellationToken ct = default); + + /// Alle codes die nog open staan; gebruikt om oude ongeldig te maken bij een nieuwe aanvraag. + Task> ListOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, CancellationToken ct = default); + + /// + /// Hoeveel codes er sinds een moment voor dit adres zijn aangevraagd waarmee NIET is + /// ingelogd; voor de rem op aanvragen. Een code waarmee iemand is binnengekomen telt niet + /// mee: die rem is er om te voorkomen dat iemand het postvak van een ander volgooit, en wie + /// de code invult zit zelf in dat postvak. Een code die is verdrongen door een nieuwe + /// aanvraag of die is stukgeraden telt wél mee - anders is de rem in twee stappen leeg te + /// trekken door een vreemde. + /// + Task CountSinceAsync(string email, DateTime sinceUtc, CancellationToken ct = default); + + /// Ruimt codes op die allang verlopen zijn. + Task RemoveExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default); + + /// Ruimt alle codes van dit adres op. Voor het opheffen van een account. + Task RemoveForEmailAsync(string email, CancellationToken ct = default); + + void Add(LoginCode code); +} + +public interface IPersonRepository +{ + Task FindByIdAsync(long id, CancellationToken ct = default); + /// + /// Eén profiel met dit adres. Er kunnen er meer zijn - twee mensen mogen allebei hun eigen + /// aantekening over dezelfde persoon hebben - dus dit geeft er één, bij voorkeur het profiel + /// dat de persoon zelf heeft geclaimd. Weet je wiens profiel je zoekt, gebruik dan + /// en kies zelf. + /// + Task FindByEmailAsync(string email, CancellationToken ct = default); + + /// Alle profielen met dit adres, geclaimde eerst. + Task> ListByEmailAsync(string email, CancellationToken ct = default); + + /// + /// Aantekeningen die ánderen over dit adres hebben staan en die nog van niemand zijn. + /// + /// Dit is de vraag "heeft iemand een profiel voor mij aangemaakt?" - gesteld door degene die + /// net bewezen heeft bij dat postvak te kunnen. Niet meegenomen: wat al geclaimd is, wat al + /// is opgegaan in een ander, wat deze gebruiker zelf beheert, en wat hij eerder heeft + /// weggewuifd. Zie docs/Identiteit.md. + /// + Task> ListAantekeningenOverAdresAsync(string email, long gebruikerId, + CancellationToken ct = default); + Task> ListByIdsAsync(IEnumerable ids, CancellationToken ct = default); + + /// Zoekt het profiel dat bij een bezwaarlink hoort, op de versleutelde vorm ervan. + Task FindByObjectionTokenAsync(byte[] tokenHash, CancellationToken ct = default); + void Add(Person person); + void Remove(Person person); +} + +public interface IFamilyRepository +{ + Task FindByIdAsync(long id, CancellationToken ct = default); + Task> ListOwnedByPersonAsync(long personId, CancellationToken ct = default); + Task> ListMembersAsync(long familyId, CancellationToken ct = default); + Task FindMemberAsync(long familyId, long personId, CancellationToken ct = default); + /// Alle families waar deze persoon lid van is; zo verspreidt het netwerk zich. + Task> ListFamiliesContainingAsync(long personId, CancellationToken ct = default); + + /// + /// Iedereen met wie deze persoon in een familie zit, inclusief hijzelf. Dat is de kring + /// waarbinnen mensen elkaar kennen: wie hier niet in staat, heeft niets met deze persoon te + /// maken. In één vraag, want dit wordt op meerdere plekken als toegangsregel gebruikt. + /// + Task> ListCircleAsync(long personId, CancellationToken ct = default); + + /// Alle lidmaatschappen van deze persoon, over alle families heen. Voor samenvoegen. + Task> ListMembershipsOfPersonAsync(long personId, CancellationToken ct = default); + + /// Alle families waarvan deze persoon de eigenaar is. Voor samenvoegen. + Task> ListOwnedAsync(long personId, CancellationToken ct = default); + void Add(Family family); + void Add(FamilyMember member); + void Remove(FamilyMember member); + void Remove(Family family); +} + +public interface IInvitationRepository +{ + Task FindByIdAsync(long id, CancellationToken ct = default); + Task FindByTokenHashAsync(byte[] tokenHash, CancellationToken ct = default); + Task> ListOpenForPersonAsync(long personId, CancellationToken ct = default); + Task HasOpenInvitationAsync(long personId, DateTime nowUtc, CancellationToken ct = default); + + /// + /// De laatste uitnodiging per persoon, ongeacht de status. Bedoeld om te kunnen tonen + /// wanneer iemand is uitgenodigd - ook als die uitnodiging inmiddels verlopen of + /// aangenomen is. In één keer voor een hele familie, zodat een lijst met twintig mensen + /// niet twintig vragen aan de database kost. + /// + Task> ListLatestForPeopleAsync(IEnumerable personIds, + CancellationToken ct = default); + + /// Alle uitnodigingen voor deze persoon, ongeacht de status. Voor samenvoegen. + Task> ListAllForPersonAsync(long personId, CancellationToken ct = default); + + /// Alle uitnodigingen die deze gebruiker heeft aangemaakt. Voor het opheffen van zijn account. + Task> ListSentByUserAsync(long userId, CancellationToken ct = default); + + void Add(Invitation invitation); + void Remove(Invitation invitation); +} + +public interface IVaultRepository +{ + Task FindByIdAsync(long id, CancellationToken ct = default); + Task> ListForPersonAsync(long personId, VaultCategory? category = null, CancellationToken ct = default); + Task FindFileAsync(long id, CancellationToken ct = default); + + /// De bijlagen bij een kluisitem, nieuwste laatst. + Task> ListFilesForItemAsync(long vaultItemId, CancellationToken ct = default); + + /// De foto's die bij een persoon horen, nieuwste laatst. + Task> ListFilesForPersonAsync(long subjectPersonId, CancellationToken ct = default); + + /// Elk bestand dat deze persoon bewaart. Voor samenvoegen. + Task> ListFilesOwnedByAsync(long ownerPersonId, CancellationToken ct = default); + + /// De bijlagen bij een bericht, nieuwste laatst. + Task> ListFilesForMessageAsync(long messageId, CancellationToken ct = default); + + /// + /// De aangewezen profielfoto van elk van deze personen, voor zover die er is. In één vraag, + /// want dit wordt gebruikt om een lijst met personen te tonen. + /// + Task> ListProfilePhotosAsync(IEnumerable personIds, CancellationToken ct = default); + Task FindGrantAsync(string resourceType, long resourceId, long userId, CancellationToken ct = default); + + /// Kluisitems die actief (niet ingetrokken) met deze gebruiker zijn gedeeld, ongeacht reden. + Task> ListSharedWithAsync(long userId, CancellationToken ct = default); + void Add(VaultItem item); + void Add(VaultFile file); + void Add(ContentGrant grant); + void Remove(VaultItem item); + void Remove(VaultFile file); + + /// Haalt alle toegang weg die aan deze gebruiker is verleend. Voor het opheffen van zijn account. + Task RemoveGrantsForUserAsync(long userId, CancellationToken ct = default); + + /// Haalt alle verleende toegang tot dit item weg. Nodig zodra de inhoud verandert of + /// verdwijnt: de sleutel die eerder is uitgedeeld hoort dan nergens meer bij. + Task RemoveGrantsAsync(long itemId, CancellationToken ct = default); + + /// + /// Trekt de toegang in die om één bepaalde reden is verleend tot de items van deze eigenaar. + /// Gebruikt als een overlijden alsnog wordt tegengesproken: de beheerder die toegang kreeg, + /// hoort die dan meteen weer kwijt te zijn. + /// + Task RevokeGrantsForOwnerAsync(long ownerPersonId, string reason, DateTime nowUtc, + CancellationToken ct = default); +} + +public interface IMessageRepository +{ + Task FindByIdAsync(long id, CancellationToken ct = default); + Task> ListOutboxAsync(long fromPersonId, CancellationToken ct = default); + Task> ListInboxAsync(long toPersonId, CancellationToken ct = default); + /// Berichten die op of vóór bezorgd moeten worden. + Task> ListDueAsync(DateTime nowUtc, int max, CancellationToken ct = default); + /// Berichten die wachten op het bevestigde overlijden van deze afzender. + Task> ListAwaitingDeathOfAsync(long fromPersonId, CancellationToken ct = default); + + /// Berichten die nog klaarstaan voor deze ontvanger, dus nog niet bezorgd zijn. + Task> ListPlannedForAsync(long toPersonId, CancellationToken ct = default); + + /// Elk bericht van of aan deze persoon, ongeacht de status. Voor samenvoegen. + Task> ListAllTouchingAsync(long personId, CancellationToken ct = default); + + /// Het bericht bij deze kijklink, voor de landingspagina van iemand zonder account. + Task FindByViewTokenHashAsync(byte[] tokenHash, CancellationToken ct = default); + + void Add(Message message); + void Remove(Message message); +} + +public interface ILegacyRepository +{ + Task> ListDeclarationsAsync(long personId, CancellationToken ct = default); + Task FindDeclarationByUserAsync(long personId, long userId, CancellationToken ct = default); + Task> ListManagersForAsync(long personId, CancellationToken ct = default); + Task> ListAssignmentsOfAsync(long managerPersonId, CancellationToken ct = default); + + /// Elke overlijdensmelding over deze persoon, ook ingetrokken. Voor samenvoegen. + Task> ListAllDeclarationsAsync(long personId, CancellationToken ct = default); + + /// Alle overlijdensmeldingen die deze gebruiker heeft gedaan. Voor het opheffen van zijn account. + Task> ListDeclarationsByUserAsync(long userId, CancellationToken ct = default); + + void Remove(LegacyManager manager); + void Remove(DeathDeclaration declaration); + void Add(DeathDeclaration declaration); + void Add(LegacyManager manager); +} + +/// +/// De bonnetjes van samenvoegingen. Alleen nodig om er een terug te kunnen draaien; nergens in +/// de schermen te zien behalve op de beheerpagina. +/// +public interface ISamenvoegRepository +{ + Task FindByIdAsync(long id, CancellationToken ct = default); + + /// Alles wat er ooit met dit profiel is gedaan, nieuwste eerst. + Task> ListVoorDuplicaatAsync(long duplicaatPersonId, CancellationToken ct = default); + + /// De samenvoegingen waarbij dit profiel het doel was, nieuwste eerst. + Task> ListVoorDoelAsync(long doelPersonId, CancellationToken ct = default); + + void Add(Samenvoeging samenvoeging); +} + +/// +/// Tellingen voor de statistiektegel op de beheerpagina. Alleen aantallen; wat er in een +/// kluisitem of bericht staat komt hier nooit in voor. +/// +public interface IStatistiekRepository +{ + Task OphalenAsync(int groeidagen, DateTime nowUtc, + CancellationToken ct = default); +} + +public interface IAuditLog +{ + Task WriteAsync(AuditAction action, long? userId, string? entityType = null, long? entityId = null, + string? details = null, CancellationToken ct = default); +} + +/// Versturen van uitnodigingen en meldingen. +/// +/// Adverteerders, hun campagnes en de tellers per uur. Zie docs/Adverteren.md. +/// +public interface IAdvertentieRepository +{ + Task FindAdverteerderAsync(long id, CancellationToken ct = default); + Task FindAdverteerderByEmailAsync(string email, CancellationToken ct = default); + Task> ListAdverteerdersAsync(CancellationToken ct = default); + void Add(Adverteerder adverteerder); + + Task FindCampagneAsync(long id, CancellationToken ct = default); + Task> ListCampagnesAsync(long adverteerderId, CancellationToken ct = default); + + /// Campagnes die op deze dag goedgekeurd en binnen hun periode zijn. + Task> ListLopendAsync(DateOnly dag, CancellationToken ct = default); + + /// Alles wat op de beheerder wacht: betaling of goedkeuring. + Task> ListWachtendAsync(CancellationToken ct = default); + + void Add(Campagne campagne); + + Task FindUurAsync(long campagneId, DateTime uurUtc, CancellationToken ct = default); + Task> ListUrenAsync(long campagneId, CancellationToken ct = default); + + /// + /// Hoe vaak elke campagne dit klokuur al is getoond. In één vraag, want dit gebeurt elke keer + /// dat iemand het overzicht opent - één vraag per campagne loopt daar uit de hand. + /// + Task> VertoningenInUurAsync(DateTime uurUtc, IEnumerable campagneIds, + CancellationToken ct = default); + + /// + /// Telt er iets bij en maakt de regel aan als hij er nog niet is. + /// + /// Dit hoort hier en niet in de dienst, omdat het in één opdracht naar de database moet: twee + /// bezoekers tegelijk zouden anders allebei een nieuwe regel aanmaken en op de unieke sleutel + /// stuklopen. De opslag lost dat op met INSERT ... ON DUPLICATE KEY UPDATE. + /// + Task TelAsync(long campagneId, DateTime uurUtc, int vertoningen, int kliks, + CancellationToken ct = default); + + /// Wat er over de hele looptijd is getoond en aangeklikt. + Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default); + + void Add(CampagneUur uur); +} + +public interface IEmailSender +{ + Task SendAsync(string toEmail, string subject, string htmlBody, CancellationToken ct = default); +} + +/// Opslag van versleutelde bijlagen buiten de database. +/// Toestellen waarop iemand meldingen wil ontvangen. +/// +/// Aanmeldingen van de app, zodat ze een herstart van de SocketServer overleven. +/// +/// Er gaat hier nooit een token doorheen, alleen de hash ervan: deze laag hoeft het token niet +/// te kennen en hoort het dus ook niet te krijgen. +/// +public interface ISessieRepository +{ + Task ZoekAsync(byte[] tokenHash, CancellationToken ct = default); + void Add(Sessie sessie); + + /// Eén sessie eraf: uitloggen op dit toestel. + Task VerwijderAsync(byte[] tokenHash, CancellationToken ct = default); + + /// Alle sessies van één gebruiker eraf, ook die op andere toestellen. + Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default); + + /// Ruimt verlopen sessies op; draait als achtergrondtaak. + Task RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default); +} + +public interface IToestelRepository +{ + Task FindByKenmerkAsync(string kenmerk, CancellationToken ct = default); + Task> ListForUserAsync(long userId, CancellationToken ct = default); + void Add(Toestel toestel); + void Remove(Toestel toestel); +} + +/// +/// Stuurt een melding naar een toestel. De echte uitvoering praat met Firebase; in de tests +/// wordt hij vervangen door iets dat alleen onthoudt wat er verstuurd zou zijn. +/// +public interface IPushSender +{ + /// Is er iets ingesteld om mee te versturen? Zo niet, dan slaan we het over. + bool IsIngesteld { get; } + + /// + /// Verstuurt één melding. Geeft terug of het kenmerk nog bruikbaar is: bij false + /// kent Google het toestel niet meer en hoort de regel afgemeld te worden. + /// + Task StuurAsync(string kenmerk, string titel, string tekst, + IReadOnlyDictionary? gegevens = null, + CancellationToken ct = default); +} + +public interface IFileStore +{ + Task WriteAsync(byte[] encryptedBytes, string suggestedName, CancellationToken ct = default); + Task ReadAsync(string storageKey, CancellationToken ct = default); + Task DeleteAsync(string storageKey, CancellationToken ct = default); +} diff --git a/src/MijnBesluit.Application/MijnBesluit.Application.csproj b/src/MijnBesluit.Application/MijnBesluit.Application.csproj new file mode 100644 index 0000000..9a288f8 --- /dev/null +++ b/src/MijnBesluit.Application/MijnBesluit.Application.csproj @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + + + + + diff --git a/src/MijnBesluit.Application/Models/Berichtkenmerk.cs b/src/MijnBesluit.Application/Models/Berichtkenmerk.cs new file mode 100644 index 0000000..6cfc107 --- /dev/null +++ b/src/MijnBesluit.Application/Models/Berichtkenmerk.cs @@ -0,0 +1,33 @@ +namespace MijnBesluit.Application.Models; + +/// +/// Maakt het Message-ID van een uitgaande e-mail. +/// +/// Waarom dit er is: .NET zet zelf geen Message-ID op een bericht. Veel mailservers vullen die +/// dan aan, maar niet allemaal. Gmail weigert sinds kort alles zonder geldige Message-ID met +/// 550-5.7.1 Messages missing a valid Message-ID header are not accepted - en dan komt er +/// dus geen enkele inlogcode aan bij iemand met een gmail-adres. +/// +/// De vorm is die uit RFC 5322: iets unieks, een apenstaartje, en een domein dat van jou is. +/// +public static class Berichtkenmerk +{ + /// Maakt een nieuw Message-ID op basis van het afzenderadres. + public static string Nieuw(string? afzenderAdres) + { + var domein = Domein(afzenderAdres); + return $"<{Guid.NewGuid():N}.{DateTime.UtcNow:yyyyMMddHHmmss}@{domein}>"; + } + + /// Het deel achter het apenstaartje, of een vervanger als het adres niet deugt. + public static string Domein(string? adres) + { + if (string.IsNullOrWhiteSpace(adres)) return "mijnbesluit.nl"; + + var apenstaartje = adres.LastIndexOf('@'); + if (apenstaartje < 0 || apenstaartje == adres.Length - 1) return "mijnbesluit.nl"; + + var domein = adres[(apenstaartje + 1)..].Trim().Trim('>').ToLowerInvariant(); + return string.IsNullOrWhiteSpace(domein) ? "mijnbesluit.nl" : domein; + } +} diff --git a/src/MijnBesluit.Application/Models/Results.cs b/src/MijnBesluit.Application/Models/Results.cs new file mode 100644 index 0000000..add024f --- /dev/null +++ b/src/MijnBesluit.Application/Models/Results.cs @@ -0,0 +1,26 @@ +namespace MijnBesluit.Application.Models; + +/// Uitkomst van een bewerking, met een foutmelding die direct aan de gebruiker getoond mag worden. +public readonly struct Result +{ + public bool Succeeded { get; } + public string? Error { get; } + + private Result(bool ok, string? error) { Succeeded = ok; Error = error; } + + public static Result Ok() => new(true, null); + public static Result Fail(string error) => new(false, error); +} + +/// Uitkomst met een waarde erbij. +public readonly struct Result +{ + public bool Succeeded { get; } + public string? Error { get; } + public T? Value { get; } + + private Result(bool ok, T? value, string? error) { Succeeded = ok; Value = value; Error = error; } + + public static Result Ok(T value) => new(true, value, null); + public static Result Fail(string error) => new(false, default, error); +} diff --git a/src/MijnBesluit.Application/Models/Statistieken.cs b/src/MijnBesluit.Application/Models/Statistieken.cs new file mode 100644 index 0000000..4f1abcf --- /dev/null +++ b/src/MijnBesluit.Application/Models/Statistieken.cs @@ -0,0 +1,23 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Application.Models; + +/// Eén dagtotaal in de groeigrafiek van de beheerpagina. +public record GroeiPunt(DateOnly Datum, int NieuweProfielen, int NieuweKluisItems); + +/// +/// Tellingen voor de statistiektegel op de beheerpagina. Puur aantallen, geen inhoud - dit is +/// bedoeld om de belasting te kunnen inschatten, niet om in iemands kluis te kijken. Een +/// beheerder mag dat toch al niet, zie BeheerService. +/// +public record Statistieken( + int Profielen, + int GeclaimdeProfielen, + int Families, + int KluisItems, + int KluisBestanden, + int Momenten, + int BezorgdeMomenten, + int Nalatenschapbeheerders, + IReadOnlyDictionary ItemsPerCategorie, + IReadOnlyList GroeiPerDag); diff --git a/src/MijnBesluit.Application/Models/VaultPayloads.cs b/src/MijnBesluit.Application/Models/VaultPayloads.cs new file mode 100644 index 0000000..0c1b758 --- /dev/null +++ b/src/MijnBesluit.Application/Models/VaultPayloads.cs @@ -0,0 +1,38 @@ +namespace MijnBesluit.Application.Models; + +/// +/// De versleutelde inhoud van een kluisitem. Per categorie wordt alleen ingevuld wat van toepassing is; +/// het geheel gaat als JSON versleuteld de database in, zodat er later velden bij kunnen zonder schemawijziging. +/// +public class VaultPayload +{ + /// Vrije toelichting, voor alle categorieën. + public string? Notes { get; set; } + + // Wachtwoord / apparaat / pincode + public string? Website { get; set; } + public string? Username { get; set; } + public string? Secret { get; set; } + public string? Pincode { get; set; } + public string? DeviceName { get; set; } + public string? RecoveryCodes { get; set; } + + // Sociale media + /// Welk medium; een van SocialeMedia.Namen, of "Anders". + public string? Platform { get; set; } + /// Alleen gevuld als Platform "Anders" is: de naam die de gebruiker zelf invult. + public string? PlatformAnders { get; set; } + + // Polis + public string? Insurer { get; set; } + public string? PolicyNumber { get; set; } + public string? ContactPhone { get; set; } + public decimal? Amount { get; set; } + + // Uitvaartwens + public string? BurialOrCremation { get; set; } + public string? Location { get; set; } + public string? Music { get; set; } + public string? Speakers { get; set; } + public string? Wishes { get; set; } +} diff --git a/src/MijnBesluit.Application/Security/EnvelopeCrypto.cs b/src/MijnBesluit.Application/Security/EnvelopeCrypto.cs new file mode 100644 index 0000000..3e5d613 --- /dev/null +++ b/src/MijnBesluit.Application/Security/EnvelopeCrypto.cs @@ -0,0 +1,101 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace MijnBesluit.Application.Security; + +/// Versleutelde inhoud: cijfertekst, nonce en authenticatietag horen altijd bij elkaar. +public readonly record struct SealedData(byte[] Ciphertext, byte[] Nonce, byte[] Tag); + +/// +/// Envelope-encryptie met AES-256-GCM in drie lagen: +/// +/// Elk item krijgt een eigen willekeurige contentsleutel (CEK). +/// De CEK wordt versleuteld met de datasleutel van de gebruiker (DEK). +/// De DEK wordt versleuteld met de mastersleutel van de server (zie ). +/// +/// Zo hoeft er nooit een sleutel in leesbare vorm in de database te staan, en kan toegang +/// voor een ander (ontvanger van een bericht, nalatenschapsbeheerder) worden gegeven door +/// alleen de CEK opnieuw in te pakken. +/// +public static class EnvelopeCrypto +{ + public const int KeySize = 32; // 256 bit + public const int NonceSize = 12; // 96 bit, de aanbevolen maat voor GCM + public const int TagSize = 16; // 128 bit + + public static byte[] NewKey() => RandomNumberGenerator.GetBytes(KeySize); + + /// Versleutelt bytes met een sleutel. wordt meegeauthenticeerd maar niet versleuteld. + public static SealedData Seal(byte[] plaintext, byte[] key, byte[]? associatedData = null) + { + ValidateKey(key); + ArgumentNullException.ThrowIfNull(plaintext); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(key, TagSize); + aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData); + return new SealedData(ciphertext, nonce, tag); + } + + /// Ontsleutelt en controleert de authenticatietag. Gooit bij geknoei met de data. + public static byte[] Open(SealedData sealedData, byte[] key, byte[]? associatedData = null) + { + ValidateKey(key); + var plaintext = new byte[sealedData.Ciphertext.Length]; + + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(sealedData.Nonce, sealedData.Ciphertext, sealedData.Tag, plaintext, associatedData); + return plaintext; + } + + /// Pakt een sleutel in met een andere sleutel; het resultaat is één blob van nonce + tag + cijfertekst. + public static byte[] WrapKey(byte[] key, byte[] wrappingKey) + { + var s = Seal(key, wrappingKey); + var blob = new byte[NonceSize + TagSize + s.Ciphertext.Length]; + Buffer.BlockCopy(s.Nonce, 0, blob, 0, NonceSize); + Buffer.BlockCopy(s.Tag, 0, blob, NonceSize, TagSize); + Buffer.BlockCopy(s.Ciphertext, 0, blob, NonceSize + TagSize, s.Ciphertext.Length); + return blob; + } + + /// Pakt een met ingepakte sleutel weer uit. + public static byte[] UnwrapKey(byte[] blob, byte[] wrappingKey) + { + if (blob.Length <= NonceSize + TagSize) + throw new CryptographicException("Ingepakte sleutel is te kort of beschadigd."); + + var nonce = new byte[NonceSize]; + var tag = new byte[TagSize]; + var ciphertext = new byte[blob.Length - NonceSize - TagSize]; + Buffer.BlockCopy(blob, 0, nonce, 0, NonceSize); + Buffer.BlockCopy(blob, NonceSize, tag, 0, TagSize); + Buffer.BlockCopy(blob, NonceSize + TagSize, ciphertext, 0, ciphertext.Length); + + return Open(new SealedData(ciphertext, nonce, tag), wrappingKey); + } + + /// Versleutelt een object als JSON. + public static SealedData SealJson(T value, byte[] key) => + Seal(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOptions)), key); + + /// Ontsleutelt JSON terug naar een object. + public static T? OpenJson(SealedData sealedData, byte[] key) => + JsonSerializer.Deserialize(Encoding.UTF8.GetString(Open(sealedData, key)), JsonOptions); + + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private static void ValidateKey(byte[] key) + { + if (key is null || key.Length != KeySize) + throw new CryptographicException($"Sleutel moet {KeySize} bytes zijn."); + } +} diff --git a/src/MijnBesluit.Application/Security/InlogcodeGenerator.cs b/src/MijnBesluit.Application/Security/InlogcodeGenerator.cs new file mode 100644 index 0000000..469ebe1 --- /dev/null +++ b/src/MijnBesluit.Application/Security/InlogcodeGenerator.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; + +namespace MijnBesluit.Application.Security; + +/// +/// Maakt de cijfercode die per e-mail wordt verstuurd. +/// +/// Zes cijfers, niet vier. In de WinDev-versie was het Random(1111,9999): ruim achtduizend +/// mogelijkheden. Met zes cijfers is dat een miljoen, wat raden zinloos maakt ook als er ooit iets +/// misgaat met de pogingenteller. Wil je terug naar vier, dan is dat hier één cijfer aanpassen. +/// +public static class InlogcodeGenerator +{ + public const int Lengte = 6; + + /// + /// Een code van zes cijfers, gelijkmatig verdeeld. Bewust niet Random uit de + /// standaardbibliotheek: die is voorspelbaar als je een paar uitkomsten kent. + /// + public static string Nieuw() + { + var bovengrens = (int)Math.Pow(10, Lengte); + return RandomNumberGenerator.GetInt32(0, bovengrens).ToString(new string('0', Lengte)); + } + + /// Haalt spaties en streepjes weg die mensen bij het overtypen invoegen. + public static string Normaliseer(string? ingevoerd) => + new((ingevoerd ?? string.Empty).Where(char.IsDigit).ToArray()); +} diff --git a/src/MijnBesluit.Application/Security/PasswordHasher.cs b/src/MijnBesluit.Application/Security/PasswordHasher.cs new file mode 100644 index 0000000..ee04a66 --- /dev/null +++ b/src/MijnBesluit.Application/Security/PasswordHasher.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MijnBesluit.Application.Security; + +/// +/// Wachtwoordhashing met PBKDF2-HMAC-SHA512. Formaat van de opgeslagen waarde: +/// pbkdf2-sha512$<iteraties>$<salt-base64>$<hash-base64>. +/// Het algoritme staat in de waarde zelf, zodat later naar Argon2id kan worden overgestapt +/// zonder bestaande wachtwoorden ongeldig te maken. +/// +public static class PasswordHasher +{ + public const string Prefix = "pbkdf2-sha512"; + + /// OWASP-richtlijn voor PBKDF2-HMAC-SHA512. + public const int DefaultIterations = 210_000; + + private const int SaltBytes = 16; + private const int HashBytes = 64; + + public static string Hash(string password, int iterations = DefaultIterations) + { + if (string.IsNullOrEmpty(password)) throw new ArgumentException("Wachtwoord is leeg.", nameof(password)); + + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var hash = Derive(password, salt, iterations); + return $"{Prefix}${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; + } + + /// Controleert een wachtwoord in constante tijd. + public static bool Verify(string password, string encoded) + { + if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(encoded)) return false; + + var parts = encoded.Split('$'); + if (parts.Length != 4 || parts[0] != Prefix) return false; + if (!int.TryParse(parts[1], out var iterations) || iterations < 1000) return false; + + byte[] salt, expected; + try + { + salt = Convert.FromBase64String(parts[2]); + expected = Convert.FromBase64String(parts[3]); + } + catch (FormatException) { return false; } + + var actual = Derive(password, salt, iterations, expected.Length); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + /// Geeft true als de hash met verouderde instellingen is gemaakt en bij de volgende login vervangen moet worden. + public static bool NeedsUpgrade(string encoded) + { + var parts = encoded.Split('$'); + if (parts.Length != 4 || parts[0] != Prefix) return true; + return !int.TryParse(parts[1], out var iterations) || iterations < DefaultIterations; + } + + private static byte[] Derive(string password, byte[] salt, int iterations, int length = HashBytes) => + Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA512, length); +} diff --git a/src/MijnBesluit.Application/Security/SecretHasher.cs b/src/MijnBesluit.Application/Security/SecretHasher.cs new file mode 100644 index 0000000..7f02b48 --- /dev/null +++ b/src/MijnBesluit.Application/Security/SecretHasher.cs @@ -0,0 +1,63 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MijnBesluit.Application.Security; + +/// +/// Hasht korte geheimen zoals inlogcodes met PBKDF2-HMAC-SHA512. Formaat van de opgeslagen waarde: +/// pbkdf2-sha512$<iteraties>$<salt-base64>$<hash-base64>. +/// +/// Het algoritme staat in de waarde zelf, zodat er later op iets anders overgestapt kan worden +/// zonder wat er al ligt ongeldig te maken. +/// +/// Waarom hashen we een code die maar een kwartier geldig is? Omdat een gestolen databasedump +/// anders elke openstaande code leesbaar maakt. Met een hash kan dat niet, ook al leeft de code +/// maar kort. +/// +public static class SecretHasher +{ + public const string Prefix = "pbkdf2-sha512"; + + /// + /// Minder rondes dan bij een wachtwoord: een code leeft een kwartier, is eenmalig en er + /// mag maar een paar keer naar geraden worden. De rem zit dus in het aantal pogingen, + /// niet in de rekentijd. + /// + public const int DefaultIterations = 100_000; + + private const int SaltBytes = 16; + private const int HashBytes = 64; + + public static string Hash(string geheim, int iterations = DefaultIterations) + { + if (string.IsNullOrEmpty(geheim)) throw new ArgumentException("Geheim is leeg.", nameof(geheim)); + + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var hash = Derive(geheim, salt, iterations); + return $"{Prefix}${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; + } + + /// Controleert een geheim in constante tijd. + public static bool Verify(string geheim, string encoded) + { + if (string.IsNullOrEmpty(geheim) || string.IsNullOrEmpty(encoded)) return false; + + var delen = encoded.Split('$'); + if (delen.Length != 4 || delen[0] != Prefix) return false; + if (!int.TryParse(delen[1], out var iterations) || iterations < 1000) return false; + + byte[] salt, verwacht; + try + { + salt = Convert.FromBase64String(delen[2]); + verwacht = Convert.FromBase64String(delen[3]); + } + catch (FormatException) { return false; } + + var werkelijk = Derive(geheim, salt, iterations, verwacht.Length); + return CryptographicOperations.FixedTimeEquals(werkelijk, verwacht); + } + + private static byte[] Derive(string geheim, byte[] salt, int iterations, int lengte = HashBytes) => + Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(geheim), salt, iterations, HashAlgorithmName.SHA512, lengte); +} diff --git a/src/MijnBesluit.Application/Security/TokenGenerator.cs b/src/MijnBesluit.Application/Security/TokenGenerator.cs new file mode 100644 index 0000000..ea431aa --- /dev/null +++ b/src/MijnBesluit.Application/Security/TokenGenerator.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MijnBesluit.Application.Security; + +/// +/// Genereert uitnodigings- en sessietokens. Alleen de hash gaat de database in; +/// het token zelf bestaat maar één keer, in de e-mail of bij de client. +/// +public static class TokenGenerator +{ + /// Nieuw token als URL-veilige base64-tekst. + public static string Create(int byteLength = 32) => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(byteLength)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + + /// SHA-256 van het token, zoals het in de database wordt bewaard. + public static byte[] HashToken(string token) => + SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)); + + /// Vergelijking in constante tijd van een aangeboden token met een opgeslagen hash. + public static bool Matches(string token, byte[] storedHash) => + storedHash.Length == 32 && CryptographicOperations.FixedTimeEquals(HashToken(token), storedHash); + + /// + /// Leidt uit het token zelf een 256-bit sleutel af, los van . + /// + /// Dat "los van" is de reden dat dit een aparte methode is en geen hergebruik van de hash: + /// die hash staat in de database om het token te herkennen. Was de afgeleide sleutel + /// daaraan gelijk, dan lag de sleutel waarmee een bericht is versleuteld gewoon naast de + /// hash in diezelfde database - dat maakt de versleuteling zinloos. + /// zorgt er bovendien voor dat twee sleutels voor hetzelfde token, voor iets anders + /// gebruikt, elkaar niet gelijk zijn. + /// + public static byte[] DeriveKey(string token, string doel) => + HMACSHA256.HashData(Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(doel)); +} diff --git a/src/MijnBesluit.Application/Services/AccountOpheffenService.cs b/src/MijnBesluit.Application/Services/AccountOpheffenService.cs new file mode 100644 index 0000000..a4ae656 --- /dev/null +++ b/src/MijnBesluit.Application/Services/AccountOpheffenService.cs @@ -0,0 +1,317 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Application.Services; + +/// +/// Een account opheffen: alles weghalen wat van deze gebruiker is. +/// +/// Google Play eist dit van elke app waarin je een account kunt maken - in de app zelf én op een +/// webadres, voor wie de app niet meer heeft. Maar het is hier meer dan een eis van een winkel: +/// dit is een kluis, en wie hem sluit hoort erop te kunnen rekenen dat hij leeg is. +/// +/// De regel waar alles op teruggaat: weg is wat van jou is; blijven doet wat van een ander +/// is. Dat klinkt vanzelfsprekend en is het niet, want in deze applicatie zitten die twee +/// dicht tegen elkaar aan. Per soort: +/// +/// +/// Je kluis - items, bijlagen, de bestanden op schijf, en je sleutel. Weg. Zonder +/// die sleutel is er sowieso niets meer te openen, maar we laten de rijen niet staan: een lege +/// kluis is iets anders dan een onleesbare. +/// +/// Berichten die nog niet bezorgd zijn - weg. Ze stonden klaar en komen er nooit; +/// iemand die zijn account opheft heeft ze niet verstuurd. +/// +/// Berichten die al bezorgd zijn - blijven staan bij de ontvanger. Die heeft ze +/// gekregen, gelezen misschien, en ze zijn vanaf dat moment van hem. Ze blijven leesbaar ook: +/// bij het bezorgen krijgt de ontvanger zijn eigen kopie van de sleutel, dus jouw sleutel +/// weggooien maakt ze niet stuk. Dat dit kan is geen toeval - zie MessageService. +/// +/// Berichten die anderen aan jou stuurden - blijven van hen, maar jouw toegang +/// gaat eraf. Je staat in hun uitgaande lijst; dat weghalen zou hun aantekening wissen. +/// +/// Je eigen families en de mensen die je daarin hebt gezet - weg, voor zover die +/// mensen nergens anders aan hangen. Heeft iemand zelf een account genomen, of staat hij ook in +/// de lijst van een ander, dan blijft hij staan: dan is hij niet meer alleen jouw aantekening. +/// +/// Jouw naam in de lijst van een ander - blijft staan. Dat is zijn aantekening over +/// jou, niet jouw gegeven. Wat eraf gaat is alles wat er aan jou vastzat: je e-mailadres, je +/// geboortedatum, je foto, en het feit dat het profiel van jou wás. Wat overblijft is een naam +/// in andermans lijst, zoals iedereen die nog nooit van MijnBesluit heeft gehoord. +/// +/// Nalatenschap - je aanwijzingen vervallen, in beide richtingen: wie jij had +/// aangewezen en bij wie jij was aangewezen. Een beheerder zonder account kan niets, en dat +/// stilletjes laten staan zou iemand een zekerheid geven die er niet is. Je overlijdensmeldingen +/// over anderen vervallen ook - een opgeheven account kan nergens meer voor instaan. +/// +/// Het logboek - blijft. Dat is de enige tabel die nooit gewijzigd of gewist wordt, +/// en die is er juist voor de vraag achteraf wie wat wanneer deed. Er staat geen inhoud in. +/// +/// +/// Er is geen weg terug en er is geen prullenbak. Daarom vraagt dit een verse code per e-mail: +/// een openstaande sessie op een telefoon die iemand anders in handen heeft, hoort een kluis +/// niet leeg te kunnen gooien. +/// +public class AccountOpheffenService +{ + private readonly IUserRepository _users; + private readonly IPersonRepository _people; + private readonly IFamilyRepository _families; + private readonly IInvitationRepository _invitations; + private readonly IVaultRepository _vault; + private readonly IMessageRepository _messages; + private readonly ILegacyRepository _legacy; + private readonly ILoginCodeRepository _codes; + private readonly IToestelRepository _toestellen; + private readonly IFileStore _files; + private readonly IEmailSender _email; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + + public AccountOpheffenService(IUserRepository users, IPersonRepository people, IFamilyRepository families, + IInvitationRepository invitations, IVaultRepository vault, + IMessageRepository messages, ILegacyRepository legacy, + ILoginCodeRepository codes, IToestelRepository toestellen, + IFileStore files, IEmailSender email, IAuditLog audit, + IUnitOfWork uow, IClock clock) + { + _users = users; + _people = people; + _families = families; + _invitations = invitations; + _vault = vault; + _messages = messages; + _legacy = legacy; + _codes = codes; + _toestellen = toestellen; + _files = files; + _email = email; + _audit = audit; + _uow = uow; + _clock = clock; + } + + /// Wat er weggaat, zodat het scherm het kan laten zien vóórdat iemand ja zegt. + public record Telling(int Kluisitems, int Bestanden, int BerichtenKlaar, int BerichtenBezorgd, + int Mensen, int Nalatenschappen); + + /// + /// Telt op wat er verdwijnt. Leest alleen; er verandert hier niets. + /// + /// Dit staat er omdat "weet je het zeker?" een lege vraag is. "Je kluis met 23 items, 4 + /// berichten die nog klaarstonden" is een vraag waar iemand echt over nadenkt. + /// + public async Task> TelOpAsync(long gebruikerId, CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(gebruikerId, ct); + if (gebruiker is null) return Result.Fail("Account niet gevonden."); + + var persoonId = gebruiker.PersonId; + + var items = await _vault.ListForPersonAsync(persoonId, null, ct); + var bestanden = await _vault.ListFilesOwnedByAsync(persoonId, ct); + var berichten = await _messages.ListAllTouchingAsync(persoonId, ct); + var vanMij = berichten.Where(m => m.FromPersonId == persoonId).ToList(); + + var mensen = 0; + foreach (var familie in await _families.ListOwnedAsync(persoonId, ct)) + mensen += (await _families.ListMembersAsync(familie.Id, ct)).Count(m => m.PersonId != persoonId); + + var nalatenschappen = (await _legacy.ListAssignmentsOfAsync(persoonId, ct)).Count; + + return Result.Ok(new Telling( + items.Count, + bestanden.Count, + vanMij.Count(m => m.DeliveredAt is null), + vanMij.Count(m => m.DeliveredAt is not null), + mensen, + nalatenschappen)); + } + + /// + /// Heft het account op. Onomkeerbaar. + /// + /// De aanroeper hoort eerst te hebben vastgesteld dat het echt deze persoon is - met een + /// verse code per e-mail. Deze dienst controleert dat niet zelf: hij weet niet hoe iemand + /// binnenkwam. + /// + public async Task VerwijderAsync(long gebruikerId, CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(gebruikerId, ct); + if (gebruiker is null) return Result.Fail("Account niet gevonden."); + + var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct); + if (persoon is null) return Result.Fail("Je profiel is niet gevonden."); + + var adres = gebruiker.Email; + var naam = persoon.DisplayName; + var persoonId = persoon.Id; + var nu = _clock.UtcNow; + + // Eerst het logboek, en pas daarna weghalen: daarna is er geen gebruiker meer om het op + // te schrijven. Dit is de enige rij die deze handeling overleeft. + await _audit.WriteAsync(AuditAction.AccountOpgeheven, gebruikerId, nameof(User), gebruikerId, + "account opgeheven op verzoek van de gebruiker", ct); + + // ---------------------------------------------------------------- de kluis + foreach (var item in await _vault.ListForPersonAsync(persoonId, null, ct)) + { + await _vault.RemoveGrantsAsync(item.Id, ct); + _vault.Remove(item); + } + + // Alle bestanden van deze persoon, ook de losse: profielfoto's, bijlagen bij berichten. + // Eerst van schijf, dan de rij. Andersom zou bij een fout halverwege een bestand + // achterlaten waar niets meer naar wijst. + foreach (var bestand in await _vault.ListFilesOwnedByAsync(persoonId, ct)) + { + if (!string.IsNullOrWhiteSpace(bestand.StorageKey)) + { + // Een bestand dat al weg is hoort het opheffen niet tegen te houden. + try { await _files.DeleteAsync(bestand.StorageKey, ct); } + catch (Exception) { /* dan blijft er een verweesd bestand staan; de rij gaat wel weg */ } + } + + _vault.Remove(bestand); + } + + // ---------------------------------------------------------------- berichten + foreach (var bericht in await _messages.ListAllTouchingAsync(persoonId, ct)) + { + // Van jou en nog niet bezorgd: weg. Van jou en al bezorgd: blijft bij de ontvanger, + // die heeft hem gekregen. Aan jou: blijft van de afzender; jouw toegang gaat + // hieronder in één keer eraf. + if (bericht.FromPersonId == persoonId && bericht.DeliveredAt is null) + _messages.Remove(bericht); + } + + await _vault.RemoveGrantsForUserAsync(gebruikerId, ct); + + // ---------------------------------------------------------------- nalatenschap + foreach (var rol in await _legacy.ListAssignmentsOfAsync(persoonId, ct)) _legacy.Remove(rol); + foreach (var rol in await _legacy.ListManagersForAsync(persoonId, ct)) _legacy.Remove(rol); + + foreach (var melding in await _legacy.ListDeclarationsByUserAsync(gebruikerId, ct)) + _legacy.Remove(melding); + foreach (var melding in await _legacy.ListAllDeclarationsAsync(persoonId, ct)) + _legacy.Remove(melding); + + // ---------------------------------------------------------------- uitnodigingen + foreach (var uitnodiging in await _invitations.ListSentByUserAsync(gebruikerId, ct)) + _invitations.Remove(uitnodiging); + + // Ook de uitnodigingen die aan jou gericht waren. Een openstaande uitnodiging is een + // sleutel tot een profiel; die hoort niet te blijven liggen bij een profiel dat van + // niemand meer is. + foreach (var uitnodiging in await _invitations.ListAllForPersonAsync(persoonId, ct)) + _invitations.Remove(uitnodiging); + + // ---------------------------------------------------------------- toestellen en codes + foreach (var toestel in await _toestellen.ListForUserAsync(gebruikerId, ct)) _toestellen.Remove(toestel); + await _codes.RemoveForEmailAsync(adres, ct); + + // ---------------------------------------------------------------- families + await _uow.SaveChangesAsync(ct); + + foreach (var familie in await _families.ListOwnedAsync(persoonId, ct)) + { + foreach (var lid in await _families.ListMembersAsync(familie.Id, ct)) + { + var vanJou = lid.PersonId != persoonId + ? await _people.FindByIdAsync(lid.PersonId, ct) + : null; + + _families.Remove(lid); + + // Een aantekening die alleen aan deze familie hing, gaat mee weg. Hangt er meer + // aan - de persoon heeft zelf een account genomen, staat ook in de lijst van een + // ander, of er liggen berichten - dan blijft hij staan. Dan is het niet meer + // alleen jouw aantekening. + if (vanJou is not null && await IsAlleenVanJouAsync(vanJou, persoonId, familie.Id, ct)) + _people.Remove(vanJou); + } + + _families.Remove(familie); + } + + await _uow.SaveChangesAsync(ct); + + // ---------------------------------------------------------------- jij zelf + // Staat er nergens meer iets naar dit profiel te wijzen, dan gaat het profiel zelf ook + // weg. Is dat niet zo - je staat in de lijst van iemand anders, of er ligt een bezorgd + // bericht van je - dan blijft de rij staan met alleen nog de naam erin. Die naam is de + // aantekening van een ander over jou. + var nogInGebruik = (await _families.ListMembershipsOfPersonAsync(persoonId, ct)).Count > 0 || + (await _messages.ListAllTouchingAsync(persoonId, ct)).Count > 0; + + if (nogInGebruik) + { + persoon.Email = null; + persoon.MobileNumber = null; + persoon.BirthDate = null; + persoon.Nickname = null; + persoon.ClaimedByUserId = null; + persoon.ClaimedAt = null; + persoon.ManagedByUserId = null; + persoon.CreatedByUserId = null; + persoon.ObjectionTokenHash = null; + persoon.ObjectionTokenExpiresAt = null; + persoon.UpdatedAt = nu; + } + else + { + _people.Remove(persoon); + } + + _users.Remove(gebruiker); + await _uow.SaveChangesAsync(ct); + + // Pas melden als het echt weg is. Een bevestiging van iets dat halverwege strandde is + // erger dan geen bevestiging. + try + { + await _email.SendAsync(adres, "Je MijnBesluit is opgeheven", + $"

Dag {Veilig(naam)},

" + + "

Je account bij MijnBesluit is opgeheven. Je kluis, je bestanden en de berichten " + + "die nog klaarstonden zijn verwijderd. Wat al bezorgd was, blijft bij degene die het " + + "heeft gekregen.

" + + "

Je kunt op elk moment opnieuw beginnen met hetzelfde e-mailadres; er staat niets " + + "meer van vroeger klaar.

" + + "

Met vriendelijke groet,
het MijnBesluit-team

", ct); + } + catch (Exception) { /* Het account is weg; een bericht dat niet aankomt verandert dat niet. */ } + + return Result.Ok(); + } + + /// + /// Hangt deze persoon alleen aan de familie die nu wordt opgeheven? Zo ja, dan was het puur + /// jouw aantekening en gaat hij mee. Zo nee, dan is hij van meer mensen dan van jou alleen. + /// + private async Task IsAlleenVanJouAsync(Person persoon, long mijnPersoonId, long familieId, + CancellationToken ct) + { + if (persoon.Id == mijnPersoonId) return false; + if (persoon.ClaimedByUserId is not null) return false; // hij heeft zelf een account + + var lidmaatschappen = await _families.ListMembershipsOfPersonAsync(persoon.Id, ct); + if (lidmaatschappen.Any(l => l.FamilyId != familieId)) return false; + + if ((await _messages.ListAllTouchingAsync(persoon.Id, ct)).Count > 0) return false; + if ((await _legacy.ListAssignmentsOfAsync(persoon.Id, ct)).Count > 0) return false; + if ((await _legacy.ListManagersForAsync(persoon.Id, ct)).Count > 0) return false; + + // Zijn eigen kluis en bestanden horen bij hém, niet bij jou. Heeft hij die, dan blijft + // hij staan - ook al is hij nooit toegekomen aan het aannemen van je uitnodiging. + if ((await _vault.ListForPersonAsync(persoon.Id, null, ct)).Count > 0) return false; + if ((await _vault.ListFilesOwnedByAsync(persoon.Id, ct)).Count > 0) return false; + + return true; + } + + private static string Veilig(string tekst) => + tekst.Replace("&", "&").Replace("<", "<").Replace(">", ">"); +} diff --git a/src/MijnBesluit.Application/Services/AccountService.cs b/src/MijnBesluit.Application/Services/AccountService.cs new file mode 100644 index 0000000..ff3357e --- /dev/null +++ b/src/MijnBesluit.Application/Services/AccountService.cs @@ -0,0 +1,809 @@ +using System.Net.Mail; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Application.Security; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Application.Services; + +/// Wat het scherm moet weten nadat er een code is verstuurd. +public record CodeVerstuurd(string Email, DateTime GeldigTot) +{ + /// Het adres deels afgeschermd, om op het scherm te tonen: j***n@kwik-bit.nl. + public string AfgeschermdAdres + { + get + { + var apenstaartje = Email.IndexOf('@'); + if (apenstaartje <= 1) return Email; + var naam = Email[..apenstaartje]; + var zichtbaar = naam.Length <= 2 ? naam[..1] : naam[..1] + new string('*', naam.Length - 2) + naam[^1..]; + return zichtbaar + Email[apenstaartje..]; + } + } +} + +/// Wat er bij een adverteerderscode is meegegeven voor een nieuw account. +public record Aanmeldgegevens(string Bedrijfsnaam, string Contactpersoon); + +/// Je eigen gegevens, zoals het profielscherm ze toont. +public record EigenProfiel(long PersoonId, string Voornaam, string Achternaam, string Email, + DateOnly? Geboortedatum, DateTime LidSinds); + +/// +/// Inloggen en registreren met een code per e-mail. Er zijn geen wachtwoorden: wie bij het +/// e-mailadres kan, kan inloggen. +/// +/// De gang van zaken is dezelfde als in de WinDev-versie — e-mailadres invullen, code aanvragen, +/// code invullen — met twee verschillen die bewust zijn gemaakt: +/// +/// +/// De code staat niet leesbaar in de database en wordt nooit naar de browser gestuurd. +/// In de WinDev-versie stond hij in mlPeople.ActivationCode en werd hij in een +/// paginavariabele meegegeven aan de browser; wie de paginabron bekeek, kon zo inloggen. +/// Zes cijfers in plaats van vier, met een rem op het aantal pogingen en op het aantal +/// aanvragen per uur. +/// +/// +public class AccountService +{ + /// Hoe lang een code bruikbaar blijft. + public static readonly TimeSpan CodeGeldigheid = TimeSpan.FromMinutes(15); + + /// Zoveel keer mag er per code fout geraden worden; daarna moet er een nieuwe komen. + public const int MaxPogingen = 5; + + /// Zoveel codes per e-mailadres per uur, zodat niemands postvak volloopt. + public const int MaxAanvragenPerUur = 5; + + /// Zo lang moet er tussen twee aanvragen zitten. + public static readonly TimeSpan MinimaleWachttijd = TimeSpan.FromSeconds(30); + + private readonly IUserRepository _users; + private readonly IPersonRepository _people; + private readonly IFamilyRepository _families; + private readonly ILoginCodeRepository _codes; + private readonly IMasterKeyProtector _keys; + private readonly IEmailSender _email; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + private readonly IVaultRepository _vault; + + public AccountService(IUserRepository users, IPersonRepository people, IFamilyRepository families, + ILoginCodeRepository codes, IMasterKeyProtector keys, IEmailSender email, + IAuditLog audit, IUnitOfWork uow, IClock clock, IVaultRepository vault) + { + _users = users; _people = people; _families = families; _codes = codes; + _keys = keys; _email = email; _audit = audit; _uow = uow; _clock = clock; _vault = vault; + } + + // ------------------------------------------------------------------ code aanvragen + + /// + /// Stuurt een inlogcode naar een bestaand account. + /// + public async Task> VraagInlogcodeAanAsync(string email, string? vanafIp = null, + CancellationToken ct = default) + { + email = Normaliseer(email); + if (!IsGeldigAdres(email)) + return Result.Fail("Vul een geldig e-mailadres in a.u.b."); + + var gebruiker = await _users.FindByEmailAsync(email, ct); + if (gebruiker is null) + return Result.Fail( + "We kennen dit e-mailadres niet. Ben je nieuw? Maak dan eerst een account aan."); + + if (gebruiker.Status != UserStatus.Actief) + return Result.Fail("Dit is geen actief gebruikersaccount."); + + return await StuurCodeAsync(email, LoginCodePurpose.Inloggen, null, null, vanafIp, ct); + } + + /// + /// Stuurt een code om een nieuw account aan te maken. De naam wordt bij de code bewaard en + /// pas gebruikt zodra de code klopt. + /// + public async Task> VraagRegistratiecodeAanAsync(string email, string voornaam, + string achternaam, string? vanafIp = null, + CancellationToken ct = default) + { + email = Normaliseer(email); + if (!IsGeldigAdres(email)) + return Result.Fail("Vul een geldig e-mailadres in a.u.b."); + if (string.IsNullOrWhiteSpace(voornaam)) + return Result.Fail("Vul je voornaam in."); + + if (await _users.EmailExistsAsync(email, ct)) + return Result.Fail( + "Er bestaat al een account met dit e-mailadres. Log in met een code."); + + return await StuurCodeAsync(email, LoginCodePurpose.Registreren, voornaam.Trim(), achternaam.Trim(), vanafIp, ct); + } + + /// + /// Stuurt een code naar iemand die wil adverteren. Bewust een eigen doel: een code voor het + /// adverteerdersgedeelte hoort nooit ergens anders te werken, en andersom net zo min. + /// + /// Er hoeft hier géén account te bestaan. Wie voor het eerst komt vult daarna zijn + /// bedrijfsgegevens in; wie terugkomt ziet zijn campagnes. Dat betekent ook dat iemand een + /// code kan laten sturen naar een adres dat niet van hem is - daar zijn dezelfde remmen voor + /// als bij het gewone inloggen: één per 30 seconden, vijf per uur. + /// + /// + /// Alleen bij een nieuw account. Wordt bij de code bewaard en pas gebruikt zodra die klopt - + /// net als de naam bij het registreren van een gebruiker. Zo hoeft het scherm niets te + /// onthouden tussen twee formulieren door, en kan niemand zich een bedrijfsnaam aanmeten + /// zonder eerst bij het e-mailadres te kunnen. + /// + public Task> VraagAdverteerderscodeAanAsync(string email, string? bedrijfsnaam = null, + string? contactpersoon = null, + string? vanafIp = null, + CancellationToken ct = default) + { + email = Normaliseer(email); + if (!IsGeldigAdres(email)) + return Task.FromResult(Result.Fail("Vul een geldig e-mailadres in a.u.b.")); + + return StuurCodeAsync(email, LoginCodePurpose.Adverteerder, bedrijfsnaam?.Trim(), + contactpersoon?.Trim(), vanafIp, ct); + } + + /// + /// Kijkt of de code van een adverteerder klopt en stempelt hem af. Geeft de bedrijfsnaam en + /// contactpersoon terug die bij de aanvraag zijn meegegeven; die zijn leeg als iemand gewoon + /// opnieuw inlogt. + /// + public async Task> ControleerAdverteerderscodeAsync( + string email, string code, CancellationToken ct = default) + { + email = Normaliseer(email); + + // Ophalen vóór het afstempelen: daarna is de regel niet meer open en zijn de gegevens + // niet meer te vinden. + var openstaand = await _codes.FindOpenAsync(email, LoginCodePurpose.Adverteerder, _clock.UtcNow, ct); + var gegevens = new Aanmeldgegevens(openstaand?.FirstName ?? "", openstaand?.LastName ?? ""); + + var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Adverteerder, ct); + return controle.Succeeded ? Result.Ok(gegevens) + : Result.Fail(controle.Error!); + } + + /// + /// Stuurt een code waarmee iemand zijn account kan opheffen. Een eigen doel, zodat deze code + /// nergens anders voor werkt en een gewone inlogcode hier niet volstaat. + /// + /// Waarom er überhaupt een code aan te pas komt: opheffen is onomkeerbaar, en een openstaande + /// sessie op een telefoon die iemand anders in handen heeft hoort een kluis niet leeg te + /// kunnen gooien. Je moet dus op dat moment bij het e-mailadres kunnen. + /// + public async Task> VraagOpheffencodeAanAsync(string email, string? vanafIp = null, + CancellationToken ct = default) + { + email = Normaliseer(email); + if (!IsGeldigAdres(email)) + return Result.Fail("Vul een geldig e-mailadres in a.u.b."); + + // Bestaat het account niet, dan zeggen we dat gewoon: dit scherm is alleen te bereiken + // door wie al is ingelogd, of door wie zelf zijn adres intypt op de openbare pagina. In + // dat tweede geval helpt "we kennen dit adres niet" iemand die een typefout maakt, en + // levert het niemand iets op wat hij niet al kon weten door een code aan te vragen. + var gebruiker = await _users.FindByEmailAsync(email, ct); + if (gebruiker is null) + return Result.Fail("We kennen dit e-mailadres niet."); + + return await StuurCodeAsync(email, LoginCodePurpose.Opheffen, null, null, vanafIp, ct); + } + + /// Kijkt of de opheffingscode klopt en stempelt hem af. Geeft het account terug. + public async Task> ControleerOpheffencodeAsync(string email, string code, + CancellationToken ct = default) + { + email = Normaliseer(email); + + var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Opheffen, ct); + if (!controle.Succeeded) return Result.Fail(controle.Error!); + + var gebruiker = await _users.FindByEmailAsync(email, ct); + return gebruiker is null ? Result.Fail("Account niet gevonden.") : Result.Ok(gebruiker); + } + + private async Task> StuurCodeAsync(string email, LoginCodePurpose doel, string? voornaam, + string? achternaam, string? vanafIp, CancellationToken ct) + { + var nu = _clock.UtcNow; + + // Rem op het aantal aanvragen: anders is dit een manier om iemands postvak vol te gooien. + var recent = await _codes.ListOpenAsync(email, doel, nu, ct); + var laatste = recent.OrderByDescending(c => c.CreatedAt).FirstOrDefault(); + if (laatste is not null && nu - laatste.CreatedAt < MinimaleWachttijd) + { + var seconden = (int)Math.Ceiling((MinimaleWachttijd - (nu - laatste.CreatedAt)).TotalSeconds); + return Result.Fail($"Er is net een code verstuurd. Wacht nog {seconden} seconden."); + } + + // Alleen codes die NIET zijn gebruikt tellen mee. Deze rem is er om te voorkomen dat + // iemand het postvak van een ander volgooit, en wie de code invult zit zelf in dat + // postvak - dat is geen misbruik maar een geslaagde aanmelding. Telden gebruikte codes + // wel mee, dan sloot de app zichzelf buiten na vijf keer inloggen in een uur, precies + // wanneer er iets anders aan de hand was en je het een paar keer opnieuw probeerde. + if (await _codes.CountSinceAsync(email, nu.AddHours(-1), ct) >= MaxAanvragenPerUur) + return Result.Fail( + "Er zijn te veel codes aangevraagd voor dit e-mailadres. Probeer het over een uur opnieuw."); + + var code = InlogcodeGenerator.Nieuw(); + var geldigTot = nu.Add(CodeGeldigheid); + + // Eerst versturen, dan pas vastleggen. Lukt het versturen niet, dan verandert er niets: + // geen code die klaarstaat maar nooit aankomt, en de vorige code blijft gewoon geldig. + // De oorzaak van de fout staat in het logboek van de server. + try + { + await _email.SendAsync(email, BouwCodeOnderwerp(code, doel), BouwCodeMail(code, doel), ct); + } + catch (Exception) + { + return Result.Fail( + "We konden de e-mail met je code niet versturen. Probeer het zo nog eens; " + + "blijft het misgaan, dan klopt er iets niet aan de e-mailinstellingen."); + } + + // Een nieuwe code maakt de vorige ongeldig; anders zouden er meerdere tegelijk werken. + foreach (var oud in recent) oud.ConsumedAt = nu; + + _codes.Add(new LoginCode + { + Email = email, + Purpose = doel, + CodeHash = SecretHasher.Hash(code), + FirstName = voornaam, + LastName = achternaam, + CreatedAt = nu, + ExpiresAt = geldigTot, + RequestedFromIp = vanafIp + }); + + await _audit.WriteAsync(AuditAction.InlogcodeAangevraagd, null, nameof(LoginCode), null, email, ct); + await _uow.SaveChangesAsync(ct); + + return Result.Ok(new CodeVerstuurd(email, geldigTot)); + } + + // ------------------------------------------------------------------ code gebruiken + + /// Logt in met een code. De code werkt één keer. + public async Task> LogInMetCodeAsync(string email, string code, CancellationToken ct = default) + { + email = Normaliseer(email); + var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Inloggen, ct); + if (!controle.Succeeded) return Result.Fail(controle.Error!); + + var nu = _clock.UtcNow; + var gebruiker = await _users.FindByEmailAsync(email, ct); + if (gebruiker is null) return Result.Fail("Dit account bestaat niet meer."); + if (gebruiker.Status != UserStatus.Actief) return Result.Fail("Dit is geen actief gebruikersaccount."); + + gebruiker.FailedLoginCount = 0; + gebruiker.LockedUntil = null; + gebruiker.LastLoginAt = nu; + gebruiker.UpdatedAt = nu; + gebruiker.EmailConfirmedAt ??= nu; + + await _audit.WriteAsync(AuditAction.Inloggen, gebruiker.Id, nameof(User), gebruiker.Id, null, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(gebruiker); + } + + /// Maakt het account aan zodra de registratiecode klopt. + public async Task> RegistreerMetCodeAsync(string email, string code, CancellationToken ct = default) + { + email = Normaliseer(email); + + // De naam is bij de code bewaard, dus die halen we op vóórdat hij wordt afgestempeld. + var openstaand = await _codes.FindOpenAsync(email, LoginCodePurpose.Registreren, _clock.UtcNow, ct); + var voornaam = openstaand?.FirstName ?? string.Empty; + var achternaam = openstaand?.LastName ?? string.Empty; + + var controle = await ControleerCodeAsync(email, code, LoginCodePurpose.Registreren, ct); + if (!controle.Succeeded) return Result.Fail(controle.Error!); + + return await MaakAccountAsync(email, voornaam, achternaam, ct); + } + + /// + /// Kijkt of de ingevulde code klopt en stempelt hem af. Telt mislukte pogingen, zodat er niet + /// eindeloos geraden kan worden. + /// + private async Task ControleerCodeAsync(string email, string ingevoerd, LoginCodePurpose doel, + CancellationToken ct) + { + var nu = _clock.UtcNow; + var code = InlogcodeGenerator.Normaliseer(ingevoerd); + + if (string.IsNullOrEmpty(code)) return Result.Fail("Je moet een code invullen!"); + + var openstaand = await _codes.FindOpenAsync(email, doel, nu, ct); + if (openstaand is null) + { + await _audit.WriteAsync(AuditAction.InloggenMislukt, null, nameof(LoginCode), null, email, ct); + await _uow.SaveChangesAsync(ct); + return Result.Fail("Deze code is verlopen of al gebruikt. Vraag een nieuwe code aan."); + } + + if (!SecretHasher.Verify(code, openstaand.CodeHash)) + { + openstaand.Attempts++; + var resterend = MaxPogingen - openstaand.Attempts; + + if (resterend <= 0) + { + openstaand.ConsumedAt = nu; + await _audit.WriteAsync(AuditAction.InloggenMislukt, null, nameof(LoginCode), openstaand.Id, email, ct); + await _uow.SaveChangesAsync(ct); + return Result.Fail("De code is te vaak fout ingevuld. Vraag een nieuwe code aan."); + } + + await _uow.SaveChangesAsync(ct); + return Result.Fail(resterend == 1 + ? "Deze code klopt niet. Je hebt nog één poging." + : $"Deze code klopt niet. Je hebt nog {resterend} pogingen."); + } + + // Twee velden, en dat is met opzet. ConsumedAt zegt "werkt niet meer" en staat ook + // gevuld bij een code die is verdrongen of stukgeraden; SignedInAt zegt "hier is iemand + // mee binnengekomen". Alleen dat laatste is het bewijs dat de aanvrager zelf in dat + // postvak zit, en alleen dat telt niet mee voor de rem op aanvragen. + openstaand.ConsumedAt = nu; + openstaand.SignedInAt = nu; + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + // ------------------------------------------------------------------ account aanmaken + + /// + /// Maakt een account met een eigen profiel en een eigen familieproject. Bestaat er al een + /// profiel met dit e-mailadres omdat iemand anders het heeft aangemaakt, dan wordt dat + /// geclaimd in plaats van gedupliceerd. + /// + /// + /// Het profiel dat aan dit account gekoppeld moet worden. Geef dit mee als je het al weet - + /// bij een uitnodiging bijvoorbeeld, waar de uitnodiging zelf zegt om welk profiel het gaat. + /// Laat het leeg en er wordt op e-mailadres gezocht. + /// + /// Zoeken op adres is niet genoeg: wie een uitnodiging krijgt en daarna van adres verandert + /// (of van wie het adres wordt gecorrigeerd), zou dan een tweede profiel krijgen terwijl het + /// eerste onbeheerd achterblijft. + /// + public async Task> MaakAccountAsync(string email, string voornaam, string achternaam, + CancellationToken ct = default, Person? profiel = null) + { + email = Normaliseer(email); + if (await _users.EmailExistsAsync(email, ct)) + return Result.Fail("Er bestaat al een account met dit e-mailadres."); + + var nu = _clock.UtcNow; + + // Alléén het profiel uit de uitnodiging, en niet ook nog zoeken op e-mailadres. Wie + // zonder uitnodiging een account maakt, krijgt zijn eigen profiel - ook als een ander al + // een aantekening met dat adres had staan. Dat is de regel uit docs/Identiteit.md: het + // adres speelt geen rol bij het koppelen, alleen de link die iemand zelf aanklikt. Zoeken + // op adres betekende andermans aantekening adopteren zonder dat die ander daar iets over + // te zeggen had, en sinds er meerdere aantekeningen met hetzelfde adres kunnen bestaan + // was het bovendien de vraag welke je dan kreeg. + // + // De aantekening die een ander over je heeft, komt alsnog bij je terecht op het moment + // dat je zijn uitnodiging aanneemt; dan gaat hij op in het profiel dat je dan al hebt. + var persoon = profiel; + if (persoon is not null) + { + // Het account bepaalt vanaf nu het adres van dit profiel. + persoon.Email = email; + persoon.UpdatedAt = nu; + } + + if (persoon is null) + { + persoon = new Person + { + FirstName = (voornaam ?? string.Empty).Trim(), + LastName = (achternaam ?? string.Empty).Trim(), + Email = email, + CreatedAt = nu, + UpdatedAt = nu + }; + _people.Add(persoon); + } + + var dataSleutel = EnvelopeCrypto.NewKey(); + var gebruiker = new User + { + Email = email, + WrappedDataKey = _keys.Protect(dataSleutel), + Person = persoon, + EmailConfirmedAt = nu, // de code is per e-mail aangekomen, dus het adres klopt + LastLoginAt = nu, + CreatedAt = nu, + UpdatedAt = nu + }; + _users.Add(gebruiker); + await _uow.SaveChangesAsync(ct); + + Domain.Rules.ProfileOwnership.Claim(persoon, gebruiker.Id, nu); + + // Foto's en documenten die een beheerder voor dit profiel heeft toegevoegd terwijl het + // nog niet geclaimd was, staan op naam van die beheerder - hij was op dat moment + // degene die was ingelogd. Zonder dit blijft zo'n bestand voor altijd van de beheerder: + // de persoon zelf kan zijn eigen profielfoto dan niet eens openen, laat staan wijzigen + // of verwijderen. We zetten ze daarom hier over, en wikkelen de sleutel van elk bestand + // opnieuw in - met de kluissleutel van de beheerder open, met die van deze nieuwe + // gebruiker weer dicht - anders zou de nieuwe eigenaar zijn eigen bestand niet kunnen + // ontsleutelen. + await BestandenOverzettenAsync(persoon.Id, dataSleutel, ct); + + var familie = new Family + { + Name = string.IsNullOrWhiteSpace(achternaam) + ? $"Familie van {persoon.DisplayName}" + : $"Familie {achternaam.Trim()}", + OwnerPersonId = persoon.Id, + CreatedAt = nu, + UpdatedAt = nu + }; + _families.Add(familie); + await _uow.SaveChangesAsync(ct); + + _families.Add(new FamilyMember + { + FamilyId = familie.Id, + PersonId = persoon.Id, + Relation = RelationType.Overig, + RelationLabel = "Ikzelf", + AddedByUserId = gebruiker.Id, + CreatedAt = nu + }); + + await _audit.WriteAsync(AuditAction.ProfielGeclaimd, gebruiker.Id, nameof(Person), persoon.Id, "registratie", ct); + await _uow.SaveChangesAsync(ct); + + Array.Clear(dataSleutel); + return Result.Ok(gebruiker); + } + + /// + /// Zet de bestanden van deze persoon die nog op naam van een beheerder staan over naar de + /// persoon zelf, met een verse sleutel. Onbereikbare beheerderaccounts of bestanden die om + /// wat voor reden dan ook niet meer te ontsleutelen zijn, slaan we gewoon over: dit is + /// opruimwerk bij het claimen van een profiel en mag nooit de registratie zelf laten + /// mislukken. + /// + private async Task BestandenOverzettenAsync(long personId, byte[] nieuweDataSleutel, CancellationToken ct) + { + var bestanden = await _vault.ListFilesForPersonAsync(personId, ct); + var sleutelsPerBeheerder = new Dictionary(); + + try + { + foreach (var bestand in bestanden) + { + if (bestand.OwnerPersonId == personId) continue; // al van hemzelf + + try + { + if (!sleutelsPerBeheerder.TryGetValue(bestand.OwnerPersonId, out var beheerderSleutel)) + { + var beheerder = await _users.FindByPersonIdAsync(bestand.OwnerPersonId, ct); + if (beheerder is null) continue; // niets meer aan te ontsleutelen + + beheerderSleutel = _keys.Unprotect(beheerder.WrappedDataKey); + sleutelsPerBeheerder[bestand.OwnerPersonId] = beheerderSleutel; + } + + var contentKey = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, beheerderSleutel); + try + { + bestand.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, nieuweDataSleutel); + bestand.OwnerPersonId = personId; + } + finally { Array.Clear(contentKey); } + } + catch (Exception) + { + // Dit ene bestand blijft dan van de beheerder, maar de rest van het profiel + // mag daar niet op vastlopen. + } + } + + if (sleutelsPerBeheerder.Count > 0) await _uow.SaveChangesAsync(ct); + } + finally + { + foreach (var sleutel in sleutelsPerBeheerder.Values) Array.Clear(sleutel); + } + } + + // ------------------------------------------------------- aantekeningen over mij + + /// Een aantekening die iemand anders over dit adres heeft staan. + /// Hoe degene die hem bijhoudt heet. Zonder naam is de vraag niet te beantwoorden. + /// Wat hij van je zou zijn, zoals die ander het invulde. Null als hij je nergens bij heeft staan. + public record Aantekeningaanbod(long PersoonId, string Van, string? Relatie, DateTime AangemaaktOp); + + /// + /// Heeft iemand een profiel voor mij aangemaakt? + /// + /// Dit is de middenweg tussen twee dingen die allebei niet deugen. Automatisch koppelen op + /// e-mailadres niet, want een adres is geen identiteit: twee mensen kunnen er één delen en + /// één mens heeft er vaak meerdere, en dan maak je van twee mensen stilletjes één. En niets + /// doen ook niet, want dan moet iedereen die zich aanmeldt eerst een uitnodiging afwachten + /// terwijl er al een aantekening over hem klaarligt. + /// + /// Dus vragen we het aan de enige die het antwoord weet: de persoon zelf, die net met een + /// code in dat postvak heeft bewezen dat het adres van hem is. Zolang hij niets zegt gebeurt + /// er niets, en degene die de aantekening bijhoudt merkt van deze hele vraag niets. + /// + /// Zie docs/Identiteit.md. + /// + public async Task> AantekeningenOverMijAsync( + long gebruikerId, CancellationToken ct = default) + { + var ik = await _users.FindByIdAsync(gebruikerId, ct); + if (ik is null || string.IsNullOrWhiteSpace(ik.Email)) return Array.Empty(); + + var aantekeningen = await _people.ListAantekeningenOverAdresAsync(Normaliseer(ik.Email), gebruikerId, ct); + if (aantekeningen.Count == 0) return Array.Empty(); + + var uitkomst = new List(aantekeningen.Count); + + foreach (var aantekening in aantekeningen) + { + if (aantekening.ManagedByUserId is not { } beheerderId) continue; + + var beheerder = await _users.FindByIdAsync(beheerderId, ct); + if (beheerder is null) continue; + + var beheerderProfiel = await _people.FindByIdAsync(beheerder.PersonId, ct); + if (beheerderProfiel is null) continue; + + // Wat hij van je zou zijn. Dat hoort erbij: "Jan de Vries heeft een profiel voor je + // aangemaakt" is een halve vraag, "als zijn schoonzoon" maakt hem beantwoordbaar. + var lidmaatschappen = await _families.ListMembershipsOfPersonAsync(aantekening.Id, ct); + var relatie = lidmaatschappen.Count > 0 + ? (lidmaatschappen[0].RelationLabel is { Length: > 0 } eigen + ? eigen + : Domain.Rules.Relaties.Naam(lidmaatschappen[0].Relation)) + : null; + + uitkomst.Add(new Aantekeningaanbod(aantekening.Id, beheerderProfiel.DisplayName, + relatie, aantekening.CreatedAt)); + } + + return uitkomst; + } + + /// + /// "Ja, dat ben ik." De aantekening gaat op in het profiel dat deze gebruiker al heeft. + /// + /// Precies hetzelfde als het aannemen van een uitnodiging terwijl je bent ingelogd - het is + /// dezelfde verklaring, alleen zonder de link. Wat er verhuist staat in SamenvoegService. + /// + public async Task NeemAantekeningOverAsync(long persoonId, long gebruikerId, + SamenvoegService samenvoegen, + CancellationToken ct = default) + { + var ik = await _users.FindByIdAsync(gebruikerId, ct); + if (ik is null) return Result.Fail("Account niet gevonden."); + + // Opnieuw ophalen en opnieuw toetsen: dit komt van een scherm, en een scherm is een + // wens en geen bewijs. Zonder deze regel kon iemand met een willekeurig persoonsnummer + // andermans aantekening naar zich toe trekken. + var aanbod = await _people.ListAantekeningenOverAdresAsync(Normaliseer(ik.Email), gebruikerId, ct); + if (aanbod.All(p => p.Id != persoonId)) + return Result.Fail("Dit profiel hoort niet (meer) bij jouw e-mailadres."); + + var gelukt = await samenvoegen.SamenvoegAsync(persoonId, ik.PersonId, gebruikerId, ct); + if (!gelukt.Succeeded) return Result.Fail(gelukt.Error!); + + await _audit.WriteAsync(AuditAction.ProfielGeclaimd, gebruikerId, nameof(Person), persoonId, + "aantekening overgenomen", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// "Nee, dat ben ik niet." Dan blijft alles zoals het is en wordt de vraag niet nog eens + /// gesteld. Degene die de aantekening bijhoudt krijgt hier geen bericht van: het weigeren + /// zou anders zelf een mededeling worden over wie er wel of niet een account heeft. + /// + public async Task WijsAantekeningAfAsync(long persoonId, long gebruikerId, + CancellationToken ct = default) + { + var ik = await _users.FindByIdAsync(gebruikerId, ct); + if (ik is null) return Result.Fail("Account niet gevonden."); + + var aanbod = await _people.ListAantekeningenOverAdresAsync(Normaliseer(ik.Email), gebruikerId, ct); + var aantekening = aanbod.FirstOrDefault(p => p.Id == persoonId); + if (aantekening is null) return Result.Fail("Dit profiel hoort niet (meer) bij jouw e-mailadres."); + + aantekening.LinkDeclinedByUserId = gebruikerId; + aantekening.LinkDeclinedAt = _clock.UtcNow; + + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + // ------------------------------------------------------------------ eigen profiel + + /// Je eigen gegevens ophalen voor het profielscherm. + public async Task> EigenProfielAsync(long gebruikerId, CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(gebruikerId, ct); + if (gebruiker is null) return Result.Fail("Account niet gevonden."); + + var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct); + if (persoon is null) return Result.Fail("Je profiel is niet gevonden."); + + return Result.Ok(new EigenProfiel( + persoon.Id, persoon.FirstName, persoon.LastName, gebruiker.Email, + persoon.BirthDate, gebruiker.CreatedAt)); + } + + /// + /// Wijzigt je eigen naam en geboortedatum. Het e-mailadres staat hier bewust niet bij: dat + /// is je inlog en gaat via een code naar het nieuwe adres. + /// + public async Task WijzigEigenProfielAsync(long gebruikerId, string voornaam, string achternaam, + DateOnly? geboortedatum, CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(gebruikerId, ct); + if (gebruiker is null) return Result.Fail("Account niet gevonden."); + + var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct); + if (persoon is null) return Result.Fail("Je profiel is niet gevonden."); + + voornaam = (voornaam ?? string.Empty).Trim(); + achternaam = (achternaam ?? string.Empty).Trim(); + if (voornaam.Length == 0 && achternaam.Length == 0) + return Result.Fail("Vul minstens een voor- of achternaam in."); + + // Een geboortedatum in de toekomst is geen tikfout die je stil wilt overnemen: daar gaat + // straks de sortering op leeftijd mee aan de haal. + if (geboortedatum is { } geboren && geboren > DateOnly.FromDateTime(_clock.UtcNow.Date)) + return Result.Fail("Een geboortedatum in de toekomst kan niet."); + + var nu = _clock.UtcNow; + persoon.FirstName = voornaam; + persoon.LastName = achternaam; + persoon.BirthDate = geboortedatum; + persoon.UpdatedAt = nu; + + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, gebruikerId, nameof(Person), persoon.Id, "eigen", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Begint het wijzigen van je inlogadres: er gaat een code naar het NIEUWE adres. Pas als die + /// code wordt ingevuld verandert er iets. Zo kan een openstaande sessie - of iemand die even + /// achter je scherm zit - je niet buitensluiten uit je eigen kluis. + /// + public async Task> VraagAdreswijzigingAanAsync(long gebruikerId, string nieuwAdres, + string? vanafIp = null, + CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(gebruikerId, ct); + if (gebruiker is null) return Result.Fail("Account niet gevonden."); + + nieuwAdres = Normaliseer(nieuwAdres); + if (!IsGeldigAdres(nieuwAdres)) + return Result.Fail("Vul een geldig e-mailadres in a.u.b."); + + if (nieuwAdres == gebruiker.Email) + return Result.Fail("Dit is al je e-mailadres."); + + if (await _users.EmailExistsAsync(nieuwAdres, ct)) + return Result.Fail("Er bestaat al een account met dit e-mailadres."); + + return await StuurCodeAsync(nieuwAdres, LoginCodePurpose.AdresWijzigen, null, null, vanafIp, ct); + } + + /// + /// Maakt de adreswijziging definitief. Het adres van je profiel gaat mee, en je wordt overal + /// uitgelogd: een sessie die met het oude adres is begonnen hoort niet door te lopen. + /// + public async Task BevestigAdreswijzigingAsync(long gebruikerId, string nieuwAdres, string code, + CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(gebruikerId, ct); + if (gebruiker is null) return Result.Fail("Account niet gevonden."); + + nieuwAdres = Normaliseer(nieuwAdres); + + // Nog een keer kijken. Tussen aanvragen en bevestigen kan iemand anders dit adres hebben + // geregistreerd; dan is de code geldig maar het adres niet meer vrij. + if (await _users.EmailExistsAsync(nieuwAdres, ct)) + return Result.Fail("Er bestaat inmiddels al een account met dit e-mailadres."); + + var controle = await ControleerCodeAsync(nieuwAdres, code, LoginCodePurpose.AdresWijzigen, ct); + if (!controle.Succeeded) return Result.Fail(controle.Error!); + + var nu = _clock.UtcNow; + var oud = gebruiker.Email; + + gebruiker.Email = nieuwAdres; + gebruiker.EmailConfirmedAt = nu; + gebruiker.SecurityStamp = Guid.NewGuid(); // alles wat nog openstaat is hiermee verlopen + gebruiker.UpdatedAt = nu; + + var persoon = await _people.FindByIdAsync(gebruiker.PersonId, ct); + if (persoon is not null) + { + persoon.Email = nieuwAdres; + persoon.UpdatedAt = nu; + } + + await _audit.WriteAsync(AuditAction.AdresGewijzigd, gebruikerId, nameof(User), gebruikerId, + $"{oud} -> {nieuwAdres}", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// Geeft de onversleutelde datasleutel van een gebruiker. Alleen binnen een geverifieerde sessie gebruiken. + public byte[] OpenDataKey(User user) => _keys.Unprotect(user.WrappedDataKey); + + /// Ruimt verlopen codes op; draaien als achtergrondtaak. + public Task RuimVerlopenCodesOpAsync(CancellationToken ct = default) => + _codes.RemoveExpiredAsync(_clock.UtcNow.AddDays(-1), ct); + + internal static string Normaliseer(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant(); + + /// Ziet dit eruit als een e-mailadres? Ook gebruikt bij het toevoegen van familie. + public static bool IsGeldigAdres(string email) + { + if (string.IsNullOrWhiteSpace(email) || email.Length > 255) return false; + try { return new MailAddress(email).Address == email; } + catch (FormatException) { return false; } + } + + /// + /// Het onderwerp van de codemail, met de code vooraan. + /// + /// Zo lees je hem van je vergrendelscherm zonder de mail te openen - dat scheelt precies de + /// handeling waar mensen op vastlopen. De keerzijde is dat de code dan ook zichtbaar is voor + /// wie over je schouder meekijkt; hij is tien minuten geldig en werkt één keer, en dat vinden + /// we het waard. Kort houden, anders knipt de telefoon hem af vóór de code. + /// + private static string BouwCodeOnderwerp(string code, LoginCodePurpose doel) => doel switch + { + LoginCodePurpose.Registreren => $"{code} is je code voor MijnBesluit", + LoginCodePurpose.AdresWijzigen => $"{code} is je code om je e-mailadres te wijzigen", + LoginCodePurpose.Adverteerder => $"{code} is je code voor adverteren op MijnBesluit", + LoginCodePurpose.Opheffen => $"{code} is je code om je MijnBesluit op te heffen", + _ => $"{code} is je inlogcode voor MijnBesluit" + }; + + private static string BouwCodeMail(string code, LoginCodePurpose doel) + { + var waarvoor = doel switch + { + LoginCodePurpose.Registreren => "om je account bij MijnBesluit aan te maken", + LoginCodePurpose.AdresWijzigen => "om dit adres aan je MijnBesluit-account te koppelen", + LoginCodePurpose.Adverteerder => "om in te loggen bij het adverteerdersgedeelte van MijnBesluit", + LoginCodePurpose.Opheffen => "om je account bij MijnBesluit op te heffen. Let op: daarmee " + + "verdwijnt je kluis, en dat is niet terug te draaien", + _ => "om in te loggen bij MijnBesluit" + }; + + return $""" +

Hallo,

+

Hierbij je code {waarvoor}:

+

{code}

+

De code is {(int)CodeGeldigheid.TotalMinutes} minuten geldig en kan één keer gebruikt worden.

+

Heb je zelf geen code aangevraagd? Dan hoef je niets te doen; zonder deze code + verandert er niets en kan niemand met je e-mailadres inloggen.

+

Met vriendelijke groet,
het MijnBesluit-team

+ """; + } +} diff --git a/src/MijnBesluit.Application/Services/AdvertentieService.cs b/src/MijnBesluit.Application/Services/AdvertentieService.cs new file mode 100644 index 0000000..9e81b22 --- /dev/null +++ b/src/MijnBesluit.Application/Services/AdvertentieService.cs @@ -0,0 +1,579 @@ +using System.Globalization; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Application.Services; + +/// Wat adverteren kost. Staat in de instellingen, zodat het tarief kan wijzigen zonder uitrol. +public class AdvertentieOptions +{ + /// + /// Wat één vertoning per uur per dag kost, in centen. + /// + /// De rekensom is: dagen × vertoningen per uur × dit bedrag. Dertig dagen, vijf keer per uur + /// en een tarief van 20 cent is dus 30 × 5 × 20 = 3000 cent, oftewel 30 euro. + /// + /// Hoe kom je aan een getal? Reken terug naar wat de adverteerder hoogstens kan krijgen. Vijf + /// keer per uur, 24 uur, 30 dagen is 3600 vertoningen. Voor 30 euro is dat ruim 8 euro per + /// duizend vertoningen - aan de lage kant voor een klein, goed omschreven publiek, en dat + /// hoort ook: in de praktijk haalt hij dat maximum nooit, want 's nachts opent niemand het + /// overzicht. Dat het geboekte aantal een máximum is en geen belofte hoort in je voorwaarden + /// te staan, en de adverteerder moet in zijn eigen scherm kunnen zien wat er werkelijk is + /// getoond. + /// + /// Dit getal staat in de instellingen (Adverteren:TariefCenten) en niet vast in de code: het + /// is het soort getal dat je na drie maanden wilt bijstellen. + /// + public int TariefCenten { get; set; } = 20; + + /// + /// Waar het bedrag niet onder komt. Een boeking van twee euro kost aan kijken, afvinken en + /// terugmailen meer dan hij opbrengt. + /// + public int MinimumCenten { get; set; } = 1000; + + /// Het meeste dat iemand per uur kan boeken. Anders koopt één campagne alle ruimte op. + public int MaxPerUur { get; set; } = 20; + + /// Hoe lang een campagne hoogstens mag lopen, in dagen. + public int MaxDagen { get; set; } = 366; + + /// De langste zijde van de afbeelding na verkleinen. + public int MaxAfbeeldingZijde { get; set; } = 800; + + /// Wat er hoogstens binnen mag komen voordat er verkleind wordt. + public int MaxAfbeeldingBytes { get; set; } = 4 * 1024 * 1024; +} + +/// +/// Adverteren: bedrijven, hun campagnes, en de weg van indienen naar draaien. +/// +/// Lees docs/Adverteren.md voordat je hier iets verandert. Daar staat waarom er goedkeuring +/// vooraf is, hoe er wordt gekozen wie er getoond wordt, en waarom het betaalpad met de hand +/// gaat: bunq.me is een betaalverzoek en geen kassa, er komt geen bericht terug. +/// +/// Wat hier bewust NIET in zit: iets wat een advertentie koppelt aan wie hem ziet. Er wordt per +/// campagne per uur geteld, meer niet. Geen profielen, geen kenmerken, geen bezoeker die door de +/// applicatie heen te volgen is. Dit is een applicatie waarin mensen hun wachtwoorden en hun +/// laatste woorden bewaren; daar hoort geen advertentienetwerk in te zitten dat meekijkt. +/// +public class AdvertentieService +{ + private readonly IAdvertentieRepository _adverteren; + private readonly IUserRepository _users; + private readonly IEmailSender _email; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + private readonly IAfbeeldingVerkleiner _verkleiner; + private readonly AdvertentieOptions _opties; + + public AdvertentieService(IAdvertentieRepository adverteren, IUserRepository users, + IEmailSender email, IAuditLog audit, IUnitOfWork uow, IClock clock, + IAfbeeldingVerkleiner verkleiner, AdvertentieOptions? opties = null) + { + _adverteren = adverteren; + _users = users; + _email = email; + _audit = audit; + _uow = uow; + _clock = clock; + _verkleiner = verkleiner; + _opties = opties ?? new AdvertentieOptions(); + } + + /// Waar de site staat; gebruikt in de e-mail aan de beheerder. + public string SiteUrl { get; set; } = "https://mijnbesluit.nl"; + + /// + /// Het bunq.me-adres waar de betaalverzoeken heen gaan. Staat in de instellingen + /// (Adverteren:BunqMe): een rekeningnummer wisselt eerder dan de code. + /// + /// Leeg maken kan; dan komt er geen knop maar staan het bedrag en het kenmerk er gewoon, + /// zodat iemand het met de hand kan overmaken. Een betaalknop die naar een verkeerde + /// rekening wijst is erger dan geen knop. + /// + public string BunqMe { get; set; } = "https://bunq.me/KwikBit"; + + public AdvertentieOptions Tarieven => _opties; + + // ------------------------------------------------------------------ rekenen + + /// + /// Wat een campagne kost, in centen: dagen × vertoningen per uur × tarief, met een bodem. + /// + /// Let op wat de adverteerder hiervoor krijgt: een *maximum* aantal vertoningen, geen + /// belofte. Zijn er in een uur minder bezoekers dan er vertoningen zijn ingekocht, dan haalt + /// niemand zijn aantal. Dat hoort in de voorwaarden te staan en op het boekscherm, en daarom + /// kan hij in zijn eigen scherm zien wat er werkelijk is getoond. + /// + public int BerekenBedrag(int dagen, int perUur) + { + if (dagen < 1 || perUur < 1) return 0; + + var bedrag = dagen * perUur * _opties.TariefCenten; + return Math.Max(bedrag, _opties.MinimumCenten); + } + + public int BerekenBedrag(DateOnly vanaf, DateOnly totEnMet, int perUur) => + BerekenBedrag(totEnMet.DayNumber - vanaf.DayNumber + 1, perUur); + + /// + /// Een bedrag in centen als tekst, met een komma: "30,00". Nederlands, want de site is dat + /// ook - er stond "€ 10.00" op het scherm. + /// + /// Niet met de taalinstelling van de server, maar met de hand: die instelling is op de ene + /// machine Nederlands en op de andere niet, en dan hangt af van waar de applicatie draait hoe + /// er "tien euro" staat. Let op het verschil met , waar juist een + /// punt hoort - bunq leest de link, geen mens. + /// + public static string Euro(int centen) => + "\u20ac " + (centen / 100m).ToString("0.00", CultureInfo.InvariantCulture).Replace('.', ','); + + /// + /// Het betaalverzoek bij een campagne: het bunq.me-adres met het bedrag en het kenmerk er al + /// in gevuld. Null als er geen adres is ingesteld of er niets te betalen valt. + /// + /// bunq.me is een betaalverzoek en geen kassa: er komt geen bericht terug dat er betaald is. + /// Deze link bespaart de adverteerder dus het overtypen, meer niet - het afvinken blijft + /// handwerk van de beheerder. Zie docs/Adverteren.md. + /// + public string? Betaallink(Campagne campagne) => Betaallink(campagne.BedragCenten, campagne.Betaalkenmerk); + + public string? Betaallink(int bedragCenten, string? kenmerk) + { + if (string.IsNullOrWhiteSpace(BunqMe) || bedragCenten <= 0) return null; + + // Een punt als decimaalteken, wat de taalinstelling van de server ook is. Zonder + // InvariantCulture maakt een Nederlandse Windows er "30,00" van, en dan staat er een + // komma in de link - bunq leest daar een ander bedrag in, of helemaal geen. + var bedrag = (bedragCenten / 100m).ToString("0.00", CultureInfo.InvariantCulture); + + return $"{BunqMe.TrimEnd('/')}?amount={bedrag}&description={Uri.EscapeDataString(kenmerk ?? "")}"; + } + + // ------------------------------------------------------------------ adverteerders + + public Task> AdverteerdersAsync(CancellationToken ct = default) => + _adverteren.ListAdverteerdersAsync(ct); + + public Task AdverteerderAsync(long id, CancellationToken ct = default) => + _adverteren.FindAdverteerderAsync(id, ct); + + public Task AdverteerderByEmailAsync(string email, CancellationToken ct = default) => + _adverteren.FindAdverteerderByEmailAsync(Normaliseer(email), ct); + + /// + /// Meldt een bedrijf aan. Het e-mailadres is de sleutel: daarmee logt hij in, en daarom kan + /// hetzelfde adres niet twee keer. + /// + public async Task> MeldAanAsync(string bedrijfsnaam, string contactpersoon, + string email, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(bedrijfsnaam)) + return Result.Fail("Vul de bedrijfsnaam in."); + + email = Normaliseer(email); + if (!email.Contains('@')) + return Result.Fail("Dat is geen e-mailadres."); + + if (await _adverteren.FindAdverteerderByEmailAsync(email, ct) is not null) + return Result.Fail("Er is al een account met dit e-mailadres."); + + var adverteerder = new Adverteerder + { + Bedrijfsnaam = bedrijfsnaam.Trim(), + Contactpersoon = contactpersoon.Trim(), + Email = email, + AangemaaktOp = _clock.UtcNow + }; + + _adverteren.Add(adverteerder); + await _uow.SaveChangesAsync(ct); + return Result.Ok(adverteerder); + } + + // ------------------------------------------------------------------ campagnes + + public Task> CampagnesAsync(long adverteerderId, CancellationToken ct = default) => + _adverteren.ListCampagnesAsync(adverteerderId, ct); + + public Task CampagneAsync(long id, CancellationToken ct = default) => + _adverteren.FindCampagneAsync(id, ct); + + /// Alles wat op de beheerder ligt te wachten: betaling of goedkeuring. + public Task> WachtOpBeheerderAsync(CancellationToken ct = default) => + _adverteren.ListWachtendAsync(ct); + + /// + /// Dient een campagne in. Hij komt op "wacht op betaling" te staan, met een betaalkenmerk dat + /// de adverteerder bij zijn bunq-betaling zet; daarmee vindt de beheerder hem terug. + /// + /// De beheerder krijgt hier bericht van, want vanaf nu ligt het bij hem. + /// + public async Task> DienInAsync(long adverteerderId, string tekst, string link, + DateOnly vanaf, DateOnly totEnMet, int perUur, + byte[]? afbeelding = null, string? afbeeldingSoort = null, + CancellationToken ct = default) + { + var adverteerder = await _adverteren.FindAdverteerderAsync(adverteerderId, ct); + if (adverteerder is null) return Result.Fail("Dit account bestaat niet."); + if (adverteerder.IsGeblokkeerd) return Result.Fail("Dit account kan geen campagnes meer indienen."); + + var controle = Controleer(tekst, link, vanaf, totEnMet, perUur); + if (!controle.Succeeded) return Result.Fail(controle.Error!); + + var nu = _clock.UtcNow; + var campagne = new Campagne + { + AdverteerderId = adverteerderId, + Tekst = tekst.Trim(), + Link = link.Trim(), + VanafDatum = vanaf, + TotEnMetDatum = totEnMet, + PerUur = perUur, + TariefCenten = _opties.TariefCenten, + BedragCenten = BerekenBedrag(vanaf, totEnMet, perUur), + Stand = CampagneStand.WachtOpBetaling, + AangemaaktOp = nu + }; + + var beeld = ZetAfbeelding(campagne, afbeelding, afbeeldingSoort); + if (!beeld.Succeeded) return Result.Fail(beeld.Error!); + + _adverteren.Add(campagne); + await _uow.SaveChangesAsync(ct); + + await _audit.WriteAsync(AuditAction.AdvertentieIngediend, null, nameof(Campagne), campagne.Id, + $"{adverteerder.Bedrijfsnaam}, {campagne.BedragCenten} cent", ct); + + // Het kenmerk kan pas als het nummer bekend is. Het staat straks in de omschrijving van + // de betaling, dus het moet kort en overtypbaar zijn. + campagne.Betaalkenmerk = $"MB-{campagne.Id:D5}"; + await _uow.SaveChangesAsync(ct); + + await MeldBijBeheerderAsync(adverteerder, campagne, ct); + await BevestigIndienenAsync(campagne, ct); + return Result.Ok(campagne); + } + + /// Wat er aan een campagne mankeert, of niets. + public Result Controleer(string tekst, string link, DateOnly vanaf, DateOnly totEnMet, int perUur) + { + if (string.IsNullOrWhiteSpace(tekst)) return Result.Fail("Vul de tekst van de advertentie in."); + if (tekst.Trim().Length > 300) return Result.Fail("De tekst mag hoogstens 300 tekens lang zijn."); + + if (string.IsNullOrWhiteSpace(link)) return Result.Fail("Vul de link in."); + + // Alleen http en https, en het moet een echt adres zijn. Zonder dit kan er een + // javascript: of data: link in, en dat is een advertentie die code uitvoert bij de lezer. + if (!Uri.TryCreate(link.Trim(), UriKind.Absolute, out var adres) || + (adres.Scheme != Uri.UriSchemeHttp && adres.Scheme != Uri.UriSchemeHttps)) + return Result.Fail("De link moet met http:// of https:// beginnen."); + + if (totEnMet < vanaf) return Result.Fail("De einddatum ligt voor de begindatum."); + + var dagen = totEnMet.DayNumber - vanaf.DayNumber + 1; + if (dagen > _opties.MaxDagen) return Result.Fail($"Een campagne loopt hoogstens {_opties.MaxDagen} dagen."); + + if (perUur < 1) return Result.Fail("Kies hoe vaak de advertentie per uur getoond mag worden."); + if (perUur > _opties.MaxPerUur) return Result.Fail($"Hoogstens {_opties.MaxPerUur} keer per uur."); + + return Result.Ok(); + } + + private Result ZetAfbeelding(Campagne campagne, byte[]? inhoud, string? soort) + { + if (inhoud is null || inhoud.Length == 0) return Result.Ok(); + + if (inhoud.Length > _opties.MaxAfbeeldingBytes) + return Result.Fail($"De afbeelding is te groot; hoogstens {_opties.MaxAfbeeldingBytes / (1024 * 1024)} MB."); + + // Verkleinen en niet alleen weigeren: iemand die een foto van zijn telefoon kiest levert + // vier megapixel aan, en dat hoeft niet in een tegel van een paar centimeter. + var kleiner = _verkleiner.Verklein(inhoud, soort ?? "image/jpeg", _opties.MaxAfbeeldingZijde); + + campagne.Afbeelding = kleiner ?? inhoud; + campagne.AfbeeldingSoort = kleiner is null ? soort : "image/jpeg"; + return Result.Ok(); + } + + // ------------------------------------------------------------------ wie er getoond wordt + + /// + /// Welke advertentie er nu getoond mag worden, of geen. + /// + /// Per keer dat iemand het overzicht opent is er ruimte voor één. De keuze gaat zo: + /// + /// 1. Neem alle campagnes die nu mogen draaien: goedgekeurd en binnen hun periode. Betaald + /// zit daar automatisch in, want goedkeuren kan niet zonder betaling. + /// 2. Kijk per campagne hoe vaak hij dit klokuur al is getoond. + /// 3. Laat de campagne zien die het verst achterloopt op zijn eigen aantal - de laagste + /// verhouding getoond gedeeld door geboekt. + /// 4. Zijn ze allemaal op hun aantal, dan verschijnt er niets. Geen opvulling, geen herhaling. + /// + /// Zo krijgt wie meer boekt ook meer, zonder dat de een de ander verdringt en zonder dat + /// iemand over zijn ingekochte aantal heen gaat. Bij gelijke stand wint de oudste campagne; + /// die is dan als eerste aan de beurt en zakt daarna vanzelf onder de ander. + /// + /// Let op: dit telt de vertoning NIET mee. Dat doet , en wel + /// pas als de tegel ook echt is meegestuurd. Hier tellen zou betekenen dat een afgebroken + /// verzoek toch van iemands tegoed afgaat. + /// + public async Task KiesAsync(DateTime nuUtc, CancellationToken ct = default) + { + var lopend = await _adverteren.ListLopendAsync(DateOnly.FromDateTime(nuUtc), ct); + if (lopend.Count == 0) return null; + + var uur = CampagneUur.Uur(nuUtc); + var getoond = await _adverteren.VertoningenInUurAsync(uur, lopend.Select(c => c.Id), ct); + + Campagne? beste = null; + var besteVerhouding = double.MaxValue; + + foreach (var campagne in lopend.OrderBy(c => c.Id)) + { + var alGetoond = getoond.TryGetValue(campagne.Id, out var aantal) ? aantal : 0; + + // Op zijn aantal: dan is hij dit uur klaar. Niet "bijna klaar, nog eentje dan" - + // iemand heeft een aantal ingekocht en daar gaan we niet overheen. + if (alGetoond >= campagne.PerUur) continue; + + var verhouding = (double)alGetoond / campagne.PerUur; + if (verhouding >= besteVerhouding) continue; + + besteVerhouding = verhouding; + beste = campagne; + } + + return beste; + } + + /// Zoals de aanroep met een tijdstip, maar met de klok van de dienst zelf. + public Task KiesAsync(CancellationToken ct = default) => KiesAsync(_clock.UtcNow, ct); + + /// Telt één vertoning mee, nu. + public Task TelVertoningAsync(long campagneId, CancellationToken ct = default) => + TelVertoningAsync(campagneId, _clock.UtcNow, ct); + + /// Telt één klik mee, nu. + public Task TelKlikAsync(long campagneId, CancellationToken ct = default) => + TelKlikAsync(campagneId, _clock.UtcNow, ct); + + /// Telt één vertoning mee, in het klokuur waar dit moment in valt. + public Task TelVertoningAsync(long campagneId, DateTime nuUtc, CancellationToken ct = default) => + _adverteren.TelAsync(campagneId, CampagneUur.Uur(nuUtc), 1, 0, ct); + + /// + /// Telt één klik mee. Er wordt niet gecontroleerd of die campagne nog loopt: een klik op een + /// tegel die net is afgelopen hoort nog steeds bij die campagne. + /// + public Task TelKlikAsync(long campagneId, DateTime nuUtc, CancellationToken ct = default) => + _adverteren.TelAsync(campagneId, CampagneUur.Uur(nuUtc), 0, 1, ct); + + /// + /// Wat er werkelijk is getoond en aangeklikt. Dit is wat de adverteerder in zijn eigen scherm + /// hoort te zien: hij heeft een maximum gekocht, geen belofte, en dan mag hij zien wat het + /// geworden is. + /// + public Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default) => + _adverteren.TotalenAsync(campagneId, ct); + + /// De tellers per uur, voor een grafiekje of een verantwoording. + public Task> UrenAsync(long campagneId, CancellationToken ct = default) => + _adverteren.ListUrenAsync(campagneId, ct); + + // ------------------------------------------------------------------ de beheerder + + /// De beheerder heeft het geld gezien en vinkt de betaling af. + public async Task BetaaldAsync(long campagneId, long beheerderUserId, CancellationToken ct = default) + { + var toegang = await AlsBeheerderAsync(beheerderUserId, ct); + if (!toegang.Succeeded) return toegang; + + var campagne = await _adverteren.FindCampagneAsync(campagneId, ct); + if (campagne is null) return Result.Fail("Deze campagne bestaat niet."); + if (campagne.IsBetaald) return Result.Fail("Deze campagne staat al als betaald."); + + campagne.BetaaldOp = _clock.UtcNow; + campagne.BetaaldAfgevinktDoorUserId = beheerderUserId; + campagne.GewijzigdOp = _clock.UtcNow; + + // Betaald is nog niet goedgekeurd. Dat zijn twee vinkjes in hetzelfde scherm, maar twee + // verschillende vragen: is het geld binnen, en mag dit naast onze tekst over uitvaarten. + if (campagne.Stand == CampagneStand.WachtOpBetaling) + campagne.Stand = CampagneStand.WachtOpGoedkeuring; + + await _audit.WriteAsync(AuditAction.AdvertentieBetaald, beheerderUserId, nameof(Campagne), campagne.Id, + $"betaling afgevinkt ({campagne.BedragCenten} cent)", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// De beheerder heeft de inhoud bekeken en keurt hem goed. Vanaf de begindatum draait hij. + public async Task GoedkeurenAsync(long campagneId, long beheerderUserId, CancellationToken ct = default) + { + var toegang = await AlsBeheerderAsync(beheerderUserId, ct); + if (!toegang.Succeeded) return toegang; + + var campagne = await _adverteren.FindCampagneAsync(campagneId, ct); + if (campagne is null) return Result.Fail("Deze campagne bestaat niet."); + + // Eerst het geld, dan de goedkeuring. Andersom draait er iets waarvoor niet betaald is, + // en dat merk je pas als het voorbij is. + if (!campagne.IsBetaald) return Result.Fail("Er is nog niet betaald. Vink eerst de betaling af."); + if (campagne.Stand == CampagneStand.Loopt) return Result.Fail("Deze campagne is al goedgekeurd."); + + campagne.Stand = CampagneStand.Loopt; + campagne.GoedgekeurdOp = _clock.UtcNow; + campagne.GoedgekeurdDoorUserId = beheerderUserId; + campagne.AfwijzingReden = null; + campagne.GewijzigdOp = _clock.UtcNow; + + await _audit.WriteAsync(AuditAction.AdvertentieGoedgekeurd, beheerderUserId, nameof(Campagne), campagne.Id, + "goedgekeurd", ct); + await _uow.SaveChangesAsync(ct); + + await BerichtAanAdverteerderAsync(campagne, "Je advertentie is goedgekeurd", + $"

Je advertentie {Veilig(campagne.Tekst)} is goedgekeurd en draait " + + $"van {campagne.VanafDatum:dd-MM-yyyy} tot en met {campagne.TotEnMetDatum:dd-MM-yyyy}.

", ct); + + return Result.Ok(); + } + + /// + /// Afwijzen, met een reden. Die reden is verplicht: een adverteerder die niet weet waarom hij + /// is afgewezen dient hetzelfde nog een keer in, en dan zit je er allebei weer naar te kijken. + /// + public async Task AfwijzenAsync(long campagneId, long beheerderUserId, string reden, + CancellationToken ct = default) + { + var toegang = await AlsBeheerderAsync(beheerderUserId, ct); + if (!toegang.Succeeded) return toegang; + + if (string.IsNullOrWhiteSpace(reden)) return Result.Fail("Geef een reden op."); + + var campagne = await _adverteren.FindCampagneAsync(campagneId, ct); + if (campagne is null) return Result.Fail("Deze campagne bestaat niet."); + + campagne.Stand = CampagneStand.Afgewezen; + campagne.AfwijzingReden = reden.Trim(); + campagne.GewijzigdOp = _clock.UtcNow; + + await _audit.WriteAsync(AuditAction.AdvertentieAfgewezen, beheerderUserId, nameof(Campagne), campagne.Id, + $"afgewezen: {reden.Trim()}", ct); + await _uow.SaveChangesAsync(ct); + + await BerichtAanAdverteerderAsync(campagne, "Je advertentie is afgewezen", + $"

Je advertentie {Veilig(campagne.Tekst)} is niet geplaatst.

" + + $"

Reden: {Veilig(reden.Trim())}

" + + "

Is er betaald, neem dan contact op; dan regelen we de terugbetaling.

", ct); + + return Result.Ok(); + } + + /// Voortijdig stoppen. Hij verdwijnt dan meteen, ook als de einddatum nog niet is bereikt. + public async Task StopzettenAsync(long campagneId, long beheerderUserId, CancellationToken ct = default) + { + var toegang = await AlsBeheerderAsync(beheerderUserId, ct); + if (!toegang.Succeeded) return toegang; + + var campagne = await _adverteren.FindCampagneAsync(campagneId, ct); + if (campagne is null) return Result.Fail("Deze campagne bestaat niet."); + + campagne.Stand = CampagneStand.Gestopt; + campagne.GewijzigdOp = _clock.UtcNow; + + await _audit.WriteAsync(AuditAction.AdvertentieGestopt, beheerderUserId, nameof(Campagne), campagne.Id, + "stopgezet", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + private async Task AlsBeheerderAsync(long userId, CancellationToken ct) + { + var gebruiker = await _users.FindByIdAsync(userId, ct); + + // Dezelfde melding als "bestaat niet". Wie geen beheerder is, hoort niet te weten dat + // hier iets te beheren valt. + return gebruiker?.IsBeheerder == true ? Result.Ok() : Result.Fail("Niet gevonden."); + } + + // ------------------------------------------------------------------ berichten + + private async Task MeldBijBeheerderAsync(Adverteerder adverteerder, Campagne campagne, CancellationToken ct) + { + var beheerders = await _users.ListBeheerdersAsync(ct); + if (beheerders.Count == 0) return; + + var bedrag = Euro(campagne.BedragCenten); + var body = + $"

{Veilig(adverteerder.Bedrijfsnaam)} heeft een advertentie ingediend.

" + + $"

{Veilig(campagne.Tekst)}
" + + $"Van {campagne.VanafDatum:dd-MM-yyyy} tot en met {campagne.TotEnMetDatum:dd-MM-yyyy}, " + + $"{campagne.PerUur} keer per uur.
" + + $"Bedrag: {bedrag}, kenmerk {campagne.Betaalkenmerk}.

" + + $"

Zodra de betaling binnen is, vink je hem af en kijk je naar de inhoud: " + + $"{SiteUrl}/beheer/adverteren

"; + + foreach (var beheerder in beheerders) + { + // Eén beheerder die niet bereikbaar is hoort de rest niet tegen te houden, en al + // helemaal niet het indienen zelf - dat is dan al opgeslagen. + try { await _email.SendAsync(beheerder.Email, "Nieuwe advertentie ingediend", body, ct); } + catch (Exception) { /* Bericht kwijt is vervelend; de campagne staat er wel. */ } + } + } + + /// + /// De bevestiging aan de adverteerder, met het bedrag, het kenmerk en de betaallink erin. + /// + /// Die link staat ook in zijn eigen scherm, maar niet iedereen komt daar terug: dit bericht + /// blijft in zijn postvak staan, en het kenmerk moet hij toch bij de hand hebben als hij met + /// de hand overmaakt. + /// + private Task BevestigIndienenAsync(Campagne campagne, CancellationToken ct) + { + var bedrag = Euro(campagne.BedragCenten); + var link = Betaallink(campagne); + + var body = + $"

We hebben je advertentie ontvangen:

" + + $"

{Veilig(campagne.Tekst)}
" + + $"Van {campagne.VanafDatum:dd-MM-yyyy} tot en met {campagne.TotEnMetDatum:dd-MM-yyyy}, " + + $"{campagne.PerUur} keer per uur.

" + + $"

Te betalen: {bedrag}, onder vermelding van " + + $"{Veilig(campagne.Betaalkenmerk ?? "")}.

" + + (link is null + ? "" + : $"

Betaal {bedrag} voor je advertentie

") + + "

Zodra de betaling binnen is kijken we naar de inhoud. Pas na goedkeuring gaat de " + + "advertentie draaien; je krijgt daar bericht van.

" + + $"

Wat er werkelijk is getoond kun je volgen op " + + $"{SiteUrl}/adverteren.

"; + + return BerichtAanAdverteerderAsync(campagne, "We hebben je advertentie ontvangen", body, ct); + } + + private async Task BerichtAanAdverteerderAsync(Campagne campagne, string onderwerp, string body, + CancellationToken ct) + { + var adverteerder = campagne.Adverteerder ?? await _adverteren.FindAdverteerderAsync(campagne.AdverteerderId, ct); + if (adverteerder is null) return; + + try { await _email.SendAsync(adverteerder.Email, onderwerp, body, ct); } + catch (Exception) { /* Zie boven: het besluit staat, het bericht is bijzaak. */ } + } + + private static string Normaliseer(string email) => (email ?? string.Empty).Trim().ToLowerInvariant(); + + /// + /// Tekst van een adverteerder gaat in een e-mail die de beheerder leest. Die tekst is niet van + /// ons, dus mag hij geen html worden. + /// + private static string Veilig(string tekst) => + (tekst ?? string.Empty).Replace("&", "&").Replace("<", "<").Replace(">", ">") + .Replace("\"", """); +} diff --git a/src/MijnBesluit.Application/Services/BeheerService.cs b/src/MijnBesluit.Application/Services/BeheerService.cs new file mode 100644 index 0000000..9c34f4e --- /dev/null +++ b/src/MijnBesluit.Application/Services/BeheerService.cs @@ -0,0 +1,164 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Domain.Rules; + +namespace MijnBesluit.Application.Services; + +/// +/// De beheerpagina. Bewust zo klein mogelijk. +/// +/// Er is precies één situatie waar niemand anders uit komt: een overlijden dat ten onrechte is +/// bevestigd, terwijl de bezwaarlink uit de waarschuwingsmail is verlopen. Het account staat dan +/// op "overleden", inloggen kan niet meer, en er is geen weg terug. Daarvoor bestaat dit. +/// +/// Wat een beheerder NIET kan, en ook nooit moet kunnen: +/// +/// +/// Een kluis openen. De enige handeling hier neemt toegang wég; hij geeft hem nooit. Wie +/// dat verandert, haalt de bodem onder het hele product vandaan. +/// Zoeken op naam. Dat zou de zoekmachine op mensen zijn die we in docs/Identiteit.md +/// uitdrukkelijk niet wilden. Zoeken kan alleen op het volledige e-mailadres: je moet dus al +/// weten wie je zoekt, omdat diegene contact met je heeft opgenomen. +/// Iemand beheerder maken. Dat gaat met de hand in de database. Wat niet via de +/// applicatie kan, kan ook niet via een lek in de applicatie. +/// +/// +/// Er is er sinds kort een tweede: een samenvoeging terugdraaien. Samenvoegen doet de persoon +/// zelf - alleen hij mag zeggen dat twee profielen hem zijn - maar het raakt ook de gegevens van +/// degene die het duplicaat ooit aanmaakte. Gaat het mis, dan hoort er een mens naar te kijken. +/// Ook die handeling geeft geen toegang; hij zet alleen terug wat er stond. +/// +public class BeheerService +{ + private readonly IUserRepository _users; + private readonly IPersonRepository _people; + private readonly ILegacyRepository _legacy; + private readonly IAuditLog _audit; + private readonly IClock _clock; + private readonly LegacyService _nalatenschap; + private readonly SamenvoegService? _samenvoegen; + private readonly IStatistiekRepository? _statistieken; + + public BeheerService(IUserRepository users, IPersonRepository people, ILegacyRepository legacy, + IAuditLog audit, IClock clock, LegacyService nalatenschap, + SamenvoegService? samenvoegen = null, IStatistiekRepository? statistieken = null) + { + _users = users; _people = people; _legacy = legacy; + _audit = audit; _clock = clock; _nalatenschap = nalatenschap; _samenvoegen = samenvoegen; + _statistieken = statistieken; + } + + /// Wat de beheerder van een profiel te zien krijgt. Geen kluis, geen berichten. + public record Profielbeeld(long PersonId, string DisplayName, string? Email, bool IsGeclaimd, + bool AccountGeblokkeerd, DateTime? OverlijdenBevestigdOp, + DateOnly? Overlijdensdatum, DeathProcedureState Stand, + int Meldingen, int MeldingenNodig, + long? OpgegaanIn, + IReadOnlyList Samenvoegingen); + + /// Is deze gebruiker beheerder? Elke handeling hier begint hiermee. + public async Task IsBeheerderAsync(long userId, CancellationToken ct = default) => + (await _users.FindByIdAsync(userId, ct))?.IsBeheerder == true; + + /// + /// Aantallen voor de statistiektegel: hoeveel profielen, kluisitems, momenten enzovoort er + /// zijn, plus de groei van de laatste dertig dagen. Puur tellingen - dit is om de belasting + /// te kunnen inschatten, niet om in iemands kluis te kijken. + /// + public async Task> StatistiekenAsync(long currentUserId, CancellationToken ct = default) + { + if (!await IsBeheerderAsync(currentUserId, ct)) + return Result.Fail("Je bent geen beheerder."); + + if (_statistieken is null) return Result.Fail("Statistieken zijn hier niet beschikbaar."); + + var statistieken = await _statistieken.OphalenAsync(30, _clock.UtcNow, ct); + return Result.Ok(statistieken); + } + + /// + /// Zoekt een profiel op het volledige e-mailadres. Niet op een deel ervan en niet op naam: + /// je hoort al te weten wie je zoekt. + /// + public async Task> ZoekOpAdresAsync(string email, long currentUserId, + CancellationToken ct = default) + { + if (!await IsBeheerderAsync(currentUserId, ct)) + return Result.Fail("Je bent geen beheerder."); + + if (string.IsNullOrWhiteSpace(email)) return Result.Fail("Vul een e-mailadres in."); + + var adres = email.Trim().ToLowerInvariant(); + var gebruiker = await _users.FindByEmailAsync(adres, ct); + var persoon = gebruiker is not null + ? await _people.FindByIdAsync(gebruiker.PersonId, ct) + : await _people.FindByEmailAsync(adres, ct); + + if (persoon is null) return Result.Fail("Geen profiel gevonden met dit e-mailadres."); + + // Opzoeken is zelf ook een handeling die je terug wilt kunnen vinden. + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), persoon.Id, + "beheer: opgezocht", ct); + + var stand = DeathProcedure.Evaluate(persoon, await _legacy.ListDeclarationsAsync(persoon.Id, ct), + await Beheerderskring.GebruikersAsync(_legacy, _users, persoon.Id, ct), + _clock.UtcNow); + + var samenvoegingen = _samenvoegen is null + ? Array.Empty() + : await _samenvoegen.GeschiedenisAsync(persoon.Id, ct); + + return Result.Ok(new Profielbeeld( + persoon.Id, persoon.DisplayName, persoon.Email, persoon.IsClaimed, + gebruiker?.Status == UserStatus.Overleden, persoon.DeathConfirmedAt, persoon.DateOfDeath, + stand.State, stand.ActiveDeclarations, stand.RequiredDeclarations, + persoon.MergedIntoPersonId, samenvoegingen)); + } + + /// + /// Draait een samenvoeging terug. Alles wat toen is verhuisd gaat terug naar het profiel dat + /// is opgegaan; wat er ná die tijd bij het doel is bijgekomen, blijft daar staan. + /// + /// Net als bij een overlijden is de reden verplicht en komt hij in het logboek. + /// + public async Task TerugdraaienAsync(long samenvoegingId, long currentUserId, string reden, + CancellationToken ct = default) + { + if (!await IsBeheerderAsync(currentUserId, ct)) + return Result.Fail("Je bent geen beheerder."); + + if (_samenvoegen is null) + return Result.Fail("Samenvoegen is hier niet beschikbaar."); + + if (string.IsNullOrWhiteSpace(reden) || reden.Trim().Length < 5) + return Result.Fail("Geef een reden op; die wordt vastgelegd."); + + return await _samenvoegen.TerugdraaienAsync(samenvoegingId, currentUserId, reden.Trim(), ct); + } + + /// + /// Draait een bevestigd overlijden terug. Alle meldingen vervallen, het account wordt weer + /// actief, en de toegang die de nalatenschapsbeheerder had gekregen wordt ingetrokken. + /// + /// De reden is verplicht en komt in het logboek. Dit is de zwaarste knop in de applicatie; + /// dan hoort er ook te staan waarom hij is ingedrukt. + /// + public async Task HerstelOverlijdenAsync(long personId, long currentUserId, string reden, + CancellationToken ct = default) + { + if (!await IsBeheerderAsync(currentUserId, ct)) + return Result.Fail("Je bent geen beheerder."); + + if (string.IsNullOrWhiteSpace(reden) || reden.Trim().Length < 5) + return Result.Fail("Geef een reden op; die wordt vastgelegd."); + + var persoon = await _people.FindByIdAsync(personId, ct); + if (persoon is null) return Result.Fail("Dit profiel bestaat niet meer."); + if (persoon.DeathConfirmedAt is null) + return Result.Fail("Voor dit profiel is geen overlijden bevestigd."); + + return await _nalatenschap.HerstelDoorBeheerderAsync(persoon, currentUserId, reden.Trim(), ct); + } +} diff --git a/src/MijnBesluit.Application/Services/Beheerderskring.cs b/src/MijnBesluit.Application/Services/Beheerderskring.cs new file mode 100644 index 0000000..c2c5dda --- /dev/null +++ b/src/MijnBesluit.Application/Services/Beheerderskring.cs @@ -0,0 +1,31 @@ +using MijnBesluit.Application.Abstractions; + +namespace MijnBesluit.Application.Services; + +/// +/// De accounts van de mensen die een profiel heeft aangewezen als nalatenschapsbeheerder. +/// +/// heeft die verzameling nodig: +/// de procedure begint pas te lopen als er een melding van een van hen bij zit. De aanwijzing +/// staat op persoonsnummer en een melding op accountnummer, dus daar zit één vertaalslag tussen. +/// +/// Die staat hier, op één plek, omdat drie diensten hem nodig hebben. Drie keer dezelfde lus is +/// drie keer een kans dat er eentje anders gaat rekenen dan de andere. +/// +public static class Beheerderskring +{ + public static async Task> GebruikersAsync( + ILegacyRepository legacy, IUserRepository users, long personId, CancellationToken ct = default) + { + var uitkomst = new HashSet(); + + foreach (var beheerder in await legacy.ListManagersForAsync(personId, ct)) + { + // Een aangewezen persoon zonder account kan niet melden; die telt hier dus niet mee. + if (await users.FindByPersonIdAsync(beheerder.ManagerPersonId, ct) is { } gebruiker) + uitkomst.Add(gebruiker.Id); + } + + return uitkomst; + } +} diff --git a/src/MijnBesluit.Application/Services/FamilyService.cs b/src/MijnBesluit.Application/Services/FamilyService.cs new file mode 100644 index 0000000..0ed3339 --- /dev/null +++ b/src/MijnBesluit.Application/Services/FamilyService.cs @@ -0,0 +1,561 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Application.Security; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Domain.Rules; + +namespace MijnBesluit.Application.Services; + +/// Eén familielid zoals het in beeld komt, inclusief of het profiel al geclaimd is. +public record FamilyMemberView( + long PersonId, + string DisplayName, + // Los van DisplayName, want dat kan een bijnaam zijn; een bewerkscherm heeft de echte + // voor- en achternaam nodig om in te vullen. + string FirstName, + string LastName, + string? Email, + RelationType Relation, + string? RelationLabel, + ProfileState State, + bool CanEdit, + DateOnly? BirthDate, + DateOnly? DateOfDeath, + + // De geschiedenis van het profiel: wanneer het is aangemaakt, wanneer er voor het laatst + // een uitnodiging uitging en wanneer de persoon het heeft overgenomen. Alles in UTC; + // het scherm rekent om naar lokale tijd. + DateTime CreatedAt, + DateTime? InvitedAt, + DateTime? InvitationExpiresAt, + int InvitationResends, + DateTime? ClaimedAt, + + // Twee dingen die alleen bij het weghalen van iemand ter sprake komen, maar die hier + // meekomen omdat het scherm ze dan meteen heeft. Vraag je ze pas op het moment van + // verwijderen op, dan staat er een seconde niets terwijl iemand al op "ja" wil drukken. + // + // IsBeheerder: deze persoon is aangewezen om jouw nalatenschap te beheren. Dan mag hij niet + // zomaar weg; zie VerwijderLidAsync. + // MomentenKlaar: hoeveel berichten van jou aan deze persoon nog klaarstaan. Die verdwijnen + // mee, en dat hoort iemand te weten vóórdat hij ja zegt. + bool IsBeheerder = false, + int MomentenKlaar = 0); + +/// +/// Familie samenstellen, mensen uitnodigen en profielen laten overgaan naar de persoon zelf. +/// Dit is het hart van de groei van MijnBesluit: wie wordt uitgenodigd en zelf familie invult, +/// wordt eigenaar van zijn profiel en nodigt op zijn beurt weer anderen uit. +/// +public class FamilyService +{ + private static readonly TimeSpan InvitationLifetime = TimeSpan.FromDays(30); + + private readonly IFamilyRepository _families; + private readonly IPersonRepository _people; + private readonly IUserRepository _users; + private readonly IInvitationRepository _invitations; + private readonly IMessageRepository _messages; + private readonly ILegacyRepository _legacy; + private readonly IEmailSender _email; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + + /// Basis-URL van de website, voor de link in de uitnodigingsmail. + public string SiteUrl { get; set; } = "https://mijnbesluit.nl"; + + public FamilyService(IFamilyRepository families, IPersonRepository people, IUserRepository users, + IInvitationRepository invitations, IMessageRepository messages, + ILegacyRepository legacy, IEmailSender email, IAuditLog audit, + IUnitOfWork uow, IClock clock) + { + _families = families; _people = people; _users = users; _invitations = invitations; + _messages = messages; _legacy = legacy; + _email = email; _audit = audit; _uow = uow; _clock = clock; + } + + /// De familieprojecten waarvan deze persoon de eigenaar is. + public Task> ListOwnedAsync(long personId, CancellationToken ct = default) => + _families.ListOwnedByPersonAsync(personId, ct); + + /// De familieprojecten waar deze persoon lid van is, van zichzelf of van een ander. + public Task> ListMemberOfAsync(long personId, CancellationToken ct = default) => + _families.ListFamiliesContainingAsync(personId, ct); + + /// De familie van een gebruiker, met per lid de eigendomsstatus. + public async Task> ListMembersAsync(long familyId, long currentUserId, + CancellationToken ct = default) + { + var now = _clock.UtcNow; + var family = await _families.FindByIdAsync(familyId, ct); + if (family is null) return Array.Empty(); + + var members = await _families.ListMembersAsync(familyId, ct); + var people = (await _people.ListByIdsAsync(members.Select(m => m.PersonId), ct)).ToDictionary(p => p.Id); + + // De laatste uitnodiging per persoon, in één vraag. Die gebruiken we voor twee dingen: + // om te bepalen of er nog een uitnodiging openstaat, en om te tonen wanneer hij uitging. + var uitnodigingen = (await _invitations.ListLatestForPeopleAsync(members.Select(m => m.PersonId), ct)) + .ToDictionary(i => i.PersonId); + + // Wie er is aangewezen om jouw nalatenschap te beheren, en hoeveel berichten er van jou + // voor elk van deze mensen klaarstaan. Allebei in één vraag voor de hele lijst: per lid + // vragen zou bij twintig mensen veertig rondjes langs de database betekenen. + // + // Dit is er voor het weghalen van iemand. Zie VerwijderLidAsync voor waarom het uitmaakt. + var beheerders = (await _legacy.ListManagersForAsync(family.OwnerPersonId, ct)) + .Select(b => b.ManagerPersonId).ToHashSet(); + + var klaar = (await _messages.ListOutboxAsync(family.OwnerPersonId, ct)) + .Where(b => b.DeliveredAt is null) + .GroupBy(b => b.ToPersonId) + .ToDictionary(g => g.Key, g => g.Count()); + + var views = new List(members.Count); + foreach (var m in members) + { + if (!people.TryGetValue(m.PersonId, out var p)) continue; + uitnodigingen.TryGetValue(p.Id, out var uitnodiging); + + var open = !p.IsClaimed && uitnodiging is not null && uitnodiging.IsOpen(now); + views.Add(new FamilyMemberView( + p.Id, p.DisplayName, p.FirstName, p.LastName, p.Email, m.Relation, m.RelationLabel, + p.State(open), ProfileOwnership.CanEdit(p, currentUserId), p.BirthDate, p.DateOfDeath, + p.CreatedAt, uitnodiging?.SentAt, uitnodiging?.ExpiresAt, + uitnodiging?.ResendCount ?? 0, p.ClaimedAt, + beheerders.Contains(p.Id), klaar.GetValueOrDefault(p.Id))); + } + + // De volgorde op het scherm: eerst de familie met de oudste bovenaan, daaronder de + // vrienden en overige personen op alfabet. Wie geen geboortedatum heeft kan niet op + // leeftijd worden gezet en komt achter de mensen die er wel een hebben. + var familie = views.Where(v => IsFamilie(v.Relation)) + .OrderBy(v => v.BirthDate is null) + .ThenBy(v => v.BirthDate ?? DateOnly.MaxValue) + .ThenBy(v => v.DisplayName, StringComparer.InvariantCultureIgnoreCase); + + var overigen = views.Where(v => !IsFamilie(v.Relation)) + .OrderBy(v => v.DisplayName, StringComparer.InvariantCultureIgnoreCase); + + return familie.Concat(overigen).ToList(); + } + + /// + /// Vrienden en "overig" zijn wel lid van je familie in MijnBesluit, maar het zijn geen + /// familieleden. Op het scherm staan ze daarom apart, onder de echte familie. + /// + /// Welke dat zijn staat bij de lijst zelf (Domain.Rules.Relaties) en niet hier: komt er een + /// soort vriend bij, dan hoort dat op één plek geregeld te zijn en niet pas nadat iemand + /// haar tussen de kleinkinderen ziet staan. + /// + private static bool IsFamilie(RelationType relatie) => Relaties.IsFamilie(relatie); + + /// + /// Voegt iemand toe aan een familie. Bestaat er al een profiel met dit e-mailadres, dan wordt dat + /// gekoppeld in plaats van gedupliceerd — zo blijft één persoon ook echt één profiel. + /// + public async Task> AddMemberAsync(long familyId, long currentUserId, string firstName, + string lastName, string? email, RelationType relation, + string? relationLabel = null, DateOnly? birthDate = null, + CancellationToken ct = default) + { + var family = await _families.FindByIdAsync(familyId, ct); + if (family is null) return Result.Fail("Deze familie bestaat niet."); + + var owner = await _people.FindByIdAsync(family.OwnerPersonId, ct); + if (owner is null || !ProfileOwnership.CanEdit(owner, currentUserId)) + return Result.Fail("Je mag deze familie niet wijzigen."); + + if (string.IsNullOrWhiteSpace(firstName) && string.IsNullOrWhiteSpace(lastName)) + return Result.Fail("Vul minstens een voor- of achternaam in."); + + var now = _clock.UtcNow; + var normalized = string.IsNullOrWhiteSpace(email) ? null : AccountService.Normaliseer(email); + + // Een adres met een tikfout valt anders pas op bij het uitnodigen, als de mail nergens + // aankomt. Beter meteen zeggen. + if (normalized is not null && !AccountService.IsGeldigAdres(normalized)) + return Result.Fail($"'{email}' ziet er niet uit als een e-mailadres."); + + // Alleen je eigen aantekeningen hergebruiken, nooit die van een ander. + // + // Hier stond eerder een zoekopdracht op e-mailadres zonder meer. Dat betekende: wie het + // adres van iemand kent, hangt die persoon aan zijn eigen familie en ziet meteen wat een + // ander over hem heeft ingevuld - naam, geboortedatum, profielfoto. Dat is een + // zoekmachine op mensen, en dat is precies wat dit niet moet zijn. + // + // Dus krijgt iedereen zijn eigen profiel. Twee ouders die allebei hun dochter toevoegen + // hebben twee aantekeningen, en dat is eerlijk: het zijn ook twee aantekeningen. Ze + // worden pas één zodra de dochter zelf beide uitnodigingen aanneemt - zij is de enige + // die mag zeggen dat het om haar gaat. Zie SamenvoegService. + // + // Voeg je iemand toe aan een tweede familie van jezelf, dan hoort dat wél hetzelfde + // profiel te zijn; anders zie je je eigen dochter twee keer. + Person? person = null; + if (normalized is not null) + { + // Álle profielen met dit adres opvragen en er het jouwe uit kiezen. Met alleen de + // eerste de beste ging dit mis zodra er meer dan één was: dan kreeg je die van een + // ander te pakken, viel de controle hieronder uit, en maakte je een tweede profiel + // aan voor iemand die je zelf al had staan. + var gevonden = await _people.ListByEmailAsync(normalized, ct); + person = gevonden.FirstOrDefault( + p => !p.IsSamengevoegd && + (p.ManagedByUserId == currentUserId || p.ClaimedByUserId == currentUserId)); + } + + if (person is null) + { + person = new Person + { + FirstName = firstName.Trim(), + LastName = lastName.Trim(), + Email = normalized, + BirthDate = birthDate, + CreatedByUserId = currentUserId, + ManagedByUserId = currentUserId, + CreatedAt = now, + UpdatedAt = now + }; + _people.Add(person); + await _uow.SaveChangesAsync(ct); + } + + if (await _families.FindMemberAsync(familyId, person.Id, ct) is not null) + return Result.Fail($"{person.DisplayName} staat al in deze familie."); + + _families.Add(new FamilyMember + { + FamilyId = familyId, + PersonId = person.Id, + Relation = relation, + RelationLabel = relationLabel, + AddedByUserId = currentUserId, + CreatedAt = now + }); + + await _uow.SaveChangesAsync(ct); + return Result.Ok(person); + } + + /// Wijzigt de gegevens van een profiel. Lukt alleen zolang de persoon het profiel niet zelf beheert. + public async Task UpdatePersonAsync(long personId, long currentUserId, Action change, + CancellationToken ct = default) + { + var person = await _people.FindByIdAsync(personId, ct); + if (person is null) return Result.Fail("Profiel niet gevonden."); + + var denial = ProfileOwnership.ExplainDenial(person, currentUserId); + if (denial is not null) return Result.Fail(denial); + + change(person); + person.UpdatedAt = _clock.UtcNow; + + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), person.Id, null, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Wijzigt naam, e-mailadres en relatie van iemand in je familie. Kan alleen zolang de + /// persoon het profiel niet zelf beheert: daarna is het van hem, en dan mag zelfs de + /// aanmaker er niets meer aan veranderen. + /// + /// Het e-mailadres mag wél nog gewijzigd worden nadat er een uitnodiging uit is gegaan - + /// juist dan, want een tikfout in het adres merk je pas als de uitnodiging nergens aankomt. + /// Stuur daarna een nieuwe uitnodiging. + /// + public async Task WijzigLidAsync(long familyId, long personId, long currentUserId, + string firstName, string lastName, string? email, + RelationType relation, string? relationLabel = null, + DateOnly? birthDate = null, CancellationToken ct = default) + { + var person = await _people.FindByIdAsync(personId, ct); + if (person is null) return Result.Fail("Profiel niet gevonden."); + + var denial = ProfileOwnership.ExplainDenial(person, currentUserId); + if (denial is not null) return Result.Fail(denial); + + if (string.IsNullOrWhiteSpace(firstName) && string.IsNullOrWhiteSpace(lastName)) + return Result.Fail("Vul minstens een voor- of achternaam in."); + + var genormaliseerd = string.IsNullOrWhiteSpace(email) ? null : AccountService.Normaliseer(email); + if (genormaliseerd is not null) + { + if (!AccountService.IsGeldigAdres(genormaliseerd)) + return Result.Fail($"'{email}' ziet er niet uit als een e-mailadres."); + + // Alleen bij JOUW eigen profielen is dit een botsing. Dat een ander ook een + // aantekening met dit adres heeft, is geen bezwaar en hoort het ook niet te zijn: dan + // zou wie het adres van iemand het eerst invulde bepalen dat niemand anders die + // persoon nog kan toevoegen. Zie docs/Identiteit.md - ze worden pas één zodra de + // persoon zelf beide uitnodigingen aanneemt. + var vanMij = (await _people.ListByEmailAsync(genormaliseerd, ct)) + .FirstOrDefault(p => p.Id != person.Id && !p.IsSamengevoegd && + (p.ManagedByUserId == currentUserId || p.ClaimedByUserId == currentUserId)); + + if (vanMij is not null) + return Result.Fail($"Je hebt {genormaliseerd} al staan bij {vanMij.DisplayName}."); + } + + var nu = _clock.UtcNow; + var adresGewijzigd = !string.Equals(person.Email, genormaliseerd, StringComparison.Ordinal); + + person.FirstName = firstName.Trim(); + person.LastName = lastName.Trim(); + person.Email = genormaliseerd; + person.BirthDate = birthDate; + person.UpdatedAt = nu; + + if (adresGewijzigd) + { + // Een verstuurde uitnodiging is een sleutel tot dit profiel. Ging hij naar een adres + // met een tikfout erin, dan ligt die sleutel in het postvak van een vreemde. Bij het + // corrigeren van het adres trekken we hem daarom in; er moet dan een nieuwe uitnodiging + // naar het juiste adres. + foreach (var open in await _invitations.ListOpenForPersonAsync(personId, ct)) + { + open.Status = InvitationStatus.Ingetrokken; + } + } + + var lid = await _families.FindMemberAsync(familyId, personId, ct); + if (lid is not null) + { + lid.Relation = relation; + lid.RelationLabel = string.IsNullOrWhiteSpace(relationLabel) ? null : relationLabel.Trim(); + } + + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), person.Id, null, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Haalt iemand uit je familie. Het profiel zelf blijft bestaan - die persoon kan in + /// andere families staan, of zijn eigen MijnBesluit hebben. Alleen de koppeling met + /// jouw familie verdwijnt, samen met een uitnodiging die nog openstond. + /// + /// Iedereen mag hier weg, ook iemand die zijn profiel zelf beheert: dat je Karin uit je + /// lijst haalt, zegt niets over haar eigen MijnBesluit. Op twee dingen na. + /// + /// 1. NIET als deze persoon jouw nalatenschap mag beheren. Dat is geen regel om iemand mee + /// te plagen: wie weggaat uit je lijst en beheerder blijft, laat een aanwijzing achter + /// die pas na je overlijden wordt gebruikt - en dan is er niemand meer die kan zeggen dat + /// het niet de bedoeling was. Wie dat wil, haalt hem eerst bij Nalatenschap weg. Twee + /// handelingen voor iets dat twee besluiten zijn. + /// + /// 2. Berichten die nog voor deze persoon klaarstaan gaan mee. Ze hebben geen ontvanger + /// meer, en een bericht dat blijft wachten op iemand die je uit je lijst hebt gehaald zou + /// jaren later alsnog bezorgd worden. Wat al bezorgd is blijft staan: dat is uit jouw + /// handen, precies zoals het scherm bij een bezorgd bericht al zegt. + /// + public async Task VerwijderLidAsync(long familyId, long personId, long currentUserId, + CancellationToken ct = default) + { + var family = await _families.FindByIdAsync(familyId, ct); + if (family is null) return Result.Fail("Deze familie bestaat niet."); + + var owner = await _people.FindByIdAsync(family.OwnerPersonId, ct); + if (owner is null || !ProfileOwnership.CanEdit(owner, currentUserId)) + return Result.Fail("Je mag deze familie niet wijzigen."); + + if (personId == family.OwnerPersonId) + return Result.Fail("Jezelf weghalen kan niet; jij bent de eigenaar."); + + var lid = await _families.FindMemberAsync(familyId, personId, ct); + if (lid is null) return Result.Fail("Deze persoon staat niet bij jouw mensen."); + + var persoon = await _people.FindByIdAsync(personId, ct); + var naam = persoon?.DisplayName ?? "Deze persoon"; + + var beheerders = await _legacy.ListManagersForAsync(family.OwnerPersonId, ct); + if (beheerders.Any(b => b.ManagerPersonId == personId)) + return Result.Fail($"{naam} beheert je nalatenschap. Haal dat eerst weg bij " + + "Nalatenschap; daarna kun je hem hier weghalen."); + + // Een openstaande uitnodiging is een sleutel tot dit profiel; die hoort mee te verdwijnen. + foreach (var open in await _invitations.ListOpenForPersonAsync(personId, ct)) + open.Status = InvitationStatus.Ingetrokken; + + // De berichten die nog voor hem klaarstonden. Bezorgde berichten blijven. + var klaar = (await _messages.ListOutboxAsync(family.OwnerPersonId, ct)) + .Where(b => b.ToPersonId == personId && b.DeliveredAt is null) + .ToList(); + + foreach (var bericht in klaar) _messages.Remove(bericht); + + _families.Remove(lid); + + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Family), familyId, + $"lid {personId} verwijderd" + + (klaar.Count > 0 ? $", met {klaar.Count} klaarstaand(e) bericht(en)" : ""), ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Verstuurt een uitnodiging per e-mail. Het token staat alleen in de mail; + /// in de database blijft uitsluitend de hash achter. + /// + public async Task> InviteAsync(long familyId, long personId, long currentUserId, + CancellationToken ct = default) + { + var person = await _people.FindByIdAsync(personId, ct); + if (person is null) return Result.Fail("Profiel niet gevonden."); + if (person.IsClaimed) return Result.Fail($"{person.DisplayName} heeft al een eigen account."); + if (string.IsNullOrWhiteSpace(person.Email)) + return Result.Fail($"Vul eerst het e-mailadres van {person.DisplayName} in."); + + var member = await _families.FindMemberAsync(familyId, personId, ct); + if (member is null) return Result.Fail("Deze persoon staat niet in deze familie."); + + var now = _clock.UtcNow; + var token = TokenGenerator.Create(); + + // Hoe vaak we het al eerder hebben geprobeerd. Dat tonen we op het scherm: zie je dat + // er al drie uitnodigingen uit zijn, dan ligt het waarschijnlijk niet aan de ontvanger + // maar aan het adres. + var eerder = await _invitations.ListLatestForPeopleAsync(new[] { personId }, ct); + var aantalEerder = eerder.Count == 0 ? 0 : eerder[0].ResendCount + 1; + + // Een uitnodiging die nog openstaat is een sleutel tot dit profiel. Sturen we een + // nieuwe, dan hoort de oude niet naast de nieuwe te blijven werken. + foreach (var open in await _invitations.ListOpenForPersonAsync(personId, ct)) + open.Status = InvitationStatus.Ingetrokken; + + var invitation = new Invitation + { + PersonId = personId, + FamilyId = familyId, + Email = person.Email!, + TokenHash = TokenGenerator.HashToken(token), + Status = InvitationStatus.Verzonden, + CreatedByUserId = currentUserId, + CreatedAt = now, + SentAt = now, + ExpiresAt = now.Add(InvitationLifetime), + ResendCount = aantalEerder + }; + _invitations.Add(invitation); + + var inviterUser = await _users.FindByIdAsync(currentUserId, ct); + var inviter = inviterUser?.PersonId is { } pid ? await _people.FindByIdAsync(pid, ct) : null; + var link = $"{SiteUrl.TrimEnd('/')}/uitnodiging/{token}"; + + await _email.SendAsync(person.Email!, "Je bent uitgenodigd voor MijnBesluit", + BuildInvitationMail(person.DisplayName, inviter?.DisplayName ?? "Iemand", link), ct); + + await _audit.WriteAsync(AuditAction.UitnodigingVerstuurd, currentUserId, nameof(Invitation), invitation.Id, person.Email, ct); + await _uow.SaveChangesAsync(ct); + + return Result.Ok(token); + } + + /// + /// Accepteert een uitnodiging: er komt een account bij het bestaande profiel en het profiel + /// gaat over naar de persoon zelf. De uitnodiger ziet het profiel daarna nog wel, maar kan het + /// niet meer wijzigen. Meteen wordt een eigen familieproject aangemaakt, zodat de nieuwe + /// deelnemer op zijn beurt kan uitnodigen. + /// + /// Er is hier geen code per e-mail nodig: de uitnodigingslink is zelf al het bewijs dat + /// iemand bij dat e-mailadres kan, want daar is hij naartoe gestuurd. + /// + /// + /// Wie er al is ingelogd, als er iemand is ingelogd. Dat maakt het verschil tussen een + /// nieuw account en samenvoegen: is dit al jouw account, dan gaat het profiel uit deze + /// uitnodiging op in het profiel dat je al hebt. + /// + /// Dat is de hele oplossing voor dubbele mensen. Jij voegt je dochter toe, je ex-partner + /// doet dat ook, en zij neemt beide uitnodigingen aan. Door die tweede aan te nemen terwijl + /// ze is ingelogd, zegt zij zelf dat het om haar gaat - en dat is de enige die dat mag + /// zeggen. Er wordt nergens op naam of geboortedatum gezocht. + /// + /// + /// Alleen nodig voor dat samenvoegen. Zonder deze dienst werkt het aannemen van een eerste + /// uitnodiging gewoon; een tweede wordt dan netjes geweigerd in plaats van stilzwijgend een + /// tweede profiel te maken. + /// + public async Task> AcceptInvitationAsync(string token, AccountService accounts, + CancellationToken ct = default, + long? currentUserId = null, + SamenvoegService? samenvoegen = null) + { + var now = _clock.UtcNow; + var invitation = await _invitations.FindByTokenHashAsync(TokenGenerator.HashToken(token), ct); + + if (invitation is null) return Result.Fail("Deze uitnodiging is niet (meer) geldig."); + if (invitation.Status == InvitationStatus.Geaccepteerd) return Result.Fail("Deze uitnodiging is al gebruikt."); + if (!invitation.IsOpen(now)) + { + invitation.Status = InvitationStatus.Verlopen; + await _uow.SaveChangesAsync(ct); + return Result.Fail("Deze uitnodiging is verlopen. Vraag om een nieuwe."); + } + + var person = await _people.FindByIdAsync(invitation.PersonId, ct); + if (person is null) return Result.Fail("Het profiel bij deze uitnodiging bestaat niet meer."); + if (person.IsClaimed) return Result.Fail("Dit profiel heeft al een eigen account."); + + // ---------------------------------------------------------------- al ingelogd + if (currentUserId is { } gebruikerId) + { + var ikzelf = await _users.FindByIdAsync(gebruikerId, ct); + if (ikzelf is null) return Result.Fail("Account niet gevonden."); + + if (samenvoegen is null) + return Result.Fail("Samenvoegen is hier niet beschikbaar. Log uit en probeer het opnieuw."); + + var gelukt = await samenvoegen.SamenvoegAsync(person.Id, ikzelf.PersonId, gebruikerId, ct); + if (!gelukt.Succeeded) return Result.Fail(gelukt.Error!); + + invitation.Status = InvitationStatus.Geaccepteerd; + invitation.AcceptedAt = now; + + await _audit.WriteAsync(AuditAction.UitnodigingGeaccepteerd, gebruikerId, + nameof(Invitation), invitation.Id, "samengevoegd", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(ikzelf); + } + + // ---------------------------------------------------------------- nog niet ingelogd + // Bestaat er al een account op dit adres, dan is dit vrijwel zeker een tweede + // uitnodiging voor iemand die hier al is. Een tweede account maken zou precies het + // probleem veroorzaken dat we willen voorkomen, dus vragen we haar eerst in te loggen. + if (await _users.EmailExistsAsync(invitation.Email, ct)) + return Result.Fail( + "Je hebt al een account met dit e-mailadres. Log eerst in; daarna kun je deze " + + "uitnodiging aannemen en wordt dit profiel aan het jouwe toegevoegd."); + + // Uitdrukkelijk het profiel uit de uitnodiging, niet een profiel dat toevallig hetzelfde + // adres heeft. Anders ontstaat er een tweede profiel zodra het adres inmiddels gewijzigd is. + var registratie = await accounts.MaakAccountAsync(invitation.Email, person.FirstName, + person.LastName, ct, person); + if (!registratie.Succeeded) return registratie; + + invitation.Status = InvitationStatus.Geaccepteerd; + invitation.AcceptedAt = now; + + await _audit.WriteAsync(AuditAction.UitnodigingGeaccepteerd, registratie.Value!.Id, + nameof(Invitation), invitation.Id, null, ct); + await _uow.SaveChangesAsync(ct); + + return registratie; + } + + private static string BuildInvitationMail(string name, string inviter, string link) => $""" +

Hallo {System.Net.WebUtility.HtmlEncode(name)},

+

{System.Net.WebUtility.HtmlEncode(inviter)} heeft in MijnBesluit een profiel voor je aangemaakt. + MijnBesluit is een digitale kluis waarin je vastlegt wat je wilt regelen, en waarmee je berichten + kunt klaarzetten voor familie en vrienden — nu of later.

+

Zolang je de uitnodiging niet aanneemt, beheert {System.Net.WebUtility.HtmlEncode(inviter)} jouw profiel. + Neem je hem aan, dan is het profiel van jou en kan niemand anders er nog iets in wijzigen.

+

Je hoeft geen wachtwoord te bedenken: bij MijnBesluit log je in met een code die we je + per e-mail sturen.

+

Uitnodiging aannemen

+

Deze link is 30 dagen geldig.

+

Met vriendelijke groet,
het MijnBesluit-team

+ """; +} diff --git a/src/MijnBesluit.Application/Services/LegacyService.cs b/src/MijnBesluit.Application/Services/LegacyService.cs new file mode 100644 index 0000000..99957a1 --- /dev/null +++ b/src/MijnBesluit.Application/Services/LegacyService.cs @@ -0,0 +1,511 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Application.Security; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Domain.Rules; + +namespace MijnBesluit.Application.Services; + +/// +/// Overlijden melden, bezwaar maken en de nalatenschap vrijgeven. +/// Er zijn meerdere onafhankelijke meldingen nodig; daarna krijgt de persoon zelf nog een +/// bezwaartermijn. Reageert hij niet, dan krijgt de aangewezen beheerder toegang tot alles +/// wat is gemarkeerd als "vrijgeven na overlijden". +/// +public class LegacyService +{ + /// Basis-URL van de website, voor de bezwaarlink in de waarschuwingsmail. + public string SiteUrl { get; set; } = "https://mijnbesluit.nl"; + + /// + /// Hoe lang de bezwaarlink blijft werken. Ruim, en bewust langer dan de bezwaartermijn: + /// juist wie de mail pas laat opmerkt heeft hem nodig, want dan is het overlijden al + /// bevestigd en kan hij niet meer inloggen. + /// + public static readonly TimeSpan BezwaarlinkGeldigheid = TimeSpan.FromDays(365); + + private readonly ILegacyRepository _legacy; + private readonly IPersonRepository _people; + private readonly IUserRepository _users; + private readonly IVaultRepository _vault; + private readonly IMessageRepository _messages; + private readonly IFamilyRepository? _families; + private readonly IEmailSender _email; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + private readonly VaultService _vaultService; + + public LegacyService(ILegacyRepository legacy, IPersonRepository people, IUserRepository users, + IVaultRepository vault, IMessageRepository messages, IEmailSender email, + IAuditLog audit, IUnitOfWork uow, IClock clock, VaultService vaultService, + IFamilyRepository? families = null) + { + _legacy = legacy; _people = people; _users = users; _vault = vault; _messages = messages; + _email = email; _audit = audit; _uow = uow; _clock = clock; _vaultService = vaultService; + _families = families; + } + + /// Wijst iemand aan als nalatenschapsbeheerder. + /// Wie er zijn aangewezen om jouw nalatenschap te beheren, op volgorde. + public record BeheerderView(long PersonId, string DisplayName, int Priority, DateTime? AccessGrantedAt); + + public async Task> ListManagersAsync(long personId, CancellationToken ct = default) + { + var lijst = new List(); + foreach (var m in await _legacy.ListManagersForAsync(personId, ct)) + { + var persoon = await _people.FindByIdAsync(m.ManagerPersonId, ct); + lijst.Add(new BeheerderView(m.ManagerPersonId, persoon?.DisplayName ?? "onbekend", + m.Priority, m.AccessGrantedAt)); + } + return lijst.OrderBy(b => b.Priority).ToList(); + } + + /// + /// Stelt in hoeveel onafhankelijke meldingen er nodig zijn en hoeveel dagen je daarna nog + /// hebt om te laten weten dat je er nog bent. Grenzen: minstens één melding, hoogstens + /// tien, en een termijn van nul tot negentig dagen. + /// + public async Task WijzigProcedureAsync(long currentUserId, int aantalMeldingen, int bezwaardagen, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + if (aantalMeldingen is < 1 or > 10) + return Result.Fail("Het aantal meldingen moet tussen 1 en 10 liggen."); + // Ondergrens één dag, geen nul. Met nul is het overlijden bevestigd op het moment dat de + // laatste melding binnenkomt, en dan is de bezwaartermijn geen termijn meer - precies de + // enige bescherming die de betrokkene heeft, weg met één instelling. + if (bezwaardagen is < 1 or > 90) + return Result.Fail("De bezwaartermijn moet tussen 1 en 90 dagen liggen."); + + var persoon = await _people.FindByIdAsync(user.PersonId, ct); + if (persoon is null) return Result.Fail("Profiel niet gevonden."); + + persoon.RequiredDeathDeclarations = aantalMeldingen; + persoon.DeathObjectionDays = bezwaardagen; + persoon.UpdatedAt = _clock.UtcNow; + + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(Person), persoon.Id, + $"procedure: {aantalMeldingen} meldingen, {bezwaardagen} dagen", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + public async Task AppointManagerAsync(long currentUserId, long managerPersonId, int priority = 1, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (managerPersonId == user.PersonId) return Result.Fail("Je kunt jezelf niet als beheerder aanwijzen."); + + var manager = await _people.FindByIdAsync(managerPersonId, ct); + if (manager is null) return Result.Fail("Deze persoon bestaat niet."); + + var existing = await _legacy.ListManagersForAsync(user.PersonId, ct); + if (existing.Any(m => m.ManagerPersonId == managerPersonId)) + return Result.Fail($"{manager.DisplayName} is al aangewezen."); + + _legacy.Add(new LegacyManager + { + PersonId = user.PersonId, + ManagerPersonId = managerPersonId, + Priority = priority, + CreatedAt = _clock.UtcNow + }); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Haalt iemand weer weg als beheerder. Nodig zodra een beheerder zelf overlijdt, of als je + /// het gewoon anders wilt regelen: aanwijzen zonder kunnen terugnemen is geen keuze. + /// + /// Eén geval kan niet: iemand die je kluis al gekregen heeft. Dan is de procedure afgerond + /// en heeft hij gezien wat erin stond; die regel weghalen zou alleen de administratie + /// opschonen en niet wat er gebeurd is. Dat zeggen we dan ook zo. + /// + public async Task RemoveManagerAsync(long currentUserId, long managerPersonId, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + var beheer = (await _legacy.ListManagersForAsync(user.PersonId, ct)) + .FirstOrDefault(m => m.ManagerPersonId == managerPersonId); + + if (beheer is null) return Result.Fail("Deze persoon beheert je nalatenschap niet."); + + if (beheer.AccessGrantedAt is not null) + { + var wie = await _people.FindByIdAsync(managerPersonId, ct); + return Result.Fail($"{wie?.DisplayName ?? "Deze persoon"} heeft je kluis al gekregen. " + + "Dat is niet meer terug te nemen."); + } + + _legacy.Remove(beheer); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Meldt dat iemand is overleden. Eén melding per gebruiker telt. + /// Zodra het vereiste aantal is bereikt, gaat de bezwaartermijn lopen en krijgt de betrokkene bericht. + /// + public async Task> DeclareDeathAsync(long personId, long currentUserId, + DateOnly? dateOfDeath = null, string? note = null, + CancellationToken ct = default) + { + var person = await _people.FindByIdAsync(personId, ct); + if (person is null) return Result.Fail("Profiel niet gevonden."); + if (!DeathProcedure.MayDeclare(person, currentUserId)) + return Result.Fail("Voor dit profiel kun je geen melding (meer) doen."); + + // Melden mag iedereen uit de kring van de betrokkene: de mensen die hij heeft toegevoegd + // en die de uitnodiging hebben aangenomen. Zij merken een overlijden als eerste, en het + // zijn er genoeg om aan het vereiste aantal te komen. + // + // Dat het daarmee niet zomaar iemand is, komt van de tweede regel: de procedure begint + // pas te lopen als er een melding van een aangewezen beheerder bij zit. Zie + // DeathProcedure voor waarom het zo en niet andersom is. + // + // Deze regel staat hier en niet in het scherm: de schermen bieden alleen je eigen mensen + // aan, maar een regel die alleen in het scherm staat is geen regel. + if (!await KentAsync(currentUserId, person.Id, ct)) + return Result.Fail( + "Je kunt alleen een melding doen over iemand die bij jou in de familie staat."); + + var now = _clock.UtcNow; + + if (await _legacy.FindDeclarationByUserAsync(personId, currentUserId, ct) is { IsActive: true }) + return Result.Fail("Je hebt deze melding al gedaan."); + + _legacy.Add(new DeathDeclaration + { + PersonId = personId, + DeclaredByUserId = currentUserId, + DateOfDeath = dateOfDeath, + Note = note, + DeclaredAt = now + }); + await _audit.WriteAsync(AuditAction.OverlijdenGemeld, currentUserId, nameof(Person), personId, note, ct); + await _uow.SaveChangesAsync(ct); + + var declarations = await _legacy.ListDeclarationsAsync(personId, ct); + var beheerders = await Beheerderskring.GebruikersAsync(_legacy, _users, personId, ct); + var state = DeathProcedure.Evaluate(person, declarations, beheerders, now); + + // Zodra de bezwaartermijn echt gaat lopen: de betrokkene krijgt bericht en kan bezwaar + // maken. Dat is precies wanneer er een einddatum is - dus niet als het aantal wel is + // gehaald maar er nog geen beheerder heeft gemeld. Anders zou iemand een schrikmail + // krijgen over een procedure die stilstaat. + if (state.ObjectionDeadline is not null && state.State == DeathProcedureState.Lopend + && !string.IsNullOrWhiteSpace(person.Email)) + { + // Een link die zonder inloggen werkt. Dat is geen gemak maar noodzaak: zodra het + // overlijden bevestigd is staat het account op "overleden" en kan de betrokkene er + // niet meer in. Zonder deze link zou hij dan vastzitten. + var bezwaartoken = TokenGenerator.Create(); + person.ObjectionTokenHash = TokenGenerator.HashToken(bezwaartoken); + person.ObjectionTokenExpiresAt = now.Add(BezwaarlinkGeldigheid); + + var link = $"{SiteUrl.TrimEnd('/')}/leefnog/{bezwaartoken}"; + + await _email.SendAsync(person.Email!, "Belangrijk: melding over jouw MijnBesluit-profiel", $""" +

Hallo {System.Net.WebUtility.HtmlEncode(person.DisplayName)},

+

Er is bij MijnBesluit gemeld dat je bent overleden. Klopt dat niet, laat het + ons dan weten vóór {state.ObjectionDeadline:dd-MM-yyyy}:

+

Ik ben er nog

+

Daar hoef je niet voor in te loggen. Deze link blijft ook daarna werken, dus + ook als je deze e-mail pas later opmerkt.

+

Reageer je niet, dan krijgt je nalatenschapsbeheerder toegang tot wat je hebt + vrijgegeven voor na je overlijden.

+ """, ct); + } + + if (state.State == DeathProcedureState.Bevestigd) await ConfirmAsync(person, ct); + if (dateOfDeath is not null && person.DateOfDeath is null) person.DateOfDeath = dateOfDeath; + + await _uow.SaveChangesAsync(ct); + return Result.Ok(state); + } + + /// De betrokkene laat weten dat hij gewoon leeft; alle meldingen vervallen. + public async Task ObjectAsync(long personId, long currentUserId, CancellationToken ct = default) + { + var person = await _people.FindByIdAsync(personId, ct); + if (person is null) return Result.Fail("Profiel niet gevonden."); + if (person.ClaimedByUserId != currentUserId) + return Result.Fail("Alleen de persoon zelf kan een overlijdensmelding tegenspreken."); + + await BezwaarAsync(person, currentUserId, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Bezwaar maken met de link uit de waarschuwingsmail, zonder in te loggen. + /// + /// Dit is de enige weg terug als het overlijden al is bevestigd: het account staat dan op + /// "overleden" en inloggen kan niet meer. De link is het bewijs - net als bij een + /// uitnodiging - en hij komt alleen aan op het adres van de betrokkene zelf. + /// + public async Task> BezwaarMetTokenAsync(string token, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(token)) return Result.Fail("Deze link is niet geldig."); + + var person = await _people.FindByObjectionTokenAsync(TokenGenerator.HashToken(token), ct); + if (person is null) return Result.Fail("Deze link is niet (meer) geldig."); + + var now = _clock.UtcNow; + if (person.ObjectionTokenExpiresAt is not { } tot || now > tot) + return Result.Fail("Deze link is verlopen. Neem contact op met de beheerder."); + + await BezwaarAsync(person, person.ClaimedByUserId, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(person.DisplayName); + } + + /// + /// Draait een bevestigd overlijden terug op gezag van een beheerder. + /// + /// Bewust internal: dit is de enige weg naar deze handeling die niet langs de + /// betrokkene zelf loopt, en die hoort alleen bereikbaar te zijn vanuit + /// , die eerst nakijkt of de aanroeper écht beheerder is. De + /// website en de app kunnen er niet bij. + /// + internal async Task HerstelDoorBeheerderAsync(Person person, long beheerderUserId, string reden, + CancellationToken ct = default) + { + await BezwaarAsync(person, beheerderUserId, ct); + + await _audit.WriteAsync(AuditAction.OverlijdenBetwist, beheerderUserId, nameof(Person), person.Id, + $"hersteld door beheerder: {reden}", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Alles wat er bij een bezwaar moet gebeuren, op één plek. Er zijn twee wegen naartoe - + /// ingelogd en via de link - en die horen precies hetzelfde te doen. + /// + /// Let op de laatste stap: is het overlijden al bevestigd geweest, dan heeft de beheerder + /// sleutels gekregen. Die moeten er weer af, anders houdt hij toegang tot de kluis van + /// iemand die gewoon leeft. + /// + private async Task BezwaarAsync(Person person, long? doorUserId, CancellationToken ct) + { + var now = _clock.UtcNow; + var wasBevestigd = person.DeathConfirmedAt is not null; + + person.DeathObjectedAt = now; + person.DeathConfirmedAt = null; + person.DateOfDeath = null; + person.UpdatedAt = now; + + // De link is gebruikt; een volgende melding levert een nieuwe op. + person.ObjectionTokenHash = null; + person.ObjectionTokenExpiresAt = null; + + foreach (var d in await _legacy.ListDeclarationsAsync(person.Id, ct)) + if (d.IsActive) d.RevokedAt = now; + + if (wasBevestigd) + { + var gebruiker = await _users.FindByPersonIdAsync(person.Id, ct); + if (gebruiker is { Status: UserStatus.Overleden }) gebruiker.Status = UserStatus.Actief; + + await _vault.RevokeGrantsForOwnerAsync(person.Id, "nalatenschap", now, ct); + + foreach (var beheer in await _legacy.ListManagersForAsync(person.Id, ct)) + beheer.AccessGrantedAt = null; + } + + await _audit.WriteAsync(AuditAction.OverlijdenBetwist, doorUserId, nameof(Person), person.Id, + wasBevestigd ? "na bevestiging" : null, ct); + } + + /// + /// Loopt de lopende procedures na en bevestigt wat aan de bezwaartermijn voldoet. + /// Draait als achtergrondtaak, bijvoorbeeld één keer per dag. + /// + public async Task ProcessPendingAsync(IEnumerable personIds, CancellationToken ct = default) + { + var now = _clock.UtcNow; + var confirmed = 0; + foreach (var id in personIds) + { + var person = await _people.FindByIdAsync(id, ct); + if (person is null || person.DeathConfirmedAt is not null) continue; + + var state = DeathProcedure.Evaluate(person, await _legacy.ListDeclarationsAsync(id, ct), + await Beheerderskring.GebruikersAsync(_legacy, _users, id, ct), + now); + if (state.State != DeathProcedureState.Bevestigd) continue; + + await ConfirmAsync(person, ct); + confirmed++; + } + await _uow.SaveChangesAsync(ct); + return confirmed; + } + + /// Bevestigt het overlijden en geeft de nalatenschap vrij aan de aangewezen beheerders. + private async Task ConfirmAsync(Person person, CancellationToken ct) + { + var now = _clock.UtcNow; + person.DeathConfirmedAt = now; + person.UpdatedAt = now; + person.DateOfDeath ??= DateOnly.FromDateTime(now); + + var ownerUser = await _users.FindByPersonIdAsync(person.Id, ct); + if (ownerUser is not null) ownerUser.Status = UserStatus.Overleden; + + await _audit.WriteAsync(AuditAction.OverlijdenBevestigd, null, nameof(Person), person.Id, null, ct); + + var released = await _vaultService.ListReleasedAsync(person.Id, ct); + + foreach (var manager in (await _legacy.ListManagersForAsync(person.Id, ct)).OrderBy(m => m.Priority)) + { + var managerPerson = await _people.FindByIdAsync(manager.ManagerPersonId, ct); + if (managerPerson?.ClaimedByUserId is not { } managerUserId) continue; + if (ownerUser is null) continue; + + foreach (var item in released) + await _vaultService.GrantAsync(item.Id, ownerUser.Id, managerUserId, "nalatenschap", ct); + + manager.AccessGrantedAt = now; + await _audit.WriteAsync(AuditAction.NalatenschapToegangVerleend, managerUserId, + nameof(Person), person.Id, null, ct); + + if (!string.IsNullOrWhiteSpace(managerPerson.Email)) + { + await _email.SendAsync(managerPerson.Email!, "Toegang tot de nalatenschap in MijnBesluit", $""" +

Hallo {System.Net.WebUtility.HtmlEncode(managerPerson.DisplayName)},

+

{System.Net.WebUtility.HtmlEncode(person.DisplayName)} heeft jou aangewezen als + nalatenschapsbeheerder. De procedure is afgerond en je hebt nu toegang tot wat + {System.Net.WebUtility.HtmlEncode(person.DisplayName)} daarvoor heeft vrijgegeven.

+ """, ct); + } + } + } + + // ------------------------------------------------------------------ aan mij toevertrouwd + + /// Een nalatenschap die jij beheert, en wat je ervan kunt inzien. + /// De hoeveelste beheerder je bent. Nummer 1 is als eerste aan zet. + /// + /// Is de procedure rond? Zo niet, dan sta je alleen aangewezen en valt er niets in te zien. + /// + /// + /// Hoeveel meldingen deze persoon heeft ingesteld voordat de procedure begint. Staat er ook + /// als de procedure nog niet loopt: zo weet je bij leven al waar je op moet rekenen, en kun + /// je het er nog met diegene over hebben. + /// + /// De bezwaartermijn die deze persoon heeft ingesteld, in dagen. + public record ToevertrouwdView(long PersonId, string DisplayName, DateOnly? DateOfDeath, + DateTime? AccessGrantedAt, int AantalItems, + bool Afgerond, int Volgorde, + int RequiredDeclarations, int ObjectionDays); + + /// + /// De nalatenschappen die aan jou zijn toevertrouwd. + /// + /// Dit is het sluitstuk van de hele overlijdensprocedure: zonder dit krijgt een beheerder + /// wel de sleutels en een e-mail, maar ziet hij nergens iets staan. + /// + /// Er staan er twee soorten in. Aanwijzingen van mensen die er nog gewoon zijn - daarvan + /// hoor je te weten dat je bent gevraagd, want daar reken je op als het zover is. En de + /// afgeronde, waarvan je ook echt kunt inzien wat er is vrijgegeven. + /// + /// Daartussen zit bewust niets. Een lopende procedure ziet er hier precies zo uit als + /// iemand die springlevend is: anders zou een beheerder aan dit scherm kunnen aflezen dat + /// er meldingen binnen zijn over iemand die dat nog kan tegenspreken. + /// + public async Task> ToevertrouwdAsync(long currentUserId, + CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(currentUserId, ct); + if (gebruiker is null) return Array.Empty(); + + var uitkomst = new List(); + + foreach (var opdracht in await _legacy.ListAssignmentsOfAsync(gebruiker.PersonId, ct)) + { + var persoon = await _people.FindByIdAsync(opdracht.PersonId, ct); + if (persoon is null) continue; + + if (persoon.DeathConfirmedAt is null) + { + // Alleen de aanwijzing zelf. Geen datum, geen aantallen: er is niets vrijgegeven + // en er valt over deze persoon niets te melden. + uitkomst.Add(new ToevertrouwdView(persoon.Id, persoon.DisplayName, null, null, 0, + false, opdracht.Priority, + persoon.RequiredDeathDeclarations, persoon.DeathObjectionDays)); + continue; + } + + var items = await _vaultService.ListReleasedAsync(persoon.Id, ct); + uitkomst.Add(new ToevertrouwdView(persoon.Id, persoon.DisplayName, persoon.DateOfDeath, + opdracht.AccessGrantedAt, items.Count, + true, opdracht.Priority, + persoon.RequiredDeathDeclarations, persoon.DeathObjectionDays)); + } + + // De afgeronde bovenaan: daar moet iemand iets mee. De rest is om te weten dat het er is. + return uitkomst.OrderByDescending(t => t.Afgerond) + .ThenBy(t => t.DisplayName, StringComparer.InvariantCultureIgnoreCase) + .ToList(); + } + + /// + /// Wat er van deze nalatenschap voor jou is vrijgegeven. De inhoud blijft versleuteld; die + /// gaat pas open als je een item echt opent, met de sleutel die bij het bevestigen voor jou + /// is klaargezet. + /// + public async Task>> NalatenschapItemsAsync(long personId, long currentUserId, + CancellationToken ct = default) + { + var gebruiker = await _users.FindByIdAsync(currentUserId, ct); + if (gebruiker is null) return Result>.Fail("Account niet gevonden."); + + var overledene = await _people.FindByIdAsync(personId, ct); + if (overledene is null) return Result>.Fail("Dit profiel bestaat niet meer."); + if (overledene.DeathConfirmedAt is null) + return Result>.Fail("Deze procedure is nog niet afgerond."); + + var beheerders = await _legacy.ListManagersForAsync(personId, ct); + if (beheerders.All(b => b.ManagerPersonId != gebruiker.PersonId)) + return Result>.Fail("Je bent geen beheerder van deze nalatenschap."); + + return Result>.Ok(await _vaultService.ListReleasedAsync(personId, ct)); + } + + /// + /// Zit deze gebruiker met dit profiel in een familie? Dat is de kring waarbinnen mensen + /// elkaar kennen, en daarmee de kring die iets over een overlijden kan zeggen. + /// + private async Task KentAsync(long userId, long personId, CancellationToken ct) + { + if (_families is null) return true; // zonder families valt er niets af te bakenen + + var gebruiker = await _users.FindByIdAsync(userId, ct); + if (gebruiker is null) return false; + + return (await _families.ListCircleAsync(gebruiker.PersonId, ct)).Contains(personId); + } + + /// Stand van zaken van de overlijdensprocedure van één profiel. + public async Task StatusAsync(long personId, CancellationToken ct = default) + { + var person = await _people.FindByIdAsync(personId, ct); + if (person is null) return new DeathProcedureResult(DeathProcedureState.Geen, 0, 3, null); + + return DeathProcedure.Evaluate(person, await _legacy.ListDeclarationsAsync(personId, ct), + await Beheerderskring.GebruikersAsync(_legacy, _users, personId, ct), + _clock.UtcNow); + } +} diff --git a/src/MijnBesluit.Application/Services/MeldingService.cs b/src/MijnBesluit.Application/Services/MeldingService.cs new file mode 100644 index 0000000..1f0be66 --- /dev/null +++ b/src/MijnBesluit.Application/Services/MeldingService.cs @@ -0,0 +1,183 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Domain.Entities; + +namespace MijnBesluit.Application.Services; + +/// +/// Meldingen op de telefoon: welke toestellen er zijn, en het versturen ernaartoe. +/// +/// Een melding zegt bewust nooit wát er in een bericht staat. Hij komt op een scherm dat ook +/// zichtbaar is als de telefoon op tafel ligt, dus staat er alleen dat er iets klaarstaat. +/// +public class MeldingService +{ + private readonly IToestelRepository _toestellen; + private readonly IUserRepository _users; + private readonly IPushSender _push; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + + public MeldingService(IToestelRepository toestellen, IUserRepository users, IPushSender push, + IUnitOfWork uow, IClock clock) + { + _toestellen = toestellen; _users = users; _push = push; _uow = uow; _clock = clock; + } + + /// + /// Meldt een toestel aan, of werkt het bij als het al bekend is. + /// + /// Hoort het kenmerk bij een andere gebruiker, dan verhuist het. Dat gebeurt echt: iemand + /// logt uit en een ander logt in op dezelfde telefoon. Zonder deze regel krijgt die tweede + /// persoon de meldingen van de eerste. + /// + public async Task AanmeldenAsync(long currentUserId, string kenmerk, string platform, + string? naam = null, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(kenmerk)) return Result.Fail("Geen kenmerk meegegeven."); + if (kenmerk.Length > 512) return Result.Fail("Dit kenmerk is te lang."); + + if (await _users.FindByIdAsync(currentUserId, ct) is null) return Result.Fail("Account niet gevonden."); + + var nu = _clock.UtcNow; + var bestaand = await _toestellen.FindByKenmerkAsync(kenmerk.Trim(), ct); + + if (bestaand is null) + { + _toestellen.Add(new Toestel + { + UserId = currentUserId, + Kenmerk = kenmerk.Trim(), + Platform = string.IsNullOrWhiteSpace(platform) ? "android" : platform.Trim().ToLowerInvariant(), + Naam = naam?.Trim(), + AangemeldOp = nu, + LaatstGezien = nu + }); + } + else + { + bestaand.UserId = currentUserId; + bestaand.Platform = string.IsNullOrWhiteSpace(platform) ? bestaand.Platform : platform.Trim().ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(naam)) bestaand.Naam = naam.Trim(); + bestaand.LaatstGezien = nu; + bestaand.AfgemeldOp = null; + } + + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// Meldt een toestel af, bijvoorbeeld bij uitloggen. + public async Task AfmeldenAsync(long currentUserId, string kenmerk, CancellationToken ct = default) + { + var toestel = await _toestellen.FindByKenmerkAsync((kenmerk ?? "").Trim(), ct); + + // Van iemand anders? Dan bestaat het voor jou niet, en zeggen we dat ook niet. + if (toestel is null || toestel.UserId != currentUserId) return Result.Ok(); + + toestel.AfgemeldOp = _clock.UtcNow; + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Stuurt een melding naar alle toestellen van een gebruiker. Toestellen die Google niet + /// meer kent worden meteen afgemeld, zodat we het niet blijven proberen. + /// + public async Task StuurAsync(long userId, string titel, string tekst, + IReadOnlyDictionary? gegevens = null, + CancellationToken ct = default) + { + if (!_push.IsIngesteld) return 0; + + var verstuurd = 0; + var aangepast = false; + + foreach (var toestel in await _toestellen.ListForUserAsync(userId, ct)) + { + if (!toestel.IsActief) continue; + + bool bruikbaar; + try + { + bruikbaar = await _push.StuurAsync(toestel.Kenmerk, titel, tekst, gegevens, ct); + } + catch (Exception) + { + // Een storing bij Google mag het bezorgen van een bericht niet tegenhouden. + continue; + } + + if (bruikbaar) verstuurd++; + else { toestel.AfgemeldOp = _clock.UtcNow; aangepast = true; } + } + + if (aangepast) await _uow.SaveChangesAsync(ct); + return verstuurd; + } + + /// De toestellen van een gebruiker, om te kunnen tonen waar meldingen heen gaan. + public Task> ToestellenAsync(long userId, CancellationToken ct = default) => + _toestellen.ListForUserAsync(userId, ct); + + /// De uitkomst van een proefmelding, met een uitleg die je aan iemand kunt laten zien. + public record Proefuitslag(bool Ingesteld, int Toestellen, int Verstuurd, string Uitleg) + { + public bool Gelukt => Verstuurd > 0; + } + + /// + /// Stuurt een proefmelding naar je eigen toestellen en zegt precies wat eraan mankeert als + /// er niets aankomt. + /// + /// Waarom dit bestaat: als een melding uitblijft zijn er drie oorzaken, en ze zien er van + /// buiten hetzelfde uit — de server heeft geen servicesleutel, het toestel heeft zich nooit + /// aangemeld, of Google weigert het kenmerk. Zonder deze knop is de enige manier om dat uit + /// elkaar te houden: een bericht klaarzetten, een kwartier wachten op de achtergrondtaak, en + /// dan in het logboek kijken. Dat is te omslachtig om vaak te doen, en juist bij het + /// inrichten doe je het vaak. + /// + /// Alleen naar je eigen toestellen. Iemand anders een melding kunnen sturen zou een manier + /// zijn om mensen lastig te vallen. + /// + public async Task ProefAsync(long userId, CancellationToken ct = default) + { + if (!_push.IsIngesteld) + { + return new Proefuitslag(false, 0, 0, + "Op de server staat geen servicesleutel voor Firebase. Zie docs/Meldingen.md, stap 3."); + } + + var toestellen = (await _toestellen.ListForUserAsync(userId, ct)).Count(t => t.IsActief); + if (toestellen == 0) + { + return new Proefuitslag(true, 0, 0, + "Er is geen toestel aangemeld. Log in de app opnieuw in en geef toestemming voor " + + "meldingen; pas daarna kent de server je telefoon."); + } + + var verstuurd = await StuurAsync(userId, "MijnBesluit", + "Dit is een proefmelding. Zie je hem, dan werkt het.", new Dictionary + { + ["soort"] = "proef" + }, ct); + + var uitleg = verstuurd > 0 + ? $"Verstuurd naar {(verstuurd == 1 ? "je toestel" : $"{verstuurd} toestellen")}. " + + "Komt hij niet aan, dan staan meldingen op de telefoon zelf uit." + : "Google nam de melding niet aan. Het kenmerk van dit toestel is verlopen; het is nu " + + "afgemeld. Log in de app opnieuw in, dan meldt hij zich met een nieuw kenmerk."; + + return new Proefuitslag(true, toestellen, verstuurd, uitleg); + } +} + +/// Wordt gebruikt zolang er niets is ingesteld: er gaat dan niets uit, en niets valt om. +public sealed class GeenPush : IPushSender +{ + public bool IsIngesteld => false; + + public Task StuurAsync(string kenmerk, string titel, string tekst, + IReadOnlyDictionary? gegevens = null, + CancellationToken ct = default) => Task.FromResult(true); +} diff --git a/src/MijnBesluit.Application/Services/MessageService.cs b/src/MijnBesluit.Application/Services/MessageService.cs new file mode 100644 index 0000000..2d1ac72 --- /dev/null +++ b/src/MijnBesluit.Application/Services/MessageService.cs @@ -0,0 +1,647 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Application.Security; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Domain.Rules; + +namespace MijnBesluit.Application.Services; + +/// De inhoud van een bericht, versleuteld opgeslagen. +public class MessageBody +{ + public string Text { get; set; } = string.Empty; +} + +/// +/// Berichten ("momenten") klaarzetten en bezorgen. Een bericht kan direct gaan, op een datum, +/// of pas nadat het overlijden van de afzender bevestigd is. Bij bezorging krijgt de ontvanger +/// zijn eigen ingepakte kopie van de contentsleutel; pas dán kan hij het lezen. +/// +public class MessageService +{ + private readonly IMessageRepository _messages; + private readonly IPersonRepository _people; + private readonly IUserRepository _users; + private readonly IVaultRepository _vault; + private readonly IMasterKeyProtector _keys; + private readonly IFileStore _files; + private readonly IEmailSender _email; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + + private readonly MeldingService? _meldingen; + + /// Hoe lang een kijklink werkt, en na hoeveel tijd zonder openen een herinnering volgt. + public static readonly TimeSpan ViewLinkLifetime = TimeSpan.FromDays(30); + + /// Waar de kijklink naartoe wijst. Wordt bij het opstarten gezet, net als bij FamilyService. + public string SiteUrl { get; set; } = "https://mijnbesluit.nl"; + + public MessageService(IMessageRepository messages, IPersonRepository people, IUserRepository users, + IVaultRepository vault, IMasterKeyProtector keys, IFileStore files, IEmailSender email, + IAuditLog audit, IUnitOfWork uow, IClock clock, + MeldingService? meldingen = null) + { + _messages = messages; _people = people; _users = users; _vault = vault; + _keys = keys; _files = files; _email = email; _audit = audit; _uow = uow; _clock = clock; + _meldingen = meldingen; + } + + /// Zet een bericht klaar voor iemand, nu of later. + public async Task> ComposeAsync(long currentUserId, long toPersonId, string subject, string text, + DateTime? deliverAt = null, bool afterDeath = false, + MessageKind kind = MessageKind.Tekst, long? vaultFileId = null, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (await _people.FindByIdAsync(toPersonId, ct) is null) + return Result.Fail("De ontvanger bestaat niet."); + if (string.IsNullOrWhiteSpace(subject)) return Result.Fail("Geef het bericht een onderwerp."); + if (deliverAt is { } at && at < _clock.UtcNow.AddMinutes(-1)) + return Result.Fail("De bezorgdatum ligt in het verleden."); + + var now = _clock.UtcNow; + var dataKey = _keys.Unprotect(user.WrappedDataKey); + var contentKey = EnvelopeCrypto.NewKey(); + try + { + var sealedData = EnvelopeCrypto.SealJson(new MessageBody { Text = text }, contentKey); + var message = new Message + { + FromPersonId = user.PersonId, + ToPersonId = toPersonId, + Kind = kind, + Subject = subject.Trim(), + Ciphertext = sealedData.Ciphertext, + Nonce = sealedData.Nonce, + Tag = sealedData.Tag, + WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey), + VaultFileId = vaultFileId, + DeliverAt = deliverAt, + DeliverAfterDeath = afterDeath, + Status = MessageStatus.Gepland, + CreatedAt = now, + UpdatedAt = now + }; + _messages.Add(message); + + await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), null, subject, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(message); + } + finally + { + Array.Clear(dataKey); + Array.Clear(contentKey); + } + } + + /// + /// De tekst van een bericht dat jij hebt klaargezet en dat nog niet bezorgd is. + /// + /// Dit is bewust iets anders dan : dat is voor de ontvanger van een + /// bezorgd bericht. Hier gaat het om je eigen concept, dat je nog wilt kunnen nalezen en + /// wijzigen. De inhoud wordt ontsleuteld met je eigen datasleutel - dezelfde waarmee hij bij + /// het klaarzetten is versleuteld. + /// + public async Task> ConceptAsync(long messageId, long currentUserId, CancellationToken ct = default) + { + var message = await _messages.FindByIdAsync(messageId, ct); + if (message is null) return Result.Fail("Dit bericht bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (message.FromPersonId != user.PersonId) return Result.Fail("Dit bericht is niet van jou."); + + if (message.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen) + return Result.Fail("Dit bericht is al bezorgd."); + + var dataKey = _keys.Unprotect(user.WrappedDataKey); + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, dataKey); + var body = EnvelopeCrypto.OpenJson( + new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey); + return Result.Ok(body?.Text ?? string.Empty); + } + finally + { + Array.Clear(dataKey); + if (contentKey is not null) Array.Clear(contentKey); + } + } + + /// + /// De tekst van een bericht dat jij hebt verstuurd, ook nadat het is bezorgd - alleen om na + /// te lezen, niet om te wijzigen (dat is , en dat mag alleen nog + /// vóór bezorging). Dit kan sowieso: je eigen exemplaar is versleuteld met je eigen + /// datasleutel, dezelfde als bij het klaarzetten, en die verandert niet door bezorging - dat + /// is alleen voor de ontvanger een apart stukje sleutel (het "grant"), zie ReadAsync. + /// + public async Task> ReadSentAsync(long messageId, long currentUserId, CancellationToken ct = default) + { + var message = await _messages.FindByIdAsync(messageId, ct); + if (message is null) return Result.Fail("Dit bericht bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (message.FromPersonId != user.PersonId) return Result.Fail("Dit bericht is niet van jou."); + + var dataKey = _keys.Unprotect(user.WrappedDataKey); + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, dataKey); + var body = EnvelopeCrypto.OpenJson( + new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey); + return Result.Ok(body?.Text ?? string.Empty); + } + finally + { + Array.Clear(dataKey); + if (contentKey is not null) Array.Clear(contentKey); + } + } + + /// + /// Wijzigt een bericht dat nog niet bezorgd is. Alleen de afzender kan dit. + /// + /// Een bezorgd bericht blijft staan zoals het is: de ontvanger heeft het al gezien, en een + /// bericht dat achteraf verandert is geen bericht meer. + /// + public async Task UpdateAsync(long messageId, long currentUserId, string subject, string text, + DateTime? deliverAt, bool afterDeath, CancellationToken ct = default) + { + var message = await _messages.FindByIdAsync(messageId, ct); + if (message is null) return Result.Fail("Dit bericht bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (message.FromPersonId != user.PersonId) return Result.Fail("Dit bericht is niet van jou."); + + if (message.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen) + return Result.Fail("Dit bericht is al bezorgd en kan niet meer gewijzigd worden."); + if (message.Status == MessageStatus.Ingetrokken) + return Result.Fail("Dit bericht is ingetrokken."); + + if (string.IsNullOrWhiteSpace(subject)) return Result.Fail("Geef het bericht een onderwerp."); + if (deliverAt is { } at && at < _clock.UtcNow.AddMinutes(-1)) + return Result.Fail("De bezorgdatum ligt in het verleden."); + + var now = _clock.UtcNow; + var dataKey = _keys.Unprotect(user.WrappedDataKey); + var contentKey = EnvelopeCrypto.NewKey(); + try + { + var sealedData = EnvelopeCrypto.SealJson(new MessageBody { Text = text }, contentKey); + + message.Subject = subject.Trim(); + message.Ciphertext = sealedData.Ciphertext; + message.Nonce = sealedData.Nonce; + message.Tag = sealedData.Tag; + message.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey); + message.DeliverAt = deliverAt; + message.DeliverAfterDeath = afterDeath; + message.UpdatedAt = now; + + await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), message.Id, "gewijzigd", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + finally + { + Array.Clear(dataKey); + Array.Clear(contentKey); + } + } + + /// + /// Trekt een bericht in dat nog niet bezorgd is. Het verdwijnt uit je uitgaande post en + /// wordt nooit meer bezorgd. + /// + public async Task WithdrawAsync(long messageId, long currentUserId, CancellationToken ct = default) + { + var message = await _messages.FindByIdAsync(messageId, ct); + if (message is null) return Result.Fail("Dit bericht bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (message.FromPersonId != user.PersonId) return Result.Fail("Dit bericht is niet van jou."); + + if (message.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen) + return Result.Fail("Dit bericht is al bezorgd; intrekken kan niet meer."); + + message.Status = MessageStatus.Ingetrokken; + message.UpdatedAt = _clock.UtcNow; + + await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), message.Id, "ingetrokken", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Een bericht uit je eigen lijst verwijderen - "Van mij" of "Voor mij", bezorgd of niet. Dit + /// haalt het alleen bij jou weg: de ander (afzender of ontvanger) houdt zijn eigen exemplaar + /// gewoon, tot die het op zijn beurt ook weghaalt. Zie Message.HiddenBySender/HiddenByRecipient. + /// + public async Task DeleteForMeAsync(long messageId, long currentUserId, CancellationToken ct = default) + { + var message = await _messages.FindByIdAsync(messageId, ct); + if (message is null) return Result.Fail("Dit bericht bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + if (message.FromPersonId == user.PersonId) message.HiddenBySender = true; + else if (message.ToPersonId == user.PersonId) message.HiddenByRecipient = true; + else return Result.Fail("Dit bericht is niet van jou."); + + message.UpdatedAt = _clock.UtcNow; + + await _audit.WriteAsync(AuditAction.BerichtGepland, currentUserId, nameof(Message), message.Id, + message.FromPersonId == user.PersonId ? "verwijderd (afzender)" : "verwijderd (ontvanger)", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Bezorgt alle berichten waarvan de datum is bereikt of waarvan de afzender bevestigd overleden is. + /// Draait als achtergrondtaak, bijvoorbeeld elk kwartier. + /// + public async Task DeliverDueAsync(int max = 200, CancellationToken ct = default) + { + var now = _clock.UtcNow; + var due = await _messages.ListDueAsync(now, max, ct); + var delivered = 0; + + foreach (var message in due) + { + var sender = await _people.FindByIdAsync(message.FromPersonId, ct); + if (sender is null) continue; + if (!MessageRelease.IsDue(message, sender, now)) continue; + if (await DeliverAsync(message, sender, ct)) delivered++; + } + + await _uow.SaveChangesAsync(ct); + return delivered; + } + + /// Bezorgt één bericht: contentsleutel omzetten naar de ontvanger en een seintje per e-mail. + private async Task DeliverAsync(Message message, Person sender, CancellationToken ct) + { + var recipient = await _people.FindByIdAsync(message.ToPersonId, ct); + if (recipient is null) return false; + + var senderUser = await _users.FindByPersonIdAsync(message.FromPersonId, ct); + if (senderUser is null) return false; + + var recipientUser = recipient.ClaimedByUserId is { } uid ? await _users.FindByIdAsync(uid, ct) : null; + + // Nog geen account? Dan sturen we in plaats daarvan een kijklink: een e-mail met een + // link naar een landingspagina waar dit ene bericht - inclusief bijlage - te zien is + // zonder in te loggen. Het bericht zelf blijft "Gepland" staan; claimt de ontvanger + // zijn profiel alsnog, dan loopt hij hier op de eerstvolgende ronde gewoon weer langs + // en wordt het bericht dan wél normaal bezorgd. + if (recipientUser is null) + { + await StuurKijklinkAsync(message, sender, recipient, ct); + return false; + } + + var senderKey = _keys.Unprotect(senderUser.WrappedDataKey); + var recipientKey = _keys.Unprotect(recipientUser.WrappedDataKey); + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, senderKey); + _vault.Add(new ContentGrant + { + ResourceType = nameof(Message), + ResourceId = message.Id, + UserId = recipientUser.Id, + WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, recipientKey), + Reason = "ontvanger", + GrantedAt = _clock.UtcNow + }); + + // Zit er een opname of bijlage bij, dan moet de ontvanger daar ook bij kunnen. Die + // heeft een eigen sleutel, versleuteld met de datasleutel van de afzender; zonder + // deze regel krijgt de ontvanger straks wel de tekst te zien maar geen video. + if (message.VaultFileId is { } bestandId && + await _vault.FindFileAsync(bestandId, ct) is { DeletedAt: null } bestand) + { + byte[]? bestandsleutel = null; + try + { + bestandsleutel = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, senderKey); + _vault.Add(new ContentGrant + { + ResourceType = nameof(VaultFile), + ResourceId = bestand.Id, + UserId = recipientUser.Id, + WrappedContentKey = EnvelopeCrypto.WrapKey(bestandsleutel, recipientKey), + Reason = "bijlage bij bericht", + GrantedAt = _clock.UtcNow + }); + } + finally + { + if (bestandsleutel is not null) Array.Clear(bestandsleutel); + } + } + } + finally + { + Array.Clear(senderKey); + Array.Clear(recipientKey); + if (contentKey is not null) Array.Clear(contentKey); + } + + message.Status = MessageStatus.Bezorgd; + message.DeliveredAt = _clock.UtcNow; + message.UpdatedAt = message.DeliveredAt.Value; + + if (!string.IsNullOrWhiteSpace(recipient.Email)) + { + await _email.SendAsync(recipient.Email!, "Er staat een bericht voor je klaar in MijnBesluit", $""" +

Hallo {System.Net.WebUtility.HtmlEncode(recipient.DisplayName)},

+

{System.Net.WebUtility.HtmlEncode(sender.DisplayName)} heeft een bericht voor je klaargezet: + {System.Net.WebUtility.HtmlEncode(message.Subject)}.

+

Log in op MijnBesluit om het te lezen.

+ """, ct); + } + + // Een melding op de telefoon, als de ontvanger de app heeft. Bewust zonder de inhoud: + // die melding komt op een scherm dat ook zichtbaar is als de telefoon op tafel ligt. + if (_meldingen is not null) + { + await _meldingen.StuurAsync(recipientUser.Id, + "Er staat een bericht voor je klaar", + $"{sender.DisplayName} heeft iets voor je klaargezet.", + new Dictionary + { + ["soort"] = "bericht", + ["berichtId"] = message.Id.ToString() + }, ct); + } + + await _audit.WriteAsync(AuditAction.BerichtBezorgd, null, nameof(Message), message.Id, null, ct); + return true; + } + + /// + /// Stuurt een kijklink naar iemand zonder account: de eerste keer meteen, en daarna alleen + /// nog als de vorige link inmiddels verlopen is én nooit geopend - dat is dan de ene + /// herinnering. Buiten die twee momenten doet deze methode niets, ook al wordt ze elke + /// ronde van weer aangeroepen zolang het bericht "Gepland" + /// blijft staan. + /// + private async Task StuurKijklinkAsync(Message message, Person sender, Person recipient, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(recipient.Email)) return; + + var now = _clock.UtcNow; + var herinnering = false; + + if (message.ViewTokenHash is not null) + { + // Al eerder een link gestuurd. Nog geldig? Dan is er niets te doen: de ontvanger + // heeft hem al, of moet hem nog openen - allebei geen reden voor een nieuwe mail. + if (message.ViewTokenExpiresAt is { } verloopt && verloopt > now) return; + + // Verlopen, maar al geopend of al eens herinnerd: verder wachten heeft geen zin. + if (message.ViewedAt is not null || message.ViewReminderSentAt is not null) return; + + herinnering = true; + } + + var senderUser = await _users.FindByPersonIdAsync(message.FromPersonId, ct); + if (senderUser is null) return; + + var token = TokenGenerator.Create(); + var senderKey = _keys.Unprotect(senderUser.WrappedDataKey); + var tokenKey = TokenGenerator.DeriveKey(token, "bericht-inhoud"); + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKey, senderKey); + message.WrappedContentKeyForToken = EnvelopeCrypto.WrapKey(contentKey, tokenKey); + + // Zit er een bijlage bij, dan krijgt die een eigen, uit hetzelfde token afgeleide + // sleutel - een andere dan die van de tekst, zodat een van de twee lekken niet + // meteen de andere blootgeeft. + if (message.VaultFileId is { } bestandId && + await _vault.FindFileAsync(bestandId, ct) is { DeletedAt: null } bestand) + { + var bestandTokenKey = TokenGenerator.DeriveKey(token, "bericht-bijlage"); + byte[]? bestandsleutel = null; + try + { + bestandsleutel = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, senderKey); + message.WrappedVaultKeyForToken = EnvelopeCrypto.WrapKey(bestandsleutel, bestandTokenKey); + } + finally + { + if (bestandsleutel is not null) Array.Clear(bestandsleutel); + } + } + } + finally + { + Array.Clear(senderKey); + Array.Clear(tokenKey); + if (contentKey is not null) Array.Clear(contentKey); + } + + message.ViewTokenHash = TokenGenerator.HashToken(token); + message.ViewTokenExpiresAt = now.Add(ViewLinkLifetime); + message.ViewTokenSentAt = now; + if (herinnering) message.ViewReminderSentAt = now; + message.UpdatedAt = now; + + var link = $"{SiteUrl.TrimEnd('/')}/moment/{token}"; + var onderwerp = herinnering + ? "Je hebt nog een bericht openstaan in MijnBesluit" + : "Er staat een bericht voor je klaar in MijnBesluit"; + var inleiding = herinnering + ? "Je hebt dit bericht nog niet bekeken. De vorige link is verlopen; deze is weer 30 dagen geldig." + : $"{System.Net.WebUtility.HtmlEncode(sender.DisplayName)} heeft een bericht voor je klaargezet, " + + $"met de titel {System.Net.WebUtility.HtmlEncode(message.Subject)}."; + + await _email.SendAsync(recipient.Email!, onderwerp, $""" +

Hallo {System.Net.WebUtility.HtmlEncode(recipient.DisplayName)},

+

{inleiding}

+

Je hoeft geen account aan te maken om het te bekijken - foto's, video's en + documenten die erbij zitten inbegrepen:

+

{link}

+

Deze link is 30 dagen geldig. Wil je zelf ook een MijnBesluit beginnen, dan kan dat + vanaf dezelfde pagina.

+ """, ct); + + await _audit.WriteAsync(AuditAction.BerichtLinkVerstuurd, null, nameof(Message), message.Id, + herinnering ? "herinnering" : "eerste link", ct); + } + + /// Wat de landingspagina van een kijklink te zien krijgt. + public class MomentWeergave + { + public string AfzenderNaam { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + public long? VaultFileId { get; set; } + public string? BestandsNaam { get; set; } + public string? BestandsType { get; set; } + + /// Kan de ontvanger vanaf hier ook meteen zijn eigen profiel gaan claimen? + public bool KanNogClaimen { get; set; } + } + + /// + /// De inhoud van een bericht bij een kijklink, voor iemand zonder account. Markeert het + /// bericht als geopend - dat is wat het verschil maakt tussen een link die na 30 dagen + /// een herinnering krijgt en een die dat niet doet. + /// + public async Task> ViewByTokenAsync(string token, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(token)) return Result.Fail("Deze link is niet geldig."); + + var message = await _messages.FindByViewTokenHashAsync(TokenGenerator.HashToken(token), ct); + if (message?.ViewTokenHash is null || !TokenGenerator.Matches(token, message.ViewTokenHash) || + message.WrappedContentKeyForToken is null) + return Result.Fail("Deze link is niet (meer) geldig."); + + if (message.ViewTokenExpiresAt is not { } verloopt || verloopt <= _clock.UtcNow) + return Result.Fail( + "Deze link is verlopen. Is er nog niets nieuws gestuurd, vraag de afzender dan " + + "gerust om het bericht opnieuw klaar te zetten."); + + var sender = await _people.FindByIdAsync(message.FromPersonId, ct); + var recipient = await _people.FindByIdAsync(message.ToPersonId, ct); + + var tokenKey = TokenGenerator.DeriveKey(token, "bericht-inhoud"); + byte[]? contentKey = null; + string tekst; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(message.WrappedContentKeyForToken, tokenKey); + var body = EnvelopeCrypto.OpenJson( + new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey); + tekst = body?.Text ?? string.Empty; + } + finally + { + Array.Clear(tokenKey); + if (contentKey is not null) Array.Clear(contentKey); + } + + var bestand = message.VaultFileId is { } bestandId ? await _vault.FindFileAsync(bestandId, ct) : null; + if (bestand?.DeletedAt is not null) bestand = null; + + if (message.ViewedAt is null) + { + message.ViewedAt = _clock.UtcNow; + await _audit.WriteAsync(AuditAction.BerichtLinkBekeken, null, nameof(Message), message.Id, null, ct); + await _uow.SaveChangesAsync(ct); + } + + return Result.Ok(new MomentWeergave + { + AfzenderNaam = sender?.DisplayName ?? "Iemand", + Subject = message.Subject, + Text = tekst, + VaultFileId = bestand?.Id, + BestandsNaam = bestand?.FileName, + BestandsType = bestand?.ContentType, + KanNogClaimen = recipient is not null && !recipient.IsClaimed + }); + } + + /// De bijlage bij een bericht dat via een kijklink wordt bekeken - zelfde token, zelfde geldigheid. + public async Task> OpenAttachmentByTokenAsync( + string token, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(token)) return Result<(VaultFile, byte[])>.Fail("Deze link is niet geldig."); + + var message = await _messages.FindByViewTokenHashAsync(TokenGenerator.HashToken(token), ct); + if (message?.ViewTokenHash is null || !TokenGenerator.Matches(token, message.ViewTokenHash) || + message.WrappedVaultKeyForToken is null) + return Result<(VaultFile, byte[])>.Fail("Deze link is niet (meer) geldig."); + + if (message.ViewTokenExpiresAt is not { } verloopt || verloopt <= _clock.UtcNow) + return Result<(VaultFile, byte[])>.Fail("Deze link is verlopen."); + + if (message.VaultFileId is not { } bestandId) + return Result<(VaultFile, byte[])>.Fail("Bij dit bericht zit geen bijlage."); + + var bestand = await _vault.FindFileAsync(bestandId, ct); + if (bestand is null || bestand.DeletedAt is not null) + return Result<(VaultFile, byte[])>.Fail("Dit bestand bestaat niet meer."); + + var tokenKey = TokenGenerator.DeriveKey(token, "bericht-bijlage"); + byte[]? bestandsleutel = null; + try + { + bestandsleutel = EnvelopeCrypto.UnwrapKey(message.WrappedVaultKeyForToken, tokenKey); + var versleuteld = await _files.ReadAsync(bestand.StorageKey, ct); + var inhoud = EnvelopeCrypto.Open(new SealedData(versleuteld, bestand.Nonce, bestand.Tag), bestandsleutel); + return Result<(VaultFile, byte[])>.Ok((bestand, inhoud)); + } + finally + { + Array.Clear(tokenKey); + if (bestandsleutel is not null) Array.Clear(bestandsleutel); + } + } + + /// Leest een bezorgd bericht en markeert het als gelezen. + public async Task> ReadAsync(long messageId, long currentUserId, CancellationToken ct = default) + { + var message = await _messages.FindByIdAsync(messageId, ct); + if (message is null) return Result.Fail("Dit bericht bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (message.ToPersonId != user.PersonId) return Result.Fail("Dit bericht is niet voor jou."); + if (message.Status == MessageStatus.Gepland) return Result.Fail("Dit bericht is nog niet vrijgegeven."); + + var grant = await _vault.FindGrantAsync(nameof(Message), message.Id, currentUserId, ct); + if (grant is null || grant.RevokedAt is not null) return Result.Fail("Je hebt geen toegang tot dit bericht."); + + var dataKey = _keys.Unprotect(user.WrappedDataKey); + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(grant.WrappedContentKey, dataKey); + var body = EnvelopeCrypto.OpenJson( + new SealedData(message.Ciphertext, message.Nonce, message.Tag), contentKey); + + if (message.Status != MessageStatus.Gelezen) + { + message.Status = MessageStatus.Gelezen; + message.ReadAt = _clock.UtcNow; + await _audit.WriteAsync(AuditAction.BerichtGelezen, currentUserId, nameof(Message), message.Id, null, ct); + await _uow.SaveChangesAsync(ct); + } + + return Result.Ok(body?.Text ?? string.Empty); + } + finally + { + Array.Clear(dataKey); + if (contentKey is not null) Array.Clear(contentKey); + } + } + + public Task> InboxAsync(long personId, CancellationToken ct = default) => + _messages.ListInboxAsync(personId, ct); + + /// Uitgaande post. Ingetrokken berichten laten we weg: die zijn weggehaald. + public async Task> OutboxAsync(long personId, CancellationToken ct = default) => + (await _messages.ListOutboxAsync(personId, ct)) + .Where(m => m.Status != MessageStatus.Ingetrokken) + .ToList(); +} diff --git a/src/MijnBesluit.Application/Services/OverzichtService.cs b/src/MijnBesluit.Application/Services/OverzichtService.cs new file mode 100644 index 0000000..9562c2d --- /dev/null +++ b/src/MijnBesluit.Application/Services/OverzichtService.cs @@ -0,0 +1,157 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Domain.Rules; + +namespace MijnBesluit.Application.Services; + +/// Eén regel op het overzicht: iets wat speelt of eraan komt. +/// "beheerder", "beheer-voor", "moment", "overlijden". +public record Overzichtsregel(string Soort, string Tekst, DateTime? Datum = null, long? Id = null); + +/// Alles wat het beginscherm laat zien, in één antwoord. +public record Overzicht( + int AantalKluis, + int AantalFamilie, + + // Momenten in twee richtingen: wat er voor jou klaarstaat en wat jij voor anderen + // hebt klaargezet. Die twee zijn het scherm Momenten zelf ook, als twee tabbladen. + int AantalVoorMij, + int AantalVanMij, + int AantalNieuweBerichten, + + /// Hoeveel mensen jouw nalatenschap straks mogen beheren. + int AantalBeheerders, + + int VooruitblikDagen, + IReadOnlyList Regels); + +/// +/// Stelt het beginscherm samen: de aantallen voor de tegels, en daarboven wat er speelt. +/// +/// Dat laatste is de bedoeling van het scherm: niet "hier zijn vier knoppen", maar "dit staat +/// er voor je klaar en dit heb je geregeld". Staat er niets, dan blijven alleen de tegels over. +/// +public class OverzichtService +{ + private readonly IUserRepository _users; + private readonly IPersonRepository _people; + private readonly IFamilyRepository _families; + private readonly IVaultRepository _vault; + private readonly IMessageRepository _messages; + private readonly ILegacyRepository _legacy; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + + public OverzichtService(IUserRepository users, IPersonRepository people, IFamilyRepository families, + IVaultRepository vault, IMessageRepository messages, ILegacyRepository legacy, + IUnitOfWork uow, IClock clock) + { + _users = users; _people = people; _families = families; _vault = vault; + _messages = messages; _legacy = legacy; _uow = uow; _clock = clock; + } + + /// Het maximum dat iemand vooruit kan kijken. Verder is het geen vooruitblik meer. + public const int MaxVooruitblikDagen = 60; + + public async Task> VoorAsync(long currentUserId, CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + var nu = _clock.UtcNow; + var ik = user.PersonId; + var regels = new List(); + + // ---------------------------------------------------------------- aantallen + var kluis = await _vault.ListForPersonAsync(ik, null, ct); + var families = await _families.ListOwnedByPersonAsync(ik, ct); + var leden = families.Count == 0 + ? Array.Empty() + : (await _families.ListMembersAsync(families[0].Id, ct)).ToArray(); + + var inbox = await _messages.ListInboxAsync(ik, ct); + var outbox = await _messages.ListOutboxAsync(ik, ct); + var nieuw = inbox.Count(m => m.Status == MessageStatus.Bezorgd); + + // ---------------------------------------------------------------- wie beheert wat + var beheerders = await _legacy.ListManagersForAsync(ik, ct); + foreach (var aanwijzing in beheerders) + { + var beheerder = await _people.FindByIdAsync(aanwijzing.ManagerPersonId, ct); + if (beheerder is null) continue; + + regels.Add(new Overzichtsregel("beheerder", + $"{beheerder.DisplayName} beheert straks jouw nalatenschap.", null, beheerder.Id)); + } + + foreach (var opdracht in await _legacy.ListAssignmentsOfAsync(ik, ct)) + { + var voor = await _people.FindByIdAsync(opdracht.PersonId, ct); + if (voor is null || voor.Id == ik) continue; + + regels.Add(new Overzichtsregel("beheer-voor", + $"Jij beheert straks de nalatenschap van {voor.DisplayName}.", null, voor.Id)); + } + + // ---------------------------------------------------------------- loopt er iets + var mij = await _people.FindByIdAsync(ik, ct); + if (mij is not null) + { + var stand = DeathProcedure.Evaluate(mij, await _legacy.ListDeclarationsAsync(ik, ct), + await Beheerderskring.GebruikersAsync(_legacy, _users, ik, ct), + nu); + if (stand.State == DeathProcedureState.Lopend) + { + regels.Add(new Overzichtsregel("overlijden", + "Er is een overlijden op jouw naam gemeld. Laat weten dat je er nog bent.", + stand.ObjectionDeadline)); + } + } + + // ---------------------------------------------------------------- wat komt eraan + var dagen = Math.Clamp(user.VooruitblikDagen, 0, MaxVooruitblikDagen); + if (dagen > 0) + { + var tot = nu.AddDays(dagen); + + foreach (var bericht in await _messages.ListPlannedForAsync(ik, ct)) + { + // Berichten die pas na iemands overlijden komen, laten we hier weg. Anders staat + // er "op 12 maart komt er iets van Karin" terwijl dat alleen gebeurt als jij er + // niet meer bent. Dat hoort niet op een beginscherm. + if (bericht.DeliverAfterDeath || bericht.DeliverAt is not { } op) continue; + if (op < nu || op > tot) continue; + + var van = await _people.FindByIdAsync(bericht.FromPersonId, ct); + regels.Add(new Overzichtsregel("moment", + $"Er komt een moment van {van?.DisplayName ?? "iemand"}.", op, bericht.Id)); + } + } + + // Wat een datum heeft eerst, op volgorde; de vaste regels daaronder. + var gesorteerd = regels + .OrderBy(r => r.Datum is null) + .ThenBy(r => r.Datum ?? DateTime.MaxValue) + .ToList(); + + return Result.Ok(new Overzicht( + kluis.Count, leden.Length, inbox.Count, outbox.Count, nieuw, + beheerders.Count, dagen, gesorteerd)); + } + + /// Stelt in hoeveel dagen vooruit iemand wil kijken. + public async Task VooruitblikAsync(long currentUserId, int dagen, CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + if (dagen < 0 || dagen > MaxVooruitblikDagen) + return Result.Fail($"Kies een aantal dagen tussen 0 en {MaxVooruitblikDagen}."); + + user.VooruitblikDagen = dagen; + user.UpdatedAt = _clock.UtcNow; + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } +} diff --git a/src/MijnBesluit.Application/Services/SamenvoegService.cs b/src/MijnBesluit.Application/Services/SamenvoegService.cs new file mode 100644 index 0000000..0d22ea4 --- /dev/null +++ b/src/MijnBesluit.Application/Services/SamenvoegService.cs @@ -0,0 +1,555 @@ +using System.Text.Json; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Application.Services; + +/// +/// Voegt twee profielen van dezelfde persoon samen. +/// +/// Waarom dit nodig is: twee mensen kunnen allebei een profiel aanmaken voor dezelfde persoon. +/// Jij voegt je dochter toe, je ex-partner doet dat ook. Dat zijn twee eerlijke aantekeningen +/// van degene die ze maakte, en zolang niemand het profiel heeft geclaimd is dat geen probleem. +/// +/// Het wordt er pas een op het moment dat zij zelf zegt: die ben ik. Dat zegt ze door beide +/// uitnodigingen aan te nemen. Vanaf dat moment hoort er nog één Ysabeau te zijn, die jij en je +/// ex allebei zien - ieder met je eigen relatie erbij, want de relatie hangt aan het +/// lidmaatschap en niet aan de persoon. +/// +/// Wat hier BEWUST niet gebeurt: op naam en geboortedatum zoeken naar mogelijke duplicaten. +/// Dat lijkt behulpzaam, maar wie zo'n vraag mag stellen heeft een zoekmachine op mensen. De +/// enige die mag zeggen dat twee profielen dezelfde persoon zijn, is die persoon zelf - en de +/// uitnodiging die zij aanneemt is precies dat bewijs. +/// +public class SamenvoegService +{ + private readonly IPersonRepository _people; + private readonly IFamilyRepository _families; + private readonly IInvitationRepository _invitations; + private readonly IMessageRepository _messages; + private readonly IVaultRepository _vault; + private readonly ILegacyRepository _legacy; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + private readonly ISamenvoegRepository? _boekhouding; + + public SamenvoegService(IPersonRepository people, IFamilyRepository families, + IInvitationRepository invitations, IMessageRepository messages, + IVaultRepository vault, ILegacyRepository legacy, IAuditLog audit, + IUnitOfWork uow, IClock clock, + ISamenvoegRepository? boekhouding = null) + { + _people = people; _families = families; _invitations = invitations; _messages = messages; + _vault = vault; _legacy = legacy; _audit = audit; _uow = uow; _clock = clock; + _boekhouding = boekhouding; + } + + /// + /// Eén handeling uit een samenvoeging, precies genoeg om hem terug te draaien. + /// + /// Soort zegt wat er is gebeurd, Id om welke regel het gaat. Bij een regel die + /// is weggegooid is er geen Id meer om naar te wijzen; die staat als volledige kopie in + /// Kopie, zodat hij opnieuw kan worden aangemaakt. + /// + public record Samenvoegstap(string Soort, long Id = 0, string? Veld = null, string? Kopie = null); + + private static readonly JsonSerializerOptions Jsonopzet = new(JsonSerializerDefaults.Web); + + /// + /// Laat opgaan in . Alles wat naar + /// het duplicaat verwees gaat mee; het duplicaat blijft staan met een verwijzing naar waar + /// hij in is opgegaan. + /// + /// + /// Wie dit doet. Dat hoort altijd de persoon zelf te zijn: alleen zij mag zeggen dat twee + /// profielen haar zijn. + /// + public async Task SamenvoegAsync(long duplicaatId, long doelId, long currentUserId, + CancellationToken ct = default) + { + if (duplicaatId == doelId) return Result.Ok(); // niets te doen + + var duplicaat = await _people.FindByIdAsync(duplicaatId, ct); + var doel = await _people.FindByIdAsync(doelId, ct); + + if (duplicaat is null || doel is null) return Result.Fail("Een van beide profielen bestaat niet meer."); + if (duplicaat.IsSamengevoegd) return Result.Ok(); // al eerder gedaan + + // Het doel moet van degene zijn die dit doet. Zonder die regel kan iemand andermans + // profielen aan elkaar knopen. + if (doel.ClaimedByUserId != currentUserId) + return Result.Fail("Je kunt alleen profielen samenvoegen met je eigen profiel."); + + // Twee geclaimde profielen samenvoegen is nooit automatisch goed: dat zijn twee echte + // accounts, allebei met een eigen kluis. Daar hoort een mens naar te kijken. + if (duplicaat.IsClaimed && duplicaat.ClaimedByUserId != currentUserId) + return Result.Fail("Dit profiel heeft al een eigen account. Neem contact op met de beheerder."); + + var nu = _clock.UtcNow; + + // Alles wat we doen schrijven we op. Zonder die lijst is achteraf niet meer te zien wélke + // lidmaatschappen, berichten en bestanden van het duplicaat waren - ze staan dan gewoon + // bij het doel, tussen alles wat daar al stond - en is terugdraaien dus onmogelijk. + var stappen = new List(); + + await VerhuisLidmaatschappenAsync(duplicaatId, doelId, stappen, ct); + await VerhuisBerichtenAsync(duplicaatId, doelId, stappen, ct); + await VerhuisBestandenAsync(duplicaatId, doelId, stappen, ct); + await VerhuisUitnodigingenAsync(duplicaatId, doelId, stappen, ct); + await VerhuisNalatenschapAsync(duplicaatId, doelId, stappen, ct); + await VerhuisFamiliesAsync(duplicaatId, doelId, stappen, ct); + + VulAanWatOntbreekt(duplicaat, doel, nu, stappen); + + duplicaat.MergedIntoPersonId = doelId; + duplicaat.UpdatedAt = nu; + + _boekhouding?.Add(new Samenvoeging + { + DuplicaatPersonId = duplicaatId, + DoelPersonId = doelId, + DoorUserId = currentUserId, + SamengevoegdOp = nu, + Stappen = JsonSerializer.Serialize(stappen, Jsonopzet) + }); + + await _audit.WriteAsync(AuditAction.ProfielGeclaimd, currentUserId, + nameof(Person), duplicaatId, $"samengevoegd met {doelId}", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// De lidmaatschappen. Staat het doel al in die familie, dan verdwijnt het lidmaatschap van + /// het duplicaat: anders zou iemand twee keer in dezelfde lijst staan. De relatie die er al + /// stond blijft dan leidend - die is ingevuld door de eigenaar van die familie. + /// + private async Task VerhuisLidmaatschappenAsync(long duplicaatId, long doelId, + List stappen, CancellationToken ct) + { + foreach (var lidmaatschap in await _families.ListMembershipsOfPersonAsync(duplicaatId, ct)) + { + var bestaand = await _families.FindMemberAsync(lidmaatschap.FamilyId, doelId, ct); + if (bestaand is null) + { + lidmaatschap.PersonId = doelId; + stappen.Add(new Samenvoegstap("lid-verplaatst", lidmaatschap.Id)); + } + else + { + // Deze regel verdwijnt. Het Id verdwijnt daarmee ook, dus gaat er een volledige + // kopie mee: terugdraaien betekent hier opnieuw aanmaken. + stappen.Add(new Samenvoegstap("lid-weggehaald", Kopie: JsonSerializer.Serialize(new + { + lidmaatschap.FamilyId, lidmaatschap.PersonId, Relatie = (int)lidmaatschap.Relation, + lidmaatschap.RelationLabel, lidmaatschap.AddedByUserId, lidmaatschap.CreatedAt + }, Jsonopzet))); + + _families.Remove(lidmaatschap); + } + } + } + + private async Task VerhuisBerichtenAsync(long duplicaatId, long doelId, + List stappen, CancellationToken ct) + { + foreach (var bericht in await _messages.ListAllTouchingAsync(duplicaatId, ct)) + { + // Afzender en ontvanger apart: een bericht van het duplicaat aan zichzelf zou anders + // maar half terugdraaien. + if (bericht.FromPersonId == duplicaatId) + { + bericht.FromPersonId = doelId; + stappen.Add(new Samenvoegstap("bericht-afzender", bericht.Id)); + } + + if (bericht.ToPersonId == duplicaatId) + { + bericht.ToPersonId = doelId; + stappen.Add(new Samenvoegstap("bericht-ontvanger", bericht.Id)); + } + } + } + + /// + /// Foto's waar de persoon op staat, en bestanden die hij bewaart. Hoogstens één foto mag de + /// profielfoto zijn; heeft het doel er al een, dan verliest die van het duplicaat die rol. + /// De foto zelf blijft gewoon staan. + /// + private async Task VerhuisBestandenAsync(long duplicaatId, long doelId, + List stappen, CancellationToken ct) + { + var alHier = await _vault.ListFilesForPersonAsync(doelId, ct); + var heeftAlProfielfoto = alHier.Any(f => f.IsProfilePhoto && f.DeletedAt is null); + + foreach (var bestand in await _vault.ListFilesForPersonAsync(duplicaatId, ct)) + { + bestand.SubjectPersonId = doelId; + stappen.Add(new Samenvoegstap("bestand-onderwerp", bestand.Id)); + + if (bestand.IsProfilePhoto && heeftAlProfielfoto) + { + bestand.IsProfilePhoto = false; + stappen.Add(new Samenvoegstap("bestand-geen-profielfoto-meer", bestand.Id)); + } + else if (bestand.IsProfilePhoto) heeftAlProfielfoto = true; + } + + foreach (var bestand in await _vault.ListFilesOwnedByAsync(duplicaatId, ct)) + { + bestand.OwnerPersonId = doelId; + stappen.Add(new Samenvoegstap("bestand-eigenaar", bestand.Id)); + } + + foreach (var item in await _vault.ListForPersonAsync(duplicaatId, null, ct)) + { + item.OwnerPersonId = doelId; + stappen.Add(new Samenvoegstap("kluisitem-eigenaar", item.Id)); + } + } + + private async Task VerhuisUitnodigingenAsync(long duplicaatId, long doelId, + List stappen, CancellationToken ct) + { + foreach (var uitnodiging in await _invitations.ListAllForPersonAsync(duplicaatId, ct)) + { + uitnodiging.PersonId = doelId; + stappen.Add(new Samenvoegstap("uitnodiging", uitnodiging.Id)); + } + } + + /// + /// Rollen rond een nalatenschap. Een dubbele aanwijzing - dezelfde beheerder twee keer voor + /// hetzelfde profiel - halen we weg, net als een tweede overlijdensmelding van dezelfde + /// gebruiker: die telt maar één keer mee en zou het aantal anders vervalsen. + /// + private async Task VerhuisNalatenschapAsync(long duplicaatId, long doelId, + List stappen, CancellationToken ct) + { + var bijDoel = await _legacy.ListManagersForAsync(doelId, ct); + foreach (var beheer in await _legacy.ListManagersForAsync(duplicaatId, ct)) + { + if (bijDoel.Any(b => b.ManagerPersonId == beheer.ManagerPersonId)) + { + stappen.Add(new Samenvoegstap("beheerder-weggehaald", Kopie: Kopieer(beheer))); + _legacy.Remove(beheer); + } + else + { + beheer.PersonId = doelId; + stappen.Add(new Samenvoegstap("beheerder-verplaatst", beheer.Id)); + } + } + + var opdrachtenDoel = await _legacy.ListAssignmentsOfAsync(doelId, ct); + foreach (var opdracht in await _legacy.ListAssignmentsOfAsync(duplicaatId, ct)) + { + if (opdrachtenDoel.Any(o => o.PersonId == opdracht.PersonId)) + { + stappen.Add(new Samenvoegstap("opdracht-weggehaald", Kopie: Kopieer(opdracht))); + _legacy.Remove(opdracht); + } + else + { + opdracht.ManagerPersonId = doelId; + stappen.Add(new Samenvoegstap("opdracht-verplaatst", opdracht.Id)); + } + } + + var meldingenDoel = await _legacy.ListAllDeclarationsAsync(doelId, ct); + foreach (var melding in await _legacy.ListAllDeclarationsAsync(duplicaatId, ct)) + { + // Een dubbele melding van dezelfde gebruiker blijft gewoon bij het duplicaat staan. + // Verhuizen zou het aantal vervalsen, weggooien zou geschiedenis wissen. + if (meldingenDoel.Any(m => m.DeclaredByUserId == melding.DeclaredByUserId)) continue; + + melding.PersonId = doelId; + stappen.Add(new Samenvoegstap("melding-verplaatst", melding.Id)); + } + } + + private static string Kopieer(LegacyManager rol) => JsonSerializer.Serialize(new + { + rol.PersonId, rol.ManagerPersonId, rol.Priority, rol.AcceptedAt, rol.AccessGrantedAt, rol.CreatedAt + }, Jsonopzet); + + private async Task VerhuisFamiliesAsync(long duplicaatId, long doelId, + List stappen, CancellationToken ct) + { + foreach (var familie in await _families.ListOwnedAsync(duplicaatId, ct)) + { + familie.OwnerPersonId = doelId; + stappen.Add(new Samenvoegstap("familie-eigenaar", familie.Id)); + } + } + + /// + /// Wat het duplicaat wist en het doel niet, nemen we over. Alleen lege velden vullen, nooit + /// overschrijven: wat er bij het doel staat is van de persoon zelf, en dat weegt zwaarder + /// dan wat een ander ooit heeft ingevuld. + /// + private static void VulAanWatOntbreekt(Person duplicaat, Person doel, DateTime nu, + List stappen) + { + var gewijzigd = false; + + if (doel.BirthDate is null && duplicaat.BirthDate is not null) + { + doel.BirthDate = duplicaat.BirthDate; + stappen.Add(new Samenvoegstap("doel-aangevuld", doel.Id, nameof(Person.BirthDate))); + gewijzigd = true; + } + + if (doel.DateOfDeath is null && duplicaat.DateOfDeath is not null) + { + doel.DateOfDeath = duplicaat.DateOfDeath; + stappen.Add(new Samenvoegstap("doel-aangevuld", doel.Id, nameof(Person.DateOfDeath))); + gewijzigd = true; + } + + if (string.IsNullOrWhiteSpace(doel.MobileNumber) && !string.IsNullOrWhiteSpace(duplicaat.MobileNumber)) + { + doel.MobileNumber = duplicaat.MobileNumber; + stappen.Add(new Samenvoegstap("doel-aangevuld", doel.Id, nameof(Person.MobileNumber))); + gewijzigd = true; + } + + if (gewijzigd) doel.UpdatedAt = nu; + } + + // ------------------------------------------------------------------ terugdraaien + + /// Wat er van een samenvoeging te zien is op de beheerpagina. + public record Samenvoegbeeld(long Id, long DuplicaatPersonId, string DuplicaatNaam, + long DoelPersonId, string DoelNaam, DateTime SamengevoegdOp, + int Stappen, bool IsTeruggedraaid, DateTime? TeruggedraaidOp); + + /// + /// De samenvoegingen waar dit profiel bij betrokken was, als duplicaat of als doel. + /// Nieuwste eerst. + /// + public async Task> GeschiedenisAsync(long personId, + CancellationToken ct = default) + { + if (_boekhouding is null) return Array.Empty(); + + var alles = (await _boekhouding.ListVoorDuplicaatAsync(personId, ct)) + .Concat(await _boekhouding.ListVoorDoelAsync(personId, ct)) + .DistinctBy(x => x.Id) + .OrderByDescending(x => x.SamengevoegdOp) + .ToList(); + + var uitkomst = new List(alles.Count); + + foreach (var bon in alles) + { + var duplicaat = await _people.FindByIdAsync(bon.DuplicaatPersonId, ct); + var doel = await _people.FindByIdAsync(bon.DoelPersonId, ct); + + uitkomst.Add(new Samenvoegbeeld( + bon.Id, bon.DuplicaatPersonId, duplicaat?.DisplayName ?? "onbekend", + bon.DoelPersonId, doel?.DisplayName ?? "onbekend", bon.SamengevoegdOp, + Lees(bon.Stappen).Count, bon.IsTeruggedraaid, bon.TeruggedraaidOp)); + } + + return uitkomst; + } + + /// + /// Draait een samenvoeging terug: alles wat toen is verhuisd gaat terug naar het duplicaat, + /// en dat profiel staat daarna weer op zichzelf. + /// + /// Dit is bewust internal: alleen mag hierbij. Een + /// gebruiker kan zelf samenvoegen omdat alleen hij mag zeggen dat twee profielen hem zijn. + /// Terugdraaien is iets anders - dat raakt ook de gegevens van de ánder, die het duplicaat + /// ooit heeft aangemaakt - en hoort daarom langs een mens te gaan. + /// + /// Wat er NIET gebeurt: wat er ná de samenvoeging is bijgekomen blijft bij het doel staan. + /// Dat is geen tekortkoming maar de bedoeling: die dingen zijn daar aangemaakt en horen daar. + /// Alleen wat in het bonnetje staat gaat terug. + /// + internal async Task TerugdraaienAsync(long samenvoegingId, long beheerderUserId, string reden, + CancellationToken ct = default) + { + if (_boekhouding is null) return Result.Fail("Samenvoegingen worden hier niet vastgelegd."); + + var bon = await _boekhouding.FindByIdAsync(samenvoegingId, ct); + if (bon is null) return Result.Fail("Deze samenvoeging bestaat niet."); + if (bon.IsTeruggedraaid) return Result.Fail("Deze samenvoeging is al teruggedraaid."); + + var duplicaat = await _people.FindByIdAsync(bon.DuplicaatPersonId, ct); + var doel = await _people.FindByIdAsync(bon.DoelPersonId, ct); + if (duplicaat is null || doel is null) return Result.Fail("Een van beide profielen bestaat niet meer."); + + // Is het doel zelf later ergens in opgegaan, dan staan de gegevens inmiddels een stap + // verderop en wijst het bonnetje niet meer naar waar ze zijn. Eerst die terugdraaien. + if (doel.IsSamengevoegd) + return Result.Fail("Het doelprofiel is zelf later samengevoegd. Draai die samenvoeging eerst terug."); + + var nu = _clock.UtcNow; + + // In omgekeerde volgorde: het aanvullen van velden ging als laatste, dus komt het eerst + // weer ongedaan. Bij verplaatsingen maakt de volgorde niet uit, bij weggegooide regels + // wel - die worden opnieuw aangemaakt en mogen niet botsen met iets wat er nog staat. + foreach (var stap in Enumerable.Reverse(Lees(bon.Stappen))) + await DraaiStapTerugAsync(stap, duplicaat, doel, ct); + + duplicaat.MergedIntoPersonId = null; + duplicaat.UpdatedAt = nu; + + bon.TeruggedraaidOp = nu; + bon.TeruggedraaidDoorUserId = beheerderUserId; + bon.Reden = reden; + + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, beheerderUserId, nameof(Person), + duplicaat.Id, $"beheer: samenvoeging {bon.Id} teruggedraaid - {reden}", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + private async Task DraaiStapTerugAsync(Samenvoegstap stap, Person duplicaat, Person doel, + CancellationToken ct) + { + var terug = duplicaat.Id; + + switch (stap.Soort) + { + case "lid-verplaatst": + foreach (var lid in await _families.ListMembershipsOfPersonAsync(doel.Id, ct)) + if (lid.Id == stap.Id) lid.PersonId = terug; + break; + + case "lid-weggehaald": + { + var oud = Ontleed(stap.Kopie); + if (oud is null) break; + + // Alleen aanmaken als er niet inmiddels weer zo'n regel staat; anders zou dezelfde + // persoon twee keer in dezelfde familie komen. + if (await _families.FindMemberAsync(oud.FamilyId, oud.PersonId, ct) is null) + { + _families.Add(new FamilyMember + { + FamilyId = oud.FamilyId, + PersonId = oud.PersonId, + Relation = (RelationType)oud.Relatie, + RelationLabel = oud.RelationLabel, + AddedByUserId = oud.AddedByUserId, + CreatedAt = oud.CreatedAt + }); + } + break; + } + + case "bericht-afzender": + case "bericht-ontvanger": + foreach (var bericht in await _messages.ListAllTouchingAsync(doel.Id, ct)) + { + if (bericht.Id != stap.Id) continue; + if (stap.Soort == "bericht-afzender") bericht.FromPersonId = terug; + else bericht.ToPersonId = terug; + } + break; + + case "bestand-onderwerp": + foreach (var bestand in await _vault.ListFilesForPersonAsync(doel.Id, ct)) + if (bestand.Id == stap.Id) bestand.SubjectPersonId = terug; + break; + + case "bestand-geen-profielfoto-meer": + if (await _vault.FindFileAsync(stap.Id, ct) is { } foto) foto.IsProfilePhoto = true; + break; + + case "bestand-eigenaar": + foreach (var bestand in await _vault.ListFilesOwnedByAsync(doel.Id, ct)) + if (bestand.Id == stap.Id) bestand.OwnerPersonId = terug; + break; + + case "kluisitem-eigenaar": + if (await _vault.FindByIdAsync(stap.Id, ct) is { } item) item.OwnerPersonId = terug; + break; + + case "uitnodiging": + foreach (var uitnodiging in await _invitations.ListAllForPersonAsync(doel.Id, ct)) + if (uitnodiging.Id == stap.Id) uitnodiging.PersonId = terug; + break; + + case "beheerder-verplaatst": + foreach (var rol in await _legacy.ListManagersForAsync(doel.Id, ct)) + if (rol.Id == stap.Id) rol.PersonId = terug; + break; + + case "opdracht-verplaatst": + foreach (var rol in await _legacy.ListAssignmentsOfAsync(doel.Id, ct)) + if (rol.Id == stap.Id) rol.ManagerPersonId = terug; + break; + + case "beheerder-weggehaald": + case "opdracht-weggehaald": + { + var oud = Ontleed(stap.Kopie); + if (oud is null) break; + + var bestaat = (await _legacy.ListManagersForAsync(oud.PersonId, ct)) + .Any(r => r.ManagerPersonId == oud.ManagerPersonId); + + if (!bestaat) + { + _legacy.Add(new LegacyManager + { + PersonId = oud.PersonId, + ManagerPersonId = oud.ManagerPersonId, + Priority = oud.Priority, + AcceptedAt = oud.AcceptedAt, + AccessGrantedAt = oud.AccessGrantedAt, + CreatedAt = oud.CreatedAt + }); + } + break; + } + + case "melding-verplaatst": + foreach (var melding in await _legacy.ListAllDeclarationsAsync(doel.Id, ct)) + if (melding.Id == stap.Id) melding.PersonId = terug; + break; + + case "familie-eigenaar": + foreach (var familie in await _families.ListOwnedAsync(doel.Id, ct)) + if (familie.Id == stap.Id) familie.OwnerPersonId = terug; + break; + + case "doel-aangevuld": + // Alleen leegmaken wat wij hebben ingevuld. Heeft de persoon het daarna zelf + // aangepast, dan is het van hem en blijft het staan. + if (stap.Veld == nameof(Person.BirthDate) && doel.BirthDate == duplicaat.BirthDate) + doel.BirthDate = null; + else if (stap.Veld == nameof(Person.DateOfDeath) && doel.DateOfDeath == duplicaat.DateOfDeath) + doel.DateOfDeath = null; + else if (stap.Veld == nameof(Person.MobileNumber) && doel.MobileNumber == duplicaat.MobileNumber) + doel.MobileNumber = null; + break; + } + } + + private static List Lees(string json) + { + try { return JsonSerializer.Deserialize>(json, Jsonopzet) ?? new(); } + catch (JsonException) { return new(); } + } + + private static T? Ontleed(string? json) where T : class + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonSerializer.Deserialize(json, Jsonopzet); } + catch (JsonException) { return null; } + } + + private sealed record Lidkopie(long FamilyId, long PersonId, int Relatie, string? RelationLabel, + long? AddedByUserId, DateTime CreatedAt); + + private sealed record Rolkopie(long PersonId, long ManagerPersonId, int Priority, + DateTime? AcceptedAt, DateTime? AccessGrantedAt, DateTime CreatedAt); +} diff --git a/src/MijnBesluit.Application/Services/VaultService.cs b/src/MijnBesluit.Application/Services/VaultService.cs new file mode 100644 index 0000000..507d2a2 --- /dev/null +++ b/src/MijnBesluit.Application/Services/VaultService.cs @@ -0,0 +1,931 @@ +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; +using MijnBesluit.Application.Security; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Domain.Rules; + +namespace MijnBesluit.Application.Services; + +/// +/// De digitale kluis. Elk item wordt versleuteld met een eigen contentsleutel; die sleutel wordt +/// ingepakt met de datasleutel van de eigenaar. Toegang geven aan een ander is daardoor niets meer +/// dan diezelfde contentsleutel nóg een keer inpakken, nu met de datasleutel van die ander. +/// +public class VaultService +{ + private readonly IVaultRepository _vault; + private readonly IPersonRepository _people; + private readonly IMessageRepository _messages; + private readonly IUserRepository _users; + private readonly IFamilyRepository? _families; + private readonly ILegacyRepository? _legacy; + private readonly IMasterKeyProtector _keys; + private readonly IFileStore _files; + private readonly IAuditLog _audit; + private readonly IUnitOfWork _uow; + private readonly IClock _clock; + private readonly IAfbeeldingVerkleiner _verkleiner; + + /// De langste zijde van de kleine versie die bij een foto wordt bewaard. + public const int ThumbnailZijde = 160; + + /// + /// De langste zijde waarop een foto wordt bewaard. Een toestelcamera maakt tegenwoordig + /// bestanden van tien megabyte; op een scherm zie je het verschil met 1600 pixels niet, in + /// je opslag en op een mobiele verbinding merk je het meteen. + /// + /// De app verkleint zelf al vóór het versturen, dus daarvandaan komt hier meestal niets meer + /// binnen dat groter is. Voor de website is dit de enige plek waar het gebeurt. + /// + public const int FotoMaxZijde = 1600; + + /// Onder deze grootte is verkleinen de moeite niet waard. + public const int VerkleinVanaf = 1024 * 1024; + + public VaultService(IVaultRepository vault, IPersonRepository people, IMessageRepository messages, + IUserRepository users, IMasterKeyProtector keys, IFileStore files, IAuditLog audit, + IUnitOfWork uow, IClock clock, IAfbeeldingVerkleiner? verkleiner = null, + IFamilyRepository? families = null, ILegacyRepository? legacy = null) + { + _vault = vault; _people = people; _messages = messages; _users = users; _keys = keys; + _files = files; _audit = audit; _uow = uow; _clock = clock; _families = families; + _verkleiner = verkleiner ?? new GeenAfbeeldingVerkleiner(); + _legacy = legacy; + } + + /// Titels en categorieën van de kluis. De inhoud blijft versleuteld tot hij echt geopend wordt. + public Task> ListAsync(long personId, VaultCategory? category = null, + CancellationToken ct = default) => + _vault.ListForPersonAsync(personId, category, ct); + + /// Slaat een nieuw kluisitem versleuteld op. + public async Task> CreateAsync(long currentUserId, VaultCategory category, string title, + VaultPayload payload, ReleasePolicy release = ReleasePolicy.AlleenIkzelf, + DateTime? releaseAt = null, long? sharedManagerPersonId = null, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (string.IsNullOrWhiteSpace(title)) return Result.Fail("Geef het item een titel."); + if (release == ReleasePolicy.OpDatum && releaseAt is null) + return Result.Fail("Kies een datum waarop dit item vrijkomt."); + + var now = _clock.UtcNow; + var dataKey = _keys.Unprotect(user.WrappedDataKey); + var contentKey = EnvelopeCrypto.NewKey(); + + try + { + var sealedData = EnvelopeCrypto.SealJson(payload, contentKey); + var item = new VaultItem + { + OwnerPersonId = user.PersonId, + Category = category, + Title = title.Trim(), + Ciphertext = sealedData.Ciphertext, + Nonce = sealedData.Nonce, + Tag = sealedData.Tag, + WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey), + ReleasePolicy = release, + ReleaseAt = releaseAt, + SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null, + CreatedAt = now, + UpdatedAt = now + }; + _vault.Add(item); + + await _audit.WriteAsync(AuditAction.KluisItemGewijzigd, currentUserId, nameof(VaultItem), null, "nieuw", ct); + await _uow.SaveChangesAsync(ct); + + if (item.SharedManagerPersonId is { } gekozenBeheerder1) + await DeelMetBeheerderAsync(item, user, gekozenBeheerder1, ct); + + return Result.Ok(item); + } + finally + { + Array.Clear(dataKey); + Array.Clear(contentKey); + } + } + + /// + /// Wijzigt een kluisitem. Alleen de eigenaar kan dit: wie er toegang toe heeft gekregen + /// mag meelezen, niet aanpassen. + /// + /// De inhoud wordt opnieuw versleuteld met een nieuwe contentsleutel. Dat is bewust: de oude + /// sleutel kan gedeeld zijn met iemand anders, en die zou anders de nieuwe inhoud kunnen lezen. + /// + public async Task UpdateAsync(long itemId, long currentUserId, VaultCategory category, string title, + VaultPayload payload, ReleasePolicy release, DateTime? releaseAt = null, + long? sharedManagerPersonId = null, CancellationToken ct = default) + { + var item = await _vault.FindByIdAsync(itemId, ct); + if (item is null || item.DeletedAt is not null) return Result.Fail("Dit item bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (item.OwnerPersonId != user.PersonId) return Result.Fail("Dit item is niet van jou."); + + if (string.IsNullOrWhiteSpace(title)) return Result.Fail("Geef het item een titel."); + if (release == ReleasePolicy.OpDatum && releaseAt is null) + return Result.Fail("Kies een datum waarop dit item vrijkomt."); + + var now = _clock.UtcNow; + var dataKey = _keys.Unprotect(user.WrappedDataKey); + var contentKey = EnvelopeCrypto.NewKey(); + try + { + var sealedData = EnvelopeCrypto.SealJson(payload, contentKey); + + item.Category = category; + item.Title = title.Trim(); + item.Ciphertext = sealedData.Ciphertext; + item.Nonce = sealedData.Nonce; + item.Tag = sealedData.Tag; + item.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey); + item.ReleasePolicy = release; + item.ReleaseAt = releaseAt; + item.SharedManagerPersonId = KanBesprekenMet(category) ? sharedManagerPersonId : null; + item.UpdatedAt = now; + + // Eerder verleende toegang was op de oude sleutel; die is nu waardeloos. Wie het item + // opnieuw mag zien (bijvoorbeeld dezelfde gekozen beheerder), krijgt dat hieronder + // opnieuw toegewezen. Kiest de eigenaar niemand meer, dan blijft de toegang gewoon weg. + await _vault.RemoveGrantsAsync(itemId, ct); + + await _audit.WriteAsync(AuditAction.KluisItemGewijzigd, currentUserId, nameof(VaultItem), item.Id, "gewijzigd", ct); + await _uow.SaveChangesAsync(ct); + + if (item.SharedManagerPersonId is { } gekozenBeheerder2) + await DeelMetBeheerderAsync(item, user, gekozenBeheerder2, ct); + + return Result.Ok(); + } + finally + { + Array.Clear(dataKey); + Array.Clear(contentKey); + } + } + + /// + /// Verwijdert een kluisitem. Het item wordt gemarkeerd als verwijderd en is nergens meer + /// op te vragen; de versleutelde inhoud blijft nog even staan, zodat een vergissing te + /// herstellen is zolang de back-up van die dag er nog is. + /// + public async Task DeleteAsync(long itemId, long currentUserId, CancellationToken ct = default) + { + var item = await _vault.FindByIdAsync(itemId, ct); + if (item is null || item.DeletedAt is not null) return Result.Fail("Dit item bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + if (item.OwnerPersonId != user.PersonId) return Result.Fail("Dit item is niet van jou."); + + item.DeletedAt = _clock.UtcNow; + item.UpdatedAt = item.DeletedAt.Value; + + // De bijlagen horen bij dit item; die gaan mee. Anders blijven er versleutelde bestanden + // op de schijf staan waar niemand meer bij kan en die niemand meer opruimt. + foreach (var bijlage in await _vault.ListFilesForItemAsync(itemId, ct)) + { + bijlage.DeletedAt = item.DeletedAt; + try { await _files.DeleteAsync(bijlage.StorageKey, ct); } + catch (Exception) { /* Een opruimfout mag het verwijderen niet tegenhouden. */ } + } + + await _vault.RemoveGrantsAsync(itemId, ct); + await _audit.WriteAsync(AuditAction.KluisItemVerwijderd, currentUserId, nameof(VaultItem), item.Id, item.Title, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Opent een kluisitem. Dat lukt als je de eigenaar bent, of als je er toegang toe hebt gekregen + /// (bijvoorbeeld als nalatenschapsbeheerder na een bevestigd overlijden). + /// + public async Task> OpenAsync(long itemId, long currentUserId, CancellationToken ct = default) + { + var item = await _vault.FindByIdAsync(itemId, ct); + if (item is null || item.DeletedAt is not null) return Result.Fail("Dit item bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + var dataKey = _keys.Unprotect(user.WrappedDataKey); + try + { + byte[]? contentKey = null; + + if (item.OwnerPersonId == user.PersonId) + { + contentKey = EnvelopeCrypto.UnwrapKey(item.WrappedContentKey, dataKey); + } + else + { + var grant = await _vault.FindGrantAsync(nameof(VaultItem), item.Id, currentUserId, ct); + if (grant is not null && grant.RevokedAt is null) + contentKey = EnvelopeCrypto.UnwrapKey(grant.WrappedContentKey, dataKey); + } + + if (contentKey is null) + return Result.Fail("Je hebt geen toegang tot dit item."); + + try + { + var payload = EnvelopeCrypto.OpenJson( + new SealedData(item.Ciphertext, item.Nonce, item.Tag), contentKey) ?? new VaultPayload(); + + await _audit.WriteAsync(AuditAction.KluisItemGelezen, currentUserId, nameof(VaultItem), item.Id, null, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(payload); + } + finally { Array.Clear(contentKey); } + } + finally { Array.Clear(dataKey); } + } + + /// + /// Geeft een ander toegang tot een kluisitem door de contentsleutel opnieuw in te pakken + /// met diens datasleutel. Wordt gebruikt bij het vrijgeven van de nalatenschap, en bij het + /// delen van een uitvaartwens om vooraf te bespreken. + /// + /// De bijlagen bij dit item (een foto, een document) krijgen in dezelfde beurt hun eigen + /// toegang: wie mag zien wat er staat, mag ook zien wat erbij hangt. Elke bijlage heeft zijn + /// eigen contentsleutel, dus dat is een aparte toewijzing per bijlage - vandaar de lus. + /// + public async Task GrantAsync(long itemId, long ownerUserId, long toUserId, string reason, + CancellationToken ct = default) + { + var item = await _vault.FindByIdAsync(itemId, ct); + if (item is null) return Result.Fail("Dit item bestaat niet meer."); + + var owner = await _users.FindByIdAsync(ownerUserId, ct); + var receiver = await _users.FindByIdAsync(toUserId, ct); + if (owner is null || receiver is null) return Result.Fail("Account niet gevonden."); + if (item.OwnerPersonId != owner.PersonId) return Result.Fail("Alleen de eigenaar kan toegang geven."); + + var ownerKey = _keys.Unprotect(owner.WrappedDataKey); + var receiverKey = _keys.Unprotect(receiver.WrappedDataKey); + try + { + await GrantOverAsync(nameof(VaultItem), item.Id, item.WrappedContentKey, toUserId, + ownerKey, receiverKey, reason, ct); + + foreach (var bestand in await _vault.ListFilesForItemAsync(itemId, ct)) + await GrantOverAsync(nameof(VaultFile), bestand.Id, bestand.WrappedContentKey, toUserId, + ownerKey, receiverKey, reason, ct); + + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + finally + { + Array.Clear(ownerKey); + Array.Clear(receiverKey); + } + } + + /// + /// Eén toewijzing: de contentsleutel van iets (een item, een bijlage) uitpakken met de + /// sleutel van de eigenaar en weer inpakken met die van de ontvanger. Bestond de toewijzing + /// al en is hij niet ingetrokken, dan gebeurt er niets - nog een keer dezelfde sleutel + /// inpakken voegt niets toe. + /// + private async Task GrantOverAsync(string resourceType, long resourceId, byte[] wrappedContentKeyVanEigenaar, + long toUserId, byte[] ownerKey, byte[] receiverKey, string reason, + CancellationToken ct) + { + if (await _vault.FindGrantAsync(resourceType, resourceId, toUserId, ct) is { RevokedAt: null }) return; + + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(wrappedContentKeyVanEigenaar, ownerKey); + _vault.Add(new ContentGrant + { + ResourceType = resourceType, + ResourceId = resourceId, + UserId = toUserId, + WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, receiverKey), + Reason = reason, + GrantedAt = _clock.UtcNow + }); + } + finally + { + if (contentKey is not null) Array.Clear(contentKey); + } + } + + /// + /// Geeft de gekozen nalatenschapsbeheerder van een Uitvaartwens meteen leestoegang tot dit + /// item, zodat het al vóór een overlijden te bespreken is. Alleen als de gekozen persoon + /// ook echt een aangewezen beheerder is én al een account heeft geclaimd (zonder account + /// is er geen datasleutel om de contentsleutel opnieuw mee in te pakken). Ontbreekt een van + /// beide, dan wordt de keuze stil overgeslagen; het item zelf blijft wel bewaard staan. + /// + /// + /// Bij welke soorten kluisitems een beheerder te kiezen is om vooraf mee te bespreken. Op dit + /// moment een uitvaartwens en een donorcodicil - allebei dingen waar je iemand graag vooraf + /// bij betrekt, in plaats van dat diegene het pas na een overlijden voor het eerst ziet. + /// + private static bool KanBesprekenMet(VaultCategory category) => + category is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil; + + private async Task DeelMetBeheerderAsync(VaultItem item, User owner, long beheerderPersonId, CancellationToken ct) + { + if (_legacy is not null) + { + var beheerders = await _legacy.ListManagersForAsync(owner.PersonId, ct); + if (!beheerders.Any(m => m.ManagerPersonId == beheerderPersonId)) return; + } + + var beheerderUser = await _users.FindByPersonIdAsync(beheerderPersonId, ct); + if (beheerderUser is null) return; // nog geen account geclaimd; geen datasleutel om mee te delen + + await GrantAsync(item.Id, owner.Id, beheerderUser.Id, "gedeeld", ct); + } + + /// + /// Kluisitems die een ander direct met je heeft gedeeld (bijvoorbeeld een uitvaartwens om + /// vooraf te bespreken), los van de gewone nalatenschap-vrijgave na een overlijden. + /// + public async Task>> ListSharedWithMeAsync(long currentUserId, CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result>.Fail("Account niet gevonden."); + + var items = await _vault.ListSharedWithAsync(user.Id, ct); + return Result>.Ok(items); + } + + /// Slaat een bijlage versleuteld op buiten de database. + /// + /// Hoe groot een bijlage mag zijn. De inhoud wordt in één keer versleuteld, dus hij moet + /// even in het geheugen passen - vandaar een grens. Voor documenten en foto's ruim genoeg; + /// voor lange video-opnamen niet, en dat hoort de gebruiker dan ook te horen. + /// + public const long MaxBestandsgrootte = 50L * 1024 * 1024; + + /// + /// Slaat een bestand versleuteld op. Het hoort bij hoogstens één ding: een kluisitem, een + /// persoon (een foto van iemand) of een bericht. Alle drie leeg mag ook - dan staat het op + /// zichzelf, zoals de opname bij een videobericht die pas later aan het bericht wordt gehangen. + /// + public async Task> StoreFileAsync(long currentUserId, string fileName, string contentType, + byte[] content, long? vaultItemId = null, + long? subjectPersonId = null, long? messageId = null, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + if (string.IsNullOrWhiteSpace(fileName)) return Result.Fail("Het bestand heeft geen naam."); + if (content.LongLength == 0) return Result.Fail("Het bestand is leeg."); + if (content.LongLength > MaxBestandsgrootte) + return Result.Fail( + $"Dit bestand is {content.LongLength / 1024 / 1024} MB; groter dan {MaxBestandsgrootte / 1024 / 1024} MB gaat niet."); + + if (new[] { vaultItemId, subjectPersonId, messageId }.Count(x => x is not null) > 1) + return Result.Fail("Een bijlage hoort bij één ding tegelijk."); + + // Hoort het bij een kluisitem, dan moet dat item wel van jou zijn. + if (vaultItemId is { } itemId) + { + var item = await _vault.FindByIdAsync(itemId, ct); + if (item is null || item.DeletedAt is not null) return Result.Fail("Dit item bestaat niet meer."); + if (item.OwnerPersonId != user.PersonId) return Result.Fail("Dit item is niet van jou."); + } + + // Een foto bij een persoon mag alleen als jij dat profiel beheert - dezelfde regel als + // voor het wijzigen van zijn naam. Zodra hij het profiel zelf heeft geclaimd, is het aan + // hem welke foto's erbij staan. + if (subjectPersonId is { } persoonId) + { + var persoon = await _people.FindByIdAsync(persoonId, ct); + if (persoon is null) return Result.Fail("Deze persoon bestaat niet."); + + var mag = persoon.Id == user.PersonId || ProfileOwnership.CanEdit(persoon, currentUserId); + if (!mag) return Result.Fail($"{persoon.DisplayName} beheert zijn profiel zelf."); + } + + // Een bijlage bij een bericht kan zolang het bericht nog niet bezorgd is, en alleen door + // de afzender: daarna is het bij de ontvanger en verandert er niets meer aan. + if (messageId is { } berichtId) + { + var bericht = await _messages.FindByIdAsync(berichtId, ct); + if (bericht is null) return Result.Fail("Dit bericht bestaat niet meer."); + if (bericht.FromPersonId != user.PersonId) return Result.Fail("Dit bericht is niet van jou."); + if (bericht.Status == MessageStatus.Bezorgd) + return Result.Fail("Dit bericht is al bezorgd; daar kan niets meer bij."); + } + + // Een grote foto gaat verkleind de kluis in. Lukt dat niet, of wordt hij er niet kleiner + // van, dan blijft het origineel staan: een foto die niet wordt opgeslagen is erger dan + // een foto die veel ruimte inneemt. + if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + && content.LongLength > VerkleinVanaf) + { + var kleiner = _verkleiner.Verklein(content, contentType, FotoMaxZijde); + if (kleiner is { Length: > 0 } && kleiner.Length < content.Length) + { + content = kleiner; + contentType = "image/jpeg"; + fileName = AlsJpeg(fileName); + } + } + + var dataKey = _keys.Unprotect(user.WrappedDataKey); + var contentKey = EnvelopeCrypto.NewKey(); + try + { + var sealedData = EnvelopeCrypto.Seal(content, contentKey); + var storageKey = await _files.WriteAsync(sealedData.Ciphertext, fileName, ct); + + var file = new VaultFile + { + OwnerPersonId = user.PersonId, + VaultItemId = vaultItemId, + SubjectPersonId = subjectPersonId, + MessageId = messageId, + FileName = SchoneNaam(fileName), + ContentType = contentType, + SizeBytes = content.LongLength, + StorageKey = storageKey, + Nonce = sealedData.Nonce, + Tag = sealedData.Tag, + WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, dataKey), + Sha256 = System.Security.Cryptography.SHA256.HashData(content), + CreatedAt = _clock.UtcNow + }; + + // Een kleine versie erbij, met dezelfde sleutel. Lukt dat niet - geen afbeelding, een + // formaat dat we niet kennen - dan blijft hij leeg en valt het scherm terug op de + // beginletters. Dat is nooit een reden om de foto zelf niet op te slaan. + var klein = _verkleiner.Verklein(content, contentType, ThumbnailZijde); + if (klein is { Length: > 0 }) file.Thumbnail = klein; + + _vault.Add(file); + await _uow.SaveChangesAsync(ct); + return Result.Ok(file); + } + finally + { + Array.Clear(dataKey); + Array.Clear(contentKey); + } + } + + /// Welke kluisitems van een overledene zijn inmiddels vrijgegeven? + /// De bijlagen bij een kluisitem. Alleen de eigenaar ziet ze. + public async Task>> ListFilesAsync(long vaultItemId, long currentUserId, + CancellationToken ct = default) + { + var item = await _vault.FindByIdAsync(vaultItemId, ct); + if (item is null || item.DeletedAt is not null) + return Result>.Fail("Dit item bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result>.Fail("Account niet gevonden."); + + // Net als bij het item zelf (zie OpenAsync): de eigenaar altijd, en verder wie er + // toegang toe heeft gekregen - bijvoorbeeld een beheerder met wie een uitvaartwens + // vooraf wordt besproken. Die ziet dan ook de foto's en documenten die erbij horen. + if (item.OwnerPersonId != user.PersonId) + { + var grant = await _vault.FindGrantAsync(nameof(VaultItem), item.Id, currentUserId, ct); + if (grant is null || grant.RevokedAt is not null) + return Result>.Fail("Dit item is niet van jou."); + } + + return Result>.Ok(await _vault.ListFilesForItemAsync(vaultItemId, ct)); + } + + /// + /// De foto's die bij een persoon horen. Je ziet ze als het jouw eigen profiel is of als jij + /// dat profiel beheert. + /// + public async Task>> ListPersonFilesAsync(long personId, long currentUserId, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result>.Fail("Account niet gevonden."); + + var persoon = await _people.FindByIdAsync(personId, ct); + if (persoon is null) return Result>.Fail("Deze persoon bestaat niet."); + + var mag = persoon.Id == user.PersonId || ProfileOwnership.CanEdit(persoon, currentUserId); + if (!mag) return Result>.Fail($"{persoon.DisplayName} beheert zijn profiel zelf."); + + return Result>.Ok(await _vault.ListFilesForPersonAsync(personId, ct)); + } + + /// De bijlagen bij een bericht. Alleen de afzender ziet ze hier. + public async Task>> ListMessageFilesAsync(long messageId, long currentUserId, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result>.Fail("Account niet gevonden."); + + var bericht = await _messages.FindByIdAsync(messageId, ct); + if (bericht is null) return Result>.Fail("Dit bericht bestaat niet meer."); + if (bericht.FromPersonId != user.PersonId) + return Result>.Fail("Dit bericht is niet van jou."); + + return Result>.Ok(await _vault.ListFilesForMessageAsync(messageId, ct)); + } + + /// De profielfoto van een persoon: het nummer van het bestand en de kleine versie. + public record Profielfoto(long PersoonId, long BestandId, byte[]? Klein); + + /// + /// De profielfoto's van een groep personen, met de kleine versie er ontsleuteld bij. + /// + /// In één keer voor de hele lijst, en bewust alleen de kleine versie: de foto zelf ophalen + /// voor twintig mensen zou een paar megabyte zijn voor plaatjes van veertig pixels. + /// + /// Personen zonder foto komen niet in het antwoord voor; het scherm toont dan de + /// beginletters. Hetzelfde geldt voor een foto van voor deze wijziging, die nog geen kleine + /// versie heeft: dan komt het bestandsnummer wel terug maar de kleine versie niet, zodat de + /// website hem alsnog gewoon kan opvragen. + /// + public async Task> ProfielfotosAsync(IEnumerable personIds, long currentUserId, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Array.Empty(); + + var bestanden = await _vault.ListProfilePhotosAsync(personIds, ct); + if (bestanden.Count == 0) return Array.Empty(); + + // Wiens foto je mag zien: die van jezelf, en die van iedereen met wie je in een familie + // zit. Dat is precies de kring die de eigenaar zelf heeft samengesteld. Deze controle + // staat hier en niet bij de aanroeper: een toegangsregel hoort niet af te hangen van de + // vraag of elk scherm eraan denkt. + var zichtbaar = await KringAsync(user.PersonId, ct); + + var uitkomst = new List(bestanden.Count); + + foreach (var bestand in bestanden) + { + if (bestand.SubjectPersonId is not { } persoonId) continue; + if (persoonId != user.PersonId && !zichtbaar.Contains(persoonId)) continue; + + byte[]? klein = null; + if (bestand.HeeftThumbnail) + { + klein = bestand.Thumbnail; + } + else if (bestand.Thumbnail is null || bestand.ThumbnailNonce is not null) + { + // Nog nooit geprobeerd, of nog een versleutelde uit de eerste opzet. Dan maken + // we hem alsnog - maar alleen als wij de foto kunnen openmaken, want daarvoor + // is de sleutel van de eigenaar nodig. + if (bestand.OwnerPersonId == user.PersonId) + { + var dataKey = _keys.Unprotect(user.WrappedDataKey); + try { klein = await MaakThumbnailAchterafAsync(bestand, dataKey, ct); } + finally { Array.Clear(dataKey); } + } + } + + uitkomst.Add(new Profielfoto(persoonId, bestand.Id, klein)); + } + + return uitkomst; + } + + /// + /// Iedereen met wie deze persoon in een familie zit. Dat is de kring waarbinnen een + /// profielfoto zichtbaar is - de kring die mensen zelf samenstellen door elkaar toe te + /// voegen en uit te nodigen. + /// + private async Task> KringAsync(long personId, CancellationToken ct) + { + if (_families is null) return new HashSet { personId }; + return (await _families.ListCircleAsync(personId, ct)).ToHashSet(); + } + + /// + /// Maakt alsnog een kleine versie bij een foto die er geen heeft, en bewaart hem. + /// + /// Dit kost één keer het openmaken van de hele foto. Daarna staat hij erbij en gebeurt dit + /// niet meer. Lukt het niet - geen verkleiner beschikbaar, of een bestand waar hij geen raad + /// mee weet - dan leggen we dat vast met een lege waarde in plaats van null. Zonder dat + /// verschil zou elke keer dat je de lijst opent opnieuw elke foto worden opengemaakt. + /// + private async Task MaakThumbnailAchterafAsync(VaultFile bestand, byte[] dataKey, CancellationToken ct) + { + if (!bestand.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return null; + + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(bestand.WrappedContentKey, dataKey); + var versleuteld = await _files.ReadAsync(bestand.StorageKey, ct); + var inhoud = EnvelopeCrypto.Open(new SealedData(versleuteld, bestand.Nonce, bestand.Tag), contentKey); + + var klein = _verkleiner.Verklein(inhoud, bestand.ContentType, ThumbnailZijde); + if (klein is not { Length: > 0 }) + { + // Geprobeerd, niet gelukt. Dit onthouden we, anders doen we het bij elke lijst + // opnieuw voor een foto waar het toch nooit van gaat komen. + bestand.Thumbnail = Array.Empty(); + await _uow.SaveChangesAsync(ct); + return null; + } + + bestand.Thumbnail = klein; + bestand.ThumbnailNonce = null; + bestand.ThumbnailTag = null; + await _uow.SaveChangesAsync(ct); + + return klein; + } + catch (Exception) + { + // Een lijst met mensen hoort niet om te vallen omdat er één foto niet openging. + return null; + } + finally + { + if (contentKey is not null) Array.Clear(contentKey); + } + } + + /// + /// De kleine versie van één bestand, ontsleuteld. Null als er geen is; de aanroeper valt dan + /// terug op het bestand zelf. + /// + public async Task> OpenThumbnailAsync(long fileId, long currentUserId, + CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + var bestand = await _vault.FindFileAsync(fileId, ct); + if (bestand is null || bestand.DeletedAt is not null) return Result.Fail("Dit bestand bestaat niet meer."); + // Een kleine versie is niet versleuteld, maar hij hoort wel bij iemand: je mag hem zien + // als het bestand van jou is, of als je met die persoon in een familie zit. + var vanMij = bestand.OwnerPersonId == user.PersonId; + var inMijnKring = bestand.SubjectPersonId is { } sp && (await KringAsync(user.PersonId, ct)).Contains(sp); + if (!vanMij && !inMijnKring) return Result.Fail("Dit bestand is niet van jou."); + + if (!bestand.HeeftThumbnail) return Result.Fail("Van dit bestand is geen kleine versie."); + + return Result.Ok(bestand.Thumbnail!); + } + + /// + /// Wijst een foto aan als de profielfoto van de persoon waar hij bij hoort. Er kan er maar + /// één zijn, dus de vorige verliest die rol - dat gebeurt hier en niet in de database, want + /// een database kan dat niet afdwingen zonder er een puinhoop van te maken. + /// + public async Task SetProfilePhotoAsync(long fileId, long currentUserId, CancellationToken ct = default) + { + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + var bestand = await _vault.FindFileAsync(fileId, ct); + if (bestand is null || bestand.DeletedAt is not null) return Result.Fail("Deze foto bestaat niet meer."); + if (bestand.SubjectPersonId is not { } persoonId) + return Result.Fail("Dit bestand hoort niet bij een persoon."); + + var persoon = await _people.FindByIdAsync(persoonId, ct); + if (persoon is null) return Result.Fail("Deze persoon bestaat niet."); + + var mag = persoon.Id == user.PersonId || ProfileOwnership.CanEdit(persoon, currentUserId); + if (!mag) return Result.Fail($"{persoon.DisplayName} beheert zijn profiel zelf."); + + foreach (var andere in await _vault.ListFilesForPersonAsync(persoonId, ct)) + andere.IsProfilePhoto = andere.Id == fileId; + + await _audit.WriteAsync(AuditAction.ProfielGewijzigd, currentUserId, nameof(VaultFile), fileId, + "profielfoto", ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Zelfherstellend gat-dichten: dit bestand hoort bij een kluisitem, het item zelf is actief + /// met deze gebruiker gedeeld, maar het bestand kreeg daar (nog) geen eigen toegang bij. Is + /// dat zo, dan maken we die toegang hier alsnog aan en geven de ingepakte sleutel terug; + /// anders null. + /// + private async Task HerstelBestandstoegangAsync(VaultFile file, long vaultItemId, User user, + long currentUserId, CancellationToken ct) + { + var itemGrant = await _vault.FindGrantAsync(nameof(VaultItem), vaultItemId, currentUserId, ct); + if (itemGrant is null || itemGrant.RevokedAt is not null) return null; + + var eigenaar = await _users.FindByPersonIdAsync(file.OwnerPersonId, ct); + if (eigenaar is null) return null; + + var eigenaarSleutel = _keys.Unprotect(eigenaar.WrappedDataKey); + var ontvangerSleutel = _keys.Unprotect(user.WrappedDataKey); + try + { + await GrantOverAsync(nameof(VaultFile), file.Id, file.WrappedContentKey, currentUserId, + eigenaarSleutel, ontvangerSleutel, "gedeeld (hersteld)", ct); + await _uow.SaveChangesAsync(ct); + } + finally + { + Array.Clear(eigenaarSleutel); + Array.Clear(ontvangerSleutel); + } + + var grant = await _vault.FindGrantAsync(nameof(VaultFile), file.Id, currentUserId, ct); + return grant?.WrappedContentKey; + } + + /// + /// Zet een bestand dat over deze gebruiker gaat definitief op zijn naam, met een verse + /// sleutel. Anders dan - dat geeft er een kopie + /// van de toegang bij, de oude eigenaar blijft eigenaar - hoort een profielfoto van jezelf + /// gewoon van jou te worden, niet gedeeld met jou te blijven. + /// + private async Task ZetBestandOverAsync(VaultFile file, User nieuweEigenaar, CancellationToken ct) + { + var oudeEigenaar = await _users.FindByPersonIdAsync(file.OwnerPersonId, ct); + if (oudeEigenaar is null) return null; + + var oudeSleutel = _keys.Unprotect(oudeEigenaar.WrappedDataKey); + var nieuweSleutel = _keys.Unprotect(nieuweEigenaar.WrappedDataKey); + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(file.WrappedContentKey, oudeSleutel); + file.WrappedContentKey = EnvelopeCrypto.WrapKey(contentKey, nieuweSleutel); + file.OwnerPersonId = nieuweEigenaar.PersonId; + await _uow.SaveChangesAsync(ct); + return file.WrappedContentKey; + } + catch (Exception) + { + return null; + } + finally + { + Array.Clear(oudeSleutel); + Array.Clear(nieuweSleutel); + if (contentKey is not null) Array.Clear(contentKey); + } + } + + /// + /// Haalt een bestand op en ontsleutelt het. Dat lukt als je de eigenaar bent, of als je er + /// toegang toe hebt gekregen - net als bij een kluisitem. + /// + public async Task> OpenFileAsync(long fileId, long currentUserId, + CancellationToken ct = default) + { + var file = await _vault.FindFileAsync(fileId, ct); + if (file is null || file.DeletedAt is not null) + return Result<(VaultFile, byte[])>.Fail("Dit bestand bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result<(VaultFile, byte[])>.Fail("Account niet gevonden."); + + byte[] wrappedKey; + if (file.OwnerPersonId == user.PersonId) + { + wrappedKey = file.WrappedContentKey; + } + else if (file.SubjectPersonId == user.PersonId) + { + // Dit bestand gaat over jou - een foto of document dat een beheerder toevoegde + // terwijl hij jouw nog niet-geclaimde profiel beheerde - maar staat nog op zijn + // naam. Dat hoort niet zo te blijven: zet het definitief op jouw naam, in plaats + // van dat je voor altijd buiten je eigen foto blijft staan. + var overgezet = await ZetBestandOverAsync(file, user, ct); + if (overgezet is null) return Result<(VaultFile, byte[])>.Fail("Je hebt geen toegang tot dit bestand."); + wrappedKey = overgezet; + } + else + { + var grant = await _vault.FindGrantAsync(nameof(VaultFile), file.Id, currentUserId, ct); + if (grant is null || grant.RevokedAt is not null) + { + // Geen rechtstreekse toegang tot dit bestand. Hoort het bij een kluisitem dat wél + // actief met deze gebruiker is gedeeld, dan is dit een gat dat we alsnog dichten + // in plaats van iemand met een foutmelding te laten zitten: het bestand kan later + // zijn toegevoegd dan het delen zelf, of gedeeld zijn vóór bestanden meededen in + // het delen van een item. + var hersteld = file.VaultItemId is { } itemId + ? await HerstelBestandstoegangAsync(file, itemId, user, currentUserId, ct) + : null; + + if (hersteld is null) return Result<(VaultFile, byte[])>.Fail("Je hebt geen toegang tot dit bestand."); + wrappedKey = hersteld; + } + else + { + wrappedKey = grant.WrappedContentKey; + } + } + + var dataKey = _keys.Unprotect(user.WrappedDataKey); + byte[]? contentKey = null; + try + { + contentKey = EnvelopeCrypto.UnwrapKey(wrappedKey, dataKey); + var versleuteld = await _files.ReadAsync(file.StorageKey, ct); + var inhoud = EnvelopeCrypto.Open(new SealedData(versleuteld, file.Nonce, file.Tag), contentKey); + + // Controle op beschadiging: de hash is bij het opslaan vastgelegd. + if (file.Sha256.Length == 32 && + !System.Security.Cryptography.SHA256.HashData(inhoud).SequenceEqual(file.Sha256)) + { + return Result<(VaultFile, byte[])>.Fail("Dit bestand is beschadigd en kan niet worden geopend."); + } + + await _audit.WriteAsync(AuditAction.KluisItemGelezen, currentUserId, nameof(VaultFile), file.Id, file.FileName, ct); + await _uow.SaveChangesAsync(ct); + return Result<(VaultFile, byte[])>.Ok((file, inhoud)); + } + finally + { + Array.Clear(dataKey); + if (contentKey is not null) Array.Clear(contentKey); + } + } + + /// Verwijdert een bijlage; de versleutelde inhoud gaat meteen uit de opslag. + public async Task DeleteFileAsync(long fileId, long currentUserId, CancellationToken ct = default) + { + var file = await _vault.FindFileAsync(fileId, ct); + if (file is null || file.DeletedAt is not null) return Result.Fail("Dit bestand bestaat niet meer."); + + var user = await _users.FindByIdAsync(currentUserId, ct); + if (user is null) return Result.Fail("Account niet gevonden."); + + if (file.OwnerPersonId != user.PersonId) + { + // Gaat dit bestand over jou, dan hoort het van jou te zijn - ook al staat het nog op + // naam van wie het ooit toevoegde (zie ZetBestandOverAsync). Anders dan bij een + // gedeeld kluisitem is hier geen ruimte voor een "van een ander": je mag je eigen + // foto's altijd weggooien. + if (file.SubjectPersonId != user.PersonId || await ZetBestandOverAsync(file, user, ct) is null) + return Result.Fail("Dit bestand is niet van jou."); + } + + file.DeletedAt = _clock.UtcNow; + try { await _files.DeleteAsync(file.StorageKey, ct); } + catch (Exception) + { + // Het bestand is hoe dan ook onbereikbaar geworden; een opruimfout mag dat niet tegenhouden. + } + + await _audit.WriteAsync(AuditAction.KluisItemVerwijderd, currentUserId, nameof(VaultFile), file.Id, file.FileName, ct); + await _uow.SaveChangesAsync(ct); + return Result.Ok(); + } + + /// + /// Zet de extensie op .jpg. Nodig zodra we een png of heic hebben verkleind: het bestand is + /// dan een jpeg, en een naam die iets anders belooft levert later een plaatje op dat nergens + /// opengaat. + /// + private static string AlsJpeg(string naam) + { + var punt = naam.LastIndexOf('.'); + var kaal = punt > 0 ? naam[..punt] : naam; + return kaal + ".jpg"; + } + + /// + /// Haalt padaanduidingen uit een bestandsnaam. Een naam als ..\..\windows\system32\iets + /// mag nooit in de opslag terechtkomen, ook al bepaalt de opslag zelf de uiteindelijke naam. + /// + private static string SchoneNaam(string naam) + { + // Bewust niet Path.GetFileName: die kijkt naar de scheidingstekens van het besturings- + // systeem waarop dit draait. Een naam met backslashes erin zou op Linux dan blijven + // staan, terwijl hij van een Windows-toestel kan komen (of van iemand met slechte + // bedoelingen). Dus knippen we op allebei. + var kaal = naam.Trim(); + var streep = kaal.LastIndexOfAny(new[] { '/', '\\', ':' }); + if (streep >= 0) kaal = kaal[(streep + 1)..]; + + foreach (var teken in Path.GetInvalidFileNameChars()) kaal = kaal.Replace(teken, '_'); + foreach (var teken in new[] { '\\', '/', ':', '*', '?', '"', '<', '>', '|' }) + kaal = kaal.Replace(teken, '_'); + + kaal = kaal.Trim(' ', '.'); + return string.IsNullOrWhiteSpace(kaal) ? "bestand" : kaal[..Math.Min(kaal.Length, 255)]; + } + + public async Task> ListReleasedAsync(long ownerPersonId, CancellationToken ct = default) + { + var owner = await _people.FindByIdAsync(ownerPersonId, ct); + if (owner is null) return Array.Empty(); + + var now = _clock.UtcNow; + var all = await _vault.ListForPersonAsync(ownerPersonId, null, ct); + return all.Where(i => MessageRelease.IsReleased(i, owner, now)).ToList(); + } +} diff --git a/src/MijnBesluit.Client/MijnBesluit.Client.csproj b/src/MijnBesluit.Client/MijnBesluit.Client.csproj new file mode 100644 index 0000000..1c21a5f --- /dev/null +++ b/src/MijnBesluit.Client/MijnBesluit.Client.csproj @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + + + + + diff --git a/src/MijnBesluit.Client/MijnBesluitClient.cs b/src/MijnBesluit.Client/MijnBesluitClient.cs new file mode 100644 index 0000000..5ecb21c --- /dev/null +++ b/src/MijnBesluit.Client/MijnBesluitClient.cs @@ -0,0 +1,704 @@ +using System.Text.Json; +using MijnBesluit.Protocol; + +namespace MijnBesluit.Client; + +/// +/// De kant van de app: benoemde methodes in plaats van losse socketacties. +/// +/// Hier zit geen regelgeving in — alles wat mag en niet mag wordt door de server bepaald. +/// Deze laag doet twee dingen: de juiste actie versturen, en het antwoord omzetten naar iets +/// waar een scherm mee kan werken. +/// +public class MijnBesluitClient : IAsyncDisposable +{ + private readonly MijnBesluitSocketClient _socket; + private readonly ITokenopslag _opslag; + + public MijnBesluitClient(string host, int poort = 5200, bool tls = true, ITokenopslag? opslag = null) + { + _socket = new MijnBesluitSocketClient(host, poort, tls); + _opslag = opslag ?? new GeheugenTokenopslag(); + + _socket.PushReceived += bericht => MeldingOntvangen?.Invoke(bericht.Message ?? string.Empty); + _socket.Disconnected += _ => Verbindingsstand(false); + } + + /// De server stuurt uit zichzelf een melding, bijvoorbeeld bij een nieuw bericht. + public event Action? MeldingOntvangen; + + /// + /// De server kent het token niet meer. Het bewaarde token is dan al weggegooid; de app hoort + /// hierop naar het inlogscherm te gaan. + /// + /// Dit gebeurt vaker dan je zou denken: de sessies van de SocketServer staan in het geheugen, + /// dus na elke herstart van de service zijn ze weg. Zonder dit bleef de app dan hangen op een + /// rode balk met een knop "opnieuw proberen" die nooit kan lukken. + /// + public event Action? SessieVerlopen; + + /// + /// De verbinding is weggevallen of weer terug. Let op: wegvallen is normaal — Android + /// verbreekt de verbinding zodra de app een tijdje op de achtergrond staat, bijvoorbeeld + /// terwijl iemand zijn e-mail leest om de inlogcode op te halen. De volgende aanroep zet + /// hem vanzelf weer op; daarom hoort deze melding weer te verdwijnen zodra dat lukt. + /// + public event Action? VerbindingVeranderd; + + /// Staat er nu een verbinding? Alleen om te tonen; verzoeken verbinden zelf opnieuw. + public bool IsVerbonden { get; private set; } = true; + + private void Verbindingsstand(bool nu) + { + if (nu == IsVerbonden) return; + IsVerbonden = nu; + VerbindingVeranderd?.Invoke(nu); + } + + /// De persoon die op dit moment is aangemeld. + public long PersoonId { get; private set; } + + public bool IsAangemeld => !string.IsNullOrEmpty(_socket.Token); + + /// Alleen voor ontwikkelen: accepteert een zelfondertekend servercertificaat. + public bool AccepteerElkCertificaat + { + get => _socket.AcceptAnyCertificate; + set => _socket.AcceptAnyCertificate = value; + } + + /// + /// Ligt er een sessie op dit toestel? Leest alleen wat er bewaard staat en vraagt de server + /// niets. + /// + /// Daar zit het hele punt: de app hoort bij het openen te weten dat jij het bent, ook als er + /// nog geen netwerk is of de server even niet reageert. Op die wetenschap gaat het slot erop + /// en vraagt de app om je vingerafdruk. Pas daarna is het aan de server om te zeggen of de + /// sessie ook echt nog loopt. + /// + public async Task HeeftBewaardeSessieAsync() + { + if (!string.IsNullOrEmpty(_socket.Token)) return true; + + var token = await _opslag.LeesAsync(); + if (string.IsNullOrEmpty(token)) return false; + + _socket.Token = token; + return true; + } + + /// Haalt een eerder bewaard sessietoken op, zodat je niet elke keer opnieuw hoeft in te loggen. + public async Task HervatSessieAsync(CancellationToken ct = default) + { + if (!await HeeftBewaardeSessieAsync()) return false; + + // Het eigen profiel opvragen doet twee dingen tegelijk: het bewijst dat het token nog + // geldig is, én het zegt wie we zijn. + // + // Dat tweede is waarom hier niet zomaar een willekeurige aanroep staat. PersoonId werd + // alleen gevuld bij het inloggen met een code, niet bij het hervatten van een sessie. Wie + // de app opende zonder opnieuw in te loggen had dus PersoonId 0, en dan weet de app niet + // welk familielid jijzelf bent: de kaart bovenaan Mijn mensen viel terug op "Mijn profiel" + // met initialen, én je stond een tweede keer gewoon in de lijst. + var profiel = await StuurAsync(SocketActions.OwnProfile, null, ct); + Verbindingsstand(_socket.IsConnected); + + if (profiel.Gelukt && profiel.Waarde is not null) + { + PersoonId = profiel.Waarde.PersoonId; + MijnProfiel = profiel.Waarde; + return true; + } + + // Mislukken is niet hetzelfde als geweigerd worden, en dat onderscheid is hier het hele + // punt. Een telefoon die net wakker wordt heeft nog geen netwerk; de server kan net + // opnieuw opstarten; er is even geen bereik. Werd het token dan weggegooid, dan moest + // iemand opnieuw inloggen met een code per e-mail terwijl er niets mis was met zijn + // sessie - en dat overkwam je juist bij het terugkomen in een app die op slot stond, + // want dan is de app vaak door Android afgesloten en start hij koud op. + // + // Alleen de server mag een sessie ongeldig verklaren. Zegt hij dat, dan heeft StuurAsync + // het token al opgeruimd via SessieKwijtAsync en is het hier dus al leeg. Staat het er + // nog, dan blijft het staan en proberen we het de volgende keer gewoon opnieuw. + if (_socket.Token is null) + { + PersoonId = 0; + MijnProfiel = null; + } + + return false; + } + + // ------------------------------------------------------------------ inloggen + + public async Task> VraagInlogcodeAanAsync(string email, CancellationToken ct = default) => + await StuurAsync(SocketActions.RequestLoginCode, new { email }, ct); + + public async Task LogInAsync(string email, string code, CancellationToken ct = default) + { + var antwoord = await StuurAsync(SocketActions.SignIn, new { email, code }, ct); + return await BewaarSessieAsync(antwoord); + } + + public async Task> VraagRegistratiecodeAanAsync( + string email, string voornaam, string achternaam, CancellationToken ct = default) => + await StuurAsync(SocketActions.RequestRegisterCode, + new { email, firstName = voornaam, lastName = achternaam }, ct); + + public async Task RegistreerAsync(string email, string code, CancellationToken ct = default) + { + var antwoord = await StuurAsync(SocketActions.Register, new { email, code }, ct); + return await BewaarSessieAsync(antwoord); + } + + public async Task NeemUitnodigingAanAsync(string token, CancellationToken ct = default) + { + var antwoord = await StuurAsync(SocketActions.AcceptInvitation, new { token }, ct); + return await BewaarSessieAsync(antwoord); + } + + /// + /// Doet de lijn naar de server dicht, maar blijft aangemeld. De app doet dit als hij op slot + /// gaat: wat dicht is, hoeft niet bewaakt te worden. + /// + /// Let op het verschil met uitloggen: het token blijft staan, en de sessie aan de serverkant + /// blijft bestaan - die hangt aan het token en niet aan de verbinding. De eerste aanroep na + /// het ontgrendelen maakt vanzelf een nieuwe verbinding. + /// + public async Task VerbindingSluitenAsync() + { + await _socket.VerbreekAsync(); + Verbindingsstand(false); + } + + /// + /// Zet de verbinding alvast weer op, na het ontgrendelen. Hoeft niet - het eerste verzoek + /// doet het ook - maar zo staat de lijn er al terwijl iemand nog naar zijn scherm kijkt, in + /// plaats van dat zijn eerste handeling op een TLS-handdruk wacht. + /// + public async Task VerbindingOpenenAsync(CancellationToken ct = default) + { + if (!IsAangemeld) return; + + try { Verbindingsstand(await _socket.ConnectAsync(ct)); } + catch (Exception) { Verbindingsstand(false); } + } + + public async Task UitloggenAsync(CancellationToken ct = default) + { + // De server krijgt alleen bericht als er op dit moment verbinding is. Anders zou + // uitloggen tien seconden blijven hangen op een verbindingspoging die toch mislukt - + // en dan lijkt de knop het niet te doen. De sessie aan de serverkant verloopt vanzelf. + if (IsAangemeld && _socket.IsConnected) + { + try { await _socket.SendAsync(SocketActions.SignOut, null, TimeSpan.FromSeconds(3), ct); } + catch (Exception) { /* Uitloggen op dit toestel gaat hoe dan ook door. */ } + } + + _socket.Token = null; + PersoonId = 0; + MijnProfiel = null; + await _opslag.WisAsync(); + } + + private async Task BewaarSessieAsync(Uitkomst antwoord) + { + if (!antwoord.Gelukt) return Uitkomst.Fout(antwoord.Melding!); + + _socket.Token = antwoord.Waarde!.Token; + PersoonId = antwoord.Waarde.PersoonId; + await _opslag.SchrijfAsync(antwoord.Waarde.Token); + return Uitkomst.Ok(); + } + + // ------------------------------------------------------------------ overzicht + + /// Alles wat het beginscherm toont, in één aanroep. + public Task> OverzichtAsync(CancellationToken ct = default) => + StuurAsync(SocketActions.Overview, null, ct); + + /// Stelt in hoeveel dagen vooruit het overzicht kijkt (0 tot 60). + public Task> VooruitblikAsync(int dagen, CancellationToken ct = default) => + StuurAsync(SocketActions.SetLookahead, new { days = dagen }, ct); + + // ------------------------------------------------------------------ eigen profiel + + /// + /// Je eigen profiel zoals het er de laatste keer uitzag, of null zolang het niet is + /// opgehaald. Staat hier en niet in elk scherm apart: je eigen naam en je eigen foto zijn + /// op meer plekken nodig dan alleen het profielscherm, en ze veranderen bijna nooit. + /// Elke ophaling ververst dit, dus wie zijn foto wisselt ziet dat overal terug. + /// + public EigenProfiel? MijnProfiel { get; private set; } + + /// Je eigen naam, adres en geboortedatum. + public async Task> EigenProfielAsync(CancellationToken ct = default) + { + var uitkomst = await StuurAsync(SocketActions.OwnProfile, null, ct); + if (uitkomst.Gelukt && uitkomst.Waarde is not null) MijnProfiel = uitkomst.Waarde; + return uitkomst; + } + + /// Wijzigt je eigen naam en geboortedatum. Het e-mailadres gaat apart, met een code. + public Task> EigenProfielOpslaanAsync( + string voornaam, string achternaam, DateOnly? geboortedatum, CancellationToken ct = default) => + StuurAsync(SocketActions.SaveOwnProfile, + new { firstName = voornaam, lastName = achternaam, birthDate = geboortedatum }, ct); + + // ------------------------------------------------------------------ account opheffen + + /// Wat er weggaat als je je account opheft. Leest alleen. + public Task> OpheffenTellingAsync(CancellationToken ct = default) => + StuurAsync(SocketActions.DeleteAccountSummary, null, ct); + + /// Vraagt de code aan waarmee het opheffen bevestigd wordt. Gaat naar je eigen adres. + public Task> OpheffenCodeAanvragenAsync(CancellationToken ct = default) => + StuurAsync(SocketActions.RequestDeleteAccount, null, ct); + + /// + /// Heft het account op. Hierna is er niets meer om mee te werken, dus ruimt de app zijn + /// bewaarde sessie ook meteen op. + /// + public async Task> OpheffenBevestigenAsync(string code, CancellationToken ct = default) + { + var uitkomst = await StuurAsync(SocketActions.ConfirmDeleteAccount, new { code }, ct); + if (uitkomst.Gelukt) await UitloggenAsync(ct); + return uitkomst; + } + + /// + /// Stuurt een code naar het NIEUWE adres. Er verandert pas iets als die code klopt; zo kan + /// niemand je met een openstaande sessie uit je eigen kluis zetten. + /// + public Task> AdreswijzigingAanvragenAsync( + string nieuwAdres, CancellationToken ct = default) => + StuurAsync(SocketActions.RequestEmailChange, new { email = nieuwAdres }, ct); + + /// Maakt de adreswijziging definitief. Je wordt daarna overal uitgelogd. + public Task> AdreswijzigingBevestigenAsync( + string nieuwAdres, string code, CancellationToken ct = default) => + StuurAsync(SocketActions.ConfirmEmailChange, new { email = nieuwAdres, code }, ct); + + // ------------------------------------------------------------------ familie + + public Task>> FamiliesAsync(CancellationToken ct = default) => + StuurAsync>(SocketActions.ListFamilies, null, ct); + + public Task>> FamilieledenAsync(long familieId, CancellationToken ct = default) => + StuurAsync>(SocketActions.ListMembers, new { familyId = familieId }, ct); + + public Task> VoegFamilielidToeAsync( + long familieId, string voornaam, string achternaam, string? email, int relatie, + DateOnly? geboortedatum = null, CancellationToken ct = default) => + StuurAsync(SocketActions.AddMember, new + { + familyId = familieId, + firstName = voornaam, + lastName = achternaam, + email, + relation = relatie, + relationLabel = (string?)null, + birthDate = geboortedatum + }, ct); + + /// + /// Wijzigt naam, e-mailadres en relatie van iemand in je familie. De server weigert dit + /// zodra de persoon het profiel zelf heeft geclaimd. + /// + public Task> WijzigFamilielidAsync( + long familieId, long persoonId, string voornaam, string achternaam, string? email, int relatie, + string? relatieOmschrijving = null, DateOnly? geboortedatum = null, + CancellationToken ct = default) => + StuurAsync(SocketActions.UpdatePerson, new + { + familyId = familieId, + personId = persoonId, + firstName = voornaam, + lastName = achternaam, + email, + relation = relatie, + relationLabel = relatieOmschrijving, + birthDate = geboortedatum + }, ct); + + public Task> NodigUitAsync(long familieId, long persoonId, CancellationToken ct = default) => + StuurAsync(SocketActions.Invite, new { familyId = familieId, personId = persoonId }, ct); + + public Task> VerwijderFamilielidAsync( + long familieId, long persoonId, CancellationToken ct = default) => + StuurAsync(SocketActions.RemoveMember, new { familyId = familieId, personId = persoonId }, ct); + + // ------------------------------------------------------------------ kluis + + public Task>> KluisAsync(int? categorie = null, CancellationToken ct = default) => + StuurAsync>(SocketActions.VaultList, new { category = categorie }, ct); + + public Task> KluisOpenenAsync(long itemId, CancellationToken ct = default) => + StuurAsync(SocketActions.VaultOpen, new { id = itemId }, ct); + + public Task> KluisOpslaanAsync( + int categorie, string titel, KluisInhoud inhoud, int vrijgave, DateTime? vrijgaveDatum = null, + long? besprekenMetPersonId = null, CancellationToken ct = default) => + StuurAsync(SocketActions.VaultSave, new + { + category = categorie, + title = titel, + payload = inhoud, + releasePolicy = vrijgave, + releaseAt = vrijgaveDatum, + sharedManagerPersonId = besprekenMetPersonId + }, ct); + + public Task> KluisWijzigenAsync( + long itemId, int categorie, string titel, KluisInhoud inhoud, int vrijgave, + DateTime? vrijgaveDatum = null, long? besprekenMetPersonId = null, CancellationToken ct = default) => + StuurAsync(SocketActions.VaultUpdate, new + { + id = itemId, + category = categorie, + title = titel, + payload = inhoud, + releasePolicy = vrijgave, + releaseAt = vrijgaveDatum, + sharedManagerPersonId = besprekenMetPersonId + }, ct); + + public Task> KluisVerwijderenAsync(long itemId, CancellationToken ct = default) => + StuurAsync(SocketActions.VaultDelete, new { id = itemId }, ct); + + // ------------------------------------------------------------------ bijlagen + + /// Hoeveel bytes er per stuk over de lijn gaan; de server gebruikt dezelfde maat. + public const int StukGrootte = 512 * 1024; + + /// + /// Zet een bestand in de kluis. Het gaat in stukken over de lijn, want één socketbericht is + /// begrensd. krijgt na elk stuk het aantal verstuurde bytes, + /// zodat een scherm een balkje kan tonen. + /// + public async Task> BestandVersturenAsync( + string bestandsnaam, string soort, byte[] inhoud, long? kluisItemId = null, + Action? voortgang = null, long? persoonId = null, long? berichtId = null, + CancellationToken ct = default) + { + if (inhoud.LongLength == 0) return Uitkomst.Fout("Het bestand is leeg."); + + var begin = await StuurAsync(SocketActions.FileBegin, new + { + fileName = bestandsnaam, + contentType = soort, + vaultItemId = kluisItemId, + subjectPersonId = persoonId, + messageId = berichtId, + totalBytes = inhoud.LongLength + }, ct); + if (!begin.Gelukt) return Uitkomst.Fout(begin.Melding!); + + var uploadId = begin.Waarde.GetProperty("uploadId").GetString()!; + var stukGrootte = begin.Waarde.TryGetProperty("stukGrootte", out var sg) ? sg.GetInt32() : StukGrootte; + + try + { + var index = 0; + for (long positie = 0; positie < inhoud.LongLength; positie += stukGrootte) + { + var lengte = (int)Math.Min(stukGrootte, inhoud.LongLength - positie); + var stuk = await StuurAsync(SocketActions.FileChunk, new + { + uploadId, + index, + data = Convert.ToBase64String(inhoud, (int)positie, lengte) + }, ct); + + if (!stuk.Gelukt) return Uitkomst.Fout(stuk.Melding!); + + index++; + voortgang?.Invoke(positie + lengte, inhoud.LongLength); + } + + var klaar = await StuurAsync(SocketActions.FileEnd, new { uploadId }, ct); + if (!klaar.Gelukt) return Uitkomst.Fout(klaar.Melding!); + + return Uitkomst.Ok(klaar.Waarde.GetProperty("bestandId").GetInt64()); + } + catch (Exception) + { + // Halverwege misgegaan: laat de server de resten opruimen in plaats van ze een half + // uur te laten staan. + await StuurAsync(SocketActions.FileCancel, new { uploadId }, CancellationToken.None); + throw; + } + } + + public Task>> BijlagenAsync(long kluisItemId, CancellationToken ct = default) => + StuurAsync>(SocketActions.FileList, new { id = kluisItemId }, ct); + + /// Haalt een bestand op, stuk voor stuk, en zet het weer aan elkaar. + public async Task> BestandOphalenAsync( + long bestandId, Action? voortgang = null, CancellationToken ct = default) + { + var eerste = await StuurAsync(SocketActions.FileRead, new { id = bestandId, index = 0 }, ct); + if (!eerste.Gelukt) return Uitkomst.Fout(eerste.Melding!); + + var naam = eerste.Waarde.GetProperty("naam").GetString() ?? "bestand"; + var soort = eerste.Waarde.GetProperty("soort").GetString() ?? "application/octet-stream"; + var grootte = eerste.Waarde.GetProperty("grootte").GetInt64(); + var stukken = eerste.Waarde.GetProperty("stukken").GetInt32(); + + using var buffer = new MemoryStream(capacity: (int)Math.Min(grootte, int.MaxValue)); + var eersteStuk = Convert.FromBase64String(eerste.Waarde.GetProperty("data").GetString() ?? ""); + buffer.Write(eersteStuk, 0, eersteStuk.Length); + voortgang?.Invoke(buffer.Length, grootte); + + for (var index = 1; index < stukken; index++) + { + var volgende = await StuurAsync(SocketActions.FileRead, new { id = bestandId, index }, ct); + if (!volgende.Gelukt) return Uitkomst.Fout(volgende.Melding!); + + var stuk = Convert.FromBase64String(volgende.Waarde.GetProperty("data").GetString() ?? ""); + buffer.Write(stuk, 0, stuk.Length); + voortgang?.Invoke(buffer.Length, grootte); + } + + return Uitkomst.Ok(new BestandInhoud(naam, soort, buffer.ToArray())); + } + + /// De foto's die bij een persoon horen. + public Task>> PersoonsfotosAsync(long persoonId, CancellationToken ct = default) => + StuurAsync>(SocketActions.FileListPerson, new { personId = persoonId }, ct); + + /// De bijlagen bij een bericht dat jij hebt klaargezet. + public Task>> BerichtbijlagenAsync(long berichtId, CancellationToken ct = default) => + StuurAsync>(SocketActions.FileListMessage, new { id = berichtId }, ct); + + /// Wijst een foto aan als profielfoto van de persoon waar hij bij hoort. + public Task> ProfielfotoAsync(long bestandId, CancellationToken ct = default) => + StuurAsync(SocketActions.FileProfilePhoto, new { id = bestandId }, ct); + + public Task> BijlageVerwijderenAsync(long bestandId, CancellationToken ct = default) => + StuurAsync(SocketActions.FileDelete, new { id = bestandId }, ct); + + // ------------------------------------------------------------------ berichten + + public Task>> InkomendAsync(CancellationToken ct = default) => + StuurAsync>(SocketActions.Inbox, null, ct); + + public Task>> UitgaandAsync(CancellationToken ct = default) => + StuurAsync>(SocketActions.Outbox, null, ct); + + public async Task> BerichtLezenAsync(long berichtId, CancellationToken ct = default) + { + var antwoord = await StuurAsync(SocketActions.MessageRead, new { id = berichtId }, ct); + if (!antwoord.Gelukt) return Uitkomst.Fout(antwoord.Melding!); + + return antwoord.Waarde.TryGetProperty("tekst", out var tekst) + ? Uitkomst.Ok(tekst.GetString() ?? string.Empty) + : Uitkomst.Ok(string.Empty); + } + + /// Je eigen verstuurde bericht nalezen, ook als het al bezorgd is. Zie MessageService.ReadSentAsync. + public async Task> BerichtVerzondenLezenAsync(long berichtId, CancellationToken ct = default) + { + var antwoord = await StuurAsync(SocketActions.MessageReadSent, new { id = berichtId }, ct); + if (!antwoord.Gelukt) return Uitkomst.Fout(antwoord.Melding!); + + return antwoord.Waarde.TryGetProperty("tekst", out var tekst) + ? Uitkomst.Ok(tekst.GetString() ?? string.Empty) + : Uitkomst.Ok(string.Empty); + } + + /// 0 tekst, 1 video, 2 audio, 3 document. + /// Een eerder verstuurde opname of bijlage die bij dit bericht hoort. + public Task> BerichtKlaarzettenAsync( + long aanPersoonId, string onderwerp, string tekst, DateTime? bezorgOp, bool naOverlijden, + int soort = 0, long? bestandId = null, CancellationToken ct = default) => + StuurAsync(SocketActions.MessageCompose, new + { + toPersonId = aanPersoonId, + subject = onderwerp, + text = tekst, + deliverAt = bezorgOp, + afterDeath = naOverlijden, + kind = soort, + vaultFileId = bestandId + }, ct); + + /// De tekst van een bericht dat jij hebt klaargezet, om het te kunnen wijzigen. + public async Task> BerichtConceptAsync(long berichtId, CancellationToken ct = default) + { + var antwoord = await StuurAsync(SocketActions.MessageDraft, new { id = berichtId }, ct); + if (!antwoord.Gelukt) return Uitkomst.Fout(antwoord.Melding!); + + return antwoord.Waarde.TryGetProperty("tekst", out var tekst) + ? Uitkomst.Ok(tekst.GetString() ?? string.Empty) + : Uitkomst.Ok(string.Empty); + } + + public Task> BerichtWijzigenAsync( + long berichtId, string onderwerp, string tekst, DateTime? bezorgOp, bool naOverlijden, + CancellationToken ct = default) => + StuurAsync(SocketActions.MessageUpdate, new + { + id = berichtId, + subject = onderwerp, + text = tekst, + deliverAt = bezorgOp, + afterDeath = naOverlijden + }, ct); + + public Task> BerichtIntrekkenAsync(long berichtId, CancellationToken ct = default) => + StuurAsync(SocketActions.MessageWithdraw, new { id = berichtId }, ct); + + public Task> BerichtVerwijderenAsync(long berichtId, CancellationToken ct = default) => + StuurAsync(SocketActions.MessageDelete, new { id = berichtId }, ct); + + // ------------------------------------------------------------------ nalatenschap + + public Task> NalatenschapStandAsync(long? persoonId = null, CancellationToken ct = default) => + StuurAsync(SocketActions.LegacyStatus, + persoonId is null ? null : new { personId = persoonId.Value }, ct); + + public Task> MeldOverlijdenAsync( + long persoonId, DateOnly? datum = null, string? notitie = null, CancellationToken ct = default) => + StuurAsync(SocketActions.DeclareDeath, + new { personId = persoonId, dateOfDeath = datum, note = notitie }, ct); + + public Task> IkBenErNogAsync(CancellationToken ct = default) => + StuurAsync(SocketActions.ObjectDeath, null, ct); + + public Task> WijsBeheerderAanAsync( + long persoonId, int volgorde = 1, CancellationToken ct = default) => + StuurAsync(SocketActions.AppointManager, + new { managerPersonId = persoonId, priority = volgorde }, ct); + + /// Haalt iemand weer weg als beheerder van jouw nalatenschap. + public Task> VerwijderBeheerderAsync( + long persoonId, CancellationToken ct = default) => + StuurAsync(SocketActions.RemoveManager, new { managerPersonId = persoonId }, ct); + + /// Wie je hebt aangewezen om je nalatenschap te beheren, op volgorde. + /// De nalatenschappen die aan jou zijn toevertrouwd en waarvan de procedure rond is. + public Task>> ToevertrouwdAsync(CancellationToken ct = default) => + StuurAsync>(SocketActions.LegacyEntrusted, null, ct); + + /// Wat er van die nalatenschap voor jou is vrijgegeven. + public Task>> NalatenschapItemsAsync(long persoonId, CancellationToken ct = default) => + StuurAsync>(SocketActions.LegacyEntrustedItems, new { personId = persoonId }, ct); + + /// + /// Kluisitems die iemand rechtstreeks met jou heeft gedeeld om vooraf te bespreken (bijvoorbeeld + /// een uitvaartwens) - los van de gewone nalatenschap-vrijgave na een overlijden. + /// + public Task>> GedeeldMetMijAsync(CancellationToken ct = default) => + StuurAsync>(SocketActions.VaultSharedWithMe, null, ct); + + /// + /// De advertentie voor het overzicht, of null als er nu geen is. Het opvragen telt de + /// vertoning mee: wat hier terugkomt wordt ook getoond. + /// + public Task> AdvertentieAsync(CancellationToken ct = default) => + StuurAsync(SocketActions.Advertentie, null, ct); + + public Task>> BeheerdersAsync(CancellationToken ct = default) => + StuurAsync>(SocketActions.LegacyManagers, null, ct); + + /// + /// Hoeveel onafhankelijke meldingen er nodig zijn voordat de procedure begint, en hoeveel + /// dagen je daarna nog hebt om te laten weten dat je er nog bent. + /// + public Task> NalatenschapInstellingenAsync( + int aantalMeldingen, int bezwaardagen, CancellationToken ct = default) => + StuurAsync(SocketActions.LegacySettings, + new { declarations = aantalMeldingen, objectionDays = bezwaardagen }, ct); + + // ------------------------------------------------------------------ meldingen + + /// + /// Meldt dit toestel aan voor meldingen. Het kenmerk komt van Firebase (Android) of APNs + /// (iOS) en verandert af en toe vanzelf, dus dit hoort bij elke start te gebeuren. + /// + public Task> ToestelAanmeldenAsync( + string kenmerk, string platform = "android", string? naam = null, CancellationToken ct = default) => + StuurAsync(SocketActions.RegisterDevice, + new { token = kenmerk, platform, name = naam }, ct); + + public Task> ToestelAfmeldenAsync(string kenmerk, CancellationToken ct = default) => + StuurAsync(SocketActions.UnregisterDevice, new { token = kenmerk }, ct); + + /// + /// Vraagt de server om een proefmelding naar de toestellen van deze gebruiker. Het antwoord + /// zegt of het is gelukt en anders waar het op vastloopt. + /// + public Task> ProefmeldingAsync(CancellationToken ct = default) => + StuurAsync(SocketActions.TestDevice, null, ct); + + // ------------------------------------------------------- aantekeningen over mij + + /// + /// Heeft iemand een profiel voor mij aangemaakt? Alleen ik kan zeggen of dat over mij gaat; + /// koppelen op e-mailadres doet MijnBesluit niet. Zie docs/Identiteit.md. + /// + public async Task>> AantekeningenOverMijAsync(CancellationToken ct = default) => + await StuurAsync>(SocketActions.NotesAboutMe, null, ct); + + /// "Ja, dat ben ik." De aantekening gaat op in het profiel dat ik al heb. + public Task> AantekeningOvernemenAsync(long persoonId, CancellationToken ct = default) => + StuurAsync(SocketActions.AcceptNote, new { personId = persoonId }, ct); + + /// "Nee, dat ben ik niet." Er verandert niets en de vraag komt niet terug. + public Task> AantekeningAfwijzenAsync(long persoonId, CancellationToken ct = default) => + StuurAsync(SocketActions.DeclineNote, new { personId = persoonId }, ct); + + // ------------------------------------------------------------------ binnenwerk + + /// Stuurt een actie en zet het antwoord om; fouten komen als melding terug. + private async Task> StuurAsync(string actie, object? payload, CancellationToken ct) + { + var antwoord = await _socket.SendAsync(actie, payload, null, ct); + + // Een geslaagd verzoek is het bewijs dat er verbinding is. Zonder dit blijft de melding + // "geen verbinding" staan na een onderbreking die allang weer voorbij is. + Verbindingsstand(_socket.IsConnected); + + if (antwoord.Status != "Ok") + { + if (antwoord.Code == SocketFoutcodes.SessieVerlopen) await SessieKwijtAsync(); + return Uitkomst.Fout(antwoord.Message ?? "Er ging iets mis."); + } + + if (antwoord.Payload is null) + return Uitkomst.Ok(default!); + + try + { + // De payload komt binnen als JsonElement; die zetten we om naar het gevraagde type. + var element = antwoord.Payload is JsonElement e + ? e + : JsonSerializer.SerializeToElement(antwoord.Payload, SocketFraming.Json); + + return Uitkomst.Ok(element.Deserialize(SocketFraming.Json)!); + } + catch (JsonException ex) + { + return Uitkomst.Fout($"Onverwacht antwoord van de server: {ex.Message}"); + } + } + + /// + /// De server kent ons token niet meer. Opruimen en één keer melden; een tweede verzoek dat + /// hetzelfde antwoord krijgt hoort niet nog een keer af te gaan. + /// + private async Task SessieKwijtAsync() + { + if (!IsAangemeld) return; + + _socket.Token = null; + PersoonId = 0; + MijnProfiel = null; + await _opslag.WisAsync(); + + SessieVerlopen?.Invoke(); + } + + public async ValueTask DisposeAsync() => await _socket.DisposeAsync(); +} diff --git a/src/MijnBesluit.Client/Modellen.cs b/src/MijnBesluit.Client/Modellen.cs new file mode 100644 index 0000000..461a119 --- /dev/null +++ b/src/MijnBesluit.Client/Modellen.cs @@ -0,0 +1,352 @@ +using System.Text.Json.Serialization; + +namespace MijnBesluit.Client; + +// De vorm van wat de SocketServer terugstuurt. De namen komen overeen met wat de dispatcher +// verstuurt; het protocol gebruikt kleine beginletters, dat regelt de JSON-instelling. + +public record SessieGegevens( + [property: JsonPropertyName("token")] string Token, + [property: JsonPropertyName("persoonId")] long PersoonId, + [property: JsonPropertyName("verlooptOp")] DateTime? VerlooptOp); + +public record CodeGestuurd( + [property: JsonPropertyName("verstuurdNaar")] string VerstuurdNaar, + [property: JsonPropertyName("geldigTot")] DateTime GeldigTot); + +public record FamilieOverzicht(long Id, string Name, bool Eigenaar); + +/// Eén regel op het beginscherm: iets wat speelt of eraan komt. +public record Overzichtsregel(string Soort, string Tekst, DateTime? Datum, long? Id) +{ + public bool IsWaarschuwing => Soort == "overlijden"; + public bool IsMoment => Soort == "moment"; +} + +/// Alles wat het beginscherm laat zien. +public record Overzicht( + int AantalKluis, + int AantalFamilie, + int AantalVoorMij, + int AantalVanMij, + int AantalNieuweBerichten, + int AantalBeheerders, + int VooruitblikDagen, + IReadOnlyList Regels); + +/// +/// De sociale media die je bij een kluisitem kunt kiezen. +/// +/// Dit is een kopie van SocialeMedia in MijnBesluit.Domain: de app ziet het domein niet, want +/// die praat alleen over de socket. Een controle in de zelftest bewaakt dat de twee lijsten +/// gelijk blijven, zodat ze niet stilletjes uit elkaar lopen. +/// +public static class SocialeMedia +{ + public const string Anders = "Anders"; + + public static readonly string[] Namen = + { + "Facebook", "Instagram", "WhatsApp", "LinkedIn", "X (voorheen Twitter)", + "TikTok", "YouTube", "Snapchat", "Pinterest", "Telegram", "Signal", + "Discord", "Reddit", "Strava", "Marktplaats", Anders + }; +} + +/// +/// Datums zoals ze op het scherm horen te staan, in het Nederlands. +/// +/// Dit is een kopie van Datums in MijnBesluit.Domain: de app ziet het domein niet. Een controle +/// in de zelftest bewaakt dat de twee gelijk blijven. +/// +/// Waarom dit met de hand gaat en niet met ToString("d MMMM yyyy"): dat volgt de taalinstelling +/// van het toestel, en dan leest een telefoon die op Engels staat "9 June 2031" terwijl de rest +/// van het scherm Nederlands is. Een app die maar in één taal bestaat, hoort ook maar in één taal +/// te rekenen. +/// +public static class Datums +{ + public static readonly string[] Maanden = + { + "januari", "februari", "maart", "april", "mei", "juni", + "juli", "augustus", "september", "oktober", "november", "december" + }; + + /// Afkortingen voor waar het krap is. "Mei" heeft geen punt: dat is geen afkorting. + public static readonly string[] KorteMaanden = + { + "Jan.", "Feb.", "Mrt.", "Apr.", "Mei", "Jun.", + "Jul.", "Aug.", "Sep.", "Okt.", "Nov.", "Dec." + }; + + /// Zondag eerst, want zo telt DayOfWeek. + public static readonly string[] Dagen = + { + "zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag" + }; +} + +/// +/// Wat iemand van je is: de lijst die je te kiezen krijgt, en hoe elke relatie heet. +/// +/// Dit is een kopie van Relaties in MijnBesluit.Domain: de app ziet het domein niet, want die +/// praat alleen over de socket. Daarom staan hier nummers en geen enum - dat is wat er over de +/// lijn gaat. Een controle in de zelftest bewaakt dat de twee lijsten gelijk blijven. +/// +/// is wat je kunt kiezen, vertaalt élke waarde die ooit +/// heeft bestaan. Kind, Bonuskind, Ouder, Bonusouder, Kleinkind en Grootouder kun je niet meer +/// kiezen, maar staan wel bij bestaande mensen in de database; zonder vertaling zou daar ineens +/// niets meer staan. +/// +public static class Relaties +{ + public static readonly (int Waarde, string Naam)[] Keuzes = + { + (1, "Partner"), + + (11, "Zoon"), + (12, "Dochter"), + (28, "Bonuszoon"), + (29, "Bonusdochter"), + (16, "Schoonzoon"), + (15, "Schoondochter"), + + (14, "Kleinzoon"), + (13, "Kleindochter"), + (33, "Achterkleinzoon"), + (32, "Achterkleindochter"), + + (19, "Vader"), + (20, "Moeder"), + (22, "Bonusvader"), + (21, "Bonusmoeder"), + (24, "Schoonvader"), + (25, "Schoonmoeder"), + (17, "Opa"), + (18, "Oma"), + + (6, "Broer"), + (7, "Zus"), + (30, "Bonusbroer"), + (31, "Bonuszus"), + (26, "Zwager"), + (27, "Schoonzus"), + (34, "Oom"), + (35, "Tante"), + + (10, "Vriend"), + (23, "Vriendin"), + (99, "Anders") + }; + + /// Hoe een relatie op het scherm heet - ook de relaties die je niet meer kunt kiezen. + public static string Naam(int relatie) => relatie switch + { + 2 => "Kind", + 3 => "Bonuskind", + 4 => "Ouder", + 5 => "Bonusouder", + 8 => "Kleinkind", + 9 => "Grootouder", + 0 => "Familie", + _ => Keuzes.FirstOrDefault(k => k.Waarde == relatie).Naam ?? "Familie" + }; +} + +/// +/// Een profiel dat iemand anders voor je heeft aangemaakt op jouw e-mailadres, en waarvan alleen +/// jij kunt zeggen of het over jou gaat. Zie docs/Identiteit.md. +/// +public record Aantekening(long PersoonId, string Van, string? Relatie, DateTime AangemaaktOp); + +/// Eigendomsstatus van een profiel: 0 beheerd, 1 uitgenodigd, 2 geclaimd. +public enum ProfielStand { Beheerd = 0, Uitgenodigd = 1, Geclaimd = 2 } + +public record Familielid( + long PersonId, + string DisplayName, + string FirstName, + string LastName, + string? Email, + int Relation, + string? RelationLabel, + ProfielStand State, + bool CanEdit, + DateOnly? BirthDate, + DateOnly? DateOfDeath, + + // De geschiedenis van het profiel. Komt rechtstreeks van de server; alles in UTC. + DateTime CreatedAt, + DateTime? InvitedAt, + DateTime? InvitationExpiresAt, + int InvitationResends, + DateTime? ClaimedAt, + + // Twee dingen die pas ter sprake komen als je iemand weghaalt, maar die meekomen met de + // lijst zodat het scherm ze meteen heeft. Zie FamilyService.VerwijderLidAsync. + bool IsBeheerder, + int MomentenKlaar, + + // De profielfoto. Het nummer om hem groot op te halen, en de kleine versie als base64 om + // hem meteen te kunnen tonen zonder tweede ronde langs de server. + long? ProfielfotoId = null, + string? Profielfoto = null) +{ + public bool IsGeclaimd => State == ProfielStand.Geclaimd; + + /// De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan. + public string? Profielfotobron => + string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}"; +} + +/// +/// Wat er verdwijnt als je je account opheft. Staat in het scherm vóórdat iemand ja zegt: +/// "weet je het zeker?" is een lege vraag, "je kluis met 23 items" is er een waar iemand echt +/// over nadenkt. +/// +public record Opheffing(int Kluisitems, int Bestanden, int BerichtenKlaar, int BerichtenBezorgd, + int Mensen, int Nalatenschappen); + +/// Je eigen gegevens, zoals het profielscherm ze toont. +public record EigenProfiel(long PersoonId, string Voornaam, string Achternaam, string Email, + DateOnly? Geboortedatum, DateTime LidSinds, + // De kleine versie van je eigen profielfoto, als base64. Die gaat mee + // in dit antwoord omdat de app hem op meer schermen laat zien dan + // alleen je profiel - in de kluis staat hij bij "Alleen voor mijzelf". + // Een eigen ronde langs de server voor een plaatje van veertig pixels + // zou duurder zijn dan het plaatje zelf. + string? Profielfoto = null) +{ + public string Naam => string.Join(" ", + new[] { Voornaam, Achternaam }.Where(d => !string.IsNullOrWhiteSpace(d))); + + /// De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan. + public string? Profielfotobron => + string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}"; +} + +/// +/// Een nalatenschap die jij beheert. is false zolang de persoon er +/// gewoon is: dan weet je alleen dat je bent aangewezen en valt er niets in te zien. +/// +public record Toevertrouwd(long PersonId, string DisplayName, DateOnly? DateOfDeath, + DateTime? AccessGrantedAt, int AantalItems, + bool Afgerond = false, int Volgorde = 1, string? Profielfoto = null, + int RequiredDeclarations = 3, int ObjectionDays = 14) +{ + /// De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan. + public string? Profielfotobron => + string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}"; +} + +public record KluisItem( + long Id, + string Title, + int Category, + int ReleasePolicy, + DateTime? ReleaseAt, + DateTime UpdatedAt, + long? SharedManagerPersonId = null); + +/// De ontsleutelde inhoud van een kluisitem. Per soort is maar een deel gevuld. +public class KluisInhoud +{ + public string? Notes { get; set; } + public string? Website { get; set; } + public string? Username { get; set; } + public string? Secret { get; set; } + public string? Pincode { get; set; } + public string? DeviceName { get; set; } + public string? RecoveryCodes { get; set; } + + /// Welk sociaal medium; een van SocialeMedia.Namen, of "Anders". + public string? Platform { get; set; } + /// Alleen gevuld als Platform "Anders" is. + public string? PlatformAnders { get; set; } + + public string? Insurer { get; set; } + public string? PolicyNumber { get; set; } + public string? ContactPhone { get; set; } + public decimal? Amount { get; set; } + public string? BurialOrCremation { get; set; } + public string? Location { get; set; } + public string? Music { get; set; } + public string? Speakers { get; set; } + public string? Wishes { get; set; } +} + +/// Een bijlage bij een kluisitem, zonder de inhoud zelf. +public record Bijlage(long Id, string FileName, string ContentType, long SizeBytes, DateTime CreatedAt, + bool IsProfilePhoto = false) +{ + /// De grootte zoals je hem aan iemand laat zien: "1,4 MB". + public string Grootte => SizeBytes >= 1024 * 1024 + ? $"{SizeBytes / 1024d / 1024d:0.#} MB" + : $"{Math.Max(1, SizeBytes / 1024)} kB"; + + public bool IsAfbeelding => ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + public bool IsVideo => ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase); +} + +/// Een opgehaald bestand, met de inhoud erbij. +public record BestandInhoud(string Naam, string Soort, byte[] Inhoud); + +public record Bericht( + long Id, + string Subject, + int Kind, + long FromPersonId, + long ToPersonId, + DateTime? DeliveredAt, + DateTime? ReadAt, + DateTime? DeliverAt, + bool DeliverAfterDeath, + int Status, + long? VaultFileId) +{ + public bool IsNieuw => Status == 2; // 2 = bezorgd, 3 = gelezen + + /// 0 tekst, 1 video, 2 audio, 3 document. + public bool IsVideo => Kind == 1; + + /// Zit er een opname of bijlage bij dit bericht? + public bool HeeftBestand => VaultFileId is > 0; +} + +/// De uitkomst van een proefmelding: is hij verstuurd, en zo niet waarom niet. +public record Proefmelding(bool Gelukt, string Uitleg); + +/// Iemand die jouw nalatenschap mag beheren. Volgorde 1 is de eerste keus. +/// +/// De advertentie die nu op het overzicht mag staan. De adressen zijn volledig: de app weet niet +/// waar de site staat en hoort dat ook niet zelf in elkaar te zetten. +/// +/// +/// Gaat naar de site en niet rechtstreeks naar de adverteerder. Daar wordt de klik geteld en +/// vandaar wordt doorgestuurd; zo is er één plek waar dat gebeurt. +/// +public record Advertentie(long Id, string Tekst, string? Afbeelding, string Link); + +public record Beheerder(long PersonId, string DisplayName, int Priority, DateTime? AccessGrantedAt, + string? Profielfoto = null) +{ + public bool HeeftToegang => AccessGrantedAt is not null; + + /// De kleine profielfoto als verwijzing die een img-element rechtstreeks aankan. + public string? Profielfotobron => + string.IsNullOrEmpty(Profielfoto) ? null : $"data:image/jpeg;base64,{Profielfoto}"; +} + +public record NalatenschapStand( + int State, + int ActiveDeclarations, + int RequiredDeclarations, + DateTime? ObjectionDeadline, + int Missing, + int ObjectionDays = 14, + bool BeheerderHeeftGemeld = false, + bool WachtOpBeheerder = false) +{ + public bool LooptEenProcedure => State == 1; + public bool IsBevestigd => State == 3; +} diff --git a/src/MijnBesluit.Client/Tokenopslag.cs b/src/MijnBesluit.Client/Tokenopslag.cs new file mode 100644 index 0000000..86915fb --- /dev/null +++ b/src/MijnBesluit.Client/Tokenopslag.cs @@ -0,0 +1,21 @@ +namespace MijnBesluit.Client; + +/// +/// Waar het sessietoken tussen twee keer starten wordt bewaard. In de app is dat de beveiligde +/// opslag van het toestel; in een test gewoon het geheugen. +/// +public interface ITokenopslag +{ + Task LeesAsync(); + Task SchrijfAsync(string token); + Task WisAsync(); +} + +/// Bewaart niets. Handig om mee te beginnen en in tests. +public sealed class GeheugenTokenopslag : ITokenopslag +{ + private string? _token; + public Task LeesAsync() => Task.FromResult(_token); + public Task SchrijfAsync(string token) { _token = token; return Task.CompletedTask; } + public Task WisAsync() { _token = null; return Task.CompletedTask; } +} diff --git a/src/MijnBesluit.Client/Uitkomst.cs b/src/MijnBesluit.Client/Uitkomst.cs new file mode 100644 index 0000000..ae611cf --- /dev/null +++ b/src/MijnBesluit.Client/Uitkomst.cs @@ -0,0 +1,31 @@ +namespace MijnBesluit.Client; + +/// +/// Wat een aanroep opleverde. Bewust geen uitzonderingen: een netwerkstoring of een afgewezen +/// code is bij een app geen uitzonderlijke situatie maar een normaal antwoord, en de melding +/// kan zo rechtstreeks op het scherm. +/// +public readonly struct Uitkomst +{ + public bool Gelukt { get; } + public string? Melding { get; } + + private Uitkomst(bool gelukt, string? melding) { Gelukt = gelukt; Melding = melding; } + + public static Uitkomst Ok() => new(true, null); + public static Uitkomst Fout(string melding) => new(false, melding); +} + +/// Zelfde, met een waarde erbij. +public readonly struct Uitkomst +{ + public bool Gelukt { get; } + public string? Melding { get; } + public T? Waarde { get; } + + private Uitkomst(bool gelukt, T? waarde, string? melding) + { Gelukt = gelukt; Waarde = waarde; Melding = melding; } + + public static Uitkomst Ok(T waarde) => new(true, waarde, null); + public static Uitkomst Fout(string melding) => new(false, default, melding); +} diff --git a/src/MijnBesluit.Domain/Entities/Adverteren.cs b/src/MijnBesluit.Domain/Entities/Adverteren.cs new file mode 100644 index 0000000..9576178 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Adverteren.cs @@ -0,0 +1,137 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// +/// Een bedrijf dat adverteert. +/// +/// Bewust een eigen soort en geen : een adverteerder is een bedrijf, geen +/// persoon met een kluis, familie of nalatenschap. Hem bij de gebruikers zetten zou betekenen +/// dat elke vraag over gebruikers hem moet uitzonderen, en één vergeten uitzondering is er één +/// te veel in een applicatie die over nalatenschappen gaat. +/// +/// Inloggen gaat wel op dezelfde manier: een code van zes cijfers per e-mail, geen wachtwoord. +/// +public class Adverteerder +{ + public long Id { get; set; } + + public string Bedrijfsnaam { get; set; } = string.Empty; + public string Contactpersoon { get; set; } = string.Empty; + + /// Waarmee hij inlogt, en waar de bevestigingen heen gaan. + public string Email { get; set; } = string.Empty; + + public string? Telefoon { get; set; } + public string? Adres { get; set; } + public string? Postcode { get; set; } + public string? Plaats { get; set; } + public string? Land { get; set; } + + /// KvK-nummer. + public string? Kvk { get; set; } + + /// Btw-nummer. + public string? Btw { get; set; } + + public DateTime AangemaaktOp { get; set; } + public DateTime? LaatstGezienOp { get; set; } + + /// + /// Geblokkeerd: hij kan geen nieuwe campagnes meer indienen. Campagnes die al draaien + /// blijven staan - die zijn betaald, en stopzetten is een aparte beslissing per campagne. + /// + public DateTime? GeblokkeerdOp { get; set; } + + public bool IsGeblokkeerd => GeblokkeerdOp is not null; +} + +/// +/// Eén geboekte advertentie: wat er staat, wanneer hij mag draaien, hoe vaak per uur, wat hij +/// kost en hoe ver hij is. +/// +/// Zie docs/Adverteren.md voor de keuzeregels en het betaalpad. +/// +public class Campagne +{ + public long Id { get; set; } + public long AdverteerderId { get; set; } + public Adverteerder? Adverteerder { get; set; } + + public string Tekst { get; set; } = string.Empty; + + /// Waar de tegel heen gaat als iemand erop tikt. + public string Link { get; set; } = string.Empty; + + /// + /// De afbeelding, verkleind bij het opslaan. Staat hier en niet bij de bestanden van + /// gebruikers: die zijn versleuteld met sleutels per gebruiker, en een advertentie is juist + /// openbaar. + /// + public byte[]? Afbeelding { get; set; } + public string? AfbeeldingSoort { get; set; } + + public DateOnly VanafDatum { get; set; } + public DateOnly TotEnMetDatum { get; set; } + + /// Hoe vaak hij per klokuur hoogstens wordt getoond. Een maximum, geen belofte. + public int PerUur { get; set; } + + /// Wat het kost, in centen. Bij het boeken berekend en dan vastgelegd. + public int BedragCenten { get; set; } + + /// + /// Het tarief waarmee gerekend is. Verandert het tarief later, dan blijft hier staan wat er + /// destijds is afgesproken - anders valt een oude factuur niet meer na te rekenen. + /// + public int TariefCenten { get; set; } + + /// Wat de adverteerder bij zijn betaling zet, zodat de beheerder hem terugvindt. + public string? Betaalkenmerk { get; set; } + + public DateTime? BetaaldOp { get; set; } + public long? BetaaldAfgevinktDoorUserId { get; set; } + + public CampagneStand Stand { get; set; } = CampagneStand.Concept; + + public DateTime? GoedgekeurdOp { get; set; } + public long? GoedgekeurdDoorUserId { get; set; } + + /// Waarom hij is afgewezen. Verplicht bij afwijzen: de adverteerder hoort te weten waarom. + public string? AfwijzingReden { get; set; } + + public DateTime AangemaaktOp { get; set; } + public DateTime? GewijzigdOp { get; set; } + + public bool IsBetaald => BetaaldOp is not null; + public bool HeeftAfbeelding => Afbeelding is { Length: > 0 }; + + /// Hoeveel dagen hij loopt; begin- en einddag tellen allebei mee. + public int Dagen => TotEnMetDatum.DayNumber - VanafDatum.DayNumber + 1; + + /// Mag hij op deze dag draaien? Alleen goedgekeurd én binnen de periode. + public bool DraaitOp(DateOnly dag) => + Stand == CampagneStand.Loopt && dag >= VanafDatum && dag <= TotEnMetDatum; +} + +/// +/// De tellers van één campagne in één klokuur. +/// +/// Eén regel per uur en niet per vertoning: bij een paar honderd bezoekers per uur loopt zo'n +/// tabel eindeloos vol, en voor het kiezen én voor de verantwoording is een teller genoeg. +/// +public class CampagneUur +{ + public long Id { get; set; } + public long CampagneId { get; set; } + + /// Het hele uur in UTC waar deze tellers bij horen. + public DateTime UurUtc { get; set; } + + public int Vertoningen { get; set; } + public int Kliks { get; set; } + + /// Het hele uur waar dit moment in valt. Eén plek, zodat de afronding overal gelijk is. + public static DateTime Uur(DateTime moment) => + new(moment.Year, moment.Month, moment.Day, moment.Hour, 0, 0, DateTimeKind.Utc); +} diff --git a/src/MijnBesluit.Domain/Entities/AuditLogEntry.cs b/src/MijnBesluit.Domain/Entities/AuditLogEntry.cs new file mode 100644 index 0000000..7e12a77 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/AuditLogEntry.cs @@ -0,0 +1,32 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// Onveranderlijk logboek: wie deed wat, wanneer en waarvandaan. +public class AuditLogEntry +{ + public long Id { get; set; } + public long? UserId { get; set; } + public AuditAction Action { get; set; } + public string? EntityType { get; set; } + public long? EntityId { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public string? Details { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +/// Vernieuwtoken voor een ingelogde sessie (web of app). +public class RefreshToken +{ + public long Id { get; set; } + public long UserId { get; set; } + public byte[] TokenHash { get; set; } = Array.Empty(); + public string? Device { get; set; } + public string? CreatedByIp { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public DateTime? RevokedAt { get; set; } + + public bool IsActive(DateTime nowUtc) => RevokedAt is null && ExpiresAt > nowUtc; +} diff --git a/src/MijnBesluit.Domain/Entities/Family.cs b/src/MijnBesluit.Domain/Entities/Family.cs new file mode 100644 index 0000000..57db92c --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Family.cs @@ -0,0 +1,40 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// Een familieproject. Iedere deelnemer kan zijn eigen familie starten. +public class Family +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + + /// De persoon om wie deze familie draait; de eigenaar van het project. + public long OwnerPersonId { get; set; } + public Person? OwnerPerson { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Members { get; set; } = new List(); +} + +/// Koppeling van een persoon aan een familie, met de relatie erbij. +public class FamilyMember +{ + public long Id { get; set; } + + public long FamilyId { get; set; } + public Family? Family { get; set; } + + public long PersonId { get; set; } + public Person? Person { get; set; } + + public RelationType Relation { get; set; } = RelationType.Onbekend; + + /// Vrije omschrijving als niet volstaat. + public string? RelationLabel { get; set; } + + public long? AddedByUserId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/MijnBesluit.Domain/Entities/Invitation.cs b/src/MijnBesluit.Domain/Entities/Invitation.cs new file mode 100644 index 0000000..44d5c69 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Invitation.cs @@ -0,0 +1,32 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// Een e-mailuitnodiging om een aangemaakt profiel te komen claimen. +public class Invitation +{ + public long Id { get; set; } + + public long PersonId { get; set; } + public Person? Person { get; set; } + + public long FamilyId { get; set; } + public Family? Family { get; set; } + + public string Email { get; set; } = string.Empty; + + /// SHA-256 van het uitnodigingstoken. Het token zelf staat alleen in de e-mail. + public byte[] TokenHash { get; set; } = Array.Empty(); + + public InvitationStatus Status { get; set; } = InvitationStatus.Concept; + + public long CreatedByUserId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? SentAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? AcceptedAt { get; set; } + public int ResendCount { get; set; } + + public bool IsOpen(DateTime nowUtc) => + (Status == InvitationStatus.Verzonden || Status == InvitationStatus.Concept) && ExpiresAt > nowUtc; +} diff --git a/src/MijnBesluit.Domain/Entities/Legacy.cs b/src/MijnBesluit.Domain/Entities/Legacy.cs new file mode 100644 index 0000000..3e87df6 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Legacy.cs @@ -0,0 +1,44 @@ +namespace MijnBesluit.Domain.Entities; + +/// Een melding dat iemand is overleden. Er zijn er meerdere nodig voordat dit bevestigd wordt. +public class DeathDeclaration +{ + public long Id { get; set; } + + /// De persoon die volgens de melder is overleden. + public long PersonId { get; set; } + public Person? Person { get; set; } + + /// De gebruiker die de melding doet. Eén melding per gebruiker per profiel. + public long DeclaredByUserId { get; set; } + + public DateOnly? DateOfDeath { get; set; } + public string? Note { get; set; } + + public DateTime DeclaredAt { get; set; } = DateTime.UtcNow; + public DateTime? RevokedAt { get; set; } + + public bool IsActive => RevokedAt is null; +} + +/// Wie de nalatenschap mag beheren zodra het overlijden bevestigd is. +public class LegacyManager +{ + public long Id { get; set; } + + /// Het profiel waarvan de nalatenschap beheerd wordt. + public long PersonId { get; set; } + public Person? Person { get; set; } + + /// De aangewezen beheerder. + public long ManagerPersonId { get; set; } + public Person? ManagerPerson { get; set; } + + /// 1 = eerste keus. Bij gelijke rechten mogen meerdere beheerders naast elkaar bestaan. + public int Priority { get; set; } = 1; + + public DateTime? AcceptedAt { get; set; } + public DateTime? AccessGrantedAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/MijnBesluit.Domain/Entities/LoginCode.cs b/src/MijnBesluit.Domain/Entities/LoginCode.cs new file mode 100644 index 0000000..5a94b6f --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/LoginCode.cs @@ -0,0 +1,53 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// +/// Een eenmalige code die per e-mail is verstuurd om in te loggen of een account aan te maken. +/// +/// De code zelf staat hier niet in: alleen een hash ervan. Wie in de database kan kijken, kan +/// dus niet met andermans code inloggen. De code bestaat maar op twee plekken: in de e-mail en +/// in het hoofd van degene die hem invult. +/// +public class LoginCode +{ + public long Id { get; set; } + + /// Het e-mailadres waar de code naartoe is gestuurd, genormaliseerd opgeslagen. + public string Email { get; set; } = string.Empty; + + public LoginCodePurpose Purpose { get; set; } + + /// Hash van de code, inclusief algoritme en salt. + public string CodeHash { get; set; } = string.Empty; + + /// Alleen gevuld bij : de naam die is ingevuld. + public string? FirstName { get; set; } + public string? LastName { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + + /// Gevuld zodra de code is gebruikt of ongeldig gemaakt. Een code werkt maar één keer. + public DateTime? ConsumedAt { get; set; } + + /// + /// Gevuld als er met deze code ook echt iemand is binnengekomen. Dat is iets anders dan + /// : die staat ook gevuld als een nieuwe aanvraag deze code ongeldig + /// maakte, of als hij te vaak fout is ingevuld. + /// + /// Het verschil telt bij de rem op het aantal aanvragen per uur. Die rem is er tegen iemand + /// die andermans postvak volgooit; wie de code invult zit zelf in dat postvak. Alleen codes + /// met dit veld gevuld tellen dus niet mee - en juist die twee andere gevallen wél, want + /// anders kan een vreemde de teller leegtrekken door codes aan te vragen of expres fout in + /// te vullen. + /// + public DateTime? SignedInAt { get; set; } + + /// Aantal mislukte pogingen. Boven de grens wordt de code weggegooid. + public int Attempts { get; set; } + + public string? RequestedFromIp { get; set; } + + public bool IsOpen(DateTime nowUtc) => ConsumedAt is null && ExpiresAt > nowUtc; +} diff --git a/src/MijnBesluit.Domain/Entities/Message.cs b/src/MijnBesluit.Domain/Entities/Message.cs new file mode 100644 index 0000000..6ebba67 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Message.cs @@ -0,0 +1,90 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// +/// Een bericht ("moment") van de ene persoon aan de andere: nu, op een datum in de toekomst, +/// of pas nadat het overlijden van de afzender is bevestigd. +/// +public class Message +{ + public long Id { get; set; } + + public long FromPersonId { get; set; } + public Person? FromPerson { get; set; } + + public long ToPersonId { get; set; } + public Person? ToPerson { get; set; } + + public MessageKind Kind { get; set; } = MessageKind.Tekst; + + /// Leesbaar onderwerp. De inhoud zelf is versleuteld. + public string Subject { get; set; } = string.Empty; + + public byte[] Ciphertext { get; set; } = Array.Empty(); + public byte[] Nonce { get; set; } = Array.Empty(); + public byte[] Tag { get; set; } = Array.Empty(); + + /// Contentsleutel versleuteld met de datasleutel van de afzender. + public byte[] WrappedContentKey { get; set; } = Array.Empty(); + + public long? VaultFileId { get; set; } + public VaultFile? VaultFile { get; set; } + + /// Bezorgen op of na dit moment. Null betekent direct. + public DateTime? DeliverAt { get; set; } + + /// Pas bezorgen nadat het overlijden van de afzender bevestigd is. + public bool DeliverAfterDeath { get; set; } + + public MessageStatus Status { get; set; } = MessageStatus.Concept; + public DateTime? DeliveredAt { get; set; } + public DateTime? ReadAt { get; set; } + + /// + /// Heeft de ontvanger geen eigen account, dan komt hier een tijdelijke link voor in de + /// plaats: een e-mail met een link naar een landingspagina waar hij het bericht - inclusief + /// foto's, video's en documenten - kan bekijken zonder in te loggen. + /// + /// Alleen de hash van het token staat hier; het token zelf staat maar één keer ergens, in + /// de e-mail. Dezelfde opzet als bij . + /// + public byte[]? ViewTokenHash { get; set; } + + /// Na deze datum werkt de link niet meer. 30 dagen na het versturen. + public DateTime? ViewTokenExpiresAt { get; set; } + + /// Wanneer de linkmail voor het eerst is verstuurd. + public DateTime? ViewTokenSentAt { get; set; } + + /// Wanneer de ontvanger de landingspagina daadwerkelijk heeft geopend. + public DateTime? ViewedAt { get; set; } + + /// + /// Wanneer de herinnering is verstuurd, voor het geval de link 30 dagen lang niet is + /// geopend. Gevuld betekent: er komt geen tweede herinnering meer. + /// + public DateTime? ViewReminderSentAt { get; set; } + + /// + /// De contentsleutel van dit bericht, nog een keer ingepakt - nu niet met de datasleutel + /// van een account, maar met een sleutel die uit het token zelf is afgeleid. Zo kan de + /// landingspagina het bericht ontsleutelen zonder dat er ooit een account bij komt kijken. + /// + public byte[]? WrappedContentKeyForToken { get; set; } + + /// Dezelfde inpakslag, maar dan voor de bijlage bij dit bericht (zie ). + public byte[]? WrappedVaultKeyForToken { get; set; } + + /// + /// Voor mij uit het zicht, niet voor de ander: iemand kan een bezorgd bericht uit zijn eigen + /// lijst opruimen zonder dat het bij de ander verdwijnt - het is tenslotte ook hun bewaarde + /// bericht. Pas als beide kanten hem hebben weggehaald, telt niemand hem nog mee (zie + /// MessageRepository.ListInboxAsync/ListOutboxAsync); de rij zelf blijft gewoon staan. + /// + public bool HiddenBySender { get; set; } + public bool HiddenByRecipient { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/MijnBesluit.Domain/Entities/Person.cs b/src/MijnBesluit.Domain/Entities/Person.cs new file mode 100644 index 0000000..e5c63ee --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Person.cs @@ -0,0 +1,91 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// +/// Een profiel. Wordt aangemaakt door iemand anders (de beheerder) en gaat over naar de +/// persoon zelf zodra die de uitnodiging accepteert én zelf familie invult: dan is het profiel "geclaimd". +/// +public class Person +{ + public long Id { get; set; } + + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string? Nickname { get; set; } + public string? Email { get; set; } + public string? MobileNumber { get; set; } + public DateOnly? BirthDate { get; set; } + public DateOnly? DateOfDeath { get; set; } + + /// Wie dit profiel heeft aangemaakt. Verandert nooit; puur historie. + public long? CreatedByUserId { get; set; } + + /// Wie het profiel nu beheert zolang het niet geclaimd is. Null bij een zelf aangemaakt account. + public long? ManagedByUserId { get; set; } + + /// Zodra dit gevuld is, is het profiel van de persoon zelf en kan de uitnodiger niets meer wijzigen. + public long? ClaimedByUserId { get; set; } + public DateTime? ClaimedAt { get; set; } + + /// Aantal onafhankelijke meldingen dat nodig is om dit profiel als overleden te bevestigen. Instelbaar per profiel. + public int RequiredDeathDeclarations { get; set; } = 3; + + /// Aantal dagen dat de persoon zelf de tijd krijgt om een overlijdensmelding tegen te spreken. + public int DeathObjectionDays { get; set; } = 14; + + public DateTime? DeathConfirmedAt { get; set; } + public DateTime? DeathObjectedAt { get; set; } + + /// + /// Het profiel waarin dit profiel is opgegaan. Leeg zolang het gewoon in gebruik is. + /// + /// Twee mensen kunnen allebei een profiel aanmaken voor dezelfde persoon. Zodra die persoon + /// beide uitnodigingen aanneemt, zegt hij zelf dat het om hem gaat en gaat het ene profiel + /// op in het andere. Het duplicaat blijft staan - er hangt geschiedenis aan - maar hoort + /// nergens meer in een lijst te verschijnen. + /// + public long? MergedIntoPersonId { get; set; } + + /// + /// Het kenmerk waarmee je zonder inloggen bezwaar kunt maken tegen een overlijdensmelding, + /// versleuteld opgeslagen. De link zelf staat alleen in de e-mail. + /// + /// Dit moet zonder inloggen kunnen, want zodra een overlijden is bevestigd staat het account + /// op en kun je er niet meer in. Wie dan ten + /// onrechte dood is verklaard, zou zonder deze link vastzitten. + /// + public byte[]? ObjectionTokenHash { get; set; } + public DateTime? ObjectionTokenExpiresAt { get; set; } + + /// + /// Wie heeft gezegd dat deze aantekening níét over hem gaat, en wanneer. + /// + /// Waar dit voor is: maakt iemand een account aan op een adres waarover een ander al een + /// aantekening heeft staan, dan vragen we hem of hij dat is. Zegt hij nee, dan hoort die + /// vraag niet bij elke volgende keer opnieuw te komen. Degene die de aantekening maakte + /// merkt hier niets van - die weet niet eens dat er iets gevraagd is. + /// + /// Alleen "nee" wordt bewaard. "Ja" is geen vlaggetje maar een samenvoeging: dan bestaat + /// deze rij als aparte aantekening niet meer. Zie . + /// + public long? LinkDeclinedByUserId { get; set; } + public DateTime? LinkDeclinedAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public bool IsClaimed => ClaimedByUserId.HasValue; + + /// Is dit profiel opgegaan in een ander? Dan hoort het nergens meer te worden getoond. + public bool IsSamengevoegd => MergedIntoPersonId.HasValue; + + public string DisplayName => + !string.IsNullOrWhiteSpace(Nickname) ? Nickname! + : string.Join(" ", new[] { FirstName, LastName }.Where(s => !string.IsNullOrWhiteSpace(s))); + + public ProfileState State(bool hasOpenInvitation) => + IsClaimed ? ProfileState.Geclaimd + : hasOpenInvitation ? ProfileState.Uitgenodigd + : ProfileState.Beheerd; +} diff --git a/src/MijnBesluit.Domain/Entities/Samenvoeging.cs b/src/MijnBesluit.Domain/Entities/Samenvoeging.cs new file mode 100644 index 0000000..fa0eace --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Samenvoeging.cs @@ -0,0 +1,44 @@ +namespace MijnBesluit.Domain.Entities; + +/// +/// Het bonnetje van een samenvoeging: wat er precies is verhuisd toen twee profielen er één +/// werden. +/// +/// Zonder dit is terugdraaien onmogelijk. Na afloop is namelijk niet meer te zien wélke +/// lidmaatschappen, berichten en bestanden bij het duplicaat hoorden — ze staan dan gewoon bij +/// het doel, tussen alles wat daar al stond. Een gok daarop zou andermans gegevens meesleuren. +/// +/// Daarom schrijft SamenvoegService elke handeling hier op, en leest hij ze bij het +/// terugdraaien in omgekeerde volgorde terug. Regels die zijn weggegooid — een dubbel +/// lidmaatschap, een dubbele beheerder — staan er als volledige kopie in, want die moeten +/// opnieuw worden aangemaakt. +/// +/// Het bonnetje blijft ook na het terugdraaien staan, met erbij. +/// Dat er ooit is samengevoegd is zelf ook geschiedenis. +/// +public class Samenvoeging +{ + public long Id { get; set; } + + /// Het profiel dat is opgegaan in een ander. + public long DuplicaatPersonId { get; set; } + + /// Het profiel waar alles naartoe is gegaan. + public long DoelPersonId { get; set; } + + /// Wie het deed. Bij een samenvoeging is dat de persoon zelf. + public long DoorUserId { get; set; } + + public DateTime SamengevoegdOp { get; set; } + + /// De stappen als JSON; zie SamenvoegService.Samenvoegstap. + public string Stappen { get; set; } = "[]"; + + public DateTime? TeruggedraaidOp { get; set; } + public long? TeruggedraaidDoorUserId { get; set; } + + /// Waarom het is teruggedraaid. Verplicht bij het terugdraaien. + public string? Reden { get; set; } + + public bool IsTeruggedraaid => TeruggedraaidOp is not null; +} diff --git a/src/MijnBesluit.Domain/Entities/Sessie.cs b/src/MijnBesluit.Domain/Entities/Sessie.cs new file mode 100644 index 0000000..cdbd2ab --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Sessie.cs @@ -0,0 +1,40 @@ +namespace MijnBesluit.Domain.Entities; + +/// +/// Een aanmelding van de app die een herstart van de SocketServer overleeft. +/// +/// Waarom dit in de database staat en niet alleen in het geheugen: de app blijft aangemeld en +/// komt er daarna in met de vingerafdruk of de code van de telefoon. Stonden de sessies alleen +/// in het geheugen, dan was elke herstart van de service - een uitrol, een Windows-update 's +/// nachts - genoeg om iedereen weer een e-mailcode te laten opvragen. Het toestel weet dan nog +/// wie je bent, de server niet meer. +/// +/// Het token zelf staat er NIET in, alleen de SHA-256 ervan. Wie de database in handen krijgt, +/// krijgt daarmee dus geen werkende sessies mee. Dezelfde afspraak als bij de inlogcodes. +/// +public class Sessie +{ + public long Id { get; set; } + + /// De SHA-256 van het sessietoken. Het token zelf kent alleen het toestel. + public byte[] TokenHash { get; set; } = Array.Empty(); + + public long UserId { get; set; } + public User? User { get; set; } + + /// Welke persoon dit is; de app heeft dat meteen nodig om zichzelf te herkennen. + public long PersonId { get; set; } + + /// Zoals het toestel zich meldde. Alleen om te tonen en om te kunnen nagaan. + public string? Toestel { get; set; } + + public DateTime AangemaaktOp { get; set; } + + /// + /// Wanneer de sessie vervalt. Schuift op bij gebruik, maar niet bij elk verzoek: dat zou een + /// schrijfactie per aanroep betekenen. Zie SessionStore. + /// + public DateTime VerlooptOp { get; set; } + + public DateTime LaatstGezien { get; set; } +} diff --git a/src/MijnBesluit.Domain/Entities/Toestel.cs b/src/MijnBesluit.Domain/Entities/Toestel.cs new file mode 100644 index 0000000..a03e132 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/Toestel.cs @@ -0,0 +1,40 @@ +namespace MijnBesluit.Domain.Entities; + +/// +/// Een toestel waarop iemand MijnBesluit heeft staan, met het kenmerk waarmee Google of Apple +/// er een melding heen kan sturen. +/// +/// Eén persoon kan meerdere toestellen hebben (telefoon, tablet), en een toestel kan van +/// gebruiker wisselen. Het kenmerk is daarom uniek, niet de combinatie met de persoon: meldt +/// hetzelfde kenmerk zich onder een andere gebruiker, dan hoort het daar voortaan bij en niet +/// meer bij de vorige. Anders krijgt iemand de meldingen van de vorige eigenaar te zien. +/// +public class Toestel +{ + public long Id { get; set; } + + public long UserId { get; set; } + public User? User { get; set; } + + /// Het kenmerk van Firebase (Android) of APNs (iOS). + public string Kenmerk { get; set; } = string.Empty; + + /// "android" of "ios". + public string Platform { get; set; } = "android"; + + /// Zoals de gebruiker het toestel kent: "Galaxy S26". Alleen om te tonen. + public string? Naam { get; set; } + + public DateTime AangemeldOp { get; set; } = DateTime.UtcNow; + + /// Laatste keer dat de app zich met dit kenmerk meldde. + public DateTime LaatstGezien { get; set; } = DateTime.UtcNow; + + /// + /// Gevuld zodra het kenmerk niet meer werkt. Google zegt dat bij het versturen; blijven + /// proberen heeft dan geen zin en levert alleen maar fouten op. + /// + public DateTime? AfgemeldOp { get; set; } + + public bool IsActief => AfgemeldOp is null; +} diff --git a/src/MijnBesluit.Domain/Entities/User.cs b/src/MijnBesluit.Domain/Entities/User.cs new file mode 100644 index 0000000..e667ecb --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/User.cs @@ -0,0 +1,58 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// +/// Een inlogaccount. Een account hoort altijd bij precies één . +/// Profielen zonder account bestaan wel (die zijn nog niet geclaimd). +/// +public class User +{ + public long Id { get; set; } + + /// Altijd genormaliseerd opgeslagen (lowercase, getrimd). + public string Email { get; set; } = string.Empty; + + /// + /// Verandert zodra iemand overal uitgelogd moet worden; maakt bestaande cookies en + /// sessies ongeldig. + /// + public Guid SecurityStamp { get; set; } = Guid.NewGuid(); + + /// De datasleutel (DEK) van deze gebruiker, versleuteld met de mastersleutel van de server. + public byte[] WrappedDataKey { get; set; } = Array.Empty(); + + public long PersonId { get; set; } + public Person? Person { get; set; } + + public UserStatus Status { get; set; } = UserStatus.Actief; + + /// + /// Mag deze gebruiker de beheerpagina gebruiken? Dat is één ding: een overlijden dat ten + /// onrechte is bevestigd terugdraaien, als de bezwaarlink verlopen is. + /// + /// Een beheerder kan géén kluis openen. Zijn enige handeling neemt toegang wég en geeft hem + /// nooit. Beheerder word je alleen met de hand in de database; er is expres geen scherm voor. + /// + public bool IsBeheerder { get; set; } + public DateTime? EmailConfirmedAt { get; set; } + public bool TwoFactorEnabled { get; set; } + + /// Aantal mislukte inlogpogingen achter elkaar. + public int FailedLoginCount { get; set; } + public DateTime? LockedUntil { get; set; } + public DateTime? LastLoginAt { get; set; } + + /// + /// Hoeveel dagen vooruit je op het overzicht ziet wat er aankomt. 0 betekent: niets tonen. + /// Maximaal 60; verder vooruit is het geen vooruitblik meer maar een agenda. + /// + public int VooruitblikDagen { get; set; } = 30; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// Kan deze gebruiker op dit moment inloggen? + public bool CanSignIn(DateTime nowUtc) => + Status == UserStatus.Actief && (LockedUntil is null || LockedUntil <= nowUtc); +} diff --git a/src/MijnBesluit.Domain/Entities/VaultItem.cs b/src/MijnBesluit.Domain/Entities/VaultItem.cs new file mode 100644 index 0000000..bb6a117 --- /dev/null +++ b/src/MijnBesluit.Domain/Entities/VaultItem.cs @@ -0,0 +1,143 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Entities; + +/// +/// Eén item in de digitale kluis. De inhoud staat als versleutelde JSON in ; +/// alleen en zijn leesbaar, zodat er gezocht en gesorteerd kan worden. +/// +public class VaultItem +{ + public long Id { get; set; } + + /// Van wie dit item is. Niet "wie het invoerde" maar "over wie het gaat". + public long OwnerPersonId { get; set; } + public Person? OwnerPerson { get; set; } + + public VaultCategory Category { get; set; } + + /// Leesbare titel, bijvoorbeeld "Uitvaartverzekering" of "Inloggegevens bank". + public string Title { get; set; } = string.Empty; + + /// Versleutelde JSON-inhoud (AES-256-GCM). + public byte[] Ciphertext { get; set; } = Array.Empty(); + public byte[] Nonce { get; set; } = Array.Empty(); + public byte[] Tag { get; set; } = Array.Empty(); + + /// De contentsleutel van dit item, versleuteld met de datasleutel van de eigenaar. + public byte[] WrappedContentKey { get; set; } = Array.Empty(); + + public ReleasePolicy ReleasePolicy { get; set; } = ReleasePolicy.AlleenIkzelf; + + /// Gevuld als gelijk is aan . + public DateTime? ReleaseAt { get; set; } + + public long? VaultFileId { get; set; } + public VaultFile? VaultFile { get; set; } + + /// + /// Bij een Uitvaartwens: de gekozen nalatenschapsbeheerder (Person.Id) met wie dit item + /// direct te bespreken is, nog vóór een overlijden. Null = niemand gekozen, dan ziet + /// niemand anders dit item (behalve via de gewone nalatenschap-vrijgave na overlijden). + /// + public long? SharedManagerPersonId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime? DeletedAt { get; set; } +} + +/// +/// Een bijlage (document, foto, video). De bytes staan versleuteld buiten de database, +/// de database bewaart alleen de verwijzing en de sleutelinformatie. +/// +public class VaultFile +{ + public long Id { get; set; } + public long OwnerPersonId { get; set; } + + /// + /// Het kluisitem waar dit bestand bij hoort. Leeg bij een bestand dat op zichzelf staat, + /// zoals de opname bij een videobericht. + /// + public long? VaultItemId { get; set; } + + /// + /// De persoon waar deze foto bij hoort. Let op het verschil met : + /// dat is wie het bestand bewaart, dit is wie erop staat. + /// + public long? SubjectPersonId { get; set; } + + /// Het bericht waar dit bestand bij hoort. + public long? MessageId { get; set; } + + /// + /// De foto die bij de persoon op de kaart komt te staan. Hoogstens één per persoon; de + /// applicatie bewaakt dat, de database niet. + /// + public bool IsProfilePhoto { get; set; } + + public string FileName { get; set; } = string.Empty; + public string ContentType { get; set; } = "application/octet-stream"; + public long SizeBytes { get; set; } + + /// Pad of objectsleutel in de bestandsopslag, bijvoorbeeld "2026/09/6f0b...bin". + public string StorageKey { get; set; } = string.Empty; + + public byte[] Nonce { get; set; } = Array.Empty(); + public byte[] Tag { get; set; } = Array.Empty(); + public byte[] WrappedContentKey { get; set; } = Array.Empty(); + + /// SHA-256 van de onversleutelde inhoud, om beschadiging te kunnen vaststellen. + public byte[] Sha256 { get; set; } = Array.Empty(); + + /// + /// Een kleine versie van de foto, voor in lijstjes. Bewust NIET versleuteld. + /// + /// Dat is een afweging en geen vergissing. Een profielfoto is juist het ding dat je aan + /// andere mensen laat zien; hem versleutelen met de sleutel van de eigenaar zou betekenen + /// dat je vader je foto niet kan zien zodra jij hem zelf hebt gezet. De foto op ware grootte + /// blijft wél versleuteld, net als de rest van de kluis. + /// + /// Leeg betekent: er is er geen. Dat is geen fout - bij een document of een video valt er + /// niets te verkleinen. Een lege (niet-null) waarde betekent: geprobeerd, niet gelukt. + /// + /// en zijn er alleen nog voor de + /// kleine versies van vóór deze wijziging, die wél versleuteld zijn. Staat daar iets in, dan + /// is de kleine versie onbruikbaar en wordt hij bij het eerste gebruik opnieuw gemaakt. + /// + public byte[]? Thumbnail { get; set; } + public byte[]? ThumbnailNonce { get; set; } + public byte[]? ThumbnailTag { get; set; } + + /// Is er een kleine versie die we kunnen tonen? + public bool HeeftThumbnail => + Thumbnail is { Length: > 0 } && ThumbnailNonce is null && ThumbnailTag is null; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// Gevuld zodra het bestand is verwijderd; de inhoud is dan al weg uit de opslag. + public DateTime? DeletedAt { get; set; } +} + +/// +/// Toegang van een andere gebruiker tot een kluisitem of bestand: de contentsleutel nogmaals +/// versleuteld, nu met de datasleutel van de ontvanger. +/// +public class ContentGrant +{ + public long Id { get; set; } + + /// "VaultItem", "VaultFile" of "Message". + public string ResourceType { get; set; } = string.Empty; + public long ResourceId { get; set; } + + public long UserId { get; set; } + public byte[] WrappedContentKey { get; set; } = Array.Empty(); + + /// Waarom deze toegang bestaat: "ontvanger", "nalatenschap", "gedeeld". + public string Reason { get; set; } = string.Empty; + + public DateTime GrantedAt { get; set; } = DateTime.UtcNow; + public DateTime? RevokedAt { get; set; } +} diff --git a/src/MijnBesluit.Domain/Enums/Enums.cs b/src/MijnBesluit.Domain/Enums/Enums.cs new file mode 100644 index 0000000..eadb16b --- /dev/null +++ b/src/MijnBesluit.Domain/Enums/Enums.cs @@ -0,0 +1,103 @@ +namespace MijnBesluit.Domain.Enums; + +/// Status van een inlogaccount. +public enum UserStatus { Actief = 0, Geblokkeerd = 1, Overleden = 2 } + +/// Eigendomsstatus van een profiel. +public enum ProfileState { Beheerd = 0, Uitgenodigd = 1, Geclaimd = 2 } + +/// Relatie van een persoon binnen een familie. +public enum RelationType +{ + Onbekend = 0, Partner = 1, Kind = 2, BonusKind = 3, Ouder = 4, BonusOuder = 5, + Broer = 6, Zus = 7, Kleinkind = 8, Grootouder = 9, Vriend = 10, Overig = 99, + + // Later toegevoegd, daarom achteraan. Bestaande nummers blijven staan: die liggen vast in + // de database, en Kind blijft bruikbaar voor wie het geslacht niet wil vastleggen. + Zoon = 11, Dochter = 12, + + // September 2026: de lijst is uitgesplitst naar hoe mensen het zelf zeggen. Je zegt "mijn + // oma", niet "mijn grootouder", en "mijn schoondochter" bestond helemaal niet. + // + // Kind, Ouder, BonusOuder, Kleinkind en Grootouder staan sindsdien niet meer in de + // keuzelijst, maar blijven hier staan en houden hun naam: ze liggen als nummer in de + // database bij iedereen die ze eerder heeft gekozen. Een waarde uit een enum halen die in + // opgeslagen rijen staat is hetzelfde als die rijen weggooien. + Kleindochter = 13, Kleinzoon = 14, + Schoondochter = 15, Schoonzoon = 16, + Opa = 17, Oma = 18, + Vader = 19, Moeder = 20, + BonusMoeder = 21, BonusVader = 22, + Vriendin = 23, + Schoonvader = 24, Schoonmoeder = 25, + Zwager = 26, Schoonzus = 27, + BonusZoon = 28, BonusDochter = 29, + BonusBroer = 30, BonusZus = 31, + Achterkleindochter = 32, Achterkleinzoon = 33, + Oom = 34, Tante = 35 +} + +/// +/// Waarvoor een eenmalige code per e-mail is verstuurd. AdresWijzigen gaat naar het NIEUWE +/// adres: alleen wie daar bij kan, mag het inlogadres verzetten. +/// +public enum LoginCodePurpose { Inloggen = 0, Registreren = 1, AdresWijzigen = 2, Adverteerder = 3, Opheffen = 4 } + +public enum InvitationStatus { Concept = 0, Verzonden = 1, Geaccepteerd = 2, Geweigerd = 3, Verlopen = 4, Ingetrokken = 5 } + +/// Soorten items in de digitale kluis. +public enum VaultCategory +{ + Uitvaartwens = 1, Polis = 2, Wachtwoord = 3, Apparaat = 4, Pincode = 5, + Document = 6, Notitie = 7, SocialMedia = 8, Donorcodicil = 9, + Rijbewijs = 10, Paspoort = 11, Overig = 99 +} + +/// Wanneer een kluisitem vrijkomt voor anderen. +public enum ReleasePolicy { AlleenIkzelf = 0, NaOverlijden = 1, OpDatum = 2 } + +public enum MessageKind { Tekst = 0, Video = 1, Audio = 2, Document = 3 } + +public enum MessageStatus { Concept = 0, Gepland = 1, Bezorgd = 2, Gelezen = 3, Ingetrokken = 4 } + +/// Uitkomst van de overlijdensprocedure voor een profiel. +public enum DeathProcedureState { Geen = 0, Lopend = 1, Betwist = 2, Bevestigd = 3 } + +public enum AuditAction +{ + Inloggen = 1, InloggenMislukt = 2, Uitloggen = 3, InlogcodeAangevraagd = 4, ProfielGewijzigd = 10, + ProfielGeclaimd = 11, AdresGewijzigd = 12, AccountOpgeheven = 13, UitnodigingVerstuurd = 20, UitnodigingGeaccepteerd = 21, + KluisItemGelezen = 30, KluisItemGewijzigd = 31, KluisItemVerwijderd = 32, + BerichtGepland = 40, BerichtBezorgd = 41, BerichtGelezen = 42, + BerichtLinkVerstuurd = 43, BerichtLinkBekeken = 44, + OverlijdenGemeld = 50, OverlijdenBetwist = 51, OverlijdenBevestigd = 52, + NalatenschapToegangVerleend = 60, + AdvertentieIngediend = 70, AdvertentieBetaald = 71, AdvertentieGoedgekeurd = 72, + AdvertentieAfgewezen = 73, AdvertentieGestopt = 74 +} + +/// +/// Hoe ver een geboekte advertentie is. De volgorde is ook de weg die hij aflegt, op +/// en na: dat zijn +/// eindpunten. +/// +public enum CampagneStand +{ + /// Nog aan het samenstellen; alleen de adverteerder zelf ziet hem. + Concept = 0, + + /// Ingediend. Er is een bedrag en een betaalkenmerk; de beheerder wacht op het geld. + WachtOpBetaling = 1, + + /// Betaling gezien. Nu moet de beheerder nog naar de inhoud kijken. + WachtOpGoedkeuring = 2, + + /// Goedgekeurd. Draait binnen zijn periode. + Loopt = 3, + + /// Afgewezen door de beheerder, met een reden erbij. + Afgewezen = 4, + + /// Voortijdig stopgezet, door de adverteerder of door de beheerder. + Gestopt = 5 +} diff --git a/src/MijnBesluit.Domain/MijnBesluit.Domain.csproj b/src/MijnBesluit.Domain/MijnBesluit.Domain.csproj new file mode 100644 index 0000000..e8cd599 --- /dev/null +++ b/src/MijnBesluit.Domain/MijnBesluit.Domain.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/src/MijnBesluit.Domain/Rules/Datums.cs b/src/MijnBesluit.Domain/Rules/Datums.cs new file mode 100644 index 0000000..4912064 --- /dev/null +++ b/src/MijnBesluit.Domain/Rules/Datums.cs @@ -0,0 +1,81 @@ +namespace MijnBesluit.Domain.Rules; + +/// +/// Datums zoals ze op het scherm horen te staan, in het Nederlands. +/// +/// Waarom dit met de hand gaat en niet met ToString("d MMMM yyyy"): dat laatste volgt de +/// taalinstelling van de machine, en de hele oplossing draait met InvariantGlobalization +/// aan. Dat is goed voor de diensten - ze hoeven geen taalgegevens mee te dragen - maar het +/// betekent ook dat er maar één cultuur bestaat: de neutrale. En die is Engels. Op de website +/// stond dus "Thursday 17 September 2026" onder een Nederlandse zin. +/// +/// De app liep hier al tegenaan en loste het op dezelfde manier op. Een product dat maar in één +/// taal bestaat, hoort ook maar in één taal te rekenen - dan is de taalinstelling van de server +/// geen factor meer, en dat is er één minder die stil kan veranderen. +/// +/// Deze lijst staat ook in MijnBesluit.Client, omdat de app het domein niet ziet. Een controle +/// in de zelftest bewaakt dat de twee gelijk blijven. +/// +public static class Datums +{ + public static readonly string[] Maanden = + { + "januari", "februari", "maart", "april", "mei", "juni", + "juli", "augustus", "september", "oktober", "november", "december" + }; + + /// Afkortingen voor waar het krap is. "Mei" heeft geen punt: dat is geen afkorting. + public static readonly string[] KorteMaanden = + { + "Jan.", "Feb.", "Mrt.", "Apr.", "Mei", "Jun.", + "Jul.", "Aug.", "Sep.", "Okt.", "Nov.", "Dec." + }; + + /// Zondag eerst, want zo telt . + public static readonly string[] Dagen = + { + "zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag" + }; + + /// "17 september 2026". Rekent om naar de tijd hier. + public static string Datum(DateTime? moment) + { + if (moment is null) return ""; + var d = moment.Value.ToLocalTime(); + return $"{d.Day} {Maanden[d.Month - 1]} {d.Year}"; + } + + /// + /// Hetzelfde voor een kale datum zonder tijd - een geboortedag, een sterfdag. Die hoort niet + /// omgerekend te worden naar een tijdzone: 9 juni is 9 juni, waar je ook bent. + /// + public static string Datum(DateOnly? dag) => + dag is null ? "" : $"{dag.Value.Day} {Maanden[dag.Value.Month - 1]} {dag.Value.Year}"; + + /// "donderdag 17 september 2026", voor waar de dag zelf iets betekent. + public static string MetDag(DateTime? moment) + { + if (moment is null) return ""; + var d = moment.Value.ToLocalTime(); + return $"{Dagen[(int)d.DayOfWeek]} {d.Day} {Maanden[d.Month - 1]} {d.Year}"; + } + + /// "17 september 2026 om 13:45". + public static string DatumTijd(DateTime? moment) + { + if (moment is null) return ""; + var d = moment.Value.ToLocalTime(); + return $"{Datum(d)} om {d:HH\\:mm}"; + } + + /// "17 Sep. 2026", voor waar elke pixel telt. + public static string Kort(DateTime? moment) + { + if (moment is null) return ""; + var d = moment.Value.ToLocalTime(); + return $"{d.Day} {KorteMaanden[d.Month - 1]} {d.Year}"; + } + + public static string Kort(DateOnly? dag) => + dag is null ? "" : $"{dag.Value.Day} {KorteMaanden[dag.Value.Month - 1]} {dag.Value.Year}"; +} diff --git a/src/MijnBesluit.Domain/Rules/DeathProcedure.cs b/src/MijnBesluit.Domain/Rules/DeathProcedure.cs new file mode 100644 index 0000000..1a38613 --- /dev/null +++ b/src/MijnBesluit.Domain/Rules/DeathProcedure.cs @@ -0,0 +1,125 @@ +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Rules; + +/// Tussenstand van de overlijdensprocedure van één profiel. +/// +/// Zit er een melding bij van iemand die dit profiel zelf heeft aangewezen? Zonder dat begint de +/// bezwaartermijn niet, ook niet als het aantal meldingen is gehaald. +/// +/// +/// De ingestelde bezwaartermijn. Staat hier zodat een scherm dat de stand ophaalt ook meteen +/// weet wat er is ingesteld; anders moet het die waarde apart gaan vragen, en vergeet het dat +/// een keer - wat in de app precies gebeurde: het aantal dagen liet zich wijzigen en opslaan, +/// maar sprong bij het herladen terug naar veertien. +/// +public readonly record struct DeathProcedureResult( + DeathProcedureState State, + int ActiveDeclarations, + int RequiredDeclarations, + DateTime? ObjectionDeadline, + int ObjectionDays = 14, + bool BeheerderHeeftGemeld = false) +{ + public int Missing => Math.Max(0, RequiredDeclarations - ActiveDeclarations); + + /// + /// Het aantal is gehaald, maar er zit geen melding van een aangewezen beheerder bij. De + /// procedure staat dan stil: niemand krijgt bericht en er verloopt geen termijn. + /// + /// Dit hoort op het scherm te staan. Anders is het een stilte waar niemand iets van merkt, + /// en dat is precies wat je bij een overlijdensprocedure niet wilt. + /// + public bool WachtOpBeheerder => Missing == 0 && !BeheerderHeeftGemeld + && State == DeathProcedureState.Lopend; +} + +/// +/// De regels rond het overlijden van een deelnemer. +/// +/// Er zijn twee dingen nodig voordat de procedure begint te lopen: +/// +/// +/// Genoeg onafhankelijke meldingen — standaard drie, per profiel instelbaar. Melden mag +/// iedereen uit je eigen kring: de mensen die jij hebt toegevoegd en die de uitnodiging hebben +/// aangenomen. Zij merken het als eerste, en dat zijn er genoeg om aan drie te komen. +/// Minstens één van die meldingen komt van iemand die jij zélf hebt aangewezen als +/// nalatenschapsbeheerder. +/// +/// +/// Die tweede regel is de kern. Alleen beheerders laten melden lag voor de hand, maar dan heb je +/// evenveel beheerders nodig als er meldingen vereist zijn — wijs je er twee aan terwijl er drie +/// meldingen nodig zijn, dan kan de procedure nooit worden afgerond en gebeurt er bij een +/// overlijden niets. Stilzwijgend. Zo werkt het nu niet: het aantal haal je met je kring, en de +/// beheerder is de stem die er in elk geval bij moet zitten. +/// +/// Daarna krijgt de persoon zelf nog een bezwaartermijn om te laten weten dat hij gewoon leeft; +/// pas als die verstrijkt is het overlijden bevestigd en krijgt de beheerder toegang. +/// +public static class DeathProcedure +{ + /// Bepaalt de stand van zaken op moment . + /// + /// De accounts van de mensen die dit profiel heeft aangewezen. Bewust een verplichte + /// parameter: wie de stand opvraagt moet erover nadenken, want zonder deze verzameling zou + /// een procedure doorlopen die had moeten wachten. + /// + public static DeathProcedureResult Evaluate( + Person person, IEnumerable declarations, + IReadOnlySet beheerderUserIds, DateTime nowUtc) + { + var active = declarations + .Where(d => d.PersonId == person.Id && d.IsActive) + .Select(d => d.DeclaredByUserId) + .Distinct() + .ToList(); + + var required = Math.Max(1, person.RequiredDeathDeclarations); + var dagen = Math.Max(0, person.DeathObjectionDays); + var vanBeheerder = active.Any(beheerderUserIds.Contains); + + // Bezwaar geldt voor de meldingen die er tóén waren, niet voor de rest van iemands leven. + // Bij bezwaar worden alle lopende meldingen ingetrokken, dus hierna staat de teller op + // nul. Komt er later een nieuwe melding, dan begint de procedure gewoon opnieuw - anders + // zou wie één keer terecht zegt "ik leef nog" nooit meer als overleden gemeld kunnen + // worden, ook niet als hij twee jaar later echt overlijdt. + if (person.DeathObjectedAt is { } objectedAt && + !declarations.Any(d => d.PersonId == person.Id && d.IsActive && d.DeclaredAt > objectedAt)) + { + return new DeathProcedureResult(DeathProcedureState.Betwist, active.Count, required, null, dagen, vanBeheerder); + } + + if (person.DeathConfirmedAt is not null) + return new DeathProcedureResult(DeathProcedureState.Bevestigd, active.Count, required, null, dagen, vanBeheerder); + + if (active.Count == 0) + return new DeathProcedureResult(DeathProcedureState.Geen, 0, required, null, dagen, false); + + if (active.Count < required) + return new DeathProcedureResult(DeathProcedureState.Lopend, active.Count, required, null, dagen, vanBeheerder); + + // Het aantal is gehaald, maar er zit geen melding van een aangewezen beheerder bij. Dan + // begint er niets: geen bericht aan de betrokkene, geen termijn die verloopt. De schermen + // laten dat zien via WachtOpBeheerder, zodat het geen stilte is waar niemand iets van merkt. + if (!vanBeheerder) + return new DeathProcedureResult(DeathProcedureState.Lopend, active.Count, required, + null, dagen, false); + + // Het aantal is gehaald én een beheerder heeft gemeld: de bezwaartermijn loopt vanaf de + // laatste melding. + var lastDeclaredAt = declarations + .Where(d => d.PersonId == person.Id && d.IsActive) + .Max(d => d.DeclaredAt); + + var deadline = lastDeclaredAt.AddDays(dagen); + + return nowUtc >= deadline + ? new DeathProcedureResult(DeathProcedureState.Bevestigd, active.Count, required, deadline, dagen, true) + : new DeathProcedureResult(DeathProcedureState.Lopend, active.Count, required, deadline, dagen, true); + } + + /// Mag deze gebruiker een overlijdensmelding doen voor dit profiel? + public static bool MayDeclare(Person person, long declaringUserId) => + person.ClaimedByUserId != declaringUserId && person.DeathConfirmedAt is null; +} diff --git a/src/MijnBesluit.Domain/Rules/MessageRelease.cs b/src/MijnBesluit.Domain/Rules/MessageRelease.cs new file mode 100644 index 0000000..6f34ab4 --- /dev/null +++ b/src/MijnBesluit.Domain/Rules/MessageRelease.cs @@ -0,0 +1,38 @@ +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Rules; + +/// Bepaalt wanneer een gepland bericht of kluisitem daadwerkelijk vrijkomt. +public static class MessageRelease +{ + /// + /// Is dit bericht nu toe aan bezorging? Een bericht met DeliverAfterDeath wacht op het + /// bevestigde overlijden van de afzender; een bericht met een datum wacht op die datum. + /// Staan beide aan, dan moet aan beide voorwaarden zijn voldaan. + /// + public static bool IsDue(Message message, Person sender, DateTime nowUtc) + { + if (message.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen or MessageStatus.Ingetrokken) + return false; + if (message.Status == MessageStatus.Concept) + return false; + + if (message.DeliverAfterDeath && sender.DeathConfirmedAt is null) + return false; + + if (message.DeliverAt is { } at && nowUtc < at) + return false; + + return true; + } + + /// Is dit kluisitem inmiddels vrijgegeven voor de nalatenschapsbeheerder of andere rechthebbenden? + public static bool IsReleased(VaultItem item, Person owner, DateTime nowUtc) => item.ReleasePolicy switch + { + ReleasePolicy.AlleenIkzelf => false, + ReleasePolicy.NaOverlijden => owner.DeathConfirmedAt is not null, + ReleasePolicy.OpDatum => item.ReleaseAt is { } at && nowUtc >= at, + _ => false + }; +} diff --git a/src/MijnBesluit.Domain/Rules/ProfileOwnership.cs b/src/MijnBesluit.Domain/Rules/ProfileOwnership.cs new file mode 100644 index 0000000..584624e --- /dev/null +++ b/src/MijnBesluit.Domain/Rules/ProfileOwnership.cs @@ -0,0 +1,43 @@ +using MijnBesluit.Domain.Entities; + +namespace MijnBesluit.Domain.Rules; + +/// +/// De eigendomsregel van MijnBesluit, op één plek: +/// wie een profiel aanmaakt mag het beheren tot de persoon zelf het claimt; daarna niet meer. +/// De uitnodiger blijft het profiel wél zien in zijn familie, met de aantekening "geclaimd". +/// +public static class ProfileOwnership +{ + /// Mag deze gebruiker de gegevens van dit profiel wijzigen? + public static bool CanEdit(Person person, long userId) => + person.IsClaimed ? person.ClaimedByUserId == userId : person.ManagedByUserId == userId; + + /// Mag deze gebruiker het profiel bekijken? Beheerders en de persoon zelf; familieleden zien alleen de naamgegevens. + public static bool CanViewDetails(Person person, long userId) => CanEdit(person, userId); + + /// + /// Draagt het profiel over aan de persoon zelf. Dit is onomkeerbaar: vanaf nu kan de + /// oorspronkelijke beheerder er niets meer aan wijzigen. + /// + /// False als het profiel al geclaimd was. + public static bool Claim(Person person, long claimingUserId, DateTime nowUtc) + { + if (person.IsClaimed) return false; + + person.ClaimedByUserId = claimingUserId; + person.ClaimedAt = nowUtc; + person.ManagedByUserId = null; + person.UpdatedAt = nowUtc; + return true; + } + + /// Reden waarom wijzigen niet mag, in gewone taal — om zo aan de gebruiker te tonen. + public static string? ExplainDenial(Person person, long userId) + { + if (CanEdit(person, userId)) return null; + return person.IsClaimed + ? $"{person.DisplayName} beheert dit profiel inmiddels zelf. Je ziet het profiel nog wel in je familie, maar wijzigen kan alleen {person.DisplayName} nog." + : "Je beheert dit profiel niet."; + } +} diff --git a/src/MijnBesluit.Domain/Rules/Relaties.cs b/src/MijnBesluit.Domain/Rules/Relaties.cs new file mode 100644 index 0000000..f4aec7b --- /dev/null +++ b/src/MijnBesluit.Domain/Rules/Relaties.cs @@ -0,0 +1,97 @@ +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Domain.Rules; + +/// +/// Wat iemand van je is: de lijst die je te kiezen krijgt, en hoe elke relatie op het scherm +/// heet. +/// +/// Waarom het zo uitgesplitst is: mensen zeggen "mijn oma", niet "mijn grootouder", en +/// "schoondochter" is geen soort kind. Een korte lijst met overkoepelende woorden leest als een +/// formulier van de gemeente; deze leest als hoe je het zelf zegt. Dat kost een langere lijst, +/// en dat is het waard. +/// +/// is wat je kunt kiezen, vertaalt élke waarde die ooit +/// heeft bestaan. Die twee zijn met opzet niet hetzelfde: Kind, Ouder, Bonusouder, Kleinkind en +/// Grootouder staan niet meer in de keuzelijst, maar staan wél als nummer in de database bij +/// iedereen die ze eerder koos. Zonder vertaling zou daar ineens niets meer staan. +/// +/// Deze lijst staat ook in MijnBesluit.Client, omdat de app het domein niet ziet. Een controle +/// in de zelftest bewaakt dat de twee gelijk blijven. +/// +public static class Relaties +{ + /// + /// Wat je kunt kiezen, in de volgorde waarin het op het scherm staat: eerst je partner, dan + /// omlaag door de generaties, dan omhoog, dan opzij, en vrienden onderaan. + /// + public static readonly (RelationType Waarde, string Naam)[] Keuzes = + { + (RelationType.Partner, "Partner"), + + (RelationType.Zoon, "Zoon"), + (RelationType.Dochter, "Dochter"), + (RelationType.BonusZoon, "Bonuszoon"), + (RelationType.BonusDochter, "Bonusdochter"), + (RelationType.Schoonzoon, "Schoonzoon"), + (RelationType.Schoondochter, "Schoondochter"), + + (RelationType.Kleinzoon, "Kleinzoon"), + (RelationType.Kleindochter, "Kleindochter"), + (RelationType.Achterkleinzoon, "Achterkleinzoon"), + (RelationType.Achterkleindochter, "Achterkleindochter"), + + (RelationType.Vader, "Vader"), + (RelationType.Moeder, "Moeder"), + (RelationType.BonusVader, "Bonusvader"), + (RelationType.BonusMoeder, "Bonusmoeder"), + (RelationType.Schoonvader, "Schoonvader"), + (RelationType.Schoonmoeder, "Schoonmoeder"), + (RelationType.Opa, "Opa"), + (RelationType.Oma, "Oma"), + + (RelationType.Broer, "Broer"), + (RelationType.Zus, "Zus"), + (RelationType.BonusBroer, "Bonusbroer"), + (RelationType.BonusZus, "Bonuszus"), + (RelationType.Zwager, "Zwager"), + (RelationType.Schoonzus, "Schoonzus"), + (RelationType.Oom, "Oom"), + (RelationType.Tante, "Tante"), + + (RelationType.Vriend, "Vriend"), + (RelationType.Vriendin, "Vriendin"), + (RelationType.Overig, "Anders") + }; + + /// + /// Hoe een relatie op het scherm heet - ook de relaties die je niet meer kunt kiezen. + /// + /// Neef en nicht staan er bewust niet bij. Dat woord betekent in het Nederlands twee heel + /// verschillende dingen tegelijk (het kind van je broer of zus, én het kind van je oom of + /// tante), en wat je ook kiest, de helft van de mensen leest het verkeerd. Wie het toch wil + /// vastleggen kiest "Anders" en schrijft het er zelf bij. + /// + public static string Naam(RelationType relatie) => relatie switch + { + // Niet meer te kiezen, wel te lezen: deze staan bij bestaande mensen in de database. + RelationType.Kind => "Kind", + RelationType.BonusKind => "Bonuskind", + RelationType.Ouder => "Ouder", + RelationType.BonusOuder => "Bonusouder", + RelationType.Kleinkind => "Kleinkind", + RelationType.Grootouder => "Grootouder", + RelationType.Onbekend => "Familie", + + _ => Keuzes.FirstOrDefault(k => k.Waarde == relatie).Naam ?? "Familie" + }; + + /// + /// Vrienden zijn wel lid van je familie in MijnBesluit, maar het zijn geen familieleden. Op + /// het scherm staan ze apart, onder de echte familie. Deze regel staat hier en niet in de + /// sortering zelf, want hij hoort bij de lijst: komt er een soort vriend bij, dan hoort hij + /// hier meteen bij te staan en niet pas nadat iemand hem onderaan mist. + /// + public static bool IsFamilie(RelationType relatie) => + relatie is not (RelationType.Vriend or RelationType.Vriendin or RelationType.Overig); +} diff --git a/src/MijnBesluit.Domain/Rules/SocialeMedia.cs b/src/MijnBesluit.Domain/Rules/SocialeMedia.cs new file mode 100644 index 0000000..37c3e10 --- /dev/null +++ b/src/MijnBesluit.Domain/Rules/SocialeMedia.cs @@ -0,0 +1,22 @@ +namespace MijnBesluit.Domain.Rules; + +/// +/// De sociale media die je bij een kluisitem kunt kiezen. Geen enum maar tekst: de namen +/// veranderen (Twitter werd X) en er komen er bij, en dan wil je geen nummers in de database +/// hebben staan waarvan niemand meer weet waar ze voor stonden. +/// +/// Deze lijst staat ook in MijnBesluit.Client (Modellen.cs), omdat de app het domein niet +/// ziet. Een controle in de zelftest bewaakt dat de twee gelijk blijven. +/// +public static class SocialeMedia +{ + /// De keuze die bij "Anders" hoort; dan wordt een eigen naam ingevuld. + public const string Anders = "Anders"; + + public static readonly string[] Namen = + { + "Facebook", "Instagram", "WhatsApp", "LinkedIn", "X (voorheen Twitter)", + "TikTok", "YouTube", "Snapchat", "Pinterest", "Telegram", "Signal", + "Discord", "Reddit", "Strava", "Marktplaats", Anders + }; +} diff --git a/src/MijnBesluit.Infrastructure/Afbeeldingen/SkiaAfbeeldingVerkleiner.cs b/src/MijnBesluit.Infrastructure/Afbeeldingen/SkiaAfbeeldingVerkleiner.cs new file mode 100644 index 0000000..0b2d9e6 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Afbeeldingen/SkiaAfbeeldingVerkleiner.cs @@ -0,0 +1,53 @@ +using MijnBesluit.Application.Abstractions; +using SkiaSharp; + +namespace MijnBesluit.Infrastructure.Afbeeldingen; + +/// +/// Verkleint foto's met SkiaSharp. +/// +/// Waarom SkiaSharp en niet System.Drawing: dat laatste werkt alleen op Windows en is daarbuiten +/// een harde fout. De website draait nu op Windows, maar een bibliotheek kiezen die je daaraan +/// vastzet is een beslissing die je later terugziet. SkiaSharp is dezelfde motor die de app zelf +/// al gebruikt om mee te tekenen. +/// +/// Er wordt hier bewust niets gelogd bij een mislukking. Een kleine versie is mooi meegenomen; +/// gaat het mis, dan komt er null terug en toont het scherm de beginletters. Een logboek dat +/// volloopt met "kon geen kleine versie maken" bij elk pdf-bestand helpt niemand. +/// +public sealed class SkiaAfbeeldingVerkleiner : IAfbeeldingVerkleiner +{ + /// Hoe hard er op de kleine versie mag worden ingeleverd. Op 160 pixels zie je 80 niet. + private const int Kwaliteit = 80; + + public byte[]? Verklein(byte[] inhoud, string soort, int maxZijde) + { + if (inhoud.Length == 0 || maxZijde <= 0) return null; + if (!soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return null; + + try + { + using var bron = SKBitmap.Decode(inhoud); + if (bron is null || bron.Width == 0 || bron.Height == 0) return null; + + // De verhouding blijft zoals hij is; het scherm snijdt zelf bij tot een rondje. + // Groter maken doen we nooit: een kleine foto blijft klein. + var schaal = Math.Min(1d, Math.Min((double)maxZijde / bron.Width, (double)maxZijde / bron.Height)); + + var breedte = Math.Max(1, (int)Math.Round(bron.Width * schaal)); + var hoogte = Math.Max(1, (int)Math.Round(bron.Height * schaal)); + + using var kleiner = bron.Resize(new SKImageInfo(breedte, hoogte), SKFilterQuality.Medium); + if (kleiner is null) return null; + + using var afbeelding = SKImage.FromBitmap(kleiner); + using var gegevens = afbeelding.Encode(SKEncodedImageFormat.Jpeg, Kwaliteit); + + return gegevens?.ToArray(); + } + catch (Exception) + { + return null; + } + } +} diff --git a/src/MijnBesluit.Infrastructure/DependencyInjection.cs b/src/MijnBesluit.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..6001574 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/DependencyInjection.cs @@ -0,0 +1,184 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Services; +using MijnBesluit.Infrastructure.Meldingen; +using MijnBesluit.Infrastructure.Messaging; +using MijnBesluit.Infrastructure.Persistence; +using MijnBesluit.Infrastructure.Repositories; +using MijnBesluit.Infrastructure.Security; +using MijnBesluit.Infrastructure.Storage; + +namespace MijnBesluit.Infrastructure; + +public static class DependencyInjection +{ + /// + /// Zet de database, de sleutels, de bestandsopslag, de e-mail en alle services klaar. + /// Zowel de website als de SocketServer roepen dit aan, zodat ze exact dezelfde regels draaien. + /// + public static IServiceCollection AddMijnBesluit(this IServiceCollection services, IConfiguration config) + { + var connectionString = config.GetConnectionString("MijnBesluit") + ?? throw new InvalidOperationException( + "Geen connectiereeks 'MijnBesluit' gevonden. Zet hem in appsettings.json, " + + "of beter: in user secrets of een omgevingsvariabele."); + + services.AddDbContext(options => + options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), + mysql => mysql.EnableRetryOnFailure(3))); + + services.AddSingleton(); + + // Het sleutelbestand: zonder dit bestand is de kluis niet te openen. + var keyFile = NaastHetProgramma(config["Security:MasterKeyFile"] ?? "keys/masterkey.json"); + + // Handig bij ontwikkelen: bij de allereerste start zelf een sleutelbestand aanmaken, + // zodat je niet eerst ergens code hoeft te draaien om te kunnen testen. Staat alleen + // aan in appsettings.Development.json. + // + // Zet dit in productie NOOIT aan. Raakt het sleutelbestand daar zoek, dan hoor je een + // harde fout te krijgen en de back-up terug te zetten — niet stilletjes een nieuwe + // sleutel, want dan is alles wat er al lag onleesbaar geworden. + if (Waar(config["Security:MaakSleutelbestandAlsHetOntbreekt"]) && !File.Exists(keyFile)) + FileMasterKeyProtector.CreateKeyFile(keyFile); + + services.AddSingleton(_ => new FileMasterKeyProtector(keyFile)); + + services.AddSingleton(_ => + new LocalFileStore(NaastHetProgramma(config["Storage:Path"] ?? "kluisbestanden"))); + + var smtp = new SmtpOptions(); + config.GetSection("Smtp").Bind(smtp); + smtp.DropFolder = NaastHetProgramma(smtp.DropFolder); + // Waar het logo bovenaan elke mail vandaan komt (EmailSjabloon) - dezelfde instelling + // als bij de uitnodigings- en bezwaarlink. + smtp.SiteUrl = config["Site:Url"] ?? "https://mijnbesluit.nl"; + + // Een voorbeeldtekst die nooit is vervangen. Dat kost je geen foutmelding maar traagheid: + // de mailserver weigert de aanmelding, .NET probeert het opnieuw met een andere methode, + // en pas daarna gaat de post alsnog de deur uit. Negen seconden lang, bij elke inlogcode. + // Eén keer per opstart een waarschuwing is genoeg om dat nooit meer een middag te laten + // duren. Een echt wachtwoord bevat geen spaties. + if (!string.IsNullOrEmpty(smtp.Password) && smtp.Password.Contains(' ')) + { + services.AddSingleton(sp => + new InstellingWaarschuwing( + sp.GetService>(), + "Het SMTP-wachtwoord bevat spaties. Staat daar nog een voorbeeldtekst? " + + "De mailserver weigert de aanmelding dan stilletjes en het versturen wordt traag. " + + "Laat gebruikersnaam en wachtwoord leeg als de mailserver op dezelfde machine " + + "staat en relayen op IP-adres al is toegestaan.")); + } + services.AddSingleton(sp => new SmtpEmailSender( + smtp, sp.GetService>())); + + // Meldingen op de telefoon. Zonder servicesleutel gebeurt er niets; de applicatie + // draait dan gewoon door, alleen zonder push. + var push = new PushOptions(); + config.GetSection("Push").Bind(push); + services.AddSingleton(sp => new FirebasePush( + push, sp.GetService>())); + + // Het verkleinen van foto's. Zonder deze regel werkt alles gewoon, alleen krijgt een + // persoon dan zijn beginletters in plaats van zijn profielfoto. + services.AddSingleton(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + // De link naar de landingspagina voor iemand zonder account moet naar de echte site wijzen. + services.AddScoped(sp => + { + var messages = ActivatorUtilities.CreateInstance(sp); + messages.SiteUrl = config["Site:Url"] ?? "https://mijnbesluit.nl"; + return messages; + }); + // De bezwaarlink in de waarschuwingsmail moet naar de echte site wijzen. + services.AddScoped(sp => + { + var nalatenschap = new LegacyService( + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService()); + nalatenschap.SiteUrl = config["Site:Url"] ?? "https://mijnbesluit.nl"; + return nalatenschap; + }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Adverteren. Het tarief staat in de instellingen en niet in de code: zo kan het wijzigen + // zonder uitrol, en blijft bij een al geboekte campagne staan waarmee gerekend is. + services.AddScoped(sp => + { + var opties = new AdvertentieOptions(); + if (int.TryParse(config["Adverteren:TariefCenten"], out var tarief) && tarief > 0) + opties.TariefCenten = tarief; + if (int.TryParse(config["Adverteren:MinimumCenten"], out var bodem) && bodem >= 0) + opties.MinimumCenten = bodem; + if (int.TryParse(config["Adverteren:MaxPerUur"], out var maxPerUur) && maxPerUur > 0) + opties.MaxPerUur = maxPerUur; + + var adverteren = new AdvertentieService( + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), opties); + adverteren.SiteUrl = config["Site:Url"] ?? "https://mijnbesluit.nl"; + + // Leeg mag: dan komt er geen betaalknop en staan het bedrag en het kenmerk er gewoon + // om met de hand over te maken. Een knop naar de verkeerde rekening is erger. + if (config["Adverteren:BunqMe"] is { } bunq) adverteren.BunqMe = bunq.Trim(); + + return adverteren; + }); + + services.AddScoped(sp => + { + var families = new FamilyService( + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService()); + families.SiteUrl = config["Site:Url"] ?? "https://mijnbesluit.nl"; + return families; + }); + + return services; + } + + /// + /// Maakt van een relatief pad een volledig pad naast het programmabestand. + /// + /// Dit is niet overbodig: Windows start een service met C:\Windows\System32 als werkmap. + /// Zonder deze omzetting zou de service zijn sleutelbestand en kluisbestanden daar zoeken — + /// en dus niet vinden. + /// + private static string NaastHetProgramma(string pad) => + Path.IsPathRooted(pad) ? pad : Path.Combine(AppContext.BaseDirectory, pad); + + private static bool Waar(string? waarde) => bool.TryParse(waarde, out var ja) && ja; +} diff --git a/src/MijnBesluit.Infrastructure/InstellingWaarschuwing.cs b/src/MijnBesluit.Infrastructure/InstellingWaarschuwing.cs new file mode 100644 index 0000000..6ac992c --- /dev/null +++ b/src/MijnBesluit.Infrastructure/InstellingWaarschuwing.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MijnBesluit.Infrastructure; + +/// +/// Zet één melding in het logboek zodra de applicatie start. Bedoeld voor instellingen die +/// niet fout genoeg zijn om mee te stoppen, maar wel iets stils kapotmaken - het soort ding +/// dat je pas een middag later vindt. +/// +public sealed class InstellingWaarschuwing : IHostedService +{ + private readonly ILogger? _log; + private readonly string _melding; + + public InstellingWaarschuwing(ILogger? log, string melding) + { + _log = log; + _melding = melding; + } + + public Task StartAsync(CancellationToken ct) + { + _log?.LogWarning("{Melding}", _melding); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; +} diff --git a/src/MijnBesluit.Infrastructure/Meldingen/FirebasePush.cs b/src/MijnBesluit.Infrastructure/Meldingen/FirebasePush.cs new file mode 100644 index 0000000..d4bedb4 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Meldingen/FirebasePush.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MijnBesluit.Application.Abstractions; + +namespace MijnBesluit.Infrastructure.Meldingen; + +public class PushOptions +{ + /// Pad naar het json-bestand met de servicesleutel uit Firebase. + public string? ServiceAccountFile { get; set; } +} + +/// +/// Verstuurt meldingen via Firebase Cloud Messaging. +/// +/// Het toegangstoken van Google is een uur geldig; we halen een nieuwe op zodra hij bijna +/// verloopt. Zonder dat zou er bij elke melding een extra aanroep naar Google gaan. +/// +public class FirebasePush : IPushSender, IDisposable +{ + private readonly Servicesleutel? _sleutel; + private readonly HttpClient _http; + private readonly ILogger _log; + private readonly SemaphoreSlim _tokenSlot = new(1, 1); + + private string? _token; + private DateTime _tokenVerlooptOp = DateTime.MinValue; + + public FirebasePush(PushOptions opties, ILogger? log = null, HttpClient? http = null) + { + _log = log ?? NullLogger.Instance; + _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(20) }; + + var pad = opties.ServiceAccountFile; + if (string.IsNullOrWhiteSpace(pad)) return; + + if (!File.Exists(pad)) + { + _log.LogError("De servicesleutel voor meldingen is niet gevonden: {Pad}. " + + "Er worden geen meldingen verstuurd.", pad); + return; + } + + try + { + _sleutel = Servicesleutel.Lees(File.ReadAllText(pad)); + _log.LogInformation("Meldingen staan aan voor Firebase-project {Project}.", _sleutel.ProjectId); + } + catch (Exception ex) + { + _log.LogError(ex, "De servicesleutel {Pad} kon niet worden gelezen. Er worden geen meldingen verstuurd.", pad); + } + } + + public bool IsIngesteld => _sleutel is not null; + + public async Task StuurAsync(string kenmerk, string titel, string tekst, + IReadOnlyDictionary? gegevens = null, + CancellationToken ct = default) + { + if (_sleutel is null) return true; + + var token = await ToegangstokenAsync(ct); + if (token is null) return true; // Niet het toestel is stuk, maar de verbinding met Google. + + // Titel en tekst gaan twee keer mee, en dat is met opzet. + // + // Het notification-deel is wat Android zelf in de balk zet als de app dicht staat. Staat + // de app open, dan doet Android daar niets mee en krijgt de app het bericht in handen; + // die leest de tekst dan uit het data-deel. Zonder die tweede kopie heette een melding + // "verstuurd" terwijl er op een open app niets te zien was. + // + // Dubbel getoond wordt hij niet: bij een dichte app roept Android de app niet aan, bij + // een open app zet Android zelf niets in de balk. + var meegestuurd = new Dictionary(gegevens ?? new Dictionary()) + { + ["titel"] = titel, + ["tekst"] = tekst + }; + + var bericht = new + { + message = new + { + token = kenmerk, + notification = new { title = titel, body = tekst }, + data = meegestuurd, + android = new { priority = "high" } + } + }; + + using var verzoek = new HttpRequestMessage(HttpMethod.Post, _sleutel.VerzendAdres) + { + Content = JsonContent.Create(bericht) + }; + verzoek.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var antwoord = await _http.SendAsync(verzoek, ct); + if (antwoord.IsSuccessStatusCode) return true; + + var inhoud = await antwoord.Content.ReadAsStringAsync(ct); + + // 404 en 400 met UNREGISTERED betekenen: dit toestel kent Google niet meer. Dan heeft + // blijven proberen geen zin en hoort de regel afgemeld te worden. + var onbruikbaar = antwoord.StatusCode == HttpStatusCode.NotFound || + inhoud.Contains("UNREGISTERED", StringComparison.OrdinalIgnoreCase) || + inhoud.Contains("INVALID_ARGUMENT", StringComparison.OrdinalIgnoreCase); + + _log.Log(onbruikbaar ? LogLevel.Information : LogLevel.Warning, + "Melding niet afgeleverd ({Status}): {Inhoud}", (int)antwoord.StatusCode, inhoud); + + return !onbruikbaar; + } + + /// Haalt een toegangstoken op, of hergebruikt het vorige zolang dat nog geldig is. + private async Task ToegangstokenAsync(CancellationToken ct) + { + if (_sleutel is null) return null; + + // Een minuut marge: anders kan een token onderweg alsnog verlopen. + if (_token is not null && DateTime.UtcNow < _tokenVerlooptOp.AddMinutes(-1)) return _token; + + await _tokenSlot.WaitAsync(ct); + try + { + if (_token is not null && DateTime.UtcNow < _tokenVerlooptOp.AddMinutes(-1)) return _token; + + var jwt = _sleutel.MaakJwt(DateTime.UtcNow); + using var antwoord = await _http.PostAsync("https://oauth2.googleapis.com/token", + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", + ["assertion"] = jwt + }), ct); + + var inhoud = await antwoord.Content.ReadAsStringAsync(ct); + if (!antwoord.IsSuccessStatusCode) + { + _log.LogError("Google gaf geen toegangstoken ({Status}): {Inhoud}", (int)antwoord.StatusCode, inhoud); + return null; + } + + using var doc = JsonDocument.Parse(inhoud); + _token = doc.RootElement.GetProperty("access_token").GetString(); + var seconden = doc.RootElement.TryGetProperty("expires_in", out var e) ? e.GetInt32() : 3600; + _tokenVerlooptOp = DateTime.UtcNow.AddSeconds(seconden); + + return _token; + } + catch (Exception ex) + { + _log.LogError(ex, "Het ophalen van een toegangstoken bij Google mislukte."); + return null; + } + finally + { + _tokenSlot.Release(); + } + } + + public void Dispose() + { + _tokenSlot.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/MijnBesluit.Infrastructure/Meldingen/Servicesleutel.cs b/src/MijnBesluit.Infrastructure/Meldingen/Servicesleutel.cs new file mode 100644 index 0000000..fddb9f8 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Meldingen/Servicesleutel.cs @@ -0,0 +1,90 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace MijnBesluit.Infrastructure.Meldingen; + +/// +/// De servicesleutel die je uit Firebase downloadt (Projectinstellingen > Serviceaccounts > +/// "Nieuwe persoonlijke sleutel genereren"). Daarin staat een privésleutel waarmee we bij Google +/// een toegangstoken ophalen om meldingen te mogen versturen. +/// +/// Dit is met de hand geschreven in plaats van met de bibliotheek van Google. Reden: het is +/// ongeveer zestig regels, het scheelt een package met veel afhankelijkheden, en het is zo te +/// controleren zonder internet. Wat er gebeurt is precies wat Google beschrijft: een JWT maken, +/// die ondertekenen met de privésleutel, en hem inruilen voor een toegangstoken. +/// +public class Servicesleutel +{ + public string ProjectId { get; } + public string ClientEmail { get; } + private readonly RSA _prive; + + private Servicesleutel(string projectId, string clientEmail, RSA prive) + { + ProjectId = projectId; + ClientEmail = clientEmail; + _prive = prive; + } + + /// Leest het json-bestand dat Firebase je laat downloaden. + public static Servicesleutel Lees(string json) + { + using var doc = JsonDocument.Parse(json); + var wortel = doc.RootElement; + + string Waarde(string naam) => + wortel.TryGetProperty(naam, out var v) ? v.GetString() ?? "" : ""; + + var projectId = Waarde("project_id"); + var clientEmail = Waarde("client_email"); + var priveSleutel = Waarde("private_key"); + + if (string.IsNullOrWhiteSpace(projectId) || string.IsNullOrWhiteSpace(clientEmail) || + string.IsNullOrWhiteSpace(priveSleutel)) + { + throw new InvalidOperationException( + "Dit lijkt geen servicesleutel van Firebase: project_id, client_email of private_key ontbreekt."); + } + + var rsa = RSA.Create(); + rsa.ImportFromPem(priveSleutel); + return new Servicesleutel(projectId, clientEmail, rsa); + } + + /// + /// Maakt de ondertekende JWT waarmee bij Google een toegangstoken wordt opgehaald. + /// Geldig vanaf nu tot een uur later; Google staat maximaal een uur toe. + /// + public string MaakJwt(DateTime nuUtc) + { + var uitgegeven = ToUnix(nuUtc); + var verloopt = uitgegeven + 3600; + + var kop = Base64Url(Encoding.UTF8.GetBytes( + """{"alg":"RS256","typ":"JWT"}""")); + + var inhoud = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new Dictionary + { + ["iss"] = ClientEmail, + ["scope"] = "https://www.googleapis.com/auth/firebase.messaging", + ["aud"] = "https://oauth2.googleapis.com/token", + ["iat"] = uitgegeven, + ["exp"] = verloopt + }))); + + var teTekenen = Encoding.ASCII.GetBytes($"{kop}.{inhoud}"); + var handtekening = _prive.SignData(teTekenen, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return $"{kop}.{inhoud}.{Base64Url(handtekening)}"; + } + + /// Het adres waar de meldingen naartoe gaan, met het projectnummer erin. + public string VerzendAdres => $"https://fcm.googleapis.com/v1/projects/{ProjectId}/messages:send"; + + public static long ToUnix(DateTime utc) => (long)(utc - DateTime.UnixEpoch).TotalSeconds; + + /// Base64 zoals JWT het wil: zonder opvultekens, en met - en _ in plaats van + en /. + public static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/src/MijnBesluit.Infrastructure/Messaging/EmailSjabloon.cs b/src/MijnBesluit.Infrastructure/Messaging/EmailSjabloon.cs new file mode 100644 index 0000000..a93c451 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Messaging/EmailSjabloon.cs @@ -0,0 +1,56 @@ +namespace MijnBesluit.Infrastructure.Messaging; + +/// +/// Zet het logo boven elke uitgaande e-mail, en een klein voettekstje eronder. De losse +/// services (uitnodigen, bezwaarlink, momenten, inlogcode, ...) bouwen zelf alleen nog de +/// binnenkant - gewone alinea's, geen tabellen - en roept dit +/// één keer aan voordat de mail de deur uitgaat. Zo verandert er niets aan die services zelf, +/// en staat het logo toch overal. +/// +/// Bewust geen watermerk (een vage afbeelding ACHTER de tekst): dat steunt op CSS-achtergronden +/// die niet elke e-mailclient tekent - Outlook op Windows rendert HTML-mail met de +/// Word-weergave-engine en laat een achtergrondafbeelding op een tekstblok regelmatig gewoon +/// weg. Een gewone <img> bovenaan werkt overal hetzelfde. +/// +/// Met tabellen opgebouwd en niet met flexbox of grid: dat is wat een e-mailclient nog +/// betrouwbaar weet te tekenen. Alle opmaak staat inline (style="...") omdat een +/// los stylesheet in een e-mail door de meeste clients wordt genegeerd. +/// +public static class EmailSjabloon +{ + public static string Inpakken(string binnenkant, string siteUrl) + { + var basis = siteUrl.TrimEnd('/'); + var logo = $"{basis}/logo.png"; + var domein = basis.Replace("https://", "").Replace("http://", ""); + + return $""" + + + + +
+ + + + + + + + + + +
+ MijnBesluit +
+ {binnenkant} +
+ MijnBesluit · + {domein} +
+
+ """; + } +} diff --git a/src/MijnBesluit.Infrastructure/Messaging/SmtpEmailSender.cs b/src/MijnBesluit.Infrastructure/Messaging/SmtpEmailSender.cs new file mode 100644 index 0000000..e68a4b9 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Messaging/SmtpEmailSender.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Net.Mail; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Models; + +namespace MijnBesluit.Infrastructure.Messaging; + +public class SmtpOptions +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 587; + public bool UseStartTls { get; set; } = true; + public string? Username { get; set; } + public string? Password { get; set; } + public string FromAddress { get; set; } = "noreply@mijnbesluit.nl"; + public string FromName { get; set; } = "MijnBesluit"; + + /// Waar het logo bovenaan elke mail vandaan komt. Zie . + public string SiteUrl { get; set; } = "https://mijnbesluit.nl"; + + /// Zet dit aan tijdens ontwikkelen: mails worden dan alleen naar de map hieronder geschreven. + public bool WriteToDiskInsteadOfSending { get; set; } + public string DropFolder { get; set; } = "mail-uitgaand"; + + /// + /// Vangnet voor het testen met echte e-mailadressen: staat hier een adres, dan gaat álle + /// uitgaande post daarheen, wat er ook als ontvanger is opgegeven. Het oorspronkelijke + /// adres komt in het onderwerp en bovenaan de mail te staan. + /// + /// Zo kun je met de namen en adressen van je eigen familie oefenen zonder dat er + /// daadwerkelijk uitnodigingen bij hen aankomen. Laat dit leeg zodra je live gaat. + /// + public string? RedirectAllTo { get; set; } +} + +/// +/// Verstuurt e-mail via SMTP. Tijdens ontwikkelen kun je de mails naar een map laten schrijven, +/// zodat je uitnodigingen kunt nalezen zonder echt te versturen. +/// +public class SmtpEmailSender : IEmailSender +{ + private readonly SmtpOptions _options; + private readonly ILogger _log; + + public SmtpEmailSender(SmtpOptions options, ILogger? log = null) + { + _options = options; + _log = log ?? NullLogger.Instance; + } + + public async Task SendAsync(string toEmail, string subject, string htmlBody, CancellationToken ct = default) + { + // Elke mail krijgt hetzelfde logo bovenaan, op deze ene plek - de losse services + // (uitnodigen, bezwaarlink, momenten, ...) bouwen alleen nog de binnenkant. + htmlBody = EmailSjabloon.Inpakken(htmlBody, _options.SiteUrl); + + var werkelijkeOntvanger = toEmail; + + // Alles omleiden tijdens het testen, zodat er niets bij echte mensen aankomt. + if (!string.IsNullOrWhiteSpace(_options.RedirectAllTo)) + { + werkelijkeOntvanger = _options.RedirectAllTo!; + subject = $"[TEST aan {toEmail}] {subject}"; + htmlBody = + $""" +

+ Testmodus: deze e-mail was bestemd voor {System.Net.WebUtility.HtmlEncode(toEmail)} + en is omgeleid naar dit adres. Zet Smtp:RedirectAllTo leeg om echt te versturen. +

+ {htmlBody} + """; + } + + using var message = new MailMessage + { + From = new MailAddress(_options.FromAddress, _options.FromName), + Subject = subject, + Body = htmlBody, + IsBodyHtml = true + }; + message.To.Add(werkelijkeOntvanger); + + // .NET zet zelf geen Message-ID, en niet elke mailserver vult die aan. Gmail weigert + // post zonder Message-ID botweg (550-5.7.1), dus zetten we hem er zelf op. + message.Headers.Add("Message-ID", Berichtkenmerk.Nieuw(_options.FromAddress)); + + if (_options.WriteToDiskInsteadOfSending) + { + Directory.CreateDirectory(_options.DropFolder); + var name = $"{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}-{Guid.NewGuid():N}.html"; + await File.WriteAllTextAsync( + Path.Combine(_options.DropFolder, name), + $"\n{htmlBody}", ct); + return; + } + + using var client = new SmtpClient(_options.Host, _options.Port) + { + EnableSsl = _options.UseStartTls, + Timeout = 20_000 + }; + + if (!string.IsNullOrEmpty(_options.Username)) + client.Credentials = new NetworkCredential(_options.Username, _options.Password); + + // Hoe lang de mailserver erover deed. Dat hoort in het log: duurt het seconden, dan + // staat de gebruiker al die tijd naar een wachtcursor te kijken, en aan de code is dan + // niets te zien - het wachten zit aan de andere kant van de verbinding. + var klok = System.Diagnostics.Stopwatch.StartNew(); + try + { + // Bewust de synchrone Send op een aparte draad, en niet SendMailAsync. + // + // Gemeten op de server: SendMailAsync deed er 9 seconden over naar 127.0.0.1:25, + // terwijl exact dezelfde aanroep vanuit Windows PowerShell 5 milliseconden kostte. + // Dat verschil zit niet in Kerio (die is snel, met en zonder inloggen), niet in DNS + // en niet in de inhoud van het bericht - het zit in de asynchrone machinerie van + // SmtpClient op .NET 8. Die klasse is daar ook officieel afgeraden. + // + // Task.Run zet de verbinding op een draad uit de pool, zodat het verzoek zelf niet + // blokkeert. Helpt dit niet, dan zegt de tijd in het logboek dat meteen. + // Twee metingen in plaats van één: hoe lang het duurde voordat er een draad vrij + // was om op te werken, en hoe lang het SMTP-gesprek zelf kostte. Bij elkaar zijn ze + // de totale wachttijd van de gebruiker, maar ze wijzen naar heel verschillende + // oorzaken - een volle draadpoel of een trage mailserver. + long opDraad = 0; + await Task.Run(() => + { + opDraad = klok.ElapsedMilliseconds; + client.Send(message); + }, ct); + klok.Stop(); + + if (klok.ElapsedMilliseconds > 2000) + { + _log.LogWarning("E-mail naar {Ontvanger} duurde {Milliseconden} ms via {Host}:{Poort}. " + + "Wachten op een vrije draad: {OpDraad} ms, het SMTP-gesprek zelf: " + + "{Gesprek} ms. Draden in de poel: {Draden}, wachtrij: {Wachtrij}.", + werkelijkeOntvanger, klok.ElapsedMilliseconds, _options.Host, _options.Port, + opDraad, klok.ElapsedMilliseconds - opDraad, + ThreadPool.ThreadCount, ThreadPool.PendingWorkItemCount); + } + else + { + _log.LogInformation("E-mail verstuurd naar {Ontvanger} via {Host}:{Poort} in {Milliseconden} ms " + + "(draad: {OpDraad} ms, gesprek: {Gesprek} ms).", + werkelijkeOntvanger, _options.Host, _options.Port, klok.ElapsedMilliseconds, + opDraad, klok.ElapsedMilliseconds - opDraad); + } + } + catch (Exception ex) + { + // Hier staat wat er echt misging; de gebruiker krijgt alleen een nette melding te zien. + _log.LogError(ex, + "Versturen van e-mail naar {Ontvanger} mislukte na {Milliseconden} ms via {Host}:{Poort} " + + "(TLS: {Tls}, gebruiker: {Gebruiker}).", + werkelijkeOntvanger, klok.ElapsedMilliseconds, _options.Host, _options.Port, _options.UseStartTls, + string.IsNullOrEmpty(_options.Username) ? "(geen)" : _options.Username); + throw; + } + } +} diff --git a/src/MijnBesluit.Infrastructure/MijnBesluit.Infrastructure.csproj b/src/MijnBesluit.Infrastructure/MijnBesluit.Infrastructure.csproj new file mode 100644 index 0000000..277d70f --- /dev/null +++ b/src/MijnBesluit.Infrastructure/MijnBesluit.Infrastructure.csproj @@ -0,0 +1,38 @@ + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs b/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs new file mode 100644 index 0000000..e97a0ca --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Persistence/MijnBesluitDbContext.cs @@ -0,0 +1,430 @@ +using Microsoft.EntityFrameworkCore; +using MijnBesluit.Domain.Entities; + +namespace MijnBesluit.Infrastructure.Persistence; + +/// +/// De EF Core-context. De tabel- en kolomnamen komen exact overeen met db/01_schema.sql, +/// zodat je het schema met het SQL-script kunt aanmaken én met EF-migraties kunt beheren. +/// +public class MijnBesluitDbContext : DbContext +{ + public MijnBesluitDbContext(DbContextOptions options) : base(options) { } + + public DbSet Users => Set(); + public DbSet Persons => Set(); + public DbSet Families => Set(); + public DbSet FamilyMembers => Set(); + public DbSet Invitations => Set(); + public DbSet VaultItems => Set(); + public DbSet VaultFiles => Set(); + public DbSet Toestellen => Set(); + public DbSet Sessies => Set(); + public DbSet ContentGrants => Set(); + public DbSet Messages => Set(); + public DbSet DeathDeclarations => Set(); + public DbSet LegacyManagers => Set(); + public DbSet RefreshTokens => Set(); + public DbSet AuditLog => Set(); + public DbSet LoginCodes => Set(); + public DbSet Samenvoegingen => Set(); + public DbSet Adverteerders => Set(); + public DbSet Campagnes => Set(); + public DbSet CampagneUren => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("persons"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.FirstName).HasColumnName("first_name").HasMaxLength(80).IsRequired(); + e.Property(x => x.LastName).HasColumnName("last_name").HasMaxLength(80).IsRequired(); + e.Property(x => x.Nickname).HasColumnName("nickname").HasMaxLength(80); + e.Property(x => x.Email).HasColumnName("email").HasMaxLength(255); + e.Property(x => x.MobileNumber).HasColumnName("mobile_number").HasMaxLength(50); + e.Property(x => x.BirthDate).HasColumnName("birth_date"); + e.Property(x => x.DateOfDeath).HasColumnName("date_of_death"); + e.Property(x => x.CreatedByUserId).HasColumnName("created_by_user_id"); + e.Property(x => x.ManagedByUserId).HasColumnName("managed_by_user_id"); + e.Property(x => x.ClaimedByUserId).HasColumnName("claimed_by_user_id"); + e.Property(x => x.ClaimedAt).HasColumnName("claimed_at"); + e.Property(x => x.MergedIntoPersonId).HasColumnName("merged_into_person_id"); + e.Property(x => x.ObjectionTokenHash).HasColumnName("objection_token_hash").HasColumnType("varbinary(32)"); + e.Property(x => x.ObjectionTokenExpiresAt).HasColumnName("objection_token_expires"); + e.Property(x => x.RequiredDeathDeclarations).HasColumnName("required_death_declarations").HasDefaultValue(3); + e.Property(x => x.DeathObjectionDays).HasColumnName("death_objection_days").HasDefaultValue(14); + e.Property(x => x.DeathConfirmedAt).HasColumnName("death_confirmed_at"); + e.Property(x => x.DeathObjectedAt).HasColumnName("death_objected_at"); + e.Property(x => x.LinkDeclinedByUserId).HasColumnName("link_declined_by_user_id"); + e.Property(x => x.LinkDeclinedAt).HasColumnName("link_declined_at"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + e.Ignore(x => x.IsClaimed); + e.Ignore(x => x.DisplayName); + + // Met opzet NIET uniek, net als in het schema. Twee mensen mogen allebei hun eigen + // aantekening over dezelfde persoon hebben; ze worden pas één zodra die persoon zelf + // zegt dat hij het is. Hier stond wél uniek, en dat sprak het schema tegen sinds + // db/15_adres_niet_uniek.sql - een tegenspraak die pas opvalt als iemand ooit het + // schema uit dit model laat maken. Zie docs/Identiteit.md. + e.HasIndex(x => x.Email).HasDatabaseName("ix_persons_email"); + }); + + b.Entity(e => + { + e.ToTable("users"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.PersonId).HasColumnName("person_id"); + e.Property(x => x.Email).HasColumnName("email").HasMaxLength(255).IsRequired(); + e.Property(x => x.SecurityStamp).HasColumnName("security_stamp"); + e.Property(x => x.WrappedDataKey).HasColumnName("wrapped_data_key").HasColumnType("varbinary(255)"); + e.Property(x => x.Status).HasColumnName("status"); + e.Property(x => x.IsBeheerder).HasColumnName("is_beheerder"); + e.Property(x => x.EmailConfirmedAt).HasColumnName("email_confirmed_at"); + e.Property(x => x.TwoFactorEnabled).HasColumnName("two_factor_enabled"); + e.Property(x => x.FailedLoginCount).HasColumnName("failed_login_count"); + e.Property(x => x.LockedUntil).HasColumnName("locked_until"); + e.Property(x => x.LastLoginAt).HasColumnName("last_login_at"); + e.Property(x => x.VooruitblikDagen).HasColumnName("vooruitblik_dagen"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + e.HasIndex(x => x.Email).IsUnique(); + e.HasOne(x => x.Person).WithMany().HasForeignKey(x => x.PersonId); + }); + + b.Entity(e => + { + e.ToTable("families"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Name).HasColumnName("name").HasMaxLength(120).IsRequired(); + e.Property(x => x.OwnerPersonId).HasColumnName("owner_person_id"); + e.Property(x => x.IsActive).HasColumnName("is_active"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + e.HasOne(x => x.OwnerPerson).WithMany().HasForeignKey(x => x.OwnerPersonId); + }); + + b.Entity(e => + { + e.ToTable("family_members"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.FamilyId).HasColumnName("family_id"); + e.Property(x => x.PersonId).HasColumnName("person_id"); + e.Property(x => x.Relation).HasColumnName("relation"); + e.Property(x => x.RelationLabel).HasColumnName("relation_label").HasMaxLength(50); + e.Property(x => x.AddedByUserId).HasColumnName("added_by_user_id"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.HasIndex(x => new { x.FamilyId, x.PersonId }).IsUnique(); + e.HasOne(x => x.Family).WithMany(f => f.Members).HasForeignKey(x => x.FamilyId); + e.HasOne(x => x.Person).WithMany().HasForeignKey(x => x.PersonId); + }); + + b.Entity(e => + { + e.ToTable("invitations"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.PersonId).HasColumnName("person_id"); + e.Property(x => x.FamilyId).HasColumnName("family_id"); + e.Property(x => x.Email).HasColumnName("email").HasMaxLength(255).IsRequired(); + e.Property(x => x.TokenHash).HasColumnName("token_hash").HasColumnType("varbinary(32)"); + e.Property(x => x.Status).HasColumnName("status"); + e.Property(x => x.CreatedByUserId).HasColumnName("created_by_user_id"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.SentAt).HasColumnName("sent_at"); + e.Property(x => x.ExpiresAt).HasColumnName("expires_at"); + e.Property(x => x.AcceptedAt).HasColumnName("accepted_at"); + e.Property(x => x.ResendCount).HasColumnName("resend_count"); + e.HasIndex(x => x.TokenHash).IsUnique(); + e.HasOne(x => x.Person).WithMany().HasForeignKey(x => x.PersonId); + }); + + b.Entity(e => + { + e.ToTable("vault_files"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.OwnerPersonId).HasColumnName("owner_person_id"); + e.Property(x => x.VaultItemId).HasColumnName("vault_item_id"); + e.Property(x => x.SubjectPersonId).HasColumnName("subject_person_id"); + e.Property(x => x.MessageId).HasColumnName("message_id"); + e.Property(x => x.IsProfilePhoto).HasColumnName("is_profile_photo"); + e.Property(x => x.FileName).HasColumnName("file_name").HasMaxLength(255).IsRequired(); + e.Property(x => x.ContentType).HasColumnName("content_type").HasMaxLength(120); + e.Property(x => x.SizeBytes).HasColumnName("size_bytes"); + e.Property(x => x.StorageKey).HasColumnName("storage_key").HasMaxLength(255).IsRequired(); + e.Property(x => x.Nonce).HasColumnName("nonce").HasColumnType("varbinary(12)"); + e.Property(x => x.Tag).HasColumnName("tag").HasColumnType("varbinary(16)"); + e.Property(x => x.WrappedContentKey).HasColumnName("wrapped_content_key").HasColumnType("varbinary(96)"); + e.Property(x => x.Sha256).HasColumnName("sha256").HasColumnType("varbinary(32)"); + e.Property(x => x.Thumbnail).HasColumnName("thumbnail"); + e.Property(x => x.ThumbnailNonce).HasColumnName("thumbnail_nonce").HasColumnType("varbinary(12)"); + e.Property(x => x.ThumbnailTag).HasColumnName("thumbnail_tag").HasColumnType("varbinary(16)"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.DeletedAt).HasColumnName("deleted_at"); + e.HasIndex(x => x.VaultItemId).HasDatabaseName("ix_vault_files_item"); + }); + + b.Entity(e => + { + e.ToTable("sessies"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.TokenHash).HasColumnName("token_hash").HasColumnType("varbinary(32)").IsRequired(); + e.Property(x => x.UserId).HasColumnName("user_id"); + e.Property(x => x.PersonId).HasColumnName("person_id"); + e.Property(x => x.Toestel).HasColumnName("toestel").HasMaxLength(120); + e.Property(x => x.AangemaaktOp).HasColumnName("aangemaakt_op"); + e.Property(x => x.VerlooptOp).HasColumnName("verloopt_op"); + e.Property(x => x.LaatstGezien).HasColumnName("laatst_gezien"); + e.HasIndex(x => x.TokenHash).IsUnique().HasDatabaseName("ux_sessies_token"); + e.HasIndex(x => x.VerlooptOp).HasDatabaseName("ix_sessies_verloopt"); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId); + }); + + b.Entity(e => + { + e.ToTable("toestellen"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.UserId).HasColumnName("user_id"); + e.Property(x => x.Kenmerk).HasColumnName("kenmerk").HasMaxLength(512).IsRequired(); + e.Property(x => x.Platform).HasColumnName("platform").HasMaxLength(20); + e.Property(x => x.Naam).HasColumnName("naam").HasMaxLength(120); + e.Property(x => x.AangemeldOp).HasColumnName("aangemeld_op"); + e.Property(x => x.LaatstGezien).HasColumnName("laatst_gezien"); + e.Property(x => x.AfgemeldOp).HasColumnName("afgemeld_op"); + e.HasIndex(x => x.Kenmerk).IsUnique().HasDatabaseName("ux_toestellen_kenmerk"); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId); + e.Ignore(x => x.IsActief); + }); + + b.Entity(e => + { + e.ToTable("vault_items"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.OwnerPersonId).HasColumnName("owner_person_id"); + e.Property(x => x.Category).HasColumnName("category"); + e.Property(x => x.Title).HasColumnName("title").HasMaxLength(150).IsRequired(); + e.Property(x => x.Ciphertext).HasColumnName("ciphertext").HasColumnType("longblob"); + e.Property(x => x.Nonce).HasColumnName("nonce").HasColumnType("varbinary(12)"); + e.Property(x => x.Tag).HasColumnName("tag").HasColumnType("varbinary(16)"); + e.Property(x => x.WrappedContentKey).HasColumnName("wrapped_content_key").HasColumnType("varbinary(96)"); + e.Property(x => x.ReleasePolicy).HasColumnName("release_policy"); + e.Property(x => x.ReleaseAt).HasColumnName("release_at"); + e.Property(x => x.VaultFileId).HasColumnName("vault_file_id"); + e.Property(x => x.SharedManagerPersonId).HasColumnName("shared_manager_person_id"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + e.Property(x => x.DeletedAt).HasColumnName("deleted_at"); + e.HasOne(x => x.OwnerPerson).WithMany().HasForeignKey(x => x.OwnerPersonId); + e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId); + }); + + b.Entity(e => + { + e.ToTable("content_grants"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.ResourceType).HasColumnName("resource_type").HasMaxLength(30).IsRequired(); + e.Property(x => x.ResourceId).HasColumnName("resource_id"); + e.Property(x => x.UserId).HasColumnName("user_id"); + e.Property(x => x.WrappedContentKey).HasColumnName("wrapped_content_key").HasColumnType("varbinary(96)"); + e.Property(x => x.Reason).HasColumnName("reason").HasMaxLength(50); + e.Property(x => x.GrantedAt).HasColumnName("granted_at"); + e.Property(x => x.RevokedAt).HasColumnName("revoked_at"); + e.HasIndex(x => new { x.ResourceType, x.ResourceId, x.UserId }).IsUnique(); + }); + + b.Entity(e => + { + e.ToTable("messages"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.FromPersonId).HasColumnName("from_person_id"); + e.Property(x => x.ToPersonId).HasColumnName("to_person_id"); + e.Property(x => x.Kind).HasColumnName("kind"); + e.Property(x => x.Subject).HasColumnName("subject").HasMaxLength(200).IsRequired(); + e.Property(x => x.Ciphertext).HasColumnName("ciphertext").HasColumnType("longblob"); + e.Property(x => x.Nonce).HasColumnName("nonce").HasColumnType("varbinary(12)"); + e.Property(x => x.Tag).HasColumnName("tag").HasColumnType("varbinary(16)"); + e.Property(x => x.WrappedContentKey).HasColumnName("wrapped_content_key").HasColumnType("varbinary(96)"); + e.Property(x => x.VaultFileId).HasColumnName("vault_file_id"); + e.Property(x => x.DeliverAt).HasColumnName("deliver_at"); + e.Property(x => x.DeliverAfterDeath).HasColumnName("deliver_after_death"); + e.Property(x => x.Status).HasColumnName("status"); + e.Property(x => x.DeliveredAt).HasColumnName("delivered_at"); + e.Property(x => x.ReadAt).HasColumnName("read_at"); + // Elk voor zich uit het zicht: zie db/20_bericht_verbergen.sql. + e.Property(x => x.HiddenBySender).HasColumnName("hidden_by_sender"); + e.Property(x => x.HiddenByRecipient).HasColumnName("hidden_by_recipient"); + // Kijklink voor iemand zonder account: zie db/18_kijklink.sql. + e.Property(x => x.ViewTokenHash).HasColumnName("view_token_hash").HasColumnType("varbinary(32)"); + e.Property(x => x.ViewTokenExpiresAt).HasColumnName("view_token_expires"); + e.Property(x => x.ViewTokenSentAt).HasColumnName("view_token_sent_at"); + e.Property(x => x.ViewedAt).HasColumnName("viewed_at"); + e.Property(x => x.ViewReminderSentAt).HasColumnName("view_reminder_sent_at"); + e.Property(x => x.WrappedContentKeyForToken).HasColumnName("wrapped_content_key_for_token").HasColumnType("varbinary(96)"); + e.Property(x => x.WrappedVaultKeyForToken).HasColumnName("wrapped_vault_key_for_token").HasColumnType("varbinary(96)"); + e.HasIndex(x => x.ViewTokenHash).HasDatabaseName("ix_messages_view_token"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + e.HasOne(x => x.FromPerson).WithMany().HasForeignKey(x => x.FromPersonId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.ToPerson).WithMany().HasForeignKey(x => x.ToPersonId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.VaultFile).WithMany().HasForeignKey(x => x.VaultFileId); + }); + + b.Entity(e => + { + e.ToTable("death_declarations"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.PersonId).HasColumnName("person_id"); + e.Property(x => x.DeclaredByUserId).HasColumnName("declared_by_user_id"); + e.Property(x => x.DateOfDeath).HasColumnName("date_of_death"); + e.Property(x => x.Note).HasColumnName("note").HasMaxLength(500); + e.Property(x => x.DeclaredAt).HasColumnName("declared_at"); + e.Property(x => x.RevokedAt).HasColumnName("revoked_at"); + e.Ignore(x => x.IsActive); + e.HasIndex(x => new { x.PersonId, x.DeclaredByUserId }).IsUnique(); + e.HasOne(x => x.Person).WithMany().HasForeignKey(x => x.PersonId); + }); + + b.Entity(e => + { + e.ToTable("legacy_managers"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.PersonId).HasColumnName("person_id"); + e.Property(x => x.ManagerPersonId).HasColumnName("manager_person_id"); + e.Property(x => x.Priority).HasColumnName("priority"); + e.Property(x => x.AcceptedAt).HasColumnName("accepted_at"); + e.Property(x => x.AccessGrantedAt).HasColumnName("access_granted_at"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.HasIndex(x => new { x.PersonId, x.ManagerPersonId }).IsUnique(); + e.HasOne(x => x.Person).WithMany().HasForeignKey(x => x.PersonId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.ManagerPerson).WithMany().HasForeignKey(x => x.ManagerPersonId).OnDelete(DeleteBehavior.Restrict); + }); + + b.Entity(e => + { + e.ToTable("samenvoegingen"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.DuplicaatPersonId).HasColumnName("duplicate_person_id"); + e.Property(x => x.DoelPersonId).HasColumnName("target_person_id"); + e.Property(x => x.DoorUserId).HasColumnName("by_user_id"); + e.Property(x => x.SamengevoegdOp).HasColumnName("merged_at"); + e.Property(x => x.Stappen).HasColumnName("steps").HasColumnType("longtext"); + e.Property(x => x.TeruggedraaidOp).HasColumnName("undone_at"); + e.Property(x => x.TeruggedraaidDoorUserId).HasColumnName("undone_by_user_id"); + e.Property(x => x.Reden).HasColumnName("reason").HasMaxLength(500); + e.Ignore(x => x.IsTeruggedraaid); + e.HasIndex(x => x.DuplicaatPersonId); + }); + + // ------------------------------------------------------------ adverteren + b.Entity(e => + { + e.ToTable("adverteerders"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Bedrijfsnaam).HasColumnName("company_name").HasMaxLength(160); + e.Property(x => x.Contactpersoon).HasColumnName("contact_name").HasMaxLength(160); + e.Property(x => x.Email).HasColumnName("email").HasMaxLength(255); + e.Property(x => x.Telefoon).HasColumnName("phone").HasMaxLength(50); + e.Property(x => x.Adres).HasColumnName("address").HasMaxLength(160); + e.Property(x => x.Postcode).HasColumnName("postal_code").HasMaxLength(20); + e.Property(x => x.Plaats).HasColumnName("city").HasMaxLength(120); + e.Property(x => x.Land).HasColumnName("country").HasMaxLength(80); + e.Property(x => x.Kvk).HasColumnName("coc_number").HasMaxLength(40); + e.Property(x => x.Btw).HasColumnName("vat_number").HasMaxLength(40); + e.Property(x => x.AangemaaktOp).HasColumnName("created_at"); + e.Property(x => x.LaatstGezienOp).HasColumnName("last_seen_at"); + e.Property(x => x.GeblokkeerdOp).HasColumnName("blocked_at"); + e.Ignore(x => x.IsGeblokkeerd); + e.HasIndex(x => x.Email).IsUnique(); + }); + + b.Entity(e => + { + e.ToTable("campagnes"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.AdverteerderId).HasColumnName("advertiser_id"); + e.Property(x => x.Tekst).HasColumnName("text").HasMaxLength(300); + e.Property(x => x.Link).HasColumnName("link").HasMaxLength(500); + e.Property(x => x.Afbeelding).HasColumnName("image").HasColumnType("longblob"); + e.Property(x => x.AfbeeldingSoort).HasColumnName("image_type").HasMaxLength(100); + e.Property(x => x.VanafDatum).HasColumnName("starts_on"); + e.Property(x => x.TotEnMetDatum).HasColumnName("ends_on"); + e.Property(x => x.PerUur).HasColumnName("per_hour"); + e.Property(x => x.BedragCenten).HasColumnName("amount_cents"); + e.Property(x => x.TariefCenten).HasColumnName("rate_cents"); + e.Property(x => x.Betaalkenmerk).HasColumnName("payment_reference").HasMaxLength(40); + e.Property(x => x.BetaaldOp).HasColumnName("paid_at"); + e.Property(x => x.BetaaldAfgevinktDoorUserId).HasColumnName("paid_by_user_id"); + e.Property(x => x.Stand).HasColumnName("state").HasConversion(); + e.Property(x => x.GoedgekeurdOp).HasColumnName("approved_at"); + e.Property(x => x.GoedgekeurdDoorUserId).HasColumnName("approved_by_user_id"); + e.Property(x => x.AfwijzingReden).HasColumnName("rejection_reason").HasMaxLength(500); + e.Property(x => x.AangemaaktOp).HasColumnName("created_at"); + e.Property(x => x.GewijzigdOp).HasColumnName("updated_at"); + e.Ignore(x => x.IsBetaald); + e.Ignore(x => x.HeeftAfbeelding); + e.Ignore(x => x.Dagen); + e.HasIndex(x => x.AdverteerderId); + e.HasIndex(x => new { x.Stand, x.VanafDatum, x.TotEnMetDatum }); + e.HasOne(x => x.Adverteerder).WithMany().HasForeignKey(x => x.AdverteerderId) + .OnDelete(DeleteBehavior.Restrict); + }); + + b.Entity(e => + { + e.ToTable("campagne_uren"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.CampagneId).HasColumnName("campaign_id"); + e.Property(x => x.UurUtc).HasColumnName("hour_utc"); + e.Property(x => x.Vertoningen).HasColumnName("shown"); + e.Property(x => x.Kliks).HasColumnName("clicks"); + e.HasIndex(x => new { x.CampagneId, x.UurUtc }).IsUnique(); + }); + + b.Entity(e => + { + e.ToTable("refresh_tokens"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.UserId).HasColumnName("user_id"); + e.Property(x => x.TokenHash).HasColumnName("token_hash").HasColumnType("varbinary(32)"); + e.Property(x => x.Device).HasColumnName("device").HasMaxLength(120); + e.Property(x => x.CreatedByIp).HasColumnName("created_by_ip").HasMaxLength(45); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.ExpiresAt).HasColumnName("expires_at"); + e.Property(x => x.RevokedAt).HasColumnName("revoked_at"); + e.HasIndex(x => x.TokenHash).IsUnique(); + }); + + b.Entity(e => + { + e.ToTable("login_codes"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Email).HasColumnName("email").HasMaxLength(255).IsRequired(); + e.Property(x => x.Purpose).HasColumnName("purpose"); + e.Property(x => x.CodeHash).HasColumnName("code_hash").HasMaxLength(255).IsRequired(); + e.Property(x => x.FirstName).HasColumnName("first_name").HasMaxLength(80); + e.Property(x => x.LastName).HasColumnName("last_name").HasMaxLength(80); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.ExpiresAt).HasColumnName("expires_at"); + e.Property(x => x.ConsumedAt).HasColumnName("consumed_at"); + e.Property(x => x.SignedInAt).HasColumnName("signed_in_at"); + e.Property(x => x.Attempts).HasColumnName("attempts"); + e.Property(x => x.RequestedFromIp).HasColumnName("requested_from_ip").HasMaxLength(45); + // De index waarop het opzoeken van een openstaande code draait. + e.HasIndex(x => new { x.Email, x.Purpose, x.ExpiresAt }); + }); + + b.Entity(e => + { + e.ToTable("audit_log"); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.UserId).HasColumnName("user_id"); + e.Property(x => x.Action).HasColumnName("action"); + e.Property(x => x.EntityType).HasColumnName("entity_type").HasMaxLength(50); + e.Property(x => x.EntityId).HasColumnName("entity_id"); + e.Property(x => x.IpAddress).HasColumnName("ip_address").HasMaxLength(45); + e.Property(x => x.UserAgent).HasColumnName("user_agent").HasMaxLength(255); + e.Property(x => x.Details).HasColumnName("details").HasMaxLength(1000); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + }); + } +} diff --git a/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs b/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs new file mode 100644 index 0000000..a525c10 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Repositories/Repositories.cs @@ -0,0 +1,667 @@ +using Microsoft.EntityFrameworkCore; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Infrastructure.Persistence; +using MijnBesluit.Application.Models; + +namespace MijnBesluit.Infrastructure.Repositories; + +/// Implementaties op EF Core. Eén context per bewerking, samen opgeslagen in één transactie. +public class EfUnitOfWork : IUnitOfWork +{ + private readonly MijnBesluitDbContext _db; + public EfUnitOfWork(MijnBesluitDbContext db) => _db = db; + public Task SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); +} + +public class UserRepository : IUserRepository +{ + private readonly MijnBesluitDbContext _db; + public UserRepository(MijnBesluitDbContext db) => _db = db; + + // De persoon hoort er altijd bij: een account zonder persoon bestaat niet, en zonder + // Include is user.Person leeg. Dat viel op doordat er bovenaan het scherm een e-mailadres + // stond in plaats van een naam - de terugval als de naam niet bekend is. + public Task FindByIdAsync(long id, CancellationToken ct = default) => + _db.Users.Include(u => u.Person).FirstOrDefaultAsync(u => u.Id == id, ct); + + public Task FindByEmailAsync(string email, CancellationToken ct = default) => + _db.Users.Include(u => u.Person).FirstOrDefaultAsync(u => u.Email == email, ct); + + public Task FindByPersonIdAsync(long personId, CancellationToken ct = default) => + _db.Users.Include(u => u.Person).FirstOrDefaultAsync(u => u.PersonId == personId, ct); + + public Task EmailExistsAsync(string email, CancellationToken ct = default) => + _db.Users.AnyAsync(u => u.Email == email, ct); + + public async Task> ListBeheerdersAsync(CancellationToken ct = default) => + await _db.Users.Where(u => u.IsBeheerder).OrderBy(u => u.Id).ToListAsync(ct); + + public void Add(User user) => _db.Users.Add(user); + public void Remove(User user) => _db.Users.Remove(user); +} + +/// Opslag van de eenmalige codes die per e-mail worden verstuurd. +public class LoginCodeRepository : ILoginCodeRepository +{ + private readonly MijnBesluitDbContext _db; + public LoginCodeRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, + CancellationToken ct = default) => + _db.LoginCodes + .Where(c => c.Email == email && c.Purpose == purpose && c.ConsumedAt == null && c.ExpiresAt > nowUtc) + .OrderByDescending(c => c.CreatedAt) + .FirstOrDefaultAsync(ct); + + public async Task> ListOpenAsync(string email, LoginCodePurpose purpose, DateTime nowUtc, + CancellationToken ct = default) => + await _db.LoginCodes + .Where(c => c.Email == email && c.Purpose == purpose && c.ConsumedAt == null && c.ExpiresAt > nowUtc) + .ToListAsync(ct); + + public Task CountSinceAsync(string email, DateTime sinceUtc, CancellationToken ct = default) => + _db.LoginCodes.CountAsync( + c => c.Email == email && c.CreatedAt >= sinceUtc && c.SignedInAt == null, ct); + + public async Task RemoveExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) + { + var oud = await _db.LoginCodes.Where(c => c.ExpiresAt < olderThanUtc).ToListAsync(ct); + _db.LoginCodes.RemoveRange(oud); + return oud.Count; + } + + public async Task RemoveForEmailAsync(string email, CancellationToken ct = default) + { + var codes = await _db.LoginCodes.Where(c => c.Email == email).ToListAsync(ct); + _db.LoginCodes.RemoveRange(codes); + return codes.Count; + } + + public void Add(LoginCode code) => _db.LoginCodes.Add(code); +} + +public class PersonRepository : IPersonRepository +{ + private readonly MijnBesluitDbContext _db; + public PersonRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindByIdAsync(long id, CancellationToken ct = default) => + _db.Persons.FirstOrDefaultAsync(p => p.Id == id, ct); + + // Geclaimde profielen eerst, en daarna op nummer. Er kunnen er meerdere zijn met hetzelfde + // adres (zie docs/Identiteit.md); wie er één vraagt hoort in elk geval altijd dezelfde terug + // te krijgen, en het profiel van de persoon zelf weegt zwaarder dan andermans aantekening. + public Task FindByEmailAsync(string email, CancellationToken ct = default) => + _db.Persons.Where(p => p.Email == email) + .OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id) + .FirstOrDefaultAsync(ct); + + public async Task> ListByEmailAsync(string email, CancellationToken ct = default) => + await _db.Persons.Where(p => p.Email == email) + .OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id) + .ToListAsync(ct); + + public async Task> ListAantekeningenOverAdresAsync(string email, long gebruikerId, + CancellationToken ct = default) => + await _db.Persons + .Where(p => p.Email == email + && p.ClaimedByUserId == null // nog van niemand zelf + && p.MergedIntoPersonId == null // niet al opgegaan in een ander + && p.ManagedByUserId != null // iemand beheert hem + && p.ManagedByUserId != gebruikerId // en dat ben ik niet zelf + && (p.LinkDeclinedByUserId == null || p.LinkDeclinedByUserId != gebruikerId)) + .OrderBy(p => p.CreatedAt) + .ToListAsync(ct); + + public async Task> ListByIdsAsync(IEnumerable ids, CancellationToken ct = default) + { + var list = ids.Distinct().ToList(); + return await _db.Persons.Where(p => list.Contains(p.Id)).ToListAsync(ct); + } + + public async Task FindByObjectionTokenAsync(byte[] tokenHash, CancellationToken ct = default) => + await _db.Persons.FirstOrDefaultAsync(p => p.ObjectionTokenHash == tokenHash, ct); + + public void Add(Person person) => _db.Persons.Add(person); + public void Remove(Person person) => _db.Persons.Remove(person); +} + +public class FamilyRepository : IFamilyRepository +{ + private readonly MijnBesluitDbContext _db; + public FamilyRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindByIdAsync(long id, CancellationToken ct = default) => + _db.Families.FirstOrDefaultAsync(f => f.Id == id, ct); + + public async Task> ListOwnedByPersonAsync(long personId, CancellationToken ct = default) => + await _db.Families.Where(f => f.OwnerPersonId == personId && f.IsActive).ToListAsync(ct); + + public async Task> ListMembersAsync(long familyId, CancellationToken ct = default) => + await _db.FamilyMembers.Where(m => m.FamilyId == familyId).OrderBy(m => m.Relation).ToListAsync(ct); + + public Task FindMemberAsync(long familyId, long personId, CancellationToken ct = default) => + _db.FamilyMembers.FirstOrDefaultAsync(m => m.FamilyId == familyId && m.PersonId == personId, ct); + + public async Task> ListFamiliesContainingAsync(long personId, CancellationToken ct = default) => + await _db.FamilyMembers.Where(m => m.PersonId == personId) + .Join(_db.Families, m => m.FamilyId, f => f.Id, (m, f) => f) + .Where(f => f.IsActive) + .ToListAsync(ct); + + public async Task> ListCircleAsync(long personId, CancellationToken ct = default) + { + // De families waar hij lid van is óf eigenaar van. Dat laatste erbij omdat een eigenaar + // in theorie zijn eigen lidmaatschap kan hebben weggehaald. + var families = _db.FamilyMembers.Where(m => m.PersonId == personId).Select(m => m.FamilyId) + .Union(_db.Families.Where(f => f.OwnerPersonId == personId).Select(f => f.Id)); + + var kring = await _db.FamilyMembers + .Where(m => families.Contains(m.FamilyId)) + .Select(m => m.PersonId) + .Distinct() + .ToListAsync(ct); + + if (!kring.Contains(personId)) kring.Add(personId); + return kring; + } + + public async Task> ListMembershipsOfPersonAsync(long personId, CancellationToken ct = default) => + await _db.FamilyMembers.Where(m => m.PersonId == personId).ToListAsync(ct); + + public async Task> ListOwnedAsync(long personId, CancellationToken ct = default) => + await _db.Families.Where(f => f.OwnerPersonId == personId).ToListAsync(ct); + + public void Add(Family family) => _db.Families.Add(family); + public void Remove(Family family) => _db.Families.Remove(family); + public void Add(FamilyMember member) => _db.FamilyMembers.Add(member); + public void Remove(FamilyMember member) => _db.FamilyMembers.Remove(member); +} + +public class InvitationRepository : IInvitationRepository +{ + private readonly MijnBesluitDbContext _db; + public InvitationRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindByIdAsync(long id, CancellationToken ct = default) => + _db.Invitations.FirstOrDefaultAsync(i => i.Id == id, ct); + + public Task FindByTokenHashAsync(byte[] tokenHash, CancellationToken ct = default) => + _db.Invitations.FirstOrDefaultAsync(i => i.TokenHash == tokenHash, ct); + + public async Task> ListOpenForPersonAsync(long personId, CancellationToken ct = default) => + await _db.Invitations.Where(i => i.PersonId == personId && + (i.Status == InvitationStatus.Verzonden || i.Status == InvitationStatus.Concept)) + .ToListAsync(ct); + + public Task HasOpenInvitationAsync(long personId, DateTime nowUtc, CancellationToken ct = default) => + _db.Invitations.AnyAsync(i => i.PersonId == personId && i.ExpiresAt > nowUtc && + (i.Status == InvitationStatus.Verzonden || i.Status == InvitationStatus.Concept), ct); + + public async Task> ListLatestForPeopleAsync(IEnumerable personIds, + CancellationToken ct = default) + { + // Bewust een List en geen array. Bij een array kiest een nieuwere C#-compiler + // MemoryExtensions.Contains (de span-versie) in plaats van Enumerable.Contains, en die + // kan Entity Framework niet vertalen: dat geeft pas op de server een ArgumentException + // over ReadOnlySpan. Met een List is het de instantiemethode en is er niets te kiezen. + var ids = personIds.Distinct().ToList(); + if (ids.Count == 0) return Array.Empty(); + + var alle = await _db.Invitations.Where(i => ids.Contains(i.PersonId)).ToListAsync(ct); + + // Groeperen doen we hier en niet in SQL: MariaDB kan geen "laatste rij per groep" zonder + // vensterfuncties, en het gaat om een familie, niet om een miljoen rijen. + return alle.GroupBy(i => i.PersonId) + .Select(g => g.OrderByDescending(i => i.SentAt ?? i.CreatedAt).First()) + .ToList(); + } + + public async Task> ListAllForPersonAsync(long personId, CancellationToken ct = default) => + await _db.Invitations.Where(i => i.PersonId == personId).ToListAsync(ct); + + public async Task> ListSentByUserAsync(long userId, CancellationToken ct = default) => + await _db.Invitations.Where(i => i.CreatedByUserId == userId).ToListAsync(ct); + + public void Add(Invitation invitation) => _db.Invitations.Add(invitation); + public void Remove(Invitation invitation) => _db.Invitations.Remove(invitation); +} + +public class VaultRepository : IVaultRepository +{ + private readonly MijnBesluitDbContext _db; + public VaultRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindByIdAsync(long id, CancellationToken ct = default) => + _db.VaultItems.FirstOrDefaultAsync(v => v.Id == id, ct); + + public async Task> ListForPersonAsync(long personId, VaultCategory? category = null, + CancellationToken ct = default) + { + var q = _db.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null); + if (category is { } c) q = q.Where(v => v.Category == c); + return await q.OrderBy(v => v.Category).ThenBy(v => v.Title).ToListAsync(ct); + } + + public Task FindFileAsync(long id, CancellationToken ct = default) => + _db.VaultFiles.FirstOrDefaultAsync(f => f.Id == id, ct); + + public async Task> ListFilesForItemAsync(long vaultItemId, CancellationToken ct = default) => + await _db.VaultFiles + .Where(f => f.VaultItemId == vaultItemId && f.DeletedAt == null) + .OrderBy(f => f.CreatedAt) + .ToListAsync(ct); + + public async Task> ListFilesForPersonAsync(long subjectPersonId, CancellationToken ct = default) => + await _db.VaultFiles + .Where(f => f.SubjectPersonId == subjectPersonId && f.DeletedAt == null) + .OrderBy(f => f.CreatedAt) + .ToListAsync(ct); + + public async Task> ListFilesForMessageAsync(long messageId, CancellationToken ct = default) => + await _db.VaultFiles + .Where(f => f.MessageId == messageId && f.DeletedAt == null) + .OrderBy(f => f.CreatedAt) + .ToListAsync(ct); + + public async Task> ListProfilePhotosAsync(IEnumerable personIds, + CancellationToken ct = default) + { + // Een List en niet een array: op een array kiest de compiler MemoryExtensions.Contains, + // en dat kan EF niet naar SQL vertalen. Zie de opmerking bij ListByIdsAsync. + var ids = personIds.Distinct().ToList(); + if (ids.Count == 0) return Array.Empty(); + + return await _db.VaultFiles + .Where(f => f.SubjectPersonId != null && ids.Contains(f.SubjectPersonId!.Value) + && f.IsProfilePhoto && f.DeletedAt == null) + .ToListAsync(ct); + } + + public Task FindGrantAsync(string resourceType, long resourceId, long userId, + CancellationToken ct = default) => + _db.ContentGrants.FirstOrDefaultAsync(g => g.ResourceType == resourceType && + g.ResourceId == resourceId && g.UserId == userId, ct); + + public async Task> ListSharedWithAsync(long userId, CancellationToken ct = default) + { + var itemIds = _db.ContentGrants + .Where(g => g.ResourceType == nameof(VaultItem) && g.UserId == userId && g.RevokedAt == null) + .Select(g => g.ResourceId); + + return await _db.VaultItems + .Where(v => itemIds.Contains(v.Id) && v.DeletedAt == null) + .OrderBy(v => v.Category).ThenBy(v => v.Title) + .ToListAsync(ct); + } + + public async Task> ListFilesOwnedByAsync(long ownerPersonId, CancellationToken ct = default) => + await _db.VaultFiles.Where(f => f.OwnerPersonId == ownerPersonId).ToListAsync(ct); + + public void Add(VaultItem item) => _db.VaultItems.Add(item); + public void Add(VaultFile file) => _db.VaultFiles.Add(file); + public void Add(ContentGrant grant) => _db.ContentGrants.Add(grant); + public void Remove(VaultItem item) => _db.VaultItems.Remove(item); + public void Remove(VaultFile file) => _db.VaultFiles.Remove(file); + + public async Task RemoveGrantsForUserAsync(long userId, CancellationToken ct = default) + { + var verleend = await _db.ContentGrants.Where(g => g.UserId == userId).ToListAsync(ct); + _db.ContentGrants.RemoveRange(verleend); + } + + public async Task RevokeGrantsForOwnerAsync(long ownerPersonId, string reason, DateTime nowUtc, + CancellationToken ct = default) + { + var items = _db.VaultItems.Where(i => i.OwnerPersonId == ownerPersonId).Select(i => i.Id); + + var verleend = await _db.ContentGrants + .Where(g => g.ResourceType == nameof(VaultItem) && items.Contains(g.ResourceId) + && g.Reason == reason && g.RevokedAt == null) + .ToListAsync(ct); + + foreach (var toegang in verleend) toegang.RevokedAt = nowUtc; + return verleend.Count; + } + + public async Task RemoveGrantsAsync(long itemId, CancellationToken ct = default) + { + var verleend = await _db.ContentGrants + .Where(g => g.ResourceType == nameof(VaultItem) && g.ResourceId == itemId) + .ToListAsync(ct); + _db.ContentGrants.RemoveRange(verleend); + } +} + +public class MessageRepository : IMessageRepository +{ + private readonly MijnBesluitDbContext _db; + public MessageRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindByIdAsync(long id, CancellationToken ct = default) => + _db.Messages.FirstOrDefaultAsync(m => m.Id == id, ct); + + public async Task> ListOutboxAsync(long fromPersonId, CancellationToken ct = default) => + await _db.Messages.Where(m => m.FromPersonId == fromPersonId && !m.HiddenBySender) + .OrderByDescending(m => m.CreatedAt).ToListAsync(ct); + + public async Task> ListInboxAsync(long toPersonId, CancellationToken ct = default) => + await _db.Messages.Where(m => m.ToPersonId == toPersonId && !m.HiddenByRecipient && + (m.Status == MessageStatus.Bezorgd || m.Status == MessageStatus.Gelezen)) + .OrderByDescending(m => m.DeliveredAt).ToListAsync(ct); + + /// + /// Kandidaten voor bezorging: gepland, en óf er geen datum is (meteen), óf de datum is + /// bereikt, óf het bericht wacht op een overlijden. De definitieve beoordeling doet + /// MessageRelease.IsDue, met de afzender erbij. + /// + /// Een bericht "meteen" heeft geen DeliverAt (null betekent direct, zie Message.cs) en geen + /// DeliverAfterDeath - die viel hier eerder buiten de boot en bleef voor altijd "Gepland" + /// staan, ook al was IsDue() er allang klaar voor. + /// + public async Task> ListDueAsync(DateTime nowUtc, int max, CancellationToken ct = default) => + await _db.Messages + .Where(m => m.Status == MessageStatus.Gepland && + (m.DeliverAfterDeath || + m.DeliverAt == null || + m.DeliverAt <= nowUtc)) + .OrderBy(m => m.DeliverAt) + .Take(max) + .ToListAsync(ct); + + public async Task> ListAwaitingDeathOfAsync(long fromPersonId, CancellationToken ct = default) => + await _db.Messages.Where(m => m.FromPersonId == fromPersonId && m.DeliverAfterDeath && + m.Status == MessageStatus.Gepland).ToListAsync(ct); + + public async Task> ListPlannedForAsync(long toPersonId, CancellationToken ct = default) => + await _db.Messages + .Where(m => m.ToPersonId == toPersonId && m.Status == MessageStatus.Gepland) + .OrderBy(m => m.DeliverAt) + .ToListAsync(ct); + + public async Task> ListAllTouchingAsync(long personId, CancellationToken ct = default) => + await _db.Messages.Where(m => m.FromPersonId == personId || m.ToPersonId == personId).ToListAsync(ct); + + public Task FindByViewTokenHashAsync(byte[] tokenHash, CancellationToken ct = default) => + _db.Messages.FirstOrDefaultAsync(m => m.ViewTokenHash != null && m.ViewTokenHash == tokenHash, ct); + + public void Add(Message message) => _db.Messages.Add(message); + public void Remove(Message message) => _db.Messages.Remove(message); +} + +public class LegacyRepository : ILegacyRepository +{ + private readonly MijnBesluitDbContext _db; + public LegacyRepository(MijnBesluitDbContext db) => _db = db; + + public async Task> ListDeclarationsAsync(long personId, CancellationToken ct = default) => + await _db.DeathDeclarations.Where(d => d.PersonId == personId).ToListAsync(ct); + + public Task FindDeclarationByUserAsync(long personId, long userId, CancellationToken ct = default) => + _db.DeathDeclarations.FirstOrDefaultAsync(d => d.PersonId == personId && d.DeclaredByUserId == userId, ct); + + public async Task> ListManagersForAsync(long personId, CancellationToken ct = default) => + await _db.LegacyManagers.Where(m => m.PersonId == personId).OrderBy(m => m.Priority).ToListAsync(ct); + + public async Task> ListAssignmentsOfAsync(long managerPersonId, CancellationToken ct = default) => + await _db.LegacyManagers.Where(m => m.ManagerPersonId == managerPersonId).ToListAsync(ct); + + public async Task> ListAllDeclarationsAsync(long personId, CancellationToken ct = default) => + await _db.DeathDeclarations.Where(d => d.PersonId == personId).ToListAsync(ct); + + public async Task> ListDeclarationsByUserAsync(long userId, CancellationToken ct = default) => + await _db.DeathDeclarations.Where(d => d.DeclaredByUserId == userId).ToListAsync(ct); + + public void Remove(DeathDeclaration declaration) => _db.DeathDeclarations.Remove(declaration); + public void Add(DeathDeclaration declaration) => _db.DeathDeclarations.Add(declaration); + public void Add(LegacyManager manager) => _db.LegacyManagers.Add(manager); + public void Remove(LegacyManager manager) => _db.LegacyManagers.Remove(manager); +} + +public class SamenvoegRepository : ISamenvoegRepository +{ + private readonly MijnBesluitDbContext _db; + public SamenvoegRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindByIdAsync(long id, CancellationToken ct = default) => + _db.Samenvoegingen.FirstOrDefaultAsync(s => s.Id == id, ct); + + public async Task> ListVoorDuplicaatAsync(long duplicaatPersonId, + CancellationToken ct = default) => + await _db.Samenvoegingen.Where(s => s.DuplicaatPersonId == duplicaatPersonId) + .OrderByDescending(s => s.SamengevoegdOp).ToListAsync(ct); + + public async Task> ListVoorDoelAsync(long doelPersonId, + CancellationToken ct = default) => + await _db.Samenvoegingen.Where(s => s.DoelPersonId == doelPersonId) + .OrderByDescending(s => s.SamengevoegdOp).ToListAsync(ct); + + public void Add(Samenvoeging samenvoeging) => _db.Samenvoegingen.Add(samenvoeging); +} + +/// Schrijft het logboek. Regels worden alleen toegevoegd, nooit gewijzigd. +public class EfAuditLog : IAuditLog +{ + private readonly MijnBesluitDbContext _db; + private readonly IClock _clock; + public EfAuditLog(MijnBesluitDbContext db, IClock clock) { _db = db; _clock = clock; } + + /// Wordt per verzoek gevuld met het IP-adres en het toestel van de aanroeper. + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + + public Task WriteAsync(AuditAction action, long? userId, string? entityType = null, long? entityId = null, + string? details = null, CancellationToken ct = default) + { + _db.AuditLog.Add(new AuditLogEntry + { + Action = action, + UserId = userId, + EntityType = entityType, + EntityId = entityId, + Details = details, + IpAddress = IpAddress, + UserAgent = UserAgent, + CreatedAt = _clock.UtcNow + }); + return Task.CompletedTask; + } +} + +/// +/// Aanmeldingen van de app. Zie voor waarom ze in de database staan. +/// +/// Er komt hier nooit een token binnen, alleen de hash ervan. +/// +public class SessieRepository : ISessieRepository +{ + private readonly MijnBesluitDbContext _db; + public SessieRepository(MijnBesluitDbContext db) => _db = db; + + public Task ZoekAsync(byte[] tokenHash, CancellationToken ct = default) => + _db.Sessies.FirstOrDefaultAsync(s => s.TokenHash == tokenHash, ct); + + public void Add(Sessie sessie) => _db.Sessies.Add(sessie); + + public async Task VerwijderAsync(byte[] tokenHash, CancellationToken ct = default) + { + var sessie = await _db.Sessies.FirstOrDefaultAsync(s => s.TokenHash == tokenHash, ct); + if (sessie is not null) _db.Sessies.Remove(sessie); + } + + public async Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default) + { + var sessies = await _db.Sessies.Where(s => s.UserId == userId).ToListAsync(ct); + _db.Sessies.RemoveRange(sessies); + } + + public async Task RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default) + { + var verlopen = await _db.Sessies.Where(s => s.VerlooptOp <= nu).ToListAsync(ct); + _db.Sessies.RemoveRange(verlopen); + return verlopen.Count; + } +} + +public class ToestelRepository : IToestelRepository +{ + private readonly MijnBesluitDbContext _db; + public ToestelRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindByKenmerkAsync(string kenmerk, CancellationToken ct = default) => + _db.Toestellen.FirstOrDefaultAsync(t => t.Kenmerk == kenmerk, ct); + + public async Task> ListForUserAsync(long userId, CancellationToken ct = default) => + await _db.Toestellen + .Where(t => t.UserId == userId && t.AfgemeldOp == null) + .OrderByDescending(t => t.LaatstGezien) + .ToListAsync(ct); + + public void Add(Toestel toestel) => _db.Toestellen.Add(toestel); + public void Remove(Toestel toestel) => _db.Toestellen.Remove(toestel); +} + +/// +/// Adverteerders, campagnes en de tellers per uur. Zie docs/Adverteren.md. +/// +public class AdvertentieRepository : IAdvertentieRepository +{ + private readonly MijnBesluitDbContext _db; + public AdvertentieRepository(MijnBesluitDbContext db) => _db = db; + + public Task FindAdverteerderAsync(long id, CancellationToken ct = default) => + _db.Adverteerders.FirstOrDefaultAsync(a => a.Id == id, ct); + + public Task FindAdverteerderByEmailAsync(string email, CancellationToken ct = default) => + _db.Adverteerders.FirstOrDefaultAsync(a => a.Email == email, ct); + + public async Task> ListAdverteerdersAsync(CancellationToken ct = default) => + await _db.Adverteerders.OrderBy(a => a.Bedrijfsnaam).ToListAsync(ct); + + public void Add(Adverteerder adverteerder) => _db.Adverteerders.Add(adverteerder); + + // De adverteerder hoort erbij: het beheerscherm laat campagnes zien met de bedrijfsnaam + // ernaast, en zonder Include is die leeg. + public Task FindCampagneAsync(long id, CancellationToken ct = default) => + _db.Campagnes.Include(c => c.Adverteerder).FirstOrDefaultAsync(c => c.Id == id, ct); + + public async Task> ListCampagnesAsync(long adverteerderId, + CancellationToken ct = default) => + await _db.Campagnes.Where(c => c.AdverteerderId == adverteerderId) + .OrderByDescending(c => c.AangemaaktOp).ToListAsync(ct); + + public async Task> ListLopendAsync(DateOnly dag, CancellationToken ct = default) => + await _db.Campagnes + .Where(c => c.Stand == CampagneStand.Loopt && c.VanafDatum <= dag && c.TotEnMetDatum >= dag) + .ToListAsync(ct); + + public async Task> ListWachtendAsync(CancellationToken ct = default) => + await _db.Campagnes.Include(c => c.Adverteerder) + .Where(c => c.Stand == CampagneStand.WachtOpBetaling || + c.Stand == CampagneStand.WachtOpGoedkeuring) + .OrderBy(c => c.AangemaaktOp).ToListAsync(ct); + + public void Add(Campagne campagne) => _db.Campagnes.Add(campagne); + + public Task FindUurAsync(long campagneId, DateTime uurUtc, CancellationToken ct = default) => + _db.CampagneUren.FirstOrDefaultAsync(u => u.CampagneId == campagneId && u.UurUtc == uurUtc, ct); + + public async Task> ListUrenAsync(long campagneId, CancellationToken ct = default) => + await _db.CampagneUren.Where(u => u.CampagneId == campagneId) + .OrderBy(u => u.UurUtc).ToListAsync(ct); + + public async Task> VertoningenInUurAsync( + DateTime uurUtc, IEnumerable campagneIds, CancellationToken ct = default) + { + var lijst = campagneIds.ToList(); + if (lijst.Count == 0) return new Dictionary(); + + return await _db.CampagneUren + .Where(u => u.UurUtc == uurUtc && lijst.Contains(u.CampagneId)) + .ToDictionaryAsync(u => u.CampagneId, u => u.Vertoningen, ct); + } + + /// + /// Eén opdracht naar de database, want twee bezoekers tegelijk zouden anders allebei een + /// nieuwe regel aanmaken en op de unieke sleutel stuklopen. ON DUPLICATE KEY UPDATE is van + /// MariaDB/MySQL; dit project draait nergens anders op. + /// + public Task TelAsync(long campagneId, DateTime uurUtc, int vertoningen, int kliks, + CancellationToken ct = default) => + _db.Database.ExecuteSqlInterpolatedAsync( + $@"INSERT INTO campagne_uren (campaign_id, hour_utc, shown, clicks) + VALUES ({campagneId}, {uurUtc}, {vertoningen}, {kliks}) + ON DUPLICATE KEY UPDATE shown = shown + {vertoningen}, clicks = clicks + {kliks}", ct); + + public async Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default) + { + var uren = await _db.CampagneUren.Where(u => u.CampagneId == campagneId) + .Select(u => new { u.Vertoningen, u.Kliks }).ToListAsync(ct); + + return (uren.Sum(u => u.Vertoningen), uren.Sum(u => u.Kliks)); + } + + public void Add(CampagneUur uur) => _db.CampagneUren.Add(uur); +} + +/// +/// Tellingen voor de statistiektegel op de beheerpagina. Rechtstreeks op de context: dit zijn +/// aantallen over de hele tabel heen, niet iets dat bij één van de andere repositories hoort. +/// +public class StatistiekRepository : IStatistiekRepository +{ + private readonly MijnBesluitDbContext _db; + public StatistiekRepository(MijnBesluitDbContext db) => _db = db; + + public async Task OphalenAsync(int groeidagen, DateTime nowUtc, CancellationToken ct = default) + { + // Samengevoegde profielen tellen niet mee: dat zijn duplicaten die zijn opgegaan in een + // ander profiel, geen extra belasting. + var profielen = await _db.Persons.CountAsync(p => p.MergedIntoPersonId == null, ct); + var geclaimd = await _db.Persons.CountAsync(p => p.MergedIntoPersonId == null && p.ClaimedByUserId != null, ct); + var families = await _db.Families.CountAsync(ct); + var kluisItems = await _db.VaultItems.CountAsync(i => i.DeletedAt == null, ct); + var kluisBestanden = await _db.VaultFiles.CountAsync(ct); + var momenten = await _db.Messages.CountAsync(ct); + var bezorgd = await _db.Messages.CountAsync(m => m.Status == MessageStatus.Bezorgd || m.Status == MessageStatus.Gelezen, ct); + var beheerders = await _db.LegacyManagers.CountAsync(ct); + + var perCategorie = await _db.VaultItems.Where(i => i.DeletedAt == null) + .GroupBy(i => i.Category) + .Select(g => new { Categorie = g.Key, Aantal = g.Count() }) + .ToListAsync(ct); + + // De groeigrafiek: nieuwe profielen en nieuwe kluisitems per dag, de laatste + // `groeidagen` dagen. Twee losse groeperingen en dan samenvoegen, want het zijn twee + // verschillende tabellen. + var vanaf = nowUtc.Date.AddDays(-(groeidagen - 1)); + + var nieuweProfielenPerDag = await _db.Persons + .Where(p => p.CreatedAt >= vanaf && p.MergedIntoPersonId == null) + .GroupBy(p => p.CreatedAt.Date) + .Select(g => new { Datum = g.Key, Aantal = g.Count() }) + .ToListAsync(ct); + + var nieuweItemsPerDag = await _db.VaultItems + .Where(i => i.CreatedAt >= vanaf) + .GroupBy(i => i.CreatedAt.Date) + .Select(g => new { Datum = g.Key, Aantal = g.Count() }) + .ToListAsync(ct); + + var profielenPerDatum = nieuweProfielenPerDag.ToDictionary(x => DateOnly.FromDateTime(x.Datum), x => x.Aantal); + var itemsPerDatum = nieuweItemsPerDag.ToDictionary(x => DateOnly.FromDateTime(x.Datum), x => x.Aantal); + + var groei = new List(); + for (var d = DateOnly.FromDateTime(vanaf); d <= DateOnly.FromDateTime(nowUtc.Date); d = d.AddDays(1)) + { + groei.Add(new GroeiPunt(d, + profielenPerDatum.TryGetValue(d, out var np) ? np : 0, + itemsPerDatum.TryGetValue(d, out var ni) ? ni : 0)); + } + + return new Statistieken(profielen, geclaimd, families, kluisItems, kluisBestanden, momenten, bezorgd, + beheerders, perCategorie.ToDictionary(x => x.Categorie, x => x.Aantal), groei); + } +} diff --git a/src/MijnBesluit.Infrastructure/Security/FileMasterKeyProtector.cs b/src/MijnBesluit.Infrastructure/Security/FileMasterKeyProtector.cs new file mode 100644 index 0000000..2268084 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Security/FileMasterKeyProtector.cs @@ -0,0 +1,108 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text.Json; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Security; + +namespace MijnBesluit.Infrastructure.Security; + +/// +/// De mastersleutel van de server, bewaard in een sleutelbestand naast de applicatie. +/// +/// Het bestand bevat een sleutelring: meerdere genummerde sleutels, waarvan er één de actieve is. +/// In een ingepakte datasleutel staan de eerste twee bytes voor het versienummer, zodat oude +/// sleutels leesbaar blijven na een rotatie. Wil je roteren, voeg dan een nieuwe versie toe en +/// zet current op dat nummer; bestaande gegevens blijven gewoon werken. +/// +/// Dit bestand is het enige dat tussen een gestolen databasedump en leesbare kluisinhoud staat. +/// Bewaar het dus NIET in dezelfde back-up als de database, en beperk de rechten tot het +/// serviceaccount. Voor een productieomgeving is een sleutelkluis of HSM de betere plek: +/// vervang dan alleen deze klasse, de rest van de code verandert niet. +/// +public class FileMasterKeyProtector : IMasterKeyProtector +{ + private readonly Dictionary _keys; + + public int CurrentKeyVersion { get; } + + public FileMasterKeyProtector(string keyFilePath) + { + if (!File.Exists(keyFilePath)) + throw new FileNotFoundException( + $"Sleutelbestand niet gevonden: {keyFilePath}. Maak er één met FileMasterKeyProtector.CreateKeyFile().", + keyFilePath); + + var ring = JsonSerializer.Deserialize(File.ReadAllText(keyFilePath)) + ?? throw new InvalidDataException("Sleutelbestand is leeg of onleesbaar."); + + _keys = ring.Keys.ToDictionary(k => k.Version, k => Convert.FromBase64String(k.Key)); + CurrentKeyVersion = ring.Current; + + if (!_keys.ContainsKey(CurrentKeyVersion)) + throw new InvalidDataException($"Sleutelbestand verwijst naar versie {CurrentKeyVersion}, die er niet in staat."); + foreach (var (version, key) in _keys) + if (key.Length != EnvelopeCrypto.KeySize) + throw new InvalidDataException($"Sleutel {version} heeft niet de vereiste {EnvelopeCrypto.KeySize} bytes."); + } + + public byte[] Protect(byte[] dataKey) + { + var wrapped = EnvelopeCrypto.WrapKey(dataKey, _keys[CurrentKeyVersion]); + var result = new byte[2 + wrapped.Length]; + BinaryPrimitives.WriteUInt16BigEndian(result, (ushort)CurrentKeyVersion); + Buffer.BlockCopy(wrapped, 0, result, 2, wrapped.Length); + return result; + } + + public byte[] Unprotect(byte[] protectedDataKey) + { + if (protectedDataKey.Length < 3) + throw new CryptographicException("Ingepakte datasleutel is beschadigd."); + + var version = BinaryPrimitives.ReadUInt16BigEndian(protectedDataKey); + if (!_keys.TryGetValue(version, out var master)) + throw new CryptographicException( + $"Deze gegevens zijn ingepakt met mastersleutel {version}, die niet in het sleutelbestand staat. " + + "Zonder die sleutel is de inhoud niet meer te openen — zet het sleutelbestand terug uit de back-up."); + + return EnvelopeCrypto.UnwrapKey(protectedDataKey.AsSpan(2).ToArray(), master); + } + + /// + /// Maakt eenmalig een nieuw sleutelbestand aan. Draai dit één keer bij het opzetten van een omgeving + /// en bewaar het resultaat op een veilige plek — raak je het kwijt, dan is de kluis onherstelbaar dicht. + /// + public static void CreateKeyFile(string path, bool overwrite = false) + { + if (File.Exists(path) && !overwrite) + throw new IOException($"Er staat al een sleutelbestand op {path}. Overschrijven maakt alle bestaande kluizen onleesbaar."); + + var ring = new KeyRing + { + Current = 1, + Keys = new List { new() { Version = 1, Key = Convert.ToBase64String(EnvelopeCrypto.NewKey()) } } + }; + + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + + File.WriteAllText(path, JsonSerializer.Serialize(ring, new JsonSerializerOptions { WriteIndented = true })); + + // Op Linux/macOS de rechten meteen dichtzetten; op Windows regel je dat met de NTFS-rechten + // van de map waarin het bestand staat. + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + private class KeyRing + { + public int Current { get; set; } + public List Keys { get; set; } = new(); + } + + private class KeyEntry + { + public int Version { get; set; } + public string Key { get; set; } = string.Empty; + } +} diff --git a/src/MijnBesluit.Infrastructure/Storage/LocalFileStore.cs b/src/MijnBesluit.Infrastructure/Storage/LocalFileStore.cs new file mode 100644 index 0000000..9069246 --- /dev/null +++ b/src/MijnBesluit.Infrastructure/Storage/LocalFileStore.cs @@ -0,0 +1,51 @@ +using MijnBesluit.Application.Abstractions; + +namespace MijnBesluit.Infrastructure.Storage; + +/// +/// Bewaart versleutelde bijlagen op schijf, gesorteerd per maand zodat één map niet +/// volloopt met honderdduizenden bestanden. Wat hier staat is al versleuteld: wie de map +/// kopieert heeft er zonder het sleutelbestand niets aan. +/// +public class LocalFileStore : IFileStore +{ + private readonly string _root; + + public LocalFileStore(string rootPath) + { + _root = rootPath; + Directory.CreateDirectory(_root); + } + + public async Task WriteAsync(byte[] encryptedBytes, string suggestedName, CancellationToken ct = default) + { + var now = DateTime.UtcNow; + var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2")); + Directory.CreateDirectory(Path.Combine(_root, relativeDir)); + + // De oorspronkelijke bestandsnaam gaat bewust niet in het pad: die kan al iets verraden. + var key = Path.Combine(relativeDir, $"{Guid.NewGuid():N}.bin").Replace('\\', '/'); + await File.WriteAllBytesAsync(Resolve(key), encryptedBytes, ct); + return key; + } + + public Task ReadAsync(string storageKey, CancellationToken ct = default) => + File.ReadAllBytesAsync(Resolve(storageKey), ct); + + public Task DeleteAsync(string storageKey, CancellationToken ct = default) + { + var path = Resolve(storageKey); + if (File.Exists(path)) File.Delete(path); + return Task.CompletedTask; + } + + /// Zet een opslagsleutel om naar een pad en weigert alles wat buiten de map zou wijzen. + private string Resolve(string storageKey) + { + var full = Path.GetFullPath(Path.Combine(_root, storageKey)); + var root = Path.GetFullPath(_root); + if (!full.StartsWith(root, StringComparison.Ordinal)) + throw new UnauthorizedAccessException("Ongeldige opslagsleutel."); + return full; + } +} diff --git a/src/MijnBesluit.Protocol/Envelope.cs b/src/MijnBesluit.Protocol/Envelope.cs new file mode 100644 index 0000000..3858100 --- /dev/null +++ b/src/MijnBesluit.Protocol/Envelope.cs @@ -0,0 +1,174 @@ +namespace MijnBesluit.Protocol; + +/// De acties die de SocketServer kent. Eén vaste lijst, gedeeld door server en app. +public static class SocketActions +{ + public const string Ping = "ping"; + + /// Vraagt een inlogcode aan voor een bestaand account. + public const string RequestLoginCode = "auth.request-code"; + + /// Logt in met het e-mailadres en de ontvangen code. + public const string SignIn = "auth.signin"; + + public const string SignOut = "auth.signout"; + + /// Vraagt een code aan om een nieuw account te maken; naam wordt meegestuurd. + public const string RequestRegisterCode = "auth.request-register-code"; + + /// Maakt het account aan met de ontvangen code. + public const string Register = "auth.register"; + + public const string AcceptInvitation = "auth.accept-invitation"; + + /// Je eigen profiel opvragen en wijzigen. + public const string OwnProfile = "account.profile"; + public const string SaveOwnProfile = "account.profile-save"; + + /// Je inlogadres wijzigen: eerst een code naar het nieuwe adres, dan bevestigen. + public const string RequestEmailChange = "account.email-request"; + public const string ConfirmEmailChange = "account.email-confirm"; + + /// + /// Je account opheffen: eerst opvragen wát er weggaat, dan een code per e-mail, dan weg. + /// Onomkeerbaar, en daarom in drie stappen. Zie AccountOpheffenService. + /// + public const string DeleteAccountSummary = "account.delete-summary"; + public const string RequestDeleteAccount = "account.delete-request"; + public const string ConfirmDeleteAccount = "account.delete-confirm"; + + /// + /// Heeft iemand een profiel voor mij aangemaakt? En het antwoord daarop: dat ben ik, of dat + /// ben ik niet. Zie docs/Identiteit.md. + /// + public const string NotesAboutMe = "account.notes"; + public const string AcceptNote = "account.notes-accept"; + public const string DeclineNote = "account.notes-decline"; + + /// Alles wat het beginscherm nodig heeft, in één keer. + public const string Overview = "home.overview"; + + /// Hoeveel dagen vooruit het overzicht kijkt. + public const string SetLookahead = "home.lookahead"; + + /// De advertentie die nu op het overzicht mag staan, of geen. + public const string Advertentie = "home.advertentie"; + + public const string ListFamilies = "family.list"; + public const string ListMembers = "family.members"; + public const string AddMember = "family.add-member"; + public const string UpdatePerson = "family.update-person"; + public const string Invite = "family.invite"; + public const string RemoveMember = "family.remove-member"; + + public const string VaultList = "vault.list"; + public const string VaultOpen = "vault.open"; + public const string VaultSave = "vault.save"; + public const string VaultUpdate = "vault.update"; + public const string VaultDelete = "vault.delete"; + + // Bijlagen. Die gaan in stukken over de lijn, want een socketbericht is begrensd. + public const string FileBegin = "vault.file-begin"; + public const string FileChunk = "vault.file-chunk"; + public const string FileEnd = "vault.file-end"; + public const string FileCancel = "vault.file-cancel"; + public const string FileList = "vault.file-list"; + public const string FileRead = "vault.file-read"; + public const string FileDelete = "vault.file-delete"; + + public const string Inbox = "message.inbox"; + public const string Outbox = "message.outbox"; + public const string MessageRead = "message.read"; + public const string MessageReadSent = "message.read-sent"; + public const string MessageCompose = "message.compose"; + public const string MessageDraft = "message.draft"; + public const string MessageUpdate = "message.update"; + public const string MessageWithdraw = "message.withdraw"; + public const string MessageDelete = "message.delete"; + + public const string DeclareDeath = "legacy.declare-death"; + public const string ObjectDeath = "legacy.object"; + public const string LegacyStatus = "legacy.status"; + public const string AppointManager = "legacy.appoint-manager"; + public const string RemoveManager = "legacy.remove-manager"; + public const string FileListPerson = "vault.file-list-person"; + public const string FileListMessage = "vault.file-list-message"; + public const string FileProfilePhoto = "vault.file-profile"; + + /// Kluisitems die iemand rechtstreeks met jou heeft gedeeld, vooraf te bespreken. + public const string VaultSharedWithMe = "vault.shared-with-me"; + + /// Nalatenschappen die aan jou zijn toevertrouwd, en wat daarvan is vrijgegeven. + public const string LegacyEntrusted = "legacy.entrusted"; + public const string LegacyEntrustedItems = "legacy.entrusted-items"; + + public const string LegacyManagers = "legacy.managers"; + public const string LegacySettings = "legacy.settings"; + + /// Het toestel aanmelden voor meldingen, met het kenmerk van Firebase of APNs. + public const string RegisterDevice = "push.register"; + public const string UnregisterDevice = "push.unregister"; + + /// Een proefmelding naar je eigen toestellen, om te zien of de hele keten werkt. + public const string TestDevice = "push.proef"; +} + +/// Een verzoek van de app aan de SocketServer. +public class SocketRequest +{ + /// Meegestuurd in het antwoord, zodat de app verzoek en antwoord kan koppelen. + public string RequestId { get; set; } = Guid.NewGuid().ToString("N"); + + /// Eén van . + public string Action { get; set; } = string.Empty; + + /// Sessietoken uit . Leeg bij inloggen en registreren. + public string? Token { get; set; } + + /// De gegevens van dit verzoek, als JSON-object. + public System.Text.Json.JsonElement? Payload { get; set; } + + /// Versie van het protocol, zodat oude apps herkend kunnen worden. + public int Version { get; set; } = 1; + + /// Vrije aanduiding van het toestel, komt in het logboek terecht. + public string? Device { get; set; } +} + +/// Het antwoord van de SocketServer. +public class SocketResponse +{ + public string RequestId { get; set; } = string.Empty; + + /// "Ok" of "Error" — dezelfde opzet als de bestaande RenH-socketserver. + public string Status { get; set; } = "Ok"; + + /// Melding in gewone taal, bedoeld om aan de gebruiker te tonen. + public string? Message { get; set; } + + /// + /// Een kort kenmerk van de fout, voor de app om op te reageren. Alleen ingevuld waar de app + /// iets anders moet doen dan de melding tonen - zie . Op de + /// melding zelf sturen zou werken tot iemand er een komma in zet. + /// + public string? Code { get; set; } + + /// Resultaat van de actie, als JSON. + public object? Payload { get; set; } + + public static SocketResponse Ok(string requestId, object? payload = null) => + new() { RequestId = requestId, Status = "Ok", Payload = payload }; + + public static SocketResponse Error(string requestId, string message, string? code = null) => + new() { RequestId = requestId, Status = "Error", Message = message, Code = code }; +} + +/// Fouten waar de app zelf iets mee moet, los van de tekst die de gebruiker leest. +public static class SocketFoutcodes +{ + /// + /// Het token is niet meer geldig. De app hoort dan niet "opnieuw proberen" aan te bieden - + /// dat lukt nooit - maar het bewaarde token weg te gooien en naar het inlogscherm te gaan. + /// + public const string SessieVerlopen = "sessie-verlopen"; +} diff --git a/src/MijnBesluit.Protocol/MijnBesluit.Protocol.csproj b/src/MijnBesluit.Protocol/MijnBesluit.Protocol.csproj new file mode 100644 index 0000000..e8cd599 --- /dev/null +++ b/src/MijnBesluit.Protocol/MijnBesluit.Protocol.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/src/MijnBesluit.Protocol/SocketClient.cs b/src/MijnBesluit.Protocol/SocketClient.cs new file mode 100644 index 0000000..7cc0fef --- /dev/null +++ b/src/MijnBesluit.Protocol/SocketClient.cs @@ -0,0 +1,207 @@ +using System.Collections.Concurrent; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; + +namespace MijnBesluit.Protocol; + +/// +/// De kant van de app: één blijvende verbinding met de SocketServer waarover meerdere verzoeken +/// tegelijk kunnen lopen. Antwoorden worden op teruggekoppeld, +/// en berichten die de server uit zichzelf stuurt komen binnen via . +/// +/// Bewust anders dan RHSocketClient: daar wordt per aanroep een nieuwe verbinding opgezet. Voor een +/// app die op de achtergrond meldingen moet kunnen ontvangen is één open verbinding handiger +/// en scheelt het bij elk verzoek een TCP- en TLS-handdruk. +/// +public class MijnBesluitSocketClient : IAsyncDisposable +{ + private readonly string _host; + private readonly int _port; + private readonly bool _useTls; + private readonly ConcurrentDictionary> _pending = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly SemaphoreSlim _connectLock = new(1, 1); + + private TcpClient? _client; + private Stream? _stream; + private CancellationTokenSource? _readLoopCts; + private Task? _readLoop; + + /// Wordt aangeroepen bij een bericht dat de server uit zichzelf stuurt. + public event Action? PushReceived; + + /// Wordt aangeroepen als de verbinding wegvalt, zodat de app dat kan tonen. + public event Action? Disconnected; + + /// Sessietoken; wordt automatisch bij elk verzoek meegestuurd. + public string? Token { get; set; } + + public string? LastError { get; private set; } + public bool IsConnected => _client?.Connected == true && _stream is not null; + + /// + /// Alleen voor ontwikkelen: accepteert een zelfondertekend servercertificaat. + /// Zet dit in productie nooit aan — dan is TLS zijn waarde kwijt. + /// + public bool AcceptAnyCertificate { get; set; } + + public MijnBesluitSocketClient(string host, int port = 5200, bool useTls = true) + { _host = host; _port = port; _useTls = useTls; } + + /// Zet de verbinding op als die er nog niet is. Meerdere aanroepen tegelijk zijn veilig. + public async Task ConnectAsync(CancellationToken ct = default) + { + if (IsConnected) return true; + + await _connectLock.WaitAsync(ct); + try + { + if (IsConnected) return true; + await CloseAsync(); + + var client = new TcpClient { NoDelay = true }; + using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + try { await client.ConnectAsync(_host, _port, timeout.Token); } + catch (Exception ex) + { + LastError = $"Geen verbinding met {_host}:{_port} ({ex.Message})."; + client.Dispose(); + return false; + } + } + + Stream stream = client.GetStream(); + if (_useTls) + { + var ssl = new SslStream(stream, false, AcceptAnyCertificate ? (_, _, _, _) => true : null); + try + { + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = _host, + EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | + System.Security.Authentication.SslProtocols.Tls13 + }, ct); + } + catch (Exception ex) + { + LastError = $"Beveiligde verbinding mislukt: {ex.Message}"; + await ssl.DisposeAsync(); + client.Dispose(); + return false; + } + stream = ssl; + } + + _client = client; + _stream = stream; + _readLoopCts = new CancellationTokenSource(); + _readLoop = ReadLoopAsync(_readLoopCts.Token); + LastError = null; + return true; + } + finally { _connectLock.Release(); } + } + + /// Stuurt een verzoek en wacht op het antwoord met hetzelfde RequestId. + public async Task SendAsync(string action, object? payload = null, + TimeSpan? timeout = null, CancellationToken ct = default) + { + var request = new SocketRequest + { + Action = action, + Token = Token, + Payload = payload is null ? null + : System.Text.Json.JsonSerializer.SerializeToElement(payload, SocketFraming.Json) + }; + + if (!await ConnectAsync(ct)) + return SocketResponse.Error(request.RequestId, LastError ?? "Geen verbinding met MijnBesluit."); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[request.RequestId] = tcs; + + try + { + await _writeLock.WaitAsync(ct); + try { await SocketFraming.WriteAsync(_stream!, request, ct); } + finally { _writeLock.Release(); } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout ?? TimeSpan.FromSeconds(30)); + using (cts.Token.Register(() => tcs.TrySetCanceled())) + { + return await tcs.Task; + } + } + catch (OperationCanceledException) + { + return SocketResponse.Error(request.RequestId, "De server antwoordde niet op tijd."); + } + catch (Exception ex) + { + LastError = ex.Message; + await CloseAsync(); + return SocketResponse.Error(request.RequestId, $"Verbindingsfout: {ex.Message}"); + } + finally { _pending.TryRemove(request.RequestId, out _); } + } + + private async Task ReadLoopAsync(CancellationToken ct) + { + Exception? reason = null; + try + { + while (!ct.IsCancellationRequested) + { + var response = await SocketFraming.ReadAsync(_stream!, ct); + if (response is null) break; + + if (!string.IsNullOrEmpty(response.RequestId) && + _pending.TryRemove(response.RequestId, out var waiting)) + { + waiting.TrySetResult(response); + } + else + { + PushReceived?.Invoke(response); + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) { reason = ex; } + finally + { + foreach (var p in _pending.Values) p.TrySetCanceled(); + _pending.Clear(); + Disconnected?.Invoke(reason); + } + } + + /// + /// Verbreekt de verbinding, maar houdt het token. De volgende aanroep maakt vanzelf een + /// nieuwe verbinding. + /// + /// Dit is iets anders dan uitloggen: daar gaat het token bij van het toestel af. Dit is + /// alleen de lijn dichtdoen - gebruikt als de app op slot gaat. + /// + public Task VerbreekAsync() => CloseAsync(); + + private async Task CloseAsync() + { + _readLoopCts?.Cancel(); + if (_stream is not null) { try { await _stream.DisposeAsync(); } catch { } } + _client?.Dispose(); + _stream = null; + _client = null; + } + + public async ValueTask DisposeAsync() + { + await CloseAsync(); + GC.SuppressFinalize(this); + } +} diff --git a/src/MijnBesluit.Protocol/SocketFraming.cs b/src/MijnBesluit.Protocol/SocketFraming.cs new file mode 100644 index 0000000..f185899 --- /dev/null +++ b/src/MijnBesluit.Protocol/SocketFraming.cs @@ -0,0 +1,122 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; + +namespace MijnBesluit.Protocol; + +/// +/// Het lijntjesprotocol tussen app en SocketServer. +/// +/// Elk bericht gaat als: [4 bytes lengte, big-endian][UTF-8 JSON]. +/// De lengte staat er dus vóór, waardoor de ontvanger exact weet wanneer een bericht af is. +/// Dat is bewust anders dan de "**BYE**"-markering uit de RenH-socketserver: TCP kent geen +/// berichtgrenzen, dus zoeken naar een markering in de datastroom kan misgaan zodra die +/// tekencombinatie toevallig in de inhoud voorkomt. Met een lengte vooraan kan dat niet. +/// +/// Eén verbinding kan meerdere verzoeken na elkaar afhandelen én de server kan uit zichzelf +/// berichten sturen (bijvoorbeeld: er staat een nieuw bericht voor je klaar). +/// +public static class SocketFraming +{ + /// Bovengrens per bericht, om te voorkomen dat een kapotte of kwaadwillende client het geheugen vol zet. + public const int MaxMessageBytes = 16 * 1024 * 1024; + + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + /// Schrijft één bericht met de lengte ervoor. + public static async Task WriteAsync(Stream stream, T message, CancellationToken ct = default) + { + var json = JsonSerializer.SerializeToUtf8Bytes(message, Json); + if (json.Length > MaxMessageBytes) + throw new InvalidOperationException($"Bericht is te groot ({json.Length} bytes)."); + + var header = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(header, json.Length); + + await stream.WriteAsync(header, ct); + await stream.WriteAsync(json, ct); + await stream.FlushAsync(ct); + } + + /// Leest één volledig bericht. Geeft null als de tegenpartij de verbinding netjes sluit. + public static async Task ReadAsync(Stream stream, CancellationToken ct = default) + { + var header = new byte[4]; + if (!await ReadExactAsync(stream, header, ct)) return default; + + var length = BinaryPrimitives.ReadInt32BigEndian(header); + if (length <= 0 || length > MaxMessageBytes) + throw new InvalidDataException($"Ongeldige berichtlengte: {length}."); + + var body = new byte[length]; + if (!await ReadExactAsync(stream, body, ct)) + throw new InvalidDataException("Verbinding verbroken midden in een bericht."); + + return JsonSerializer.Deserialize(body, Json); + } + + /// Leest net zolang tot de buffer helemaal vol is; TCP levert data in willekeurige brokken aan. + private static async Task ReadExactAsync(Stream stream, byte[] buffer, CancellationToken ct) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset), ct); + if (read == 0) return false; + offset += read; + } + return true; + } + + /// Schrijft één bericht, blokkerend. Voor de leeslus die op een eigen thread draait. + public static void Write(Stream stream, T message) + { + var json = JsonSerializer.SerializeToUtf8Bytes(message, Json); + if (json.Length > MaxMessageBytes) + throw new InvalidOperationException($"Bericht is te groot ({json.Length} bytes)."); + + var header = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(header, json.Length); + + stream.Write(header, 0, header.Length); + stream.Write(json, 0, json.Length); + stream.Flush(); + } + + /// Leest één volledig bericht, blokkerend. Geeft null als de tegenpartij netjes afsluit. + public static T? Read(Stream stream) + { + var header = new byte[4]; + if (!ReadExact(stream, header)) return default; + + var length = BinaryPrimitives.ReadInt32BigEndian(header); + if (length <= 0 || length > MaxMessageBytes) + throw new InvalidDataException($"Ongeldige berichtlengte: {length}."); + + var body = new byte[length]; + if (!ReadExact(stream, body)) + throw new InvalidDataException("Verbinding verbroken midden in een bericht."); + + return JsonSerializer.Deserialize(body, Json); + } + + /// Blokkerende tegenhanger van ReadExactAsync. + private static bool ReadExact(Stream stream, byte[] buffer) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = stream.Read(buffer, offset, buffer.Length - offset); + if (read == 0) return false; + offset += read; + } + return true; + } + + /// Hulpje om de payload van een verzoek uit te pakken naar een concreet type. + public static T? PayloadAs(this SocketRequest request) => + request.Payload is { } element ? element.Deserialize(Json) : default; +} diff --git a/src/MijnBesluit.Protocol/SocketListener.cs b/src/MijnBesluit.Protocol/SocketListener.cs new file mode 100644 index 0000000..894f723 --- /dev/null +++ b/src/MijnBesluit.Protocol/SocketListener.cs @@ -0,0 +1,379 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; + +namespace MijnBesluit.Protocol; + +/// Instellingen van de SocketServer. +public class SocketListenerOptions +{ + public IPAddress Address { get; set; } = IPAddress.Any; + + /// Standaardpoort van MijnBesluit. Draait zelfstandig, dus zonder IIS. + public int Port { get; set; } = 5200; + + /// + /// Servercertificaat voor TLS. Zonder certificaat luistert de server onversleuteld; + /// doe dat alleen lokaal tijdens ontwikkelen — er gaan wachtwoorden en pincodes over deze lijn. + /// + public X509Certificate2? Certificate { get; set; } + + public int MaxConcurrentConnections { get; set; } = 500; + + /// + /// Hoeveel verzoeken van dezelfde verbinding tegelijk mogen worden afgehandeld. + /// Eén app stuurt zelden meer tegelijk; de grens voorkomt dat één verbinding de server bezet houdt. + /// + public int MaxRequestsPerConnection { get; set; } = 8; + + /// + /// Hoeveel verzoeken er over de hele server tegelijk mogen lopen. Elk verzoek krijgt een + /// eigen thread, dus deze grens bepaalt hoeveel threads er maximaal bij kunnen komen. + /// Verzoeken daarboven wachten op de verbindingsthread, wat vanzelf tegendruk geeft. + /// + public int MaxConcurrentRequests { get; set; } = 200; + + /// + /// Stapelgrootte van een verzoekthread. 512 kB is ruim voldoende voor een databaseaanroep + /// en scheelt de helft van wat Windows standaard reserveert. + /// + public int RequestThreadStackBytes { get; set; } = 512 * 1024; + + /// Verbreekt een verbinding waarover een tijd lang niets gebeurt. + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10); + + /// Hoe lang de TLS-handdruk mag duren voordat de verbinding wordt afgebroken. + public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(15); +} + +/// Wat de server over een binnengekomen verbinding weet. +public sealed class SocketConnection +{ + public string Id { get; } = Guid.NewGuid().ToString("N"); + public string? RemoteAddress { get; init; } + public bool IsEncrypted { get; init; } + public DateTime ConnectedAt { get; } = DateTime.UtcNow; + + /// Gevuld zodra de verbinding zich met een geldig token heeft gemeld. + public long? UserId { get; set; } + + /// Naam van de thread die deze verbinding bedient; handig in het logboek. + public string? ThreadName { get; internal set; } + + internal Stream? Stream { get; set; } + + /// + /// Er mag maar één antwoord tegelijk de lijn op. Verzoeken worden parallel afgehandeld, + /// maar het wegschrijven gaat één voor één — anders raken twee berichten door elkaar. + /// + internal object WriteLock { get; } = new(); + + /// Stuurt uit zichzelf een bericht naar deze app, bijvoorbeeld een melding van een nieuw bericht. + public void Push(SocketResponse message) + { + var stream = Stream; + if (stream is null) return; + lock (WriteLock) SocketFraming.Write(stream, message); + } + + /// Zelfde als , voor aanroepers die met await werken. + public Task PushAsync(SocketResponse message, CancellationToken ct = default) + { + Push(message); + return Task.CompletedTask; + } +} + +/// +/// De SocketServer van MijnBesluit: een zelfstandige TCP-luisteraar met TLS. Er komt geen +/// webserver aan te pas. +/// +/// De opzet volgt het model dat in WinDev gebruikelijk is, omdat dat hier ook het beste past: +/// +/// * één thread die niets anders doet dan verbindingen aannemen; +/// * per verbinding een eigen thread met een blokkerende leeslus — zolang die verbinding +/// bestaat is dat dezelfde thread, wat foutzoeken een stuk overzichtelijker maakt (de thread +/// heeft de naam van de verbinding); +/// * elk binnengekomen verzoek op zijn eigen thread, zodat een trage database-actie de +/// volgende verzoeken van diezelfde app niet ophoudt. De leeslus gaat meteen door met lezen. +/// +/// Antwoorden mogen daardoor in een andere volgorde terugkomen dan de verzoeken binnenkwamen; +/// de app koppelt ze op requestId. Het wegschrijven zelf is geserialiseerd, zodat er +/// nooit twee antwoorden door elkaar over de lijn gaan. +/// +public class SocketListener : IAsyncDisposable, IDisposable +{ + private readonly SocketListenerOptions _options; + private readonly Func> _handler; + private readonly Action? _log; + private readonly Action? _fout; + private readonly SemaphoreSlim _verbindingSlots; + private readonly SemaphoreSlim _verzoekSlots; + private readonly List _connections = new(); + private readonly object _sync = new(); + + private TcpListener? _listener; + private CancellationTokenSource? _cts; + private Thread? _acceptThread; + private int _volgnummer; + private int _verzoeknummer; + + public SocketListener(SocketListenerOptions options, + Func> handler, + Action? log = null, + Action? fout = null) + { + _options = options; + _handler = handler; + _log = log; + _fout = fout ?? log; + _verbindingSlots = new SemaphoreSlim(options.MaxConcurrentConnections, options.MaxConcurrentConnections); + _verzoekSlots = new SemaphoreSlim(Math.Max(1, options.MaxConcurrentRequests)); + } + + /// De poort waarop echt geluisterd wordt. Handig als poort 0 is opgegeven. + public int Port => ((IPEndPoint)_listener!.LocalEndpoint).Port; + + /// Alle op dit moment verbonden apps. + public IReadOnlyList Connections { get { lock (_sync) return _connections.ToList(); } } + + public void Start() + { + _cts = new CancellationTokenSource(); + _listener = new TcpListener(_options.Address, _options.Port); + _listener.Start(); + + _log?.Invoke($"SocketServer luistert op {_listener.LocalEndpoint} " + + (_options.Certificate is null ? "(ONVERSLEUTELD - alleen voor lokaal testen)" : "(TLS)")); + + _acceptThread = new Thread(() => AcceptLoop(_cts.Token)) + { + IsBackground = true, + Name = "MijnBesluit-accepteren" + }; + _acceptThread.Start(); + } + + /// Neemt verbindingen aan en geeft ze elk hun eigen thread. Doet verder niets. + private void AcceptLoop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + TcpClient client; + try + { + client = _listener!.AcceptTcpClient(); + } + catch (SocketException) when (ct.IsCancellationRequested) { break; } + catch (ObjectDisposedException) { break; } + catch (Exception ex) + { + _log?.Invoke($"Accepteren mislukt: {ex.Message}"); + continue; + } + + // Vol? Dan de verbinding netjes weigeren in plaats van laten wachten. + if (!_verbindingSlots.Wait(TimeSpan.FromSeconds(2))) + { + _log?.Invoke("Maximum aantal verbindingen bereikt; nieuwe verbinding geweigerd."); + try { client.Close(); } catch { } + continue; + } + + var nummer = Interlocked.Increment(ref _volgnummer); + var thread = new Thread(() => BedienVerbinding(client, ct)) + { + IsBackground = true, + Name = $"MijnBesluit-verbinding-{nummer}" + }; + thread.Start(); + } + } + + /// + /// De hele levensduur van één verbinding, op één thread: TLS opzetten, verzoeken lezen, + /// elk verzoek doorgeven aan een eigen thread, en aan het eind netjes opruimen. + /// + private void BedienVerbinding(TcpClient client, CancellationToken ct) + { + var connection = new SocketConnection + { + RemoteAddress = (client.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString(), + IsEncrypted = _options.Certificate is not null, + ThreadName = Thread.CurrentThread.Name + }; + + lock (_sync) _connections.Add(connection); + + // Zoveel verzoeken van deze ene verbinding mogen tegelijk lopen. + using var verzoekSlots = new SemaphoreSlim(Math.Max(1, _options.MaxRequestsPerConnection)); + var lopendeVerzoeken = new CountdownEvent(1); + + try + { + client.NoDelay = true; + client.ReceiveTimeout = (int)_options.IdleTimeout.TotalMilliseconds; + client.SendTimeout = (int)TimeSpan.FromSeconds(30).TotalMilliseconds; + + Stream stream = client.GetStream(); + + if (_options.Certificate is not null) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + using (var handdruk = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + handdruk.CancelAfter(_options.HandshakeTimeout); + ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = _options.Certificate, + ClientCertificateRequired = false, + EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | + System.Security.Authentication.SslProtocols.Tls13 + }, handdruk.Token).GetAwaiter().GetResult(); + } + stream = ssl; + } + + connection.Stream = stream; + + while (!ct.IsCancellationRequested) + { + SocketRequest? request; + try + { + request = SocketFraming.Read(stream); + } + catch (IOException) + { + // Leestimeout of verbroken verbinding: niets bijzonders, gewoon afsluiten. + break; + } + catch (Exception ex) + { + _log?.Invoke($"Leesfout van {connection.RemoteAddress}: {ex.Message}"); + break; + } + + if (request is null) break; // netjes afgesloten door de app + + // Tegendruk: eerst een plek binnen deze verbinding, daarna een plek op de server. + // Zolang die er niet is, wordt er van deze verbinding even niets meer gelezen. + verzoekSlots.Wait(ct); + _verzoekSlots.Wait(ct); + lopendeVerzoeken.AddCount(); + + // Het verzoek krijgt een eigen thread; deze thread leest meteen verder. + // Bewust een echte thread en niet de threadpool: de afhandeling blokkeert + // (database, bestanden), en dat zou de pool uithongeren — dan gaan verzoeken + // alsnog achter elkaar lopen in plaats van naast elkaar. + var verzoekThread = new Thread(() => + { + try { VerwerkVerzoek(request, connection, stream, ct); } + finally + { + verzoekSlots.Release(); + _verzoekSlots.Release(); + lopendeVerzoeken.Signal(); + } + }, _options.RequestThreadStackBytes) + { + IsBackground = true, + Name = $"MijnBesluit-verzoek-{Interlocked.Increment(ref _verzoeknummer)}" + }; + verzoekThread.Start(); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _log?.Invoke($"Verbinding met {connection.RemoteAddress} beëindigd: {ex.Message}"); + } + finally + { + // Wachten tot de laatste verzoeken van deze verbinding klaar zijn, zodat er niet + // naar een stroom wordt geschreven die al is opgeruimd. + lopendeVerzoeken.Signal(); + try { lopendeVerzoeken.Wait(TimeSpan.FromSeconds(30)); } catch { } + + lock (_sync) _connections.Remove(connection); + connection.Stream = null; + try { client.Close(); } catch { } + _verbindingSlots.Release(); + } + } + + /// Handelt één verzoek af en schrijft het antwoord terug. + private void VerwerkVerzoek(SocketRequest request, SocketConnection connection, Stream stream, CancellationToken ct) + { + SocketResponse response; + try + { + response = _handler(request, connection, ct).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + // Nooit interne details naar buiten: die horen in het logboek, niet op het scherm van de app. + // + // En wel als FOUT en niet als mededeling. Dit is de enige plek waar de reden staat + // waarom een gebruiker "Er ging iets mis" te zien kreeg; ging dat op tussen de + // gewone regels door - of viel het buiten wat het Windows-logboek doorlaat - dan sta + // je naar een lege melding te kijken en begint het gissen. + _fout?.Invoke($"Fout bij '{request.Action}': {ex}"); + response = SocketResponse.Error(request.RequestId, "Er ging iets mis. Probeer het later opnieuw."); + } + + response.RequestId = request.RequestId; + + try + { + lock (connection.WriteLock) + { + if (connection.Stream is not null) SocketFraming.Write(stream, response); + } + } + catch (Exception ex) + { + _log?.Invoke($"Antwoord op '{request.Action}' kon niet worden verstuurd: {ex.Message}"); + } + } + + /// Stuurt een bericht naar alle verbindingen van één gebruiker. + public void PushToUser(long userId, SocketResponse message) + { + foreach (var c in Connections.Where(c => c.UserId == userId)) + { + try { c.Push(message); } catch { /* verbinding weg; volgende keer beter */ } + } + } + + /// Zelfde als , voor aanroepers die met await werken. + public Task PushToUserAsync(long userId, SocketResponse message, CancellationToken ct = default) + { + PushToUser(userId, message); + return Task.CompletedTask; + } + + public void Dispose() + { + _cts?.Cancel(); + try { _listener?.Stop(); } catch { } + + // De verbindingsthreads zijn achtergrondthreads en stoppen vanzelf zodra hun + // socket dichtgaat; hier alleen nog de open verbindingen sluiten. + foreach (var c in Connections) + { + try { c.Stream?.Dispose(); } catch { } + } + + _acceptThread?.Join(TimeSpan.FromSeconds(5)); + _cts?.Dispose(); + GC.SuppressFinalize(this); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/MijnBesluit.Server/Bestandsoverdracht.cs b/src/MijnBesluit.Server/Bestandsoverdracht.cs new file mode 100644 index 0000000..04bc2e7 --- /dev/null +++ b/src/MijnBesluit.Server/Bestandsoverdracht.cs @@ -0,0 +1,145 @@ +using System.Collections.Concurrent; +using MijnBesluit.Application.Abstractions; + +namespace MijnBesluit.Server; + +/// +/// Waar een bijlage bij hoort. Hoogstens één van de drie is gevuld; alle drie leeg mag ook, +/// dan staat het bestand op zichzelf - zoals een video-opname die pas bij het opslaan aan een +/// bericht wordt gehangen. +/// +public record Bijlagedoel(long? KluisItemId = null, long? PersoonId = null, long? BerichtId = null); + +/// +/// Houdt bestanden bij die in stukken binnenkomen of weggaan. +/// +/// Waarom in stukken: één socketbericht is maximaal 16 MB, en de inhoud gaat als tekst +/// (base64) mee, wat er nog een derde bij optelt. Een foto van de telefoon past daar vaak net +/// in, een video nooit. Dus knippen we het op, met een volgnummer erbij zodat de ontvanger +/// merkt als er een stuk mist. +/// +/// Onvoltooide overdrachten worden na een half uur opgeruimd. Zonder dat zou een app die +/// halverwege wegvalt geheugen laten staan tot de service herstart. +/// +public class Bestandsoverdracht +{ + /// Hoeveel bytes er per stuk over de lijn gaan. Base64 maakt er ruim 680 kB van. + public const int StukGrootte = 512 * 1024; + + /// Zo lang blijft een halve overdracht bewaard voordat hij wordt weggegooid. + public static readonly TimeSpan Verlooptijd = TimeSpan.FromMinutes(30); + + /// Zoveel overdrachten tegelijk per gebruiker; meer is geen gebruik maar misbruik. + public const int MaxPerGebruiker = 3; + + private readonly ConcurrentDictionary _lopend = new(); + private readonly IClock _clock; + + public Bestandsoverdracht(IClock clock) => _clock = clock; + + private sealed class Overdracht + { + public required Bijlagedoel? Doel { get; init; } + public required string Id { get; init; } + public required long GebruikerId { get; init; } + public required string Bestandsnaam { get; init; } + public required string Soort { get; init; } + public required long TotaalBytes { get; init; } + public required MemoryStream Inhoud { get; init; } + public int VolgendStuk { get; set; } + public DateTime Begonnen { get; set; } + } + + /// Begint een nieuwe overdracht en geeft het kenmerk terug waarmee de stukken komen. + public string Begin(long gebruikerId, string bestandsnaam, string soort, Bijlagedoel? doel, long totaalBytes) + { + RuimOp(); + + if (_lopend.Values.Count(o => o.GebruikerId == gebruikerId) >= MaxPerGebruiker) + throw new InvalidOperationException("Er lopen al te veel uploads tegelijk. Maak er eerst een af."); + + var id = Guid.NewGuid().ToString("N"); + _lopend[id] = new Overdracht + { + Id = id, + GebruikerId = gebruikerId, + Bestandsnaam = bestandsnaam, + Soort = soort, + Doel = doel, + TotaalBytes = totaalBytes, + Inhoud = new MemoryStream(capacity: (int)Math.Min(totaalBytes, int.MaxValue)), + Begonnen = _clock.UtcNow + }; + return id; + } + + /// Neemt één stuk aan. Het volgnummer moet kloppen, anders is er iets kwijtgeraakt. + public void Stuk(string id, long gebruikerId, int volgnummer, byte[] inhoud) + { + var overdracht = Zoek(id, gebruikerId); + + if (volgnummer != overdracht.VolgendStuk) + throw new InvalidOperationException( + $"Stuk {volgnummer} kwam binnen terwijl {overdracht.VolgendStuk} aan de beurt was. Begin opnieuw."); + + if (overdracht.Inhoud.Length + inhoud.Length > overdracht.TotaalBytes) + throw new InvalidOperationException("Er komt meer binnen dan aangekondigd."); + + overdracht.Inhoud.Write(inhoud, 0, inhoud.Length); + overdracht.VolgendStuk++; + } + + /// Sluit de overdracht af en geeft de volledige inhoud terug. + public (string Bestandsnaam, string Soort, Bijlagedoel? Doel, byte[] Inhoud) Klaar(string id, long gebruikerId) + { + var overdracht = Zoek(id, gebruikerId); + + if (overdracht.Inhoud.Length != overdracht.TotaalBytes) + throw new InvalidOperationException( + $"Er kwam {overdracht.Inhoud.Length} byte binnen van de aangekondigde {overdracht.TotaalBytes}."); + + _lopend.TryRemove(id, out _); + return (overdracht.Bestandsnaam, overdracht.Soort, overdracht.Doel, overdracht.Inhoud.ToArray()); + } + + /// Breekt een overdracht af, bijvoorbeeld als de gebruiker annuleert. + public void Afbreken(string id, long gebruikerId) + { + if (_lopend.TryGetValue(id, out var overdracht) && overdracht.GebruikerId == gebruikerId) + { + _lopend.TryRemove(id, out _); + overdracht.Inhoud.Dispose(); + } + } + + /// Aantal overdrachten dat nu loopt. Alleen om te kunnen controleren. + public int Lopend => _lopend.Count; + + private Overdracht Zoek(string id, long gebruikerId) + { + if (!_lopend.TryGetValue(id, out var overdracht)) + throw new InvalidOperationException("Deze upload is niet (meer) bekend. Begin opnieuw."); + + // Van iemand anders? Dan bestaat hij voor jou niet. + if (overdracht.GebruikerId != gebruikerId) + throw new InvalidOperationException("Deze upload is niet (meer) bekend. Begin opnieuw."); + + if (_clock.UtcNow - overdracht.Begonnen > Verlooptijd) + { + _lopend.TryRemove(id, out _); + throw new InvalidOperationException("Deze upload heeft te lang stilgelegen. Begin opnieuw."); + } + + return overdracht; + } + + private void RuimOp() + { + var nu = _clock.UtcNow; + foreach (var (id, overdracht) in _lopend) + { + if (nu - overdracht.Begonnen <= Verlooptijd) continue; + if (_lopend.TryRemove(id, out var weg)) weg.Inhoud.Dispose(); + } + } +} diff --git a/src/MijnBesluit.Server/MijnBesluit.Server.csproj b/src/MijnBesluit.Server/MijnBesluit.Server.csproj new file mode 100644 index 0000000..aaf11d4 --- /dev/null +++ b/src/MijnBesluit.Server/MijnBesluit.Server.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + + + + + + diff --git a/src/MijnBesluit.Server/SessionStore.cs b/src/MijnBesluit.Server/SessionStore.cs new file mode 100644 index 0000000..2b65d4a --- /dev/null +++ b/src/MijnBesluit.Server/SessionStore.cs @@ -0,0 +1,225 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Application.Security; + +namespace MijnBesluit.Server; + +/// Een ingelogde sessie van de app. +public sealed class Session +{ + public required string Token { get; init; } + public required long UserId { get; init; } + public required long PersonId { get; init; } + public string? Device { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime ExpiresAt { get; set; } + + /// + /// Tot wanneer de database denkt dat deze sessie loopt. Zie : de + /// vervaltijd schuift bij elk verzoek op, maar wordt niet bij elk verzoek weggeschreven. + /// + internal DateTime OpgeslagenTot { get; set; } +} + +/// Wat er van een bewaarde sessie terugkomt. Het token staat er niet in - dat kent alleen het toestel. +public sealed record Sessiegegevens(long UserId, long PersonId, string? Toestel, + DateTime AangemaaktOp, DateTime VerlooptOp); + +/// +/// Waar sessies blijven staan als de service opnieuw start. +/// +/// Deze laag krijgt nooit een token te zien, alleen de hash ervan: ze hoeft het token niet te +/// kennen en hoort het dus ook niet te krijgen. +/// +public interface ISessiebewaring +{ + Task BewaarAsync(byte[] hash, Sessiegegevens sessie, DateTime nu, CancellationToken ct = default); + Task ZoekAsync(byte[] hash, CancellationToken ct = default); + Task SchuifOpAsync(byte[] hash, DateTime verlooptOp, DateTime nu, CancellationToken ct = default); + Task VerwijderAsync(byte[] hash, CancellationToken ct = default); + Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default); + Task RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default); +} + +/// +/// De sessies van de SocketServer: in het geheugen om snel te zijn, en in de database om een +/// herstart te overleven. +/// +/// Waarom dat tweede erbij is gekomen: de app blijft aangemeld en gaat daarna open met een +/// vingerafdruk of de code van de telefoon. Stonden de sessies alleen in het geheugen, dan was +/// elke herstart van de service genoeg om iedereen weer een code per e-mail te laten opvragen - +/// een uitrol, een Windows-update 's nachts, een reboot van de server. Het toestel wist dan nog +/// wie je was, de server niet meer, en juist dat zou de vingerafdruk moeten voorkomen. +/// +/// Het geheugen blijft ervoor zorgen dat een gewoon verzoek geen database aanraakt. De database +/// wordt pas geraadpleegd bij een token dat we niet in het geheugen hebben - dus feitelijk bij +/// het eerste verzoek van elk toestel na een herstart. +/// +/// Wat er bewaard wordt is de SHA-256 van het token, nooit het token zelf. Wie de database in +/// handen krijgt, krijgt dus geen werkende sessies mee. +/// +/// Zonder bewaring werkt alles gewoon, alleen zijn de sessies dan weg na een herstart. Zo hoeven +/// de zelftests geen database te hebben. +/// +public class SessionStore +{ + private readonly ConcurrentDictionary _sessions = new(); + private readonly IClock _clock; + private readonly TimeSpan _lifetime; + private readonly ISessiebewaring? _bewaring; + + /// + /// Hoeveel de vervaltijd mag zijn opgeschoven voordat we dat ook echt wegschrijven. + /// + /// Elk verzoek schuift de termijn op. Dat ook elke keer opslaan zou een schrijfactie per + /// aanroep betekenen, voor iets waar bij een termijn van veertien dagen een uur speling + /// niets uitmaakt. Het ergste geval is dat een sessie na een herstart een uur eerder + /// verloopt dan hij had gemogen. + /// + public static readonly TimeSpan SchuifopDrempel = TimeSpan.FromHours(1); + + public SessionStore(IClock clock, TimeSpan? lifetime = null, ISessiebewaring? bewaring = null) + { + _clock = clock; + _lifetime = lifetime ?? TimeSpan.FromDays(14); + _bewaring = bewaring; + } + + /// De hash waaronder een token in de database staat. + public static byte[] Hash(string token) => SHA256.HashData(Encoding.UTF8.GetBytes(token)); + + public async Task CreateAsync(long userId, long personId, string? device, + CancellationToken ct = default) + { + var now = _clock.UtcNow; + var session = new Session + { + Token = TokenGenerator.Create(48), + UserId = userId, + PersonId = personId, + Device = device, + CreatedAt = now, + ExpiresAt = now.Add(_lifetime), + OpgeslagenTot = now.Add(_lifetime) + }; + _sessions[session.Token] = session; + + if (_bewaring is not null) + { + await _bewaring.BewaarAsync(Hash(session.Token), + new Sessiegegevens(userId, personId, device, now, session.ExpiresAt), now, ct); + } + + return session; + } + + /// + /// Zoekt de sessie bij een token en schuift de vervaltijd op. Null als het token niet (meer) + /// geldig is. + /// + /// Kennen we het token niet uit het geheugen, dan kijken we in de database. Dat is het geval + /// waar deze hele tabel voor bestaat: het eerste verzoek van een toestel nadat de service + /// opnieuw is gestart. + /// + /// Gaat de database niet, dan komt die fout hier naar buiten en wordt hij niet stilzwijgend + /// een "je sessie is verlopen". Dat verschil is belangrijk: bij dat laatste gooit de app het + /// token weg en moet iemand een code uit zijn mail halen terwijl er niets mis was met zijn + /// sessie. + /// + public async Task ValidateAsync(string? token, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(token)) return null; + + var now = _clock.UtcNow; + + if (!_sessions.TryGetValue(token, out var session)) + { + session = await OphalenAsync(token, now, ct); + if (session is null) return null; + } + + if (session.ExpiresAt <= now) + { + _sessions.TryRemove(token, out _); + if (_bewaring is not null) await _bewaring.VerwijderAsync(Hash(token), ct); + return null; + } + + session.ExpiresAt = now.Add(_lifetime); + + // Niet bij elk verzoek naar de database; zie SchuifopDrempel. + if (_bewaring is not null && session.ExpiresAt - session.OpgeslagenTot > SchuifopDrempel) + { + session.OpgeslagenTot = session.ExpiresAt; + await _bewaring.SchuifOpAsync(Hash(token), session.ExpiresAt, now, ct); + } + + return session; + } + + /// Haalt een sessie uit de database terug in het geheugen. + private async Task OphalenAsync(string token, DateTime now, CancellationToken ct) + { + if (_bewaring is null) return null; + + var bewaard = await _bewaring.ZoekAsync(Hash(token), ct); + if (bewaard is null) return null; + + if (bewaard.VerlooptOp <= now) + { + await _bewaring.VerwijderAsync(Hash(token), ct); + return null; + } + + var session = new Session + { + Token = token, + UserId = bewaard.UserId, + PersonId = bewaard.PersonId, + Device = bewaard.Toestel, + CreatedAt = bewaard.AangemaaktOp, + ExpiresAt = bewaard.VerlooptOp, + OpgeslagenTot = bewaard.VerlooptOp + }; + + _sessions[token] = session; + return session; + } + + public async Task RemoveAsync(string? token, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(token)) return; + + _sessions.TryRemove(token, out _); + if (_bewaring is not null) await _bewaring.VerwijderAsync(Hash(token), ct); + } + + /// Beëindigt alle sessies van één gebruiker, bijvoorbeeld bij het opheffen van een account. + public async Task RemoveAllForAsync(long userId, CancellationToken ct = default) + { + foreach (var kv in _sessions.Where(kv => kv.Value.UserId == userId)) + _sessions.TryRemove(kv.Key, out _); + + if (_bewaring is not null) await _bewaring.VerwijderAllesVanAsync(userId, ct); + } + + /// Ruimt verlopen sessies op; draaien als achtergrondtaak. + public async Task SweepAsync(CancellationToken ct = default) + { + var now = _clock.UtcNow; + var removed = 0; + foreach (var kv in _sessions.Where(kv => kv.Value.ExpiresAt <= now)) + if (_sessions.TryRemove(kv.Key, out _)) removed++; + + var uitDeDatabase = _bewaring is not null ? await _bewaring.RuimVerlopenOpAsync(now, ct) : 0; + + // Niet optellen: dezelfde sessie staat meestal op allebei de plekken. Het getal is + // alleen voor het logboek, en dan is het grootste van de twee het eerlijkste. + return Math.Max(removed, uitDeDatabase); + } + + /// Hoeveel sessies er op dit moment in het geheugen staan. Alleen om te tonen. + public int Count => _sessions.Count; +} diff --git a/src/MijnBesluit.Server/SocketDispatcher.cs b/src/MijnBesluit.Server/SocketDispatcher.cs new file mode 100644 index 0000000..3e45649 --- /dev/null +++ b/src/MijnBesluit.Server/SocketDispatcher.cs @@ -0,0 +1,925 @@ +using MijnBesluit.Application.Models; +using MijnBesluit.Application.Services; +using MijnBesluit.Domain.Enums; +using MijnBesluit.Protocol; + +namespace MijnBesluit.Server; + +/// Alles wat één verzoek nodig heeft. De host levert dit per verzoek aan, met een verse databasecontext. +public class RequestScope +{ + public required AccountService Accounts { get; init; } + public required FamilyService Families { get; init; } + public required VaultService Vault { get; init; } + public required MessageService Messages { get; init; } + public required LegacyService Legacy { get; init; } + public required OverzichtService Overzicht { get; init; } + + /// + /// Voor het samenvoegen van dubbele profielen. Optioneel: zonder deze dienst werkt alles, + /// alleen wordt een tweede uitnodiging dan geweigerd in plaats van samengevoegd. + /// + public SamenvoegService? Samenvoegen { get; init; } + + /// Optioneel: zonder meldingen werkt de rest gewoon. + public MeldingService? Meldingen { get; init; } + + /// Optioneel: zonder deze dienst komt er simpelweg geen advertentie op het overzicht. + public AdvertentieService? Adverteren { get; init; } + + /// + /// Het opheffen van een account. Optioneel, zodat een server zonder deze dienst gewoon draait; + /// de knop in de app krijgt dan een nette melding in plaats van een foutpagina. + /// + public AccountOpheffenService? Opheffen { get; init; } +} + +/// +/// Vertaalt een binnengekomen socketverzoek naar een aanroep van de Application-laag. +/// Alle regels zitten in die laag, niet hier: de website en de app dwingen dus precies +/// dezelfde dingen af, en er is maar één plek waar iets fout kan gaan. +/// +public class SocketDispatcher +{ + private readonly SessionStore _sessions; + private readonly Func>, Task> _withScope; + private readonly Bestandsoverdracht _overdracht; + private readonly Action? _traagVerzoek; + + /// Vanaf hoeveel milliseconden we een verzoek traag noemen en dat melden. + public const int TraagVanafMs = 2000; + + /// + /// Wordt aangeroepen met de actie en de duur zodra een verzoek langer duurt dan + /// . Hiermee komt in het logboek te staan wélke actie traag was; + /// zonder dat staar je naar een gebruiker die wacht en weet je niet waar het zit. + /// Optioneel, zodat de zelftest hem niet hoeft mee te geven. + /// + public SocketDispatcher(SessionStore sessions, + Func>, Task> withScope, + Bestandsoverdracht? overdracht = null, + Action? traagVerzoek = null) + { + _sessions = sessions; + _withScope = withScope; + _overdracht = overdracht ?? new Bestandsoverdracht(new SysteemKlok()); + _traagVerzoek = traagVerzoek; + } + + /// + /// Hoe een bijlage over de lijn gaat. Eén plek, zodat de lijst bij een kluisitem, bij een + /// persoon en bij een bericht er precies hetzelfde uitziet en de app er één model voor heeft. + /// + private static object Bijlagen(IEnumerable bestanden) => + bestanden.Select(f => new + { + f.Id, f.FileName, f.ContentType, f.SizeBytes, f.CreatedAt, f.IsProfilePhoto + }); + + /// Voor als er geen klok wordt meegegeven; de overdracht heeft er wel een nodig. + private sealed class SysteemKlok : MijnBesluit.Application.Abstractions.IClock + { + public DateTime UtcNow => DateTime.UtcNow; + } + + /// Acties die zonder geldige sessie mogen. + private static readonly HashSet Anonymous = new() + { + SocketActions.Ping, SocketActions.RequestLoginCode, SocketActions.SignIn, + SocketActions.RequestRegisterCode, SocketActions.Register, SocketActions.AcceptInvitation + }; + + /// + /// Alle acties die deze versie van de server kent, rechtstreeks uit . + /// Zo kan er geen actie bijkomen die hier vergeten wordt. + /// + private static readonly HashSet Bekend = typeof(SocketActions) + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToHashSet(StringComparer.Ordinal); + + public async Task HandleAsync(SocketRequest request, SocketConnection connection, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Action)) + return SocketResponse.Error(request.RequestId, "Geen actie opgegeven."); + + // Eerst kijken of we de actie überhaupt kennen, en pas daarna naar de sessie. Andersom + // krijgt een app die nieuwer is dan de server "log opnieuw in" te zien, en gaat iemand + // uren zoeken in de verkeerde hoek. Nu staat er meteen dat de server de actie niet kent. + if (!Bekend.Contains(request.Action)) + return SocketResponse.Error(request.RequestId, $"Onbekende actie '{request.Action}'."); + + Session? session = null; + if (!Anonymous.Contains(request.Action)) + { + session = await _sessions.ValidateAsync(request.Token, ct); + if (session is null) + return SocketResponse.Error(request.RequestId, "Je sessie is verlopen. Log opnieuw in.", + SocketFoutcodes.SessieVerlopen); + connection.UserId = session.UserId; + } + + var klok = System.Diagnostics.Stopwatch.StartNew(); + var antwoord = await _withScope(scope => DispatchAsync(request, session, connection, scope, ct)); + klok.Stop(); + + if (klok.ElapsedMilliseconds > TraagVanafMs) + _traagVerzoek?.Invoke(request.Action, klok.ElapsedMilliseconds); + + return antwoord; + } + + private async Task DispatchAsync(SocketRequest request, Session? session, + SocketConnection connection, RequestScope scope, + CancellationToken ct) + { + var id = request.RequestId; + + switch (request.Action) + { + case SocketActions.Ping: + return SocketResponse.Ok(id, new { tijd = DateTime.UtcNow, versleuteld = connection.IsEncrypted }); + + case SocketActions.RequestLoginCode: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres in."); + + var r = await scope.Accounts.VraagInlogcodeAanAsync(p.Email, connection.RemoteAddress, ct); + return r.Succeeded + ? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.SignIn: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres en de code in."); + + var result = await scope.Accounts.LogInMetCodeAsync(p.Email, p.Code, ct); + if (!result.Succeeded) return SocketResponse.Error(id, result.Error!); + + var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct); + connection.UserId = s.UserId; + return SocketResponse.Ok(id, new { token = s.Token, verlooptOp = s.ExpiresAt, persoonId = s.PersonId }); + } + + case SocketActions.SignOut: + await _sessions.RemoveAsync(request.Token, ct); + connection.UserId = null; + return SocketResponse.Ok(id); + + case SocketActions.RequestRegisterCode: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld."); + + var r = await scope.Accounts.VraagRegistratiecodeAanAsync(p.Email, p.FirstName, p.LastName, + connection.RemoteAddress, ct); + return r.Succeeded + ? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.Register: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Vul je e-mailadres en de code in."); + + var result = await scope.Accounts.RegistreerMetCodeAsync(p.Email, p.Code, ct); + if (!result.Succeeded) return SocketResponse.Error(id, result.Error!); + + var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct); + connection.UserId = s.UserId; + return SocketResponse.Ok(id, new { token = s.Token, persoonId = s.PersonId }); + } + + case SocketActions.AcceptInvitation: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen uitnodiging meegegeven."); + + // Is er een geldige sessie meegestuurd, dan neemt iemand die hier al is een + // tweede uitnodiging aan. Dan voegen we samen in plaats van een tweede account + // te maken. Deze actie mag ook zonder sessie, dus dit mag niet hard mislukken. + var ingelogd = (await _sessions.ValidateAsync(request.Token, ct))?.UserId; + + var result = await scope.Families.AcceptInvitationAsync( + p.Token, scope.Accounts, ct, ingelogd, scope.Samenvoegen); + if (!result.Succeeded) return SocketResponse.Error(id, result.Error!); + + var s = await _sessions.CreateAsync(result.Value!.Id, result.Value.PersonId, request.Device, ct); + return SocketResponse.Ok(id, new { token = s.Token, persoonId = s.PersonId }); + } + + case SocketActions.OwnProfile: + { + var r = await scope.Accounts.EigenProfielAsync(session!.UserId, ct); + if (!r.Succeeded) return SocketResponse.Error(id, r.Error!); + + // Je eigen kleine profielfoto gaat mee. De app toont hem op meer schermen dan + // alleen je profiel - in de kluis staat hij als tekentje bij "Alleen voor + // mijzelf" - en dit antwoord komt er bij het opstarten toch al. Een eigen ronde + // langs de server voor een plaatje van veertig pixels zou meer kosten dan het + // plaatje zelf. + var fotos = await KleineFotosAsync(scope, new[] { r.Value!.PersoonId }, session.UserId, ct); + fotos.TryGetValue(r.Value.PersoonId, out var klein); + + return SocketResponse.Ok(id, new + { + r.Value.PersoonId, + r.Value.Voornaam, + r.Value.Achternaam, + r.Value.Email, + r.Value.Geboortedatum, + r.Value.LidSinds, + profielfoto = klein + }); + } + + case SocketActions.SaveOwnProfile: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven."); + + var r = await scope.Accounts.WijzigEigenProfielAsync( + session!.UserId, p.FirstName, p.LastName, p.BirthDate, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { gelukt = true }) + : SocketResponse.Error(id, r.Error!); + } + + // ------------------------------------------------ aantekeningen over mij + // + // "Heeft iemand een profiel voor mij aangemaakt?" Het adres komt uit de sessie en + // niet uit het verzoek: je vraagt dit alleen over jezelf, en dan hoort er ook geen + // adres meegestuurd te worden dat iemand kan veranderen. Zie docs/Identiteit.md. + case SocketActions.NotesAboutMe: + { + var lijst = await scope.Accounts.AantekeningenOverMijAsync(session!.UserId, ct); + return SocketResponse.Ok(id, lijst.Select(a => new + { + persoonId = a.PersoonId, van = a.Van, relatie = a.Relatie, aangemaaktOp = a.AangemaaktOp + })); + } + + case SocketActions.AcceptNote: + { + if (scope.Samenvoegen is null) return SocketResponse.Error(id, "Dit kan hier niet."); + + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen profiel meegegeven."); + + var r = await scope.Accounts.NeemAantekeningOverAsync(p.PersonId, session!.UserId, + scope.Samenvoegen, ct); + return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.DeclineNote: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen profiel meegegeven."); + + var r = await scope.Accounts.WijsAantekeningAfAsync(p.PersonId, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.DeleteAccountSummary: + { + if (scope.Opheffen is null) return SocketResponse.Error(id, "Dit kan hier niet."); + + var r = await scope.Opheffen.TelOpAsync(session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.RequestDeleteAccount: + { + // Het adres komt uit de sessie en niet uit het verzoek: je kunt alleen je eigen + // account opheffen, en dan hoort er ook geen adres meegestuurd te worden dat + // iemand kan veranderen. + var ik = await scope.Accounts.EigenProfielAsync(session!.UserId, ct); + if (!ik.Succeeded) return SocketResponse.Error(id, ik.Error!); + + var r = await scope.Accounts.VraagOpheffencodeAanAsync(ik.Value!.Email, null, ct); + return r.Succeeded + ? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.ConfirmDeleteAccount: + { + if (scope.Opheffen is null) return SocketResponse.Error(id, "Dit kan hier niet."); + + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen code meegegeven."); + + var ik = await scope.Accounts.EigenProfielAsync(session!.UserId, ct); + if (!ik.Succeeded) return SocketResponse.Error(id, ik.Error!); + + var controle = await scope.Accounts.ControleerOpheffencodeAsync(ik.Value!.Email, p.Code, ct); + if (!controle.Succeeded) return SocketResponse.Error(id, controle.Error!); + if (controle.Value!.Id != session.UserId) + return SocketResponse.Error(id, "Deze code hoort niet bij dit account."); + + var weg = await scope.Opheffen.VerwijderAsync(session.UserId, ct); + if (!weg.Succeeded) return SocketResponse.Error(id, weg.Error!); + + // Alle sessies van dit account eraf - ook die op andere toestellen. Er is niets + // meer om mee te werken, en een openstaande sessie op een account dat niet meer + // bestaat geeft bij elk volgend verzoek een fout waar niemand iets van begrijpt. + await _sessions.RemoveAllForAsync(session.UserId, ct); + + return SocketResponse.Ok(id, new { gelukt = true }); + } + + case SocketActions.RequestEmailChange: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen e-mailadres meegegeven."); + + var r = await scope.Accounts.VraagAdreswijzigingAanAsync(session!.UserId, p.Email, null, ct); + return r.Succeeded + ? SocketResponse.Ok(id, new { verstuurdNaar = r.Value!.AfgeschermdAdres, geldigTot = r.Value.GeldigTot }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.ConfirmEmailChange: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen code meegegeven."); + + var r = await scope.Accounts.BevestigAdreswijzigingAsync(session!.UserId, p.Email, p.Code, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { gelukt = true }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.Overview: + { + var r = await scope.Overzicht.VoorAsync(session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.SetLookahead: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen aantal dagen meegegeven."); + var r = await scope.Overzicht.VooruitblikAsync(session!.UserId, p.Days, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { dagen = p.Days }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.ListFamilies: + { + // De app weet bij het opstarten nog niet welke familie van wie is; hiermee + // haalt hij dat op, inclusief de families waar hij alleen lid van is. + var eigen = await scope.Families.ListOwnedAsync(session!.PersonId, ct); + var lid = await scope.Families.ListMemberOfAsync(session.PersonId, ct); + + return SocketResponse.Ok(id, eigen + .Select(f => new { f.Id, f.Name, eigenaar = true }) + .Concat(lid.Where(f => eigen.All(e => e.Id != f.Id)) + .Select(f => new { f.Id, f.Name, eigenaar = false }))); + } + + case SocketActions.ListMembers: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen familie opgegeven."); + var leden = await scope.Families.ListMembersAsync(p.FamilyId, session!.UserId, ct); + + // De profielfoto's gaan als kleine versie mee in hetzelfde antwoord; zie + // KleineFotosAsync. Het nummer gaat ook mee, voor als de app de grote wil. + var bestanden = (await scope.Vault.ProfielfotosAsync(leden.Select(l => l.PersonId), + session.UserId, ct)) + .ToDictionary(f => f.PersoonId); + + return SocketResponse.Ok(id, leden.Select(l => + { + bestanden.TryGetValue(l.PersonId, out var foto); + return new + { + l.PersonId, l.DisplayName, l.FirstName, l.LastName, l.Email, + l.Relation, l.RelationLabel, l.State, l.CanEdit, l.BirthDate, l.DateOfDeath, + l.CreatedAt, l.InvitedAt, l.InvitationExpiresAt, l.InvitationResends, l.ClaimedAt, + l.IsBeheerder, l.MomentenKlaar, + profielfotoId = foto?.BestandId, + profielfoto = foto?.Klein is { Length: > 0 } klein ? Convert.ToBase64String(klein) : null + }; + })); + } + + case SocketActions.AddMember: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld."); + var r = await scope.Families.AddMemberAsync(p.FamilyId, session!.UserId, p.FirstName, p.LastName, + p.Email, p.Relation, p.RelationLabel, p.BirthDate, ct); + return r.Succeeded + ? SocketResponse.Ok(id, new { persoonId = r.Value!.Id, naam = r.Value.DisplayName }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.UpdatePerson: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld."); + var r = await scope.Families.WijzigLidAsync(p.FamilyId, p.PersonId, session!.UserId, + p.FirstName, p.LastName, p.Email, + p.Relation, p.RelationLabel, p.BirthDate, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.RemoveMember: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven."); + var r = await scope.Families.VerwijderLidAsync(p.FamilyId, p.PersonId, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.Invite: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven."); + var r = await scope.Families.InviteAsync(p.FamilyId, p.PersonId, session!.UserId, ct); + // Het token gaat bewust alleen naar de e-mail, niet terug naar de app. + return r.Succeeded ? SocketResponse.Ok(id, new { verstuurd = true }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.VaultList: + { + var p = request.PayloadAs(); + var items = await scope.Vault.ListAsync(session!.PersonId, p?.Category, ct); + return SocketResponse.Ok(id, items.Select(i => new + { + i.Id, i.Title, i.Category, i.ReleasePolicy, i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId + })); + } + + case SocketActions.VaultOpen: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen item opgegeven."); + var r = await scope.Vault.OpenAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.VaultSave: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven."); + var r = await scope.Vault.CreateAsync(session!.UserId, p.Category, p.Title, + p.Payload ?? new VaultPayload(), + p.ReleasePolicy, p.ReleaseAt, p.SharedManagerPersonId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { itemId = r.Value!.Id }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.VaultUpdate: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven."); + var r = await scope.Vault.UpdateAsync(p.Id, session!.UserId, p.Category, p.Title, + p.Payload ?? new VaultPayload(), + p.ReleasePolicy, p.ReleaseAt, p.SharedManagerPersonId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.VaultDelete: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen item opgegeven."); + var r = await scope.Vault.DeleteAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!); + } + + // ---------------------------------------------------------------- bijlagen + case SocketActions.FileBegin: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bestandsgegevens meegegeven."); + + if (p.TotalBytes <= 0) return SocketResponse.Error(id, "Het bestand is leeg."); + if (p.TotalBytes > VaultService.MaxBestandsgrootte) + return SocketResponse.Error(id, + $"Dit bestand is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB."); + + try + { + var doel = new Bijlagedoel(p.VaultItemId, p.SubjectPersonId, p.MessageId); + var uploadId = _overdracht.Begin(session!.UserId, p.FileName, p.ContentType ?? "application/octet-stream", + doel, p.TotalBytes); + return SocketResponse.Ok(id, new { uploadId, stukGrootte = Bestandsoverdracht.StukGrootte }); + } + catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); } + } + + case SocketActions.FileChunk: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen gegevens meegegeven."); + + try + { + _overdracht.Stuk(p.UploadId, session!.UserId, p.Index, Convert.FromBase64String(p.Data ?? "")); + return SocketResponse.Ok(id, new { volgende = p.Index + 1 }); + } + catch (FormatException) { return SocketResponse.Error(id, "Dit stuk is onleesbaar aangekomen."); } + catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); } + } + + case SocketActions.FileEnd: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen upload opgegeven."); + + byte[] inhoud; + string naam, soort; + Bijlagedoel? doel; + try + { + (naam, soort, doel, inhoud) = _overdracht.Klaar(p.UploadId, session!.UserId); + } + catch (InvalidOperationException ex) { return SocketResponse.Error(id, ex.Message); } + + var r = await scope.Vault.StoreFileAsync(session.UserId, naam, soort, inhoud, + doel?.KluisItemId, doel?.PersoonId, doel?.BerichtId, ct); + return r.Succeeded + ? SocketResponse.Ok(id, new { bestandId = r.Value!.Id, naam = r.Value.FileName, grootte = r.Value.SizeBytes }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.FileCancel: + { + var p = request.PayloadAs(); + if (p is not null) _overdracht.Afbreken(p.UploadId, session!.UserId); + return SocketResponse.Ok(id); + } + + case SocketActions.FileList: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen item opgegeven."); + + var r = await scope.Vault.ListFilesAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.FileRead: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bestand opgegeven."); + + var r = await scope.Vault.OpenFileAsync(p.Id, session!.UserId, ct); + if (!r.Succeeded) return SocketResponse.Error(id, r.Error!); + + var (bestand, inhoud) = r.Value; + var stukken = (int)Math.Ceiling(inhoud.LongLength / (double)Bestandsoverdracht.StukGrootte); + var index = Math.Max(0, p.Index); + + if (index >= stukken) + return SocketResponse.Error(id, "Dat stuk bestaat niet."); + + var begin = (long)index * Bestandsoverdracht.StukGrootte; + var lengte = (int)Math.Min(Bestandsoverdracht.StukGrootte, inhoud.LongLength - begin); + + return SocketResponse.Ok(id, new + { + naam = bestand.FileName, + soort = bestand.ContentType, + grootte = bestand.SizeBytes, + stukken, + index, + data = Convert.ToBase64String(inhoud, (int)begin, lengte) + }); + } + + case SocketActions.FileDelete: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bestand opgegeven."); + var r = await scope.Vault.DeleteFileAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.Inbox: + { + var berichten = await scope.Messages.InboxAsync(session!.PersonId, ct); + return SocketResponse.Ok(id, berichten.Select(m => new + { + m.Id, m.Subject, m.Kind, m.FromPersonId, m.DeliveredAt, m.ReadAt, m.Status, m.VaultFileId + })); + } + + case SocketActions.Outbox: + { + var berichten = await scope.Messages.OutboxAsync(session!.PersonId, ct); + return SocketResponse.Ok(id, berichten.Select(m => new + { + m.Id, m.Subject, m.Kind, m.ToPersonId, m.DeliverAt, m.DeliverAfterDeath, m.Status, m.VaultFileId + })); + } + + case SocketActions.MessageRead: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven."); + var r = await scope.Messages.ReadAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.MessageReadSent: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven."); + var r = await scope.Messages.ReadSentAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.MessageCompose: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld."); + var r = await scope.Messages.ComposeAsync(session!.UserId, p.ToPersonId, p.Subject, p.Text, + p.DeliverAt, p.AfterDeath, p.Kind, p.VaultFileId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { berichtId = r.Value!.Id }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.MessageDraft: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven."); + var r = await scope.Messages.ConceptAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { tekst = r.Value }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.MessageUpdate: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Niet alle gegevens zijn ingevuld."); + var r = await scope.Messages.UpdateAsync(p.Id, session!.UserId, p.Subject, p.Text, + p.DeliverAt, p.AfterDeath, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { gewijzigd = true }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.MessageWithdraw: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven."); + var r = await scope.Messages.WithdrawAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { ingetrokken = true }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.MessageDelete: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven."); + var r = await scope.Messages.DeleteForMeAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { verwijderd = true }) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.DeclareDeath: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen profiel opgegeven."); + var r = await scope.Legacy.DeclareDeathAsync(p.PersonId, session!.UserId, p.DateOfDeath, p.Note, ct); + return r.Succeeded ? SocketResponse.Ok(id, r.Value) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.ObjectDeath: + { + var r = await scope.Legacy.ObjectAsync(session!.PersonId, session.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.Advertentie: + { + // De keuze en het tellen horen bij elkaar: wat hier wordt uitgekozen gaat ook + // echt mee terug, dus dat is het moment om het mee te tellen. + if (scope.Adverteren is null) return SocketResponse.Ok(id, null); + + var campagne = await scope.Adverteren.KiesAsync(ct); + if (campagne is null) return SocketResponse.Ok(id, null); + + await scope.Adverteren.TelVertoningAsync(campagne.Id, ct); + + // Volledige adressen, want de app weet niet waar de site staat en hoort dat ook + // niet zelf in elkaar te zetten. De afbeelding en de doorverwijzing zijn openbaar; + // zie Advertenties.cs in het webproject. + var basis = scope.Adverteren.SiteUrl.TrimEnd('/'); + + return SocketResponse.Ok(id, new + { + campagne.Id, + campagne.Tekst, + Afbeelding = campagne.HeeftAfbeelding ? $"{basis}/advertentie/{campagne.Id}/afbeelding" : null, + Link = $"{basis}/advertentie/{campagne.Id}/uit" + }); + } + + case SocketActions.LegacyStatus: + { + var p = request.PayloadAs(); + var status = await scope.Legacy.StatusAsync(p?.PersonId ?? session!.PersonId, ct); + return SocketResponse.Ok(id, status); + } + + case SocketActions.RegisterDevice: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen kenmerk meegegeven."); + if (scope.Meldingen is null) return SocketResponse.Ok(id, new { meldingen = false }); + + var r = await scope.Meldingen.AanmeldenAsync(session!.UserId, p.Token, + p.Platform ?? "android", p.Name, ct); + return r.Succeeded ? SocketResponse.Ok(id, new { meldingen = true }) + : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.TestDevice: + { + // Alleen naar je eigen toestellen; er gaat geen persoonsnummer in de vraag mee. + if (scope.Meldingen is null) + return SocketResponse.Ok(id, new { gelukt = false, uitleg = "Meldingen zijn hier niet ingericht." }); + + var proef = await scope.Meldingen.ProefAsync(session!.UserId, ct); + return SocketResponse.Ok(id, new { gelukt = proef.Gelukt, uitleg = proef.Uitleg }); + } + + case SocketActions.UnregisterDevice: + { + var p = request.PayloadAs(); + if (p is null || scope.Meldingen is null) return SocketResponse.Ok(id); + await scope.Meldingen.AfmeldenAsync(session!.UserId, p.Token, ct); + return SocketResponse.Ok(id); + } + + case SocketActions.AppointManager: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven."); + var r = await scope.Legacy.AppointManagerAsync(session!.UserId, p.ManagerPersonId, p.Priority, ct); + return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.FileListPerson: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven."); + var r = await scope.Vault.ListPersonFilesAsync(p.PersonId, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.FileListMessage: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen bericht opgegeven."); + var r = await scope.Vault.ListMessageFilesAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id, Bijlagen(r.Value!)) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.FileProfilePhoto: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen foto opgegeven."); + var r = await scope.Vault.SetProfilePhotoAsync(p.Id, session!.UserId, ct); + return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.LegacyEntrusted: + { + var r = await scope.Legacy.ToevertrouwdAsync(session!.UserId, ct); + var fotos = await KleineFotosAsync(scope, r.Select(t => t.PersonId), session.UserId, ct); + + return SocketResponse.Ok(id, r.Select(t => new + { + t.PersonId, t.DisplayName, t.DateOfDeath, t.AccessGrantedAt, t.AantalItems, + t.Afgerond, t.Volgorde, t.RequiredDeclarations, t.ObjectionDays, + profielfoto = fotos.GetValueOrDefault(t.PersonId) + })); + } + + case SocketActions.LegacyEntrustedItems: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven."); + + var r = await scope.Legacy.NalatenschapItemsAsync(p.PersonId, session!.UserId, ct); + if (!r.Succeeded) return SocketResponse.Error(id, r.Error!); + + return SocketResponse.Ok(id, r.Value!.Select(i => new + { + i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy, + i.ReleaseAt, i.CreatedAt + })); + } + + case SocketActions.RemoveManager: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen persoon opgegeven."); + + var r = await scope.Legacy.RemoveManagerAsync(session!.UserId, p.ManagerPersonId, ct); + return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!); + } + + case SocketActions.VaultSharedWithMe: + { + var r = await scope.Vault.ListSharedWithMeAsync(session!.UserId, ct); + if (!r.Succeeded) return SocketResponse.Error(id, r.Error!); + + return SocketResponse.Ok(id, r.Value!.Select(i => new + { + i.Id, i.Title, Category = (int)i.Category, ReleasePolicy = (int)i.ReleasePolicy, + i.ReleaseAt, i.UpdatedAt, i.SharedManagerPersonId + })); + } + + case SocketActions.LegacyManagers: + { + var lijst = await scope.Legacy.ListManagersAsync(session!.PersonId, ct); + var fotos = await KleineFotosAsync(scope, lijst.Select(b => b.PersonId), session.UserId, ct); + + return SocketResponse.Ok(id, lijst.Select(b => new + { + b.PersonId, b.DisplayName, b.Priority, b.AccessGrantedAt, + profielfoto = fotos.GetValueOrDefault(b.PersonId) + })); + } + + case SocketActions.LegacySettings: + { + var p = request.PayloadAs(); + if (p is null) return SocketResponse.Error(id, "Geen instellingen meegegeven."); + var r = await scope.Legacy.WijzigProcedureAsync(session!.UserId, p.Declarations, p.ObjectionDays, ct); + return r.Succeeded ? SocketResponse.Ok(id) : SocketResponse.Error(id, r.Error!); + } + + default: + return SocketResponse.Error(id, $"Onbekende actie '{request.Action}'."); + } + } + + /// + /// De kleine profielfoto's van een stel mensen, als base64 en per persoon. + /// + /// De app krijgt ze mee in het antwoord waar de mensen zelf in staan. Dat scheelt een + /// tweede ronde per persoon, en het zijn een paar kilobyte per stuk: de foto zelf ophalen + /// voor twintig mensen zou megabytes kosten voor plaatjes van veertig pixels. + /// + /// Wie je mag zien bepaalt de kluis zelf; hier staat geen tweede oordeel. + /// + private static async Task> KleineFotosAsync( + RequestScope scope, IEnumerable persoonIds, long gebruikerId, CancellationToken ct) + { + var uitkomst = new Dictionary(); + + foreach (var foto in await scope.Vault.ProfielfotosAsync(persoonIds, gebruikerId, ct)) + { + if (foto.Klein is { Length: > 0 } klein) + uitkomst[foto.PersoonId] = Convert.ToBase64String(klein); + } + + return uitkomst; + } +} + +// De vorm van wat de app meestuurt. Bewust kleine, expliciete typen: zo is in één oogopslag +// duidelijk welke gegevens een actie nodig heeft. +public record EmailPayload(string Email); +public record SignInPayload(string Email, string Code); + +/// Alleen een code. Bij het opheffen van een account komt het adres uit de sessie. +public record CodePayload(string Code); +public record RegisterPayload(string Email, string FirstName, string LastName); +public record AcceptInvitationPayload(string Token); +public record OwnProfilePayload(string FirstName, string LastName, DateOnly? BirthDate); +public record FamilyIdPayload(long FamilyId); +public record IdPayload(long Id); +public record PersonIdPayload(long PersonId); +public record AddMemberPayload(long FamilyId, string FirstName, string LastName, string? Email, + RelationType Relation, string? RelationLabel, DateOnly? BirthDate); +public record UpdateMemberPayload(long FamilyId, long PersonId, string FirstName, string LastName, + string? Email, RelationType Relation, string? RelationLabel, + DateOnly? BirthDate); +public record InvitePayload(long FamilyId, long PersonId); +public record ProcedurePayload(int Declarations, int ObjectionDays); +public record VaultListPayload(VaultCategory? Category); +public record VaultUpdatePayload(long Id, VaultCategory Category, string Title, VaultPayload? Payload, + ReleasePolicy ReleasePolicy, DateTime? ReleaseAt, long? SharedManagerPersonId = null); +public record MessageUpdatePayload(long Id, string Subject, string Text, DateTime? DeliverAt, bool AfterDeath); +public record FileBeginPayload(string FileName, string? ContentType, long? VaultItemId, long TotalBytes, + long? SubjectPersonId = null, long? MessageId = null); +public record FileIdPayload(long Id); +public record FileChunkPayload(string UploadId, int Index, string? Data); +public record UploadIdPayload(string UploadId); +public record FileReadPayload(long Id, int Index); +public record DevicePayload(string Token, string? Platform, string? Name); +public record LookaheadPayload(int Days); +public record VaultSavePayload(VaultCategory Category, string Title, VaultPayload? Payload, + ReleasePolicy ReleasePolicy, DateTime? ReleaseAt, long? SharedManagerPersonId = null); +public record ComposePayload(long ToPersonId, string Subject, string Text, DateTime? DeliverAt, + bool AfterDeath, MessageKind Kind, long? VaultFileId); +public record DeclareDeathPayload(long PersonId, DateOnly? DateOfDeath, string? Note); +public record AppointManagerPayload(long ManagerPersonId, int Priority); +public record ManagerPayload(long ManagerPersonId); diff --git a/src/MijnBesluit.ServiceBeheer/MijnBesluit.ServiceBeheer.csproj b/src/MijnBesluit.ServiceBeheer/MijnBesluit.ServiceBeheer.csproj new file mode 100644 index 0000000..5317625 --- /dev/null +++ b/src/MijnBesluit.ServiceBeheer/MijnBesluit.ServiceBeheer.csproj @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + false + + diff --git a/src/MijnBesluit.ServiceBeheer/ServiceInstellingen.cs b/src/MijnBesluit.ServiceBeheer/ServiceInstellingen.cs new file mode 100644 index 0000000..c1ef174 --- /dev/null +++ b/src/MijnBesluit.ServiceBeheer/ServiceInstellingen.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace MijnBesluit.ServiceBeheer; + +/// +/// Het poortnummer en waar het vandaan komt: appsettings.json naast de exe. +/// De service leest hetzelfde bestand, dus wat het schermpje opslaat is meteen wat de +/// service gebruikt zodra hij (opnieuw) start. +/// +public class ServiceInstellingen +{ + public const int StandaardPoort = 5200; + + private readonly string _pad; + + public ServiceInstellingen(string appsettingsPad) => _pad = appsettingsPad; + + /// De instellingen die naast een programmabestand horen. + public static ServiceInstellingen Naast(string exePad) => + new(Path.Combine(Path.GetDirectoryName(Path.GetFullPath(exePad)) ?? ".", "appsettings.json")); + + public string Pad => _pad; + + /// Leest het ingestelde poortnummer. Staat er niets bruikbaars, dan de standaardpoort. + public int LeesPoort() + { + var wortel = LeesBestand(); + var waarde = wortel?["Socket"]?["Port"]; + if (waarde is null) return StandaardPoort; + + try + { + return waarde.GetValueKind() switch + { + JsonValueKind.Number => waarde.GetValue(), + JsonValueKind.String => int.TryParse(waarde.GetValue(), out var p) ? p : StandaardPoort, + _ => StandaardPoort + }; + } + catch { return StandaardPoort; } + } + + /// + /// Schrijft het poortnummer weg en laat de rest van het bestand ongemoeid. + /// Er wordt eerst naar een tijdelijk bestand geschreven en daarna omgezet, zodat een + /// onderbreking nooit een half bestand achterlaat. + /// + public void SchrijfPoort(int poort) + { + var probleem = ControleerPoort(poort); + if (probleem is not null) throw new ArgumentOutOfRangeException(nameof(poort), probleem); + + var wortel = LeesBestand() as JsonObject ?? new JsonObject(); + + if (wortel["Socket"] is not JsonObject socket) + { + socket = new JsonObject(); + wortel["Socket"] = socket; + } + socket["Port"] = poort; + + var tijdelijk = _pad + ".tmp"; + File.WriteAllText(tijdelijk, wortel.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + + if (File.Exists(_pad)) File.Replace(tijdelijk, _pad, null); + else File.Move(tijdelijk, _pad); + } + + private JsonNode? LeesBestand() + { + if (!File.Exists(_pad)) return null; + try { return JsonNode.Parse(File.ReadAllText(_pad), null, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); } + catch (JsonException) { return null; } + } + + /// + /// Kijkt of een poortnummer bruikbaar is. Geeft null als het goed is, anders een zin die + /// je zo aan de gebruiker kunt tonen. + /// + public static string? ControleerPoort(int poort) + { + if (poort is < 1 or > 65535) + return "Een poortnummer ligt tussen 1 en 65535."; + if (poort < 1024) + return "Poorten onder 1024 zijn gereserveerd voor het systeem. Kies er een boven 1024."; + return null; + } + + /// + /// Kijkt of er op dit moment al iets op die poort luistert. Handig om te voorkomen dat de + /// service wordt geïnstalleerd en daarna meteen omvalt omdat de poort bezet is. + /// + public static bool PoortIsVrij(int poort) + { + if (ControleerPoort(poort) is not null) return false; + + TcpListener? proef = null; + try + { + proef = new TcpListener(IPAddress.Any, poort); + proef.Start(); + return true; + } + catch (SocketException) { return false; } + finally { try { proef?.Stop(); } catch { } } + } +} diff --git a/src/MijnBesluit.ServiceBeheer/Win32.cs b/src/MijnBesluit.ServiceBeheer/Win32.cs new file mode 100644 index 0000000..24d44fa --- /dev/null +++ b/src/MijnBesluit.ServiceBeheer/Win32.cs @@ -0,0 +1,123 @@ +using System.Runtime.InteropServices; + +namespace MijnBesluit.ServiceBeheer; + +/// +/// De stukjes van de Windows Service Control Manager die we nodig hebben. +/// Rechtstreeks via advapi32 in plaats van via sc.exe: dan hoeft er geen uitvoer geparsed +/// te worden en komt er een echte foutcode terug als iets niet lukt. +/// +internal static class Win32 +{ + internal const uint SC_MANAGER_CONNECT = 0x0001; + internal const uint SC_MANAGER_CREATE_SERVICE = 0x0002; + + internal const uint SERVICE_QUERY_CONFIG = 0x0001; + internal const uint SERVICE_CHANGE_CONFIG = 0x0002; + internal const uint SERVICE_QUERY_STATUS = 0x0004; + internal const uint SERVICE_START = 0x0010; + internal const uint SERVICE_STOP = 0x0020; + internal const uint DELETE = 0x00010000; + internal const uint SERVICE_ALL_ACCESS = 0xF01FF; + + internal const uint SERVICE_WIN32_OWN_PROCESS = 0x00000010; + internal const uint SERVICE_AUTO_START = 0x00000002; + internal const uint SERVICE_DEMAND_START = 0x00000003; + internal const uint SERVICE_ERROR_NORMAL = 0x00000001; + + internal const uint SERVICE_CONTROL_STOP = 0x00000001; + + internal const uint SERVICE_STOPPED = 0x00000001; + internal const uint SERVICE_START_PENDING = 0x00000002; + internal const uint SERVICE_STOP_PENDING = 0x00000003; + internal const uint SERVICE_RUNNING = 0x00000004; + + internal const int SERVICE_CONFIG_DESCRIPTION = 1; + internal const int SERVICE_CONFIG_FAILURE_ACTIONS = 2; + internal const int SERVICE_CONFIG_DELAYED_AUTO_START_INFO = 3; + + internal const int ERROR_ACCESS_DENIED = 5; + internal const int ERROR_SERVICE_DOES_NOT_EXIST = 1060; + internal const int ERROR_SERVICE_EXISTS = 1073; + internal const int ERROR_SERVICE_MARKED_FOR_DELETE = 1072; + internal const int ERROR_SERVICE_NOT_ACTIVE = 1062; + internal const int ERROR_SERVICE_ALREADY_RUNNING = 1056; + + [StructLayout(LayoutKind.Sequential)] + internal struct SERVICE_STATUS + { + public uint dwServiceType; + public uint dwCurrentState; + public uint dwControlsAccepted; + public uint dwWin32ExitCode; + public uint dwServiceSpecificExitCode; + public uint dwCheckPoint; + public uint dwWaitHint; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct SERVICE_DESCRIPTION + { + public IntPtr lpDescription; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct SERVICE_DELAYED_AUTO_START_INFO + { + [MarshalAs(UnmanagedType.Bool)] public bool fDelayedAutostart; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct SC_ACTION + { + public uint Type; // 0 = niets, 1 = opnieuw starten + public uint Delay; // in milliseconden + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct SERVICE_FAILURE_ACTIONS + { + public uint dwResetPeriod; + public IntPtr lpRebootMsg; + public IntPtr lpCommand; + public uint cActions; + public IntPtr lpsaActions; + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern IntPtr OpenSCManagerW(string? machineName, string? databaseName, uint desiredAccess); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern IntPtr OpenServiceW(IntPtr scManager, string serviceName, uint desiredAccess); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern IntPtr CreateServiceW( + IntPtr scManager, string serviceName, string displayName, uint desiredAccess, + uint serviceType, uint startType, uint errorControl, string binaryPathName, + string? loadOrderGroup, IntPtr tagId, string? dependencies, + string? serviceStartName, string? password); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeleteService(IntPtr service); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool StartServiceW(IntPtr service, uint numArgs, string[]? args); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool ControlService(IntPtr service, uint control, ref SERVICE_STATUS status); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool QueryServiceStatus(IntPtr service, out SERVICE_STATUS status); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool ChangeServiceConfig2W(IntPtr service, int infoLevel, IntPtr info); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CloseServiceHandle(IntPtr handle); +} diff --git a/src/MijnBesluit.ServiceBeheer/WindowsServiceBeheer.cs b/src/MijnBesluit.ServiceBeheer/WindowsServiceBeheer.cs new file mode 100644 index 0000000..362314b --- /dev/null +++ b/src/MijnBesluit.ServiceBeheer/WindowsServiceBeheer.cs @@ -0,0 +1,345 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace MijnBesluit.ServiceBeheer; + +/// De toestand van de service, zoals het schermpje hem laat zien. +public enum ServiceToestand +{ + NietGeinstalleerd, + Gestopt, + Bezig, + Draait +} + +/// Wat er over de service bekend is. +public record ServiceInfo(ServiceToestand Toestand, string? Melding = null) +{ + public bool IsGeinstalleerd => Toestand != ServiceToestand.NietGeinstalleerd; + + public string Omschrijving => Toestand switch + { + ServiceToestand.NietGeinstalleerd => "Niet geïnstalleerd", + ServiceToestand.Gestopt => "Geïnstalleerd, gestopt", + ServiceToestand.Bezig => "Bezig met starten of stoppen", + ServiceToestand.Draait => "Draait", + _ => "Onbekend" + }; +} + +/// +/// Installeren, verwijderen, starten en stoppen van de Windows-service. +/// +/// Alles loopt via de Service Control Manager. Daar zijn beheerdersrechten voor nodig; die +/// vraag je niet halverwege aan, dus controleert dat vooraf door +/// te proberen de SCM te openen met schrijfrechten. Lukt dat niet, dan draait het programma +/// niet als administrator. +/// +[SupportedOSPlatform("windows")] +public class WindowsServiceBeheer +{ + public string ServiceNaam { get; } + public string Weergavenaam { get; } + public string Beschrijving { get; } + + public WindowsServiceBeheer(string serviceNaam = "MijnBesluitSocket", + string weergavenaam = "MijnBesluit SocketServer", + string beschrijving = "Neemt de verbindingen van de MijnBesluit-app aan en handelt ze af.") + { + ServiceNaam = serviceNaam; + Weergavenaam = weergavenaam; + Beschrijving = beschrijving; + } + + /// + /// Draait dit programma met beheerdersrechten? We proberen simpelweg de SCM te openen met + /// het recht om services aan te maken; dat is precies het recht dat we straks nodig hebben. + /// + public static bool IsAdministrator() + { + if (!OperatingSystem.IsWindows()) return false; + + var scm = Win32.OpenSCManagerW(null, null, Win32.SC_MANAGER_CONNECT | Win32.SC_MANAGER_CREATE_SERVICE); + if (scm == IntPtr.Zero) return false; + + Win32.CloseServiceHandle(scm); + return true; + } + + /// Kijkt of de service bestaat en, zo ja, of hij draait. + public ServiceInfo Status() + { + var scm = Win32.OpenSCManagerW(null, null, Win32.SC_MANAGER_CONNECT); + if (scm == IntPtr.Zero) + return new ServiceInfo(ServiceToestand.NietGeinstalleerd, + "Kon de Windows-servicelijst niet openen: " + LaatsteFout()); + + try + { + var service = Win32.OpenServiceW(scm, ServiceNaam, Win32.SERVICE_QUERY_STATUS); + if (service == IntPtr.Zero) + { + var fout = Marshal.GetLastWin32Error(); + return fout == Win32.ERROR_SERVICE_DOES_NOT_EXIST + ? new ServiceInfo(ServiceToestand.NietGeinstalleerd) + : new ServiceInfo(ServiceToestand.NietGeinstalleerd, Fouttekst(fout)); + } + + try + { + if (!Win32.QueryServiceStatus(service, out var status)) + return new ServiceInfo(ServiceToestand.Gestopt, LaatsteFout()); + + return new ServiceInfo(status.dwCurrentState switch + { + Win32.SERVICE_RUNNING => ServiceToestand.Draait, + Win32.SERVICE_STOPPED => ServiceToestand.Gestopt, + _ => ServiceToestand.Bezig + }); + } + finally { Win32.CloseServiceHandle(service); } + } + finally { Win32.CloseServiceHandle(scm); } + } + + /// + /// Installeert de service en start hem meteen. + /// + /// Volledig pad naar de exe. Wordt tussen aanhalingstekens gezet, + /// zodat een pad met spaties geen probleem is. + public void Installeer(string exePad, bool startDirect = true) + { + if (!File.Exists(exePad)) + throw new FileNotFoundException($"Het programmabestand is niet gevonden: {exePad}", exePad); + + var scm = Win32.OpenSCManagerW(null, null, Win32.SC_MANAGER_CONNECT | Win32.SC_MANAGER_CREATE_SERVICE); + if (scm == IntPtr.Zero) throw Fout("Kon de Windows-servicelijst niet openen."); + + try + { + // De aanhalingstekens zijn niet optioneel: zonder die zou Windows bij een pad met + // spaties het verkeerde programma kunnen starten. + var opdracht = $"\"{exePad}\" --service"; + + var service = Win32.CreateServiceW( + scm, ServiceNaam, Weergavenaam, + Win32.SERVICE_ALL_ACCESS, + Win32.SERVICE_WIN32_OWN_PROCESS, + Win32.SERVICE_AUTO_START, + Win32.SERVICE_ERROR_NORMAL, + opdracht, + null, IntPtr.Zero, null, + null, // LocalSystem + null); + + if (service == IntPtr.Zero) + { + var fout = Marshal.GetLastWin32Error(); + throw fout switch + { + Win32.ERROR_SERVICE_EXISTS => + new InvalidOperationException("Deze service is al geïnstalleerd."), + Win32.ERROR_SERVICE_MARKED_FOR_DELETE => + new InvalidOperationException( + "De service is verwijderd maar Windows is er nog niet mee klaar. " + + "Sluit het venster Services en probeer het over een halve minuut opnieuw."), + Win32.ERROR_ACCESS_DENIED => + new UnauthorizedAccessException("Hiervoor zijn beheerdersrechten nodig."), + _ => Fout("Aanmaken van de service is mislukt.", fout) + }; + } + + try + { + ZetBeschrijving(service); + ZetHerstelgedrag(service); + if (startDirect) Start(service); + } + finally { Win32.CloseServiceHandle(service); } + } + finally { Win32.CloseServiceHandle(scm); } + } + + /// Stopt de service als hij draait en verwijdert hem daarna. + public void Verwijder() + { + var scm = Win32.OpenSCManagerW(null, null, Win32.SC_MANAGER_CONNECT); + if (scm == IntPtr.Zero) throw Fout("Kon de Windows-servicelijst niet openen."); + + try + { + var service = Win32.OpenServiceW(scm, ServiceNaam, + Win32.SERVICE_QUERY_STATUS | Win32.SERVICE_STOP | Win32.DELETE); + + if (service == IntPtr.Zero) + { + var fout = Marshal.GetLastWin32Error(); + if (fout == Win32.ERROR_SERVICE_DOES_NOT_EXIST) + throw new InvalidOperationException("Deze service is niet geïnstalleerd."); + if (fout == Win32.ERROR_ACCESS_DENIED) + throw new UnauthorizedAccessException("Hiervoor zijn beheerdersrechten nodig."); + throw Fout("Kon de service niet openen.", fout); + } + + try + { + Stop(service); + if (!Win32.DeleteService(service)) + { + var fout = Marshal.GetLastWin32Error(); + if (fout != Win32.ERROR_SERVICE_MARKED_FOR_DELETE) + throw Fout("Verwijderen van de service is mislukt.", fout); + } + } + finally { Win32.CloseServiceHandle(service); } + } + finally { Win32.CloseServiceHandle(scm); } + } + + /// Start een geïnstalleerde service. + public void Start() => MetService(Win32.SERVICE_START | Win32.SERVICE_QUERY_STATUS, Start); + + /// Stopt een draaiende service. + public void Stop() => MetService(Win32.SERVICE_STOP | Win32.SERVICE_QUERY_STATUS, Stop); + + private void MetService(uint rechten, Action wat) + { + var scm = Win32.OpenSCManagerW(null, null, Win32.SC_MANAGER_CONNECT); + if (scm == IntPtr.Zero) throw Fout("Kon de Windows-servicelijst niet openen."); + + try + { + var service = Win32.OpenServiceW(scm, ServiceNaam, rechten); + if (service == IntPtr.Zero) + { + var fout = Marshal.GetLastWin32Error(); + if (fout == Win32.ERROR_SERVICE_DOES_NOT_EXIST) + throw new InvalidOperationException("Deze service is niet geïnstalleerd."); + throw Fout("Kon de service niet openen.", fout); + } + + try { wat(service); } + finally { Win32.CloseServiceHandle(service); } + } + finally { Win32.CloseServiceHandle(scm); } + } + + private void Start(IntPtr service) + { + if (!Win32.StartServiceW(service, 0, null)) + { + var fout = Marshal.GetLastWin32Error(); + if (fout != Win32.ERROR_SERVICE_ALREADY_RUNNING) + throw Fout("De service is aangemaakt, maar starten lukte niet. " + + "Kijk in Logboeken > Windows-logboeken > Toepassing wat er misging.", fout); + } + + WachtOpToestand(service, Win32.SERVICE_RUNNING, TimeSpan.FromSeconds(30)); + } + + private void Stop(IntPtr service) + { + if (!Win32.QueryServiceStatus(service, out var status)) return; + if (status.dwCurrentState == Win32.SERVICE_STOPPED) return; + + var stopStatus = new Win32.SERVICE_STATUS(); + if (!Win32.ControlService(service, Win32.SERVICE_CONTROL_STOP, ref stopStatus)) + { + var fout = Marshal.GetLastWin32Error(); + if (fout != Win32.ERROR_SERVICE_NOT_ACTIVE) + throw Fout("Stoppen van de service is mislukt.", fout); + } + + WachtOpToestand(service, Win32.SERVICE_STOPPED, TimeSpan.FromSeconds(30)); + } + + /// Wacht tot de service in de gevraagde toestand staat, of tot de tijd om is. + private static void WachtOpToestand(IntPtr service, uint gewenst, TimeSpan maximaal) + { + var einde = DateTime.UtcNow + maximaal; + while (DateTime.UtcNow < einde) + { + if (!Win32.QueryServiceStatus(service, out var status)) return; + if (status.dwCurrentState == gewenst) return; + Thread.Sleep(250); + } + + throw new TimeoutException( + gewenst == Win32.SERVICE_RUNNING + ? "De service is niet binnen 30 seconden gestart." + : "De service is niet binnen 30 seconden gestopt."); + } + + private void ZetBeschrijving(IntPtr service) + { + var tekst = Marshal.StringToHGlobalUni(Beschrijving); + try + { + var info = new Win32.SERVICE_DESCRIPTION { lpDescription = tekst }; + var blok = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(info, blok, false); + Win32.ChangeServiceConfig2W(service, Win32.SERVICE_CONFIG_DESCRIPTION, blok); + } + finally { Marshal.FreeHGlobal(blok); } + } + finally { Marshal.FreeHGlobal(tekst); } + } + + /// + /// Zorgt dat Windows de service zelf opnieuw start als hij onverwacht stopt: na 10 seconden, + /// daarna na 30, en verder elke 2 minuten. De teller wordt na een dag zonder problemen gewist. + /// + private void ZetHerstelgedrag(IntPtr service) + { + var acties = new[] + { + new Win32.SC_ACTION { Type = 1, Delay = 10_000 }, + new Win32.SC_ACTION { Type = 1, Delay = 30_000 }, + new Win32.SC_ACTION { Type = 1, Delay = 120_000 } + }; + + var maat = Marshal.SizeOf(); + var lijst = Marshal.AllocHGlobal(maat * acties.Length); + var blok = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + for (var i = 0; i < acties.Length; i++) + Marshal.StructureToPtr(acties[i], lijst + i * maat, false); + + var info = new Win32.SERVICE_FAILURE_ACTIONS + { + dwResetPeriod = 86_400, + lpRebootMsg = IntPtr.Zero, + lpCommand = IntPtr.Zero, + cActions = (uint)acties.Length, + lpsaActions = lijst + }; + + Marshal.StructureToPtr(info, blok, false); + Win32.ChangeServiceConfig2W(service, Win32.SERVICE_CONFIG_FAILURE_ACTIONS, blok); + } + finally + { + Marshal.FreeHGlobal(blok); + Marshal.FreeHGlobal(lijst); + } + } + + private static string LaatsteFout() => Fouttekst(Marshal.GetLastWin32Error()); + + private static string Fouttekst(int foutcode) => + foutcode == Win32.ERROR_ACCESS_DENIED + ? "Hiervoor zijn beheerdersrechten nodig." + : new Win32Exception(foutcode).Message; + + private static Exception Fout(string wat, int? foutcode = null) + { + var code = foutcode ?? Marshal.GetLastWin32Error(); + return code == Win32.ERROR_ACCESS_DENIED + ? new UnauthorizedAccessException($"{wat} Hiervoor zijn beheerdersrechten nodig.") + : new InvalidOperationException($"{wat} ({new Win32Exception(code).Message})"); + } +} diff --git a/src/MijnBesluit.SocketServer/AchtergrondTaken.cs b/src/MijnBesluit.SocketServer/AchtergrondTaken.cs new file mode 100644 index 0000000..a200a19 --- /dev/null +++ b/src/MijnBesluit.SocketServer/AchtergrondTaken.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MijnBesluit.Application.Services; +using MijnBesluit.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using MijnBesluit.Server; + +namespace MijnBesluit.SocketServer; + +/// +/// Wat er vanzelf moet gebeuren: geplande berichten bezorgen, afgelopen bezwaartermijnen +/// afronden en verlopen sessies opruimen. Draait elk kwartier. +/// +public class AchtergrondTaken : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15); + + private readonly IServiceScopeFactory _scopes; + private readonly SessionStore _sessions; + private readonly ILogger _log; + + public AchtergrondTaken(IServiceScopeFactory scopes, SessionStore sessions, ILogger log) + { _scopes = scopes; _sessions = sessions; _log = log; } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + while (!stoppingToken.IsCancellationRequested) + { + try { await RondeAsync(stoppingToken); } + catch (Exception ex) { _log.LogError(ex, "Achtergrondtaak mislukt; volgende ronde over {Interval}.", Interval); } + + try { await timer.WaitForNextTickAsync(stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + + private async Task RondeAsync(CancellationToken ct) + { + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Overlijdensprocedures waarvan de bezwaartermijn kan zijn afgelopen. + var legacy = scope.ServiceProvider.GetRequiredService(); + var kandidaten = await db.DeathDeclarations + .Where(d => d.RevokedAt == null) + .Select(d => d.PersonId) + .Distinct() + .ToListAsync(ct); + + var bevestigd = await legacy.ProcessPendingAsync(kandidaten, ct); + if (bevestigd > 0) _log.LogInformation("{Aantal} overlijdensprocedure(s) afgerond.", bevestigd); + + // Berichten die aan de beurt zijn. + var messages = scope.ServiceProvider.GetRequiredService(); + var bezorgd = await messages.DeliverDueAsync(500, ct); + if (bezorgd > 0) _log.LogInformation("{Aantal} bericht(en) bezorgd.", bezorgd); + + var opgeruimd = await _sessions.SweepAsync(ct); + if (opgeruimd > 0) _log.LogInformation("{Aantal} verlopen sessie(s) opgeruimd.", opgeruimd); + } +} diff --git a/src/MijnBesluit.SocketServer/BeheerScherm.cs b/src/MijnBesluit.SocketServer/BeheerScherm.cs new file mode 100644 index 0000000..8e0e174 --- /dev/null +++ b/src/MijnBesluit.SocketServer/BeheerScherm.cs @@ -0,0 +1,316 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Drawing; +using System.Windows.Forms; +using MijnBesluit.ServiceBeheer; + +namespace MijnBesluit.SocketServer; + +/// +/// Het schermpje dat verschijnt als je MijnBesluit.SocketServer.exe handmatig start. +/// +/// Is de service nog niet geïnstalleerd, dan vraagt het om een poortnummer en staat er een +/// knop Install. Bestaat de service al, dan staat er UnInstall, met daarnaast +/// een knop om hem te starten of te stoppen. Precies zoals je in WinDev gewend bent. +/// +/// Het venster is met de hand opgebouwd — geen ontwerpbestand — zodat je in één bestand kunt +/// zien wat er staat en het makkelijk aan te passen is. +/// +public class BeheerScherm : Form +{ + private readonly WindowsServiceBeheer _service = + new(ServiceGegevens.Naam, ServiceGegevens.Weergavenaam, ServiceGegevens.Beschrijving); + + private readonly ServiceInstellingen _instellingen; + private readonly string _exePad; + private readonly bool _isAdministrator; + + private readonly Label _statusWaarde = new(); + private readonly Label _poortUitleg = new(); + private readonly NumericUpDown _poort = new(); + private readonly Button _hoofdknop = new(); + private readonly Button _startStop = new(); + private readonly Button _verhoog = new(); + private readonly Label _waarschuwing = new(); + private readonly ToolTip _tip = new(); + + public BeheerScherm() + { + _exePad = Environment.ProcessPath ?? System.Windows.Forms.Application.ExecutablePath; + _instellingen = ServiceInstellingen.Naast(_exePad); + _isAdministrator = WindowsServiceBeheer.IsAdministrator(); + + BouwScherm(); + Verversen(); + } + + private void BouwScherm() + { + Text = "MijnBesluit SocketServer"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterScreen; + AutoScaleMode = AutoScaleMode.Font; + Font = new Font("Segoe UI", 9F); + BackColor = Color.White; + + const int Links = 20; + const int Breedte = 470; + var grijs = Color.FromArgb(100, 96, 92); + + var titel = new Label + { + Text = "MijnBesluit SocketServer", + Font = new Font("Segoe UI", 13F, FontStyle.Bold), + Location = new Point(Links - 2, 16), + AutoSize = true + }; + + var ondertitel = new Label + { + Text = "De Windows-service waarmee de app verbinding maakt.", + ForeColor = grijs, + Location = new Point(Links, 48), + AutoSize = true + }; + + var statusLabel = new Label { Text = "Status:", Location = new Point(Links, 88), Size = new Size(86, 20) }; + _statusWaarde.Location = new Point(Links + 90, 88); + _statusWaarde.Size = new Size(Breedte - Links * 2 - 90, 20); + _statusWaarde.Font = new Font("Segoe UI", 9F, FontStyle.Bold); + _statusWaarde.AutoEllipsis = true; + + var poortLabel = new Label { Text = "Poortnummer:", Location = new Point(Links, 122), Size = new Size(86, 22) }; + _poort.Location = new Point(Links + 90, 120); + _poort.Size = new Size(90, 25); + _poort.Minimum = 1024; + _poort.Maximum = 65535; + _poort.Value = Begrens(_instellingen.LeesPoort()); + _poort.ValueChanged += (_, _) => PoortGewijzigd(); + + _poortUitleg.Location = new Point(Links + 190, 124); + _poortUitleg.Size = new Size(Breedte - Links * 2 - 190, 20); + _poortUitleg.ForeColor = grijs; + + var padLabel = new Label + { + Text = "Programma:", Location = new Point(Links, 158), + Size = new Size(86, 20), ForeColor = grijs + }; + + var padWaarde = new Label + { + Text = _exePad, + Location = new Point(Links + 90, 158), + Size = new Size(Breedte - Links * 2 - 90, 32), + ForeColor = grijs, + AutoEllipsis = true + }; + _tip.SetToolTip(padWaarde, _exePad + Environment.NewLine + "Instellingen: " + _instellingen.Pad); + + // De waarschuwing en de knop om opnieuw te starten verschijnen alleen als het + // programma niet als administrator draait; het venster groeit daar dan voor mee. + var y = 200; + + if (!_isAdministrator) + { + _waarschuwing.Text = "Installeren en verwijderen van een Windows-service kan alleen als " + + "administrator. Start het programma opnieuw met beheerdersrechten."; + _waarschuwing.Location = new Point(Links, y); + _waarschuwing.Size = new Size(Breedte - Links * 2, 36); + _waarschuwing.ForeColor = Color.FromArgb(138, 90, 34); + _waarschuwing.Visible = true; + + _verhoog.Text = "Opnieuw starten als administrator"; + _verhoog.Location = new Point(Links, y + 42); + _verhoog.Size = new Size(236, 32); + _verhoog.Visible = true; + _verhoog.Click += (_, _) => StartOpnieuwAlsAdministrator(); + + y += 90; + } + + _hoofdknop.Location = new Point(Links, y); + _hoofdknop.Size = new Size(120, 36); + _hoofdknop.Click += (_, _) => HoofdknopIngedrukt(); + + _startStop.Location = new Point(Links + 130, y); + _startStop.Size = new Size(120, 36); + _startStop.Click += (_, _) => StartStopIngedrukt(); + + var sluiten = new Button + { + Text = "Sluiten", + Location = new Point(Breedte - Links - 90, y), + Size = new Size(90, 36) + }; + sluiten.Click += (_, _) => Close(); + CancelButton = sluiten; + + ClientSize = new Size(Breedte, y + 36 + 20); + + Controls.AddRange(new Control[] + { + titel, ondertitel, statusLabel, _statusWaarde, poortLabel, _poort, _poortUitleg, + padLabel, padWaarde, _waarschuwing, _verhoog, _hoofdknop, _startStop, sluiten + }); + } + + /// Meldt meteen of de gekozen poort vrij is, zodat je dat niet pas na het installeren merkt. + private void PoortGewijzigd() + { + if (!_poort.Enabled) return; + _poortUitleg.Text = ServiceInstellingen.PoortIsVrij((int)_poort.Value) + ? "Deze poort is vrij." + : "Let op: er luistert al iets op deze poort."; + } + + /// Haalt de stand van zaken op en zet het scherm daarop in. + private void Verversen() + { + var info = _service.Status(); + + _statusWaarde.Text = info.Melding is null ? info.Omschrijving : $"{info.Omschrijving} — {info.Melding}"; + _statusWaarde.ForeColor = info.Toestand switch + { + ServiceToestand.Draait => Color.FromArgb(47, 106, 90), + ServiceToestand.NietGeinstalleerd => Color.FromArgb(100, 96, 92), + _ => Color.FromArgb(138, 90, 34) + }; + + if (info.IsGeinstalleerd) + { + _poort.Value = Begrens(_instellingen.LeesPoort()); + _poort.Enabled = false; + _poortUitleg.Text = "Verwijder de service om de poort te wijzigen."; + + _hoofdknop.Text = "UnInstall"; + _startStop.Visible = true; + _startStop.Text = info.Toestand == ServiceToestand.Draait ? "Stoppen" : "Starten"; + _startStop.Enabled = _isAdministrator && info.Toestand != ServiceToestand.Bezig; + } + else + { + _poort.Enabled = _isAdministrator; + PoortGewijzigd(); + + _hoofdknop.Text = "Install"; + _startStop.Visible = false; + } + + _hoofdknop.Enabled = _isAdministrator; + } + + private void HoofdknopIngedrukt() + { + var info = _service.Status(); + if (info.IsGeinstalleerd) Verwijderen(); + else Installeren(); + } + + private void Installeren() + { + var poort = (int)_poort.Value; + + var probleem = ServiceInstellingen.ControleerPoort(poort); + if (probleem is not null) + { + Melden(probleem, MessageBoxIcon.Warning); + return; + } + + if (!ServiceInstellingen.PoortIsVrij(poort)) + { + var doorgaan = MessageBox.Show(this, + $"Er luistert al iets op poort {poort}. De service kan dan niet starten.\n\n" + + "Wil je toch doorgaan?", + "Poort bezet", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); + + if (doorgaan != DialogResult.Yes) return; + } + + Bezig(() => + { + // Eerst de poort opslaan: de service leest hetzelfde bestand zodra hij start. + _instellingen.SchrijfPoort(poort); + _service.Installeer(_exePad); + }, + $"De service is geïnstalleerd en gestart op poort {poort}.\n\n" + + "Hij start voortaan vanzelf mee met Windows."); + } + + private void Verwijderen() + { + var zeker = MessageBox.Show(this, + "De service wordt gestopt en verwijderd. De app kan daarna geen verbinding meer maken.\n\n" + + "Weet je het zeker?", + "Service verwijderen", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); + + if (zeker != DialogResult.Yes) return; + + Bezig(() => _service.Verwijder(), "De service is gestopt en verwijderd."); + } + + private void StartStopIngedrukt() + { + var draait = _service.Status().Toestand == ServiceToestand.Draait; + Bezig(() => { if (draait) _service.Stop(); else _service.Start(); }, + draait ? "De service is gestopt." : "De service is gestart."); + } + + /// + /// Voert een handeling uit met een wachtcursor, vangt de fout netjes af en ververst daarna + /// het scherm. De handelingen duren hooguit een paar seconden, dus het scherm mag zolang wachten. + /// + private void Bezig(Action wat, string gelukt) + { + Cursor = Cursors.WaitCursor; + _hoofdknop.Enabled = false; + _startStop.Enabled = false; + try + { + wat(); + Verversen(); + Melden(gelukt, MessageBoxIcon.Information); + } + catch (UnauthorizedAccessException ex) + { + Melden(ex.Message, MessageBoxIcon.Warning); + } + catch (Exception ex) + { + Melden(ex.Message, MessageBoxIcon.Error); + } + finally + { + Cursor = Cursors.Default; + Verversen(); + } + } + + private void Melden(string tekst, MessageBoxIcon soort) => + MessageBox.Show(this, tekst, "MijnBesluit SocketServer", MessageBoxButtons.OK, soort); + + /// Start dezelfde exe opnieuw, nu met de UAC-vraag om beheerdersrechten. + private void StartOpnieuwAlsAdministrator() + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = _exePad, + UseShellExecute = true, + Verb = "runas" + }); + Close(); + } + catch (Win32Exception) + { + // De gebruiker heeft de UAC-vraag weggeklikt; dan gebeurt er gewoon niets. + } + } + + private decimal Begrens(int poort) => + Math.Clamp(poort, (int)_poort.Minimum, (int)_poort.Maximum); +} diff --git a/src/MijnBesluit.SocketServer/MijnBesluit.SocketServer.csproj b/src/MijnBesluit.SocketServer/MijnBesluit.SocketServer.csproj new file mode 100644 index 0000000..3f26662 --- /dev/null +++ b/src/MijnBesluit.SocketServer/MijnBesluit.SocketServer.csproj @@ -0,0 +1,39 @@ + + + + net8.0-windows + WinExe + true + app.manifest + + + x64 + win-x64 + false + + enable + enable + mijnbesluit-socketserver + MijnBesluit.SocketServer + MijnBesluit.SocketServer + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/src/MijnBesluit.SocketServer/Program.cs b/src/MijnBesluit.SocketServer/Program.cs new file mode 100644 index 0000000..6d21eca --- /dev/null +++ b/src/MijnBesluit.SocketServer/Program.cs @@ -0,0 +1,144 @@ +using System.Windows.Forms; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.WindowsServices; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Infrastructure; +using MijnBesluit.Server; + +namespace MijnBesluit.SocketServer; + +/// +/// De SocketServer van MijnBesluit kan twee dingen zijn, afhankelijk van hoe hij wordt gestart: +/// +/// * Start Windows hem als service (of geef je zelf --service mee), dan draait de server. +/// * Start je hem met een dubbelklik of vanaf de opdrachtregel, dan verschijnt het +/// beheerschermpje waarmee je de service installeert of verwijdert. +/// +/// Zo is er maar één exe om uit te leveren, net zoals je in WinDev gewend bent. +/// +public static class Program +{ + // WinForms heeft een STA-thread nodig; vandaar het attribuut en een gewone Main + // in plaats van top-level statements. + [STAThread] + public static int Main(string[] args) + { + if (WindowsServiceHelpers.IsWindowsService()) + { + DraaiAlsServiceAsync(args).GetAwaiter().GetResult(); + return 0; + } + + if (args.Contains("--service")) + { + // Zelf gestart om mee te kijken. Dan hoort het logboek in beeld te komen. + Consolebeeld(); + DraaiAlsServiceAsync(args).GetAwaiter().GetResult(); + return 0; + } + + ApplicationConfiguration.Initialize(); + System.Windows.Forms.Application.Run(new BeheerScherm()); + return 0; + } + + /// + /// Zorgt dat het logboek te zien is als iemand de server zelf start met --service. + /// + /// Waarom dit nodig is: deze exe is een WinExe - dat moet, want het beheerschermpje is + /// WinForms. Windows geeft zo'n programma géén console, ook niet als je hem vanaf een + /// opdrachtprompt start. De server draaide dan wel, maar alles wat hij logde verdween in het + /// niets; je keek naar een leeg venster terwijl er van alles gebeurde. Dat kostte een avond + /// zoeken naar een fout waarvan de reden al de hele tijd werd opgeschreven. + /// + /// Eerst proberen we het venster te gebruiken waaruit hij is gestart, en anders maken we er + /// zelf een. Wordt de uitvoer al ergens heen gestuurd (> log.txt), dan blijven we + /// eraf: dan is er niets mis en zou een console die omleiding juist in de weg zitten. + /// + private static void Consolebeeld() + { + if (Console.IsOutputRedirected) return; + + try + { + if (!AttachConsole(-1) && !AllocConsole()) return; + + // Console.Out is op dit moment al gemaakt, en wijst naar niets. Opnieuw openen dus, + // anders blijft het stil terwijl er wél een venster staat. + var uit = new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(uit); + Console.SetError(uit); + } + catch (Exception) + { + // Zonder console draait de server gewoon door; dat is altijd nog beter dan niet + // starten omdat het logboek niet in beeld kwam. + } + } + + /// Het venster gebruiken waaruit we zijn gestart. -1 is ATTACH_PARENT_PROCESS. + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int procesId); + + /// Zelf een venster maken, voor als er geen bovenliggend venster is. + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AllocConsole(); + + private static async Task DraaiAlsServiceAsync(string[] args) + { + // Windows start een service met C:\Windows\System32 als werkmap. Zonder deze regel zou + // de service zijn eigen appsettings.json en sleutelbestand niet kunnen vinden. + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = args, + ContentRootPath = AppContext.BaseDirectory + }); + + // Geheimen staan buiten de publicatiemap, in hetzelfde bestand dat de website gebruikt. + // Zo is er één plek met de databasegegevens en de SMTP-instellingen, en overleven ze + // een nieuwe publicatie. Bewust niet optioneel: ontbreekt het bestand, dan zou de + // service stilletjes terugvallen op de voorbeeldwaarden en even later een + // onbegrijpelijke "Access denied" van de database geven. + var extraInstellingen = builder.Configuration["ExtraConfigFile"]; + if (!string.IsNullOrWhiteSpace(extraInstellingen)) + { + if (!File.Exists(extraInstellingen)) + { + throw new FileNotFoundException( + $"Het instellingenbestand '{extraInstellingen}' is niet gevonden. Het pad staat in " + + "appsettings.json bij \"ExtraConfigFile\". Zie docs/SocketServer-installeren.md.", + extraInstellingen); + } + + builder.Configuration.AddJsonFile(extraInstellingen, optional: false, reloadOnChange: true); + } + + builder.Services.AddMijnBesluit(builder.Configuration); + // De sessies staan in het geheugen én in de database. Dat tweede is wat een herstart + // van de service overleeft: zonder dat moet na elke uitrol of reboot iedereen weer een + // code uit zijn mail halen, ook al staat de app nog aangemeld op zijn telefoon. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => new SessionStore( + sp.GetRequiredService(), null, sp.GetRequiredService())); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + + // Zorgt dat Windows de service netjes kan starten en stoppen, en stuurt het logboek naar + // Logboeken > Windows-logboeken > Toepassing. + builder.Services.AddWindowsService(o => o.ServiceName = ServiceGegevens.Naam); + + await builder.Build().RunAsync(); + } +} + +/// De naam waaronder de service in Windows bekendstaat. Op één plek, zodat het +/// schermpje en de service het altijd over dezelfde service hebben. +public static class ServiceGegevens +{ + public const string Naam = "MijnBesluitSocket"; + public const string Weergavenaam = "MijnBesluit SocketServer"; + public const string Beschrijving = + "Neemt de verbindingen van de MijnBesluit-app aan, handelt ze af en bezorgt geplande berichten."; +} diff --git a/src/MijnBesluit.SocketServer/Sessiebewaring.cs b/src/MijnBesluit.SocketServer/Sessiebewaring.cs new file mode 100644 index 0000000..c514a8e --- /dev/null +++ b/src/MijnBesluit.SocketServer/Sessiebewaring.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using MijnBesluit.Application.Abstractions; +using MijnBesluit.Domain.Entities; +using MijnBesluit.Server; + +namespace MijnBesluit.SocketServer; + +/// +/// Waar de sessies van de SocketServer echt blijven staan: in de database. +/// +/// Dit is het stukje tussen de sessiekast (die alleen hashes en tijden kent) en de opslag. De +/// kast zit in de server en mag niets van de database weten; deze klasse woont hier, waar alles +/// bij elkaar komt, en haalt voor elke handeling een verse databasecontext op. +/// +/// Een verse context per handeling en niet één blijvende: de context is bedoeld voor één +/// bewerking tegelijk, en hier kunnen meerdere toestellen tegelijkertijd iets vragen. +/// +public class Sessiebewaring : ISessiebewaring +{ + private readonly IServiceScopeFactory _scopes; + + public Sessiebewaring(IServiceScopeFactory scopes) => _scopes = scopes; + + private async Task MetOpslagAsync(Func> werk) + { + using var scope = _scopes.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var opslag = scope.ServiceProvider.GetRequiredService(); + return await werk(repo, opslag); + } + + public Task BewaarAsync(byte[] hash, Sessiegegevens sessie, DateTime nu, CancellationToken ct = default) => + MetOpslagAsync(async (repo, opslag) => + { + repo.Add(new Sessie + { + TokenHash = hash, + UserId = sessie.UserId, + PersonId = sessie.PersonId, + Toestel = sessie.Toestel, + AangemaaktOp = sessie.AangemaaktOp, + VerlooptOp = sessie.VerlooptOp, + LaatstGezien = nu + }); + await opslag.SaveChangesAsync(ct); + return true; + }); + + public Task ZoekAsync(byte[] hash, CancellationToken ct = default) => + MetOpslagAsync(async (repo, _) => + { + var sessie = await repo.ZoekAsync(hash, ct); + return sessie is null + ? null + : new Sessiegegevens(sessie.UserId, sessie.PersonId, sessie.Toestel, + sessie.AangemaaktOp, sessie.VerlooptOp); + }); + + public Task SchuifOpAsync(byte[] hash, DateTime verlooptOp, DateTime nu, CancellationToken ct = default) => + MetOpslagAsync(async (repo, opslag) => + { + var sessie = await repo.ZoekAsync(hash, ct); + if (sessie is null) return false; + + sessie.VerlooptOp = verlooptOp; + sessie.LaatstGezien = nu; + await opslag.SaveChangesAsync(ct); + return true; + }); + + public Task VerwijderAsync(byte[] hash, CancellationToken ct = default) => + MetOpslagAsync(async (repo, opslag) => + { + await repo.VerwijderAsync(hash, ct); + await opslag.SaveChangesAsync(ct); + return true; + }); + + public Task VerwijderAllesVanAsync(long userId, CancellationToken ct = default) => + MetOpslagAsync(async (repo, opslag) => + { + await repo.VerwijderAllesVanAsync(userId, ct); + await opslag.SaveChangesAsync(ct); + return true; + }); + + public Task RuimVerlopenOpAsync(DateTime nu, CancellationToken ct = default) => + MetOpslagAsync(async (repo, opslag) => + { + var aantal = await repo.RuimVerlopenOpAsync(nu, ct); + if (aantal > 0) await opslag.SaveChangesAsync(ct); + return aantal; + }); +} diff --git a/src/MijnBesluit.SocketServer/SocketServerService.cs b/src/MijnBesluit.SocketServer/SocketServerService.cs new file mode 100644 index 0000000..371b4f0 --- /dev/null +++ b/src/MijnBesluit.SocketServer/SocketServerService.cs @@ -0,0 +1,176 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MijnBesluit.Application.Services; +using MijnBesluit.Protocol; +using MijnBesluit.Server; +using MijnBesluit.ServiceBeheer; + +namespace MijnBesluit.SocketServer; + +/// +/// Houdt de SocketServer draaiende zolang de service draait. +/// +/// Per binnengekomen verzoek wordt een verse scope gemaakt, zodat elk verzoek zijn eigen +/// databasecontext en transactie heeft. Dat is ook precies wat nodig is nu elk verzoek op zijn +/// eigen thread loopt: een EF Core-context is niet bedoeld om door meerdere threads tegelijk +/// gebruikt te worden. +/// +public class SocketServerService : BackgroundService +{ + private readonly IServiceScopeFactory _scopes; + private readonly SessionStore _sessions; + private readonly IConfiguration _config; + private readonly ILogger _log; + + private SocketListener? _listener; + + public SocketServerService(IServiceScopeFactory scopes, SessionStore sessions, + IConfiguration config, ILogger log) + { + _scopes = scopes; _sessions = sessions; _config = config; _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var dispatcher = new SocketDispatcher(_sessions, async work => + { + using var scope = _scopes.CreateScope(); + return await work(new RequestScope + { + Accounts = scope.ServiceProvider.GetRequiredService(), + Families = scope.ServiceProvider.GetRequiredService(), + Vault = scope.ServiceProvider.GetRequiredService(), + Messages = scope.ServiceProvider.GetRequiredService(), + Legacy = scope.ServiceProvider.GetRequiredService(), + Meldingen = scope.ServiceProvider.GetRequiredService(), + Adverteren = scope.ServiceProvider.GetRequiredService(), + Opheffen = scope.ServiceProvider.GetRequiredService(), + Overzicht = scope.ServiceProvider.GetRequiredService(), + Samenvoegen = scope.ServiceProvider.GetRequiredService() + }); + }, + traagVerzoek: (actie, ms) => + _log.LogWarning("Verzoek '{Actie}' duurde {Milliseconden} ms. De gebruiker wacht hierop.", + actie, ms)); + + var poort = int.TryParse(_config["Socket:Port"], out var p) ? p : ServiceInstellingen.StandaardPoort; + + var options = new SocketListenerOptions + { + Address = IPAddress.TryParse(_config["Socket:Address"], out var adres) ? adres : IPAddress.Any, + Port = poort, + Certificate = LaadCertificaat(), + MaxConcurrentConnections = int.TryParse(_config["Socket:MaxVerbindingen"], out var max) ? max : 500, + MaxRequestsPerConnection = int.TryParse(_config["Socket:MaxVerzoekenPerVerbinding"], out var mv) ? mv : 8, + MaxConcurrentRequests = int.TryParse(_config["Socket:MaxVerzoekenTotaal"], out var mt) ? mt : 200 + }; + + if (options.Certificate is null) + { + _log.LogWarning("Er is geen servercertificaat ingesteld. De SocketServer luistert ONVERSLEUTELD. " + + "Doe dit alleen lokaal: er gaan wachtwoorden en pincodes over deze verbinding."); + } + + try + { + _listener = new SocketListener(options, dispatcher.HandleAsync, + bericht => _log.LogInformation("{Bericht}", bericht), + fout => _log.LogError("{Fout}", fout)); + _listener.Start(); + } + catch (Exception ex) + { + // Dit is bijna altijd "poort al in gebruik". Duidelijk melden en de service laten + // stoppen is beter dan eindeloos blijven proberen. + _log.LogCritical(ex, "De SocketServer kon niet op poort {Poort} gaan luisteren.", poort); + throw; + } + + _log.LogInformation( + "MijnBesluit SocketServer luistert op poort {Poort} ({Beveiliging}). Eigen thread per " + + "verbinding; maximaal {PerVerbinding} verzoeken tegelijk per verbinding en {Totaal} in totaal.", + _listener.Port, options.Certificate is null ? "onversleuteld" : "TLS", + options.MaxRequestsPerConnection, options.MaxConcurrentRequests); + + try { await Task.Delay(Timeout.Infinite, stoppingToken); } + catch (OperationCanceledException) { } + finally + { + _log.LogInformation("SocketServer stopt; {Aantal} verbinding(en) worden afgesloten.", + _listener?.Connections.Count ?? 0); + _listener?.Dispose(); + } + } + + /// + /// Haalt het servercertificaat op: uit een pfx-bestand, of uit de certificaatopslag van + /// Windows op vingerafdruk. Zonder certificaat draait de server onversleuteld — alleen + /// bedoeld om lokaal te testen. + /// + private X509Certificate2? LaadCertificaat() + { + var pad = _config["Socket:CertificateFile"]; + if (!string.IsNullOrWhiteSpace(pad)) + { + var volledig = Path.IsPathRooted(pad) ? pad : Path.Combine(AppContext.BaseDirectory, pad); + if (File.Exists(volledig)) + { + try { return new X509Certificate2(volledig, _config["Socket:CertificatePassword"]); } + catch (Exception ex) { _log.LogError(ex, "Het certificaat {Pad} kon niet worden geladen.", volledig); } + } + else + { + _log.LogError("Het ingestelde certificaat is niet gevonden: {Pad}", volledig); + } + } + + var vingerafdruk = _config["Socket:CertificateThumbprint"]; + if (!string.IsNullOrWhiteSpace(vingerafdruk) && OperatingSystem.IsWindows()) + { + // Vingerafdrukken die uit het certificaatscherm van Windows komen, bevatten vaak + // spaties en een onzichtbaar teken vooraan; die halen we er hier uit. + var schoon = new string(vingerafdruk.Where(char.IsLetterOrDigit).ToArray()); + + // Twee opslagen, want het hangt ervan af wie het certificaat heeft neergezet: + // "My" is de persoonlijke opslag van de machine, "WebHosting" is waar IIS-panelen + // en Let's Encrypt-tools hun certificaten zetten. IIS kan met allebei overweg, dus + // wij ook - anders zoek je je wezenloos naar een certificaat dat er gewoon is. + foreach (var naam in new[] { "My", "WebHosting" }) + { + using var store = new X509Store(naam, StoreLocation.LocalMachine); + + try { store.Open(OpenFlags.ReadOnly); } + catch (Exception ex) + { + _log.LogDebug(ex, "Certificaatopslag {Opslag} kon niet worden geopend.", naam); + continue; + } + + var gevonden = store.Certificates.Find(X509FindType.FindByThumbprint, schoon, false); + if (gevonden.Count == 0) continue; + + var certificaat = gevonden[0]; + if (!certificaat.HasPrivateKey) + { + // Zonder privesleutel is er geen TLS mee op te zetten. Dat gebeurt als het + // certificaat wel is geimporteerd maar de sleutel niet meekwam. + _log.LogError("Het certificaat {Vingerafdruk} heeft geen privesleutel; TLS kan er niet mee.", schoon); + return null; + } + + _log.LogInformation("Certificaat gevonden in opslag {Opslag}, geldig tot {Tot:d}.", + naam, certificaat.NotAfter); + return certificaat; + } + + _log.LogError("Certificaat met vingerafdruk {Vingerafdruk} niet gevonden in de opslagen " + + "My en WebHosting van de machine.", schoon); + } + + return null; + } +} diff --git a/src/MijnBesluit.SocketServer/app.manifest b/src/MijnBesluit.SocketServer/app.manifest new file mode 100644 index 0000000..33bdeb5 --- /dev/null +++ b/src/MijnBesluit.SocketServer/app.manifest @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + PerMonitorV2 + true + + + diff --git a/src/MijnBesluit.SocketServer/appsettings.json b/src/MijnBesluit.SocketServer/appsettings.json new file mode 100644 index 0000000..a205822 --- /dev/null +++ b/src/MijnBesluit.SocketServer/appsettings.json @@ -0,0 +1,38 @@ +{ + "ExtraConfigFile": "C:\\MijnBesluitData\\appsettings.secrets.json", + + "ConnectionStrings": { + "MijnBesluit": "Server=127.0.0.1;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=VERVANG_DIT;" + }, + "Socket": { + "Address": "0.0.0.0", + "Port": 5200, + "MaxVerbindingen": 500, + "MaxVerzoekenPerVerbinding": 8, + "MaxVerzoekenTotaal": 200, + "CertificateFile": "", + "CertificatePassword": "", + "CertificateThumbprint": "" + }, + "Security": { + "MasterKeyFile": "C:\\MijnBesluitData\\keys\\masterkey.json" + }, + "Storage": { + "Path": "C:\\MijnBesluitData\\kluisbestanden" + }, + "Site": { + "Url": "https://mijnbesluit.nl" + }, + "Smtp": { + "Host": "localhost", + "Port": 587, + "UseStartTls": true, + "FromAddress": "noreply@mijnbesluit.nl", + "FromName": "MijnBesluit", + "WriteToDiskInsteadOfSending": false, + "DropFolder": "mail-uitgaand" + }, + "Logging": { + "LogLevel": { "Default": "Information", "Microsoft": "Warning" } + } +} diff --git a/src/MijnBesluit.Uitrol/MijnBesluit.Uitrol.csproj b/src/MijnBesluit.Uitrol/MijnBesluit.Uitrol.csproj new file mode 100644 index 0000000..5d6e827 --- /dev/null +++ b/src/MijnBesluit.Uitrol/MijnBesluit.Uitrol.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/MijnBesluit.Uitrol/Uitrolprotocol.cs b/src/MijnBesluit.Uitrol/Uitrolprotocol.cs new file mode 100644 index 0000000..a8d2088 --- /dev/null +++ b/src/MijnBesluit.Uitrol/Uitrolprotocol.cs @@ -0,0 +1,194 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MijnBesluit.Uitrol; + +/// +/// Wat de uitrolagent kan. Een vaste lijst, en dat is het hele punt. +/// +/// Er zit met opzet geen "voer dit uit" bij. Een agent die opdrachten van buiten uitvoert is een +/// achterdeur met een nette naam, en deze draait op de server waar de kluizen en de masterkey +/// staan. Elke actie hieronder komt neer op één bekend script met één bekend pad; de kant die +/// verstuurt kiest alleen wélke. +/// +public static class Uitrolacties +{ + /// Zegt of de agent er is en welke versies er nu draaien. Verandert niets. + public const string Stand = "uitrol.stand"; + + /// Begin van een pakket: naam, grootte en hash. Hier zit de handtekening op. + public const string Begin = "uitrol.begin"; + + /// Een stuk van het pakket. + public const string Deel = "uitrol.deel"; + + /// Het pakket is compleet: controleren en uitrollen. + public const string Klaar = "uitrol.klaar"; + + /// Wat er sinds de vorige vraag aan logregels bij is gekomen. + public const string Log = "uitrol.log"; + + public static readonly IReadOnlySet Alle = + new HashSet(StringComparer.Ordinal) { Stand, Begin, Deel, Klaar, Log }; +} + +/// Wat er uitgerold wordt. Elk soort hoort bij één script op de server. +public enum Uitrolsoort +{ + /// De website in IIS. + Site = 1, + + /// De SocketServer als Windows-service. + SocketServer = 2, + + /// De databasescripts uit db\, vanaf nummer 03. + Database = 3 +} + +/// Het begin van een pakket, met de handtekening eroverheen. +public record Uitrolopdracht(Uitrolsoort Soort, string Bestandsnaam, long Bytes, string Sha256, + long Tijdstip, string Nonce, string Handtekening) +{ + /// Waar de handtekening over gaat: deze soort, dit pakket. + public string Doel => $"pakket:{(int)Soort}:{Sha256}"; +} + +/// Eén pakket binnen een opdracht die als bestand wordt neergelegd. +public record Uitrolpakket(Uitrolsoort Soort, string Bestandsnaam, string Sha256); + +/// +/// Een opdracht die niet over een poort komt, maar als bestand in een map komt te liggen. +/// +/// Waarom dit er naast de luisterstand is: over een externe-bureaubladverbinding kan je pc geen +/// poort op de server bereiken - je ziet het bureaublad, maar er loopt geen lijn naar een poort. +/// Wat je wél hebt is de doorgegeven schijf: de agent kan bij de publicatiemap op jouw pc. Dus +/// legt het uitrolvenster daar een briefje neer, en de agent die staat te wachten pakt het op. +/// Jij drukt op één knop en hoeft op de server niets te doen. +/// +/// Het blijft met opzet een apart briefje en niet "er staat een nieuwe zip, ga je gang": anders +/// rolt de agent uit zodra je om een heel andere reden een keer bouwt. Een opdracht hoort te +/// ontstaan doordat iemand op een knop drukt. +/// +/// Er zit geen handtekening op, en dat is hier geen gat. Deze map is de publicatiemap op jouw +/// eigen pc, alleen zichtbaar binnen jouw eigen sessie. Wie daarin kan schrijven, zit al achter +/// jouw toetsenbord. De hash per pakket zit er wél op, en die doet het werk dat hier telt: +/// hij vangt een zip die nog half geschreven was. +/// +public record Uitrolbrief(string Van, DateTime Gemaakt, IReadOnlyList Pakketten) +{ + /// Hoe het briefje heet. Eén vaste naam, zodat de agent nergens naar hoeft te raden. + public const string Bestandsnaam = "uitrol-opdracht.json"; + + /// Waar de agent schrijft wat hij doet, zodat het venster op je pc kan meelezen. + public const string Logbestandsnaam = "uitrol-log.txt"; + + /// De laatste regel in dat logboek. Hieraan ziet het venster dat het klaar is. + public const string Slotregel = "[uitrol-klaar]"; + + /// Hetzelfde, maar er ging iets mis. + public const string Slotregelmislukt = "[uitrol-mislukt]"; + + /// + /// De vaste volgorde, en die is niet die van de vinkjes: eerst de databasescripts, dan de + /// website, dan de SocketServer. Een nieuwe kolom moet er zijn voordat de code erom vraagt; + /// andersom staat de site stuk in de tussentijd. De SocketServer gaat als laatste omdat het + /// herstarten daarvan alle apps hun verbinding kost. + /// + public IEnumerable InVolgorde => Pakketten.OrderBy(p => p.Soort switch + { + Uitrolsoort.Database => 0, + Uitrolsoort.Site => 1, + Uitrolsoort.SocketServer => 2, + _ => 3 + }); +} + +/// Vragen hoe het ervoor staat. Verandert niets, maar wordt wel ondertekend. +public record Standvraag(long Tijdstip, string Nonce, string Handtekening) +{ + public string Doel => "stand"; +} + +public record Uitroldeel(int Index, string Data); + +/// Een regel uit het logboek van de agent, zoals de ontwikkelmachine hem te zien krijgt. +public record Uitrolregel(DateTime Moment, string Tekst, bool IsFout); + +/// Wat de agent terugmeldt over zichzelf. +public record Uitrolstand(string Versie, string Scriptmap, bool Bezig, string? LaatsteUitrol); + +/// +/// De handtekening waarmee de agent weet dat een opdracht van jou komt. +/// +/// Waarom dit er is bovenop TLS: TLS zegt dat niemand onderweg meeleest, niet wie er aanbelt. +/// Zonder handtekening is een agent die op een poort luistert een uitnodiging aan iedereen die +/// bij die poort kan - en ook op een besloten netwerk staat meer dan alleen jouw pc. +/// +/// Wat er in de handtekening zit en waarom: +/// +/// Het doel, en daarin de soort en de hash van het pakket. Zo kan niemand onderweg +/// een ander pakket onder dezelfde handtekening schuiven. +/// Een tijdstip. Een opgevangen opdracht is na een paar minuten niets meer waard. +/// Een nonce. Binnen die paar minuten kan dezelfde opdracht niet twee keer. +/// +/// +/// Het geheim staat aan beide kanten in een bestand, niet in de code en niet in de broncodemap. +/// +public static class Uitrolsleutel +{ + /// Hoe lang een opdracht geldig is. Ruim genoeg voor een klok die een halve minuut afwijkt. + public static readonly TimeSpan Speling = TimeSpan.FromMinutes(2); + + /// Het kortste geheim dat we accepteren. Korter is geen geheim maar een wachtwoord. + public const int MinimaleSleutellengte = 32; + + public static string NieuweSleutel() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)); + + public static string NieuweNonce() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + + public static string Hash(byte[] pakket) => Convert.ToHexString(SHA256.HashData(pakket)); + + /// Dezelfde hash, maar van een bestand op schijf - zonder het helemaal in te lezen. + public static string HashVanBestand(string pad) + { + using var stroom = File.OpenRead(pad); + return Convert.ToHexString(SHA256.HashData(stroom)); + } + + public static long Nu() => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + /// Zet de handtekening onder een doel. + public static string Teken(string geheim, string doel, long tijdstip, string nonce) + { + var sleutel = Encoding.UTF8.GetBytes(geheim); + var tekst = Encoding.UTF8.GetBytes($"{doel}|{tijdstip}|{nonce}"); + return Convert.ToHexString(HMACSHA256.HashData(sleutel, tekst)); + } + + /// + /// Klopt de handtekening, is de opdracht niet te oud, en is deze nonce nog niet eerder + /// gebruikt? De aanroeper geeft de nonces mee die hij al heeft gezien. + /// + public static bool Klopt(string geheim, string doel, long tijdstip, string nonce, string handtekening, + DateTime nuUtc, ISet gebruikteNonces) + { + if (string.IsNullOrWhiteSpace(geheim) || geheim.Length < MinimaleSleutellengte) return false; + if (string.IsNullOrWhiteSpace(handtekening) || string.IsNullOrWhiteSpace(nonce)) return false; + + var moment = DateTimeOffset.FromUnixTimeSeconds(tijdstip).UtcDateTime; + if (moment > nuUtc + Speling || moment < nuUtc - Speling) return false; + + var verwacht = Teken(geheim, doel, tijdstip, nonce); + + // Vergelijken in vaste tijd. Een gewone vergelijking stopt bij het eerste verschil, en + // dat verschil is met genoeg pogingen te meten. + var gelijk = CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(verwacht), Encoding.UTF8.GetBytes(handtekening)); + + if (!gelijk) return false; + + // Pas onthouden als de handtekening klopt. Anders kan iemand die alleen het nonce kent + // jouw opdracht ongeldig maken door hem als eerste te sturen. + return gebruikteNonces.Add(nonce); + } +} diff --git a/src/MijnBesluit.Uitrol/Wijzigingen.cs b/src/MijnBesluit.Uitrol/Wijzigingen.cs new file mode 100644 index 0000000..d06462f --- /dev/null +++ b/src/MijnBesluit.Uitrol/Wijzigingen.cs @@ -0,0 +1,184 @@ +using System.Text.Json; + +namespace MijnBesluit.Uitrol; + +// Dit hoort bij het uitrolvenster, maar staat in deze gedeelde bibliotheek en niet in het +// WPF-project. Reden: het is gewone code zonder scherm, en zo is hij na te rekenen in de +// zelfcontroles - een WPF-project is op een bouwmachine zonder Windows niet te bouwen. + +/// Wat er van één onderdeel te zeggen valt: moet het mee, en waarom. +/// Is er sinds de vorige uitrol iets gewijzigd dat hierin terechtkomt? +/// Wat er is gewijzigd, in gewone taal. Leeg als er niets is. +/// Het moment van het nieuwste bestand dat meetelt. Wordt onthouden na een geslaagde uitrol. +public record Stand(bool Nodig, string Reden, DateTime Nieuwste); + +/// +/// Welke vinkjes er aan horen te staan als je het venster opent. +/// +/// Waarom dit er is: na een middag werken weet je niet meer precies wat je hebt aangeraakt, en +/// dan vink je te veel aan (kost tijd, en de site gaat onnodig even uit) of te weinig (en dan +/// draait er een website tegen een SocketServer die iets anders verwacht). Het venster kan dat +/// zelf zien: het onthoudt per onderdeel wanneer het voor het laatst is uitgerold, en kijkt of +/// er sindsdien iets is gewijzigd in de mappen die erin terechtkomen. +/// +/// Het zet de vinkjes en zegt waarom; jij houdt het laatste woord. Bij twijfel zet hij hem aan. +/// Te veel uitrollen kost een minuut, te weinig kost een avond zoeken. +/// +public static class Wijzigingen +{ + /// + /// Welke mappen in welk onderdeel terechtkomen. + /// + /// De gedeelde projecten staan bij allebei: verandert er iets in Domain of Application, dan + /// zit dat zowel in de website als in de SocketServer. Protocol zit in de SocketServer én in + /// de app, want dat is de taal die ze samen spreken - maar niet in de website, die praat niet + /// over de socket. + /// + private static readonly Dictionary Mappen = new() + { + ["site"] = new[] + { + "src/MijnBesluit.Web", + "src/MijnBesluit.Application", + "src/MijnBesluit.Infrastructure", + "src/MijnBesluit.Domain" + }, + ["socketserver"] = new[] + { + "src/MijnBesluit.SocketServer", + "src/MijnBesluit.Server", + "src/MijnBesluit.Protocol", + "src/MijnBesluit.Application", + "src/MijnBesluit.Infrastructure", + "src/MijnBesluit.Domain" + }, + ["database"] = new[] { "db" }, + ["apk"] = new[] + { + "src/MijnBesluit.App", + "src/MijnBesluit.AppUi", + "src/MijnBesluit.Client", + "src/MijnBesluit.Protocol" + } + }; + + /// + /// Wat er niet meetelt: wat de compiler zelf maakt, en wat niet in het pakket komt. + /// + /// Als mapnaam en niet als stuk pad met streepjes erin, want die streepjes verschillen per + /// besturingssysteem - en dan zou deze lijst op de ene machine werken en op de andere niet. + /// + private static readonly string[] Overslaan = { "bin", "obj", ".vs", ".git", "node_modules" }; + + /// Ligt dit bestand in een map die niet meetelt? + private static bool Overslaanbaar(string bestand, string wortel) + { + var deel = Path.GetRelativePath(wortel, bestand); + + return deel.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(stuk => Overslaan.Contains(stuk, StringComparer.OrdinalIgnoreCase)); + } + + /// + /// Kijkt per onderdeel of er sinds de vorige uitrol iets is gewijzigd. + /// + public static Dictionary Bepaal(string projectmap, Uitrolgeheugen geheugen) + { + var uitkomst = new Dictionary(); + + foreach (var (onderdeel, mappen) in Mappen) + { + var sinds = geheugen.Laatste(onderdeel); + DateTime nieuwste = default; + string? nieuwsteNaam = null; + var aantal = 0; + + foreach (var deelmap in mappen) + { + // De lijst hierboven staat met schuine strepen; hier worden het de strepen van + // deze machine. + var pad = Path.Combine(projectmap, + deelmap.Replace('/', Path.DirectorySeparatorChar)); + if (!Directory.Exists(pad)) continue; + + foreach (var bestand in Directory.EnumerateFiles(pad, "*", SearchOption.AllDirectories)) + { + if (Overslaanbaar(bestand, pad)) continue; + + var moment = File.GetLastWriteTimeUtc(bestand); + if (moment > nieuwste) { nieuwste = moment; nieuwsteNaam = Path.GetFileName(bestand); } + if (sinds is not null && moment > sinds) aantal++; + } + } + + // Nog nooit uitgerold: dan weten we niets, en is aanzetten het veilige antwoord. + if (sinds is null) + { + uitkomst[onderdeel] = new Stand(true, "nog niet eerder uitgerold vanuit dit venster", nieuwste); + continue; + } + + uitkomst[onderdeel] = aantal switch + { + 0 => new Stand(false, "", nieuwste), + 1 => new Stand(true, $"{nieuwsteNaam} is gewijzigd", nieuwste), + _ => new Stand(true, $"{aantal} bestanden gewijzigd, laatst {nieuwsteNaam}", nieuwste) + }; + } + + return uitkomst; + } +} + +/// +/// Wanneer elk onderdeel voor het laatst is uitgerold. Staat naast de exe, niet in de projectmap: +/// het gaat over déze machine en hoort niet in de broncode. +/// +/// Wat er wordt onthouden is niet "hoe laat het toen was" maar "hoe nieuw het nieuwste bestand +/// toen was". Dat scheelt een valkuil: wijzig je iets tijdens het bouwen, dan zou een klok van +/// het moment-van-slagen die wijziging als uitgerold aanmerken terwijl hij er niet in zit. +/// +public class Uitrolgeheugen +{ + private Dictionary _momenten = new(StringComparer.OrdinalIgnoreCase); + + private static string Pad => Path.Combine(AppContext.BaseDirectory, "laatste-uitrol.json"); + + public static Uitrolgeheugen Lees() + { + var geheugen = new Uitrolgeheugen(); + + try + { + if (File.Exists(Pad)) + geheugen._momenten = JsonSerializer.Deserialize>(File.ReadAllText(Pad)) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + catch (Exception) + { + // Onleesbaar? Dan weten we niets, en zet het venster alles aan. Dat is de veilige kant. + } + + return geheugen; + } + + public virtual DateTime? Laatste(string onderdeel) => + _momenten.TryGetValue(onderdeel, out var moment) ? moment : null; + + public virtual void Onthoud(string onderdeel, DateTime nieuwste) + { + _momenten[onderdeel] = nieuwste; + + try { File.WriteAllText(Pad, JsonSerializer.Serialize(_momenten, new JsonSerializerOptions { WriteIndented = true })); } + catch (Exception) { /* dan staat het venster de volgende keer ruimer aan; geen ramp */ } + } + + /// De naam waaronder een soort in dit geheugen staat. + public static string Naam(Uitrolsoort soort) => soort switch + { + Uitrolsoort.Site => "site", + Uitrolsoort.SocketServer => "socketserver", + Uitrolsoort.Database => "database", + _ => soort.ToString().ToLowerInvariant() + }; +} diff --git a/src/MijnBesluit.Uitrolagent/Agentinstellingen.cs b/src/MijnBesluit.Uitrolagent/Agentinstellingen.cs new file mode 100644 index 0000000..04f1486 --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/Agentinstellingen.cs @@ -0,0 +1,129 @@ +using System.Text.Json; + +namespace MijnBesluit.Uitrolagent; + +/// Wat er in uitrolagent.json staat. +public class Agentinstellingen +{ + public string Geheim { get; set; } = ""; + public int Poort { get; set; } = 5210; + + public string? CertificateFile { get; set; } + public string? CertificatePassword { get; set; } + public string? CertificateThumbprint { get; set; } + + public string Scriptmap { get; set; } = @"C:\Beheer"; + public string Werkmap { get; set; } = @"C:\Beheer\uitrol"; + + /// + /// Waar de agent naar een opdracht van je pc kijkt als hij staat te wachten. + /// + /// Over een externe-bureaubladverbinding is dat de publicatiemap op je eigen pc, zichtbaar + /// als \\tsclient. Met een VPN kan het ook een gedeelde map op die pc zijn; het mechanisme + /// is hetzelfde. Zie docs/Uitrollen-met-het-venster.md. + /// + public string Opdrachtmap { get; set; } = @"\\tsclient\D\publish"; + + public string MysqlExe { get; set; } = @"C:\Program Files\MariaDB 11.4\bin\mysql.exe"; + public string DbNaam { get; set; } = "mijnbesluit"; + public string? DbGebruiker { get; set; } + public string? DbWachtwoord { get; set; } + + /// + /// Een tweede instellingenbestand, buiten de publicatiemap, dat over dit bestand heen gaat. + /// + /// Waarvoor: hier staat een wachtwoord van een databaseaccount in. Dat hoort niet in de map + /// die je bij elke publicatie overschrijft - dan ben je het na de eerstvolgende uitrol kwijt, + /// en merk je dat pas als het databasedeel omvalt met "Er is geen databasegebruiker + /// ingesteld". Dezelfde afspraak als bij de SocketServer, die zijn geheimen in + /// C:\MijnBesluitData haalt. + /// + /// Alleen wat er in dat tweede bestand écht staat gaat eroverheen. Een sleutel die je daar + /// weglaat, houdt dus de waarde uit uitrolagent.json. + /// + public string ExtraConfigFile { get; set; } = @"C:\MijnBesluitData\uitrolagent.secrets.json"; + + /// + /// Waar een klacht over het geheimenbestand heen gaat. Niets meegeven betekent: naar de + /// console. Het agentvenster geeft hier zijn eigen logvenster mee, want een WPF-programma + /// heeft geen console en zou zo'n klacht nooit laten zien. + /// + public static Agentinstellingen Lees(string pad, Action? meld = null) + { + var instellingen = File.Exists(pad) + ? JsonSerializer.Deserialize(File.ReadAllText(pad), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new Agentinstellingen() + : new Agentinstellingen(); + + instellingen.LeesGeheimenBij(meld); + return instellingen; + } + + /// + /// Legt het tweede bestand over het eerste heen, sleutel voor sleutel. + /// + /// Met opzet per sleutel en niet in één keer het hele object: anders zou een geheimenbestand + /// waarin alleen het wachtwoord staat, alle andere instellingen terugzetten op de + /// standaardwaarden. Dat soort stilte kost een avond. + /// + private void LeesGeheimenBij(Action? meld) + { + if (string.IsNullOrWhiteSpace(ExtraConfigFile) || !File.Exists(ExtraConfigFile)) return; + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(ExtraConfigFile)); + + foreach (var veld in document.RootElement.EnumerateObject()) + { + // Regels die met // beginnen zijn uitleg in het bestand, geen instelling. + if (veld.Name.StartsWith("//", StringComparison.Ordinal)) continue; + + var eigenschap = typeof(Agentinstellingen).GetProperty(veld.Name, + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.IgnoreCase); + + if (eigenschap is null || !eigenschap.CanWrite) continue; + + if (eigenschap.PropertyType == typeof(int) && veld.Value.TryGetInt32(out var getal)) + eigenschap.SetValue(this, getal); + else if (veld.Value.ValueKind == JsonValueKind.String) + eigenschap.SetValue(this, veld.Value.GetString()); + } + } + catch (Exception ex) + { + // Hardop, en doorgaan met wat er wél staat. Stil falen zou betekenen dat je pas bij + // de volgende uitrol merkt dat je geheimen er niet in zaten. + var klacht = $"Let op: {ExtraConfigFile} kon niet worden gelezen ({ex.Message})."; + + if (meld is not null) meld(klacht, true); + else Console.Error.WriteLine(klacht); + } + } + + /// + /// Wat er mis is, of niets. De agent start niet met een half ingevuld bestand: een uitrolkanaal + /// dat "het doet het wel ongeveer" is precies wat je hier niet wilt. + /// + public IReadOnlyList WatErMankeert() + { + var fouten = new List(); + + if (string.IsNullOrWhiteSpace(Geheim)) + fouten.Add("Er staat geen Geheim in uitrolagent.json. Maak er een met --nieuwe-sleutel."); + else if (Geheim.Length < MijnBesluit.Uitrol.Uitrolsleutel.MinimaleSleutellengte) + fouten.Add($"Het geheim is te kort; minstens {MijnBesluit.Uitrol.Uitrolsleutel.MinimaleSleutellengte} tekens."); + + if (string.IsNullOrWhiteSpace(CertificateFile) && string.IsNullOrWhiteSpace(CertificateThumbprint)) + fouten.Add("Er is geen certificaat ingesteld. Zonder TLS gaat het pakket open over de lijn."); + + if (Poort is < 1 or > 65535) fouten.Add("Het poortnummer klopt niet."); + + if (!Directory.Exists(Scriptmap)) + fouten.Add($"De scriptmap bestaat niet: {Scriptmap}"); + + return fouten; + } +} diff --git a/src/MijnBesluit.Uitrolagent/Consolevenster.cs b/src/MijnBesluit.Uitrolagent/Consolevenster.cs new file mode 100644 index 0000000..38e7e2f --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/Consolevenster.cs @@ -0,0 +1,39 @@ +using System.Runtime.InteropServices; + +namespace MijnBesluit.Uitrolagent; + +/// +/// Dit consolevenster boven de andere vensters houden. +/// +/// Een consolevenster heeft geen knoppenbalk om een vinkje op te zetten, zoals het uitrolvenster +/// op de ontwikkelmachine er een heeft (zie VinkAltijdBoven daar). Hier is het argument +/// --boven het dichtstbijzijnde equivalent: dit programma draait op de server vaak lang +/// te wachten (--wacht) of te luisteren (--luister), en dan is het net zo prettig om het venster +/// in beeld te houden zonder ernaartoe te hoeven wisselen. +/// +internal static class Consolevenster +{ + [DllImport("kernel32.dll", ExactSpelling = true)] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int x, int y, int cx, int cy, uint uFlags); + + private static readonly IntPtr HwndTopmost = new(-1); + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOSIZE = 0x0001; + + /// + /// Zet dit consolevenster boven de andere vensters. Alleen zinvol op Windows; op een andere + /// besturing bestaat dit venster niet zo en doet de aanroep dus niets. + /// + public static void ZetAltijdBoven() + { + if (!OperatingSystem.IsWindows()) return; + + var venster = GetConsoleWindow(); + if (venster != IntPtr.Zero) + SetWindowPos(venster, HwndTopmost, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + } +} diff --git a/src/MijnBesluit.Uitrolagent/Logboek.cs b/src/MijnBesluit.Uitrolagent/Logboek.cs new file mode 100644 index 0000000..c835310 --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/Logboek.cs @@ -0,0 +1,43 @@ +using MijnBesluit.Uitrol; + +namespace MijnBesluit.Uitrolagent; + +/// +/// Wat de agent aan het doen is, in regels. De ontwikkelmachine haalt ze op met uitrol.log en +/// laat ze zien terwijl het draait - anders kijk je twee minuten naar een venster dat niets zegt +/// terwijl er van alles gebeurt. +/// +/// Alles blijft ook op het scherm van de server staan. Loopt de verbinding weg halverwege een +/// uitrol, dan gaat het uitrollen gewoon door en kun je op de server zien hoe het afliep. +/// +public class Logboek +{ + private readonly object _slot = new(); + private readonly List _regels = new(); + + public void Schrijf(string tekst, bool isFout) + { + var regel = new Uitrolregel(DateTime.UtcNow, tekst, isFout); + + lock (_slot) _regels.Add(regel); + + var kleur = Console.ForegroundColor; + if (isFout) Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" {regel.Moment.ToLocalTime():HH:mm:ss} {tekst}"); + Console.ForegroundColor = kleur; + } + + /// De regels vanaf een bepaald nummer, zodat de andere kant alleen het nieuwe ophaalt. + public (IReadOnlyList Regels, int Volgende) Vanaf(int vanaf) + { + lock (_slot) + { + if (vanaf < 0) vanaf = 0; + if (vanaf >= _regels.Count) return (Array.Empty(), _regels.Count); + + return (_regels.GetRange(vanaf, _regels.Count - vanaf), _regels.Count); + } + } + + public int Aantal { get { lock (_slot) return _regels.Count; } } +} diff --git a/src/MijnBesluit.Uitrolagent/MijnBesluit.Uitrolagent.csproj b/src/MijnBesluit.Uitrolagent/MijnBesluit.Uitrolagent.csproj new file mode 100644 index 0000000..ea17a15 --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/MijnBesluit.Uitrolagent.csproj @@ -0,0 +1,27 @@ + + + + + Exe + net8.0 + enable + enable + MijnBesluit.Uitrolagent + MijnBesluit.Uitrolagent + x64 + + + + + + + + + + PreserveNewest + + + + diff --git a/src/MijnBesluit.Uitrolagent/Ontvangst.cs b/src/MijnBesluit.Uitrolagent/Ontvangst.cs new file mode 100644 index 0000000..e97b1ac --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/Ontvangst.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using MijnBesluit.Protocol; +using MijnBesluit.Uitrol; + +namespace MijnBesluit.Uitrolagent; + +/// +/// Neemt de opdrachten aan. Elke binnenkomende opdracht komt hier langs, en hier staat wat er +/// wél mag. +/// +/// De volgorde is vast: eerst uitrol.begin met een handtekening, dan de delen, dan +/// uitrol.klaar. Zonder geldig begin wordt er geen enkel deel aangenomen, en een deel dat +/// niet bij het lopende pakket hoort evenmin. Er kan er maar één tegelijk: twee uitrollen door +/// elkaar heen is nooit de bedoeling en altijd een probleem. +/// +public class Ontvangst +{ + /// Het grootste pakket dat we aannemen. Ruim boven wat een publicatie van de site is. + private const long MaxPakketBytes = 300L * 1024 * 1024; + + private readonly Agentinstellingen _instellingen; + private readonly Logboek _logboek; + private readonly object _slot = new(); + + /// Nonces die we al hebben gezien. Ze verlopen vanzelf met de speling op het tijdstip. + private readonly HashSet _nonces = new(StringComparer.Ordinal); + private DateTime _noncesOpgeruimd = DateTime.UtcNow; + + private Uitrolopdracht? _lopend; + private string? _lopendeVerbinding; + private FileStream? _pakket; + private int _volgendDeel; + private bool _bezig; + private string? _laatste; + + public Ontvangst(Agentinstellingen instellingen, Logboek logboek) + { + _instellingen = instellingen; + _logboek = logboek; + } + + public async Task BehandelAsync(SocketRequest verzoek, SocketConnection verbinding, + CancellationToken ct) + { + var id = verzoek.RequestId; + + try + { + return verzoek.Action switch + { + Uitrolacties.Stand => Stand(id, verzoek), + Uitrolacties.Begin => Begin(id, verzoek, verbinding), + Uitrolacties.Deel => Deel(id, verzoek, verbinding), + Uitrolacties.Klaar => await KlaarAsync(id, verbinding, ct), + Uitrolacties.Log => Log(id, verzoek), + _ => SocketResponse.Error(id, "Onbekende actie.") + }; + } + catch (Exception ex) + { + // Wat er ook misgaat: de andere kant hoort een nette melding te krijgen, en op de + // server staat wat het echt was. + _logboek.Schrijf($"Fout bij {verzoek.Action}: {ex}", true); + return SocketResponse.Error(id, "Er ging iets mis; kijk op de server."); + } + } + + // ------------------------------------------------------------------ stand + + private SocketResponse Stand(string id, SocketRequest verzoek) + { + var vraag = verzoek.PayloadAs(); + if (vraag is null) return SocketResponse.Error(id, "Geen vraag meegegeven."); + + if (!Geldig(vraag.Doel, vraag.Tijdstip, vraag.Nonce, vraag.Handtekening)) + return SocketResponse.Error(id, "Niet ondertekend of niet geldig."); + + lock (_slot) + { + return SocketResponse.Ok(id, new Uitrolstand( + typeof(Ontvangst).Assembly.GetName().Version?.ToString() ?? "onbekend", + _instellingen.Scriptmap, _bezig, _laatste)); + } + } + + // ------------------------------------------------------------------ pakket aannemen + + private SocketResponse Begin(string id, SocketRequest verzoek, SocketConnection verbinding) + { + var opdracht = verzoek.PayloadAs(); + if (opdracht is null) return SocketResponse.Error(id, "Geen opdracht meegegeven."); + + if (!Geldig(opdracht.Doel, opdracht.Tijdstip, opdracht.Nonce, opdracht.Handtekening)) + { + _logboek.Schrijf($"Geweigerd: opdracht zonder geldige handtekening van {verbinding.RemoteAddress}.", true); + return SocketResponse.Error(id, "Niet ondertekend of niet geldig."); + } + + if (opdracht.Bytes is <= 0 or > MaxPakketBytes) + return SocketResponse.Error(id, "Dit pakket heeft een onmogelijke grootte."); + + if (!Enum.IsDefined(opdracht.Soort)) return SocketResponse.Error(id, "Onbekende soort."); + + lock (_slot) + { + if (_bezig) return SocketResponse.Error(id, "Er loopt al een uitrol. Wacht tot die klaar is."); + + Opruimen(); + + // De naam uit het verzoek wordt NIET gebruikt om een pad mee te maken. De agent + // bepaalt zelf waar het pakket landt; anders is een bestandsnaam met ..\ erin genoeg + // om ergens anders op de schijf te schrijven. + var pad = Path.Combine(_instellingen.Werkmap, $"{opdracht.Soort}.zip".ToLowerInvariant()); + + _lopend = opdracht; + _lopendeVerbinding = verbinding.Id; + _volgendDeel = 0; + _pakket = new FileStream(pad, FileMode.Create, FileAccess.Write, FileShare.None); + + _logboek.Schrijf($"Pakket voor {opdracht.Soort} aangekondigd " + + $"({opdracht.Bytes / 1024 / 1024} MB) van {verbinding.RemoteAddress}.", false); + + return SocketResponse.Ok(id, new { klaar = true }); + } + } + + private SocketResponse Deel(string id, SocketRequest verzoek, SocketConnection verbinding) + { + var deel = verzoek.PayloadAs(); + if (deel is null) return SocketResponse.Error(id, "Geen deel meegegeven."); + + lock (_slot) + { + if (_lopend is null || _pakket is null) return SocketResponse.Error(id, "Er is geen pakket begonnen."); + if (_lopendeVerbinding != verbinding.Id) return SocketResponse.Error(id, "Dit pakket hoort bij een andere verbinding."); + if (deel.Index != _volgendDeel) return SocketResponse.Error(id, $"Deel {_volgendDeel} werd verwacht."); + + var bytes = Convert.FromBase64String(deel.Data); + + if (_pakket.Length + bytes.Length > _lopend.Bytes) + return SocketResponse.Error(id, "Er komt meer binnen dan aangekondigd."); + + _pakket.Write(bytes); + _volgendDeel++; + + return SocketResponse.Ok(id, new { ontvangen = _pakket.Length }); + } + } + + private async Task KlaarAsync(string id, SocketConnection verbinding, CancellationToken ct) + { + Uitrolopdracht opdracht; + string pad; + + lock (_slot) + { + if (_lopend is null || _pakket is null) return SocketResponse.Error(id, "Er is geen pakket begonnen."); + if (_lopendeVerbinding != verbinding.Id) return SocketResponse.Error(id, "Dit pakket hoort bij een andere verbinding."); + + opdracht = _lopend; + pad = _pakket.Name; + + if (_pakket.Length != opdracht.Bytes) + { + Opruimen(); + return SocketResponse.Error(id, "Het pakket is niet compleet aangekomen."); + } + + _pakket.Dispose(); + _pakket = null; + _bezig = true; + } + + try + { + // De hash die ondertekend is, moet die van dit bestand zijn. Anders is het pakket + // onderweg veranderd - of het is een ander pakket met een geleende handtekening. + var werkelijk = Uitrolsleutel.Hash(await File.ReadAllBytesAsync(pad, ct)); + if (!string.Equals(werkelijk, opdracht.Sha256, StringComparison.OrdinalIgnoreCase)) + { + _logboek.Schrijf("Geweigerd: het pakket komt niet overeen met de ondertekende hash.", true); + lock (_slot) { _bezig = false; Opruimen(); } + return SocketResponse.Error(id, "Het pakket komt niet overeen met de handtekening."); + } + + _logboek.Schrijf($"Pakket klopt. Uitrollen van {opdracht.Soort} begint.", false); + + var werk = new Uitrolwerk(_instellingen, _logboek.Schrijf); + var gelukt = await werk.VoerUitAsync(opdracht.Soort, pad, ct); + + lock (_slot) + { + _bezig = false; + _laatste = $"{DateTime.Now:g} - {opdracht.Soort} - {(gelukt ? "gelukt" : "mislukt")}"; + Opruimen(); + } + + _logboek.Schrijf(gelukt ? $"{opdracht.Soort} is uitgerold." : $"{opdracht.Soort} is NIET uitgerold.", !gelukt); + + return gelukt + ? SocketResponse.Ok(id, new { gelukt = true }) + : SocketResponse.Error(id, "Het uitrollen is niet gelukt; zie het logboek."); + } + catch (Exception) + { + lock (_slot) { _bezig = false; Opruimen(); } + throw; + } + } + + // ------------------------------------------------------------------ logboek + + private SocketResponse Log(string id, SocketRequest verzoek) + { + var vanaf = 0; + if (verzoek.Payload is JsonElement el && el.TryGetProperty("vanaf", out var v)) vanaf = v.GetInt32(); + + var (regels, volgende) = _logboek.Vanaf(vanaf); + bool bezig; lock (_slot) bezig = _bezig; + + return SocketResponse.Ok(id, new { regels, volgende, bezig }); + } + + // ------------------------------------------------------------------ hulpjes + + private bool Geldig(string doel, long tijdstip, string nonce, string handtekening) + { + lock (_slot) + { + // Af en toe de lijst met nonces leeggooien. Ze zijn alleen nodig zolang een opdracht + // geldig is; alles ouder dan dat wordt toch al op het tijdstip afgewezen. + if (DateTime.UtcNow - _noncesOpgeruimd > Uitrolsleutel.Speling * 2) + { + _nonces.Clear(); + _noncesOpgeruimd = DateTime.UtcNow; + } + + return Uitrolsleutel.Klopt(_instellingen.Geheim, doel, tijdstip, nonce, handtekening, + DateTime.UtcNow, _nonces); + } + } + + /// Alles van een (afgebroken) pakket weg. Aanroepen met het slot vast. + private void Opruimen() + { + _pakket?.Dispose(); + _pakket = null; + _lopend = null; + _lopendeVerbinding = null; + _volgendDeel = 0; + } +} diff --git a/src/MijnBesluit.Uitrolagent/Program.cs b/src/MijnBesluit.Uitrolagent/Program.cs new file mode 100644 index 0000000..a63394c --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/Program.cs @@ -0,0 +1,303 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using MijnBesluit.Protocol; +using MijnBesluit.Uitrol; +using MijnBesluit.Uitrolagent; + +// De uitrolagent. Draait op de server, neemt een pakket aan van de ontwikkelmachine en rolt het +// uit met de scripts die daar al voor bestonden. +// +// Wat dit programma IS: een kanaal waarmee code op deze server terechtkomt. Dat is geen bijzaak +// maar het hele doel, en daarom zitten er drie grendels op: +// +// 1. TLS, zodat er niemand meeleest of onderweg iets vervangt. +// 2. Een handtekening per opdracht met een gedeeld geheim, zodat de agent weet dat het pakket +// van jou komt. TLS zegt alleen dat niemand meeluistert, niet wie er aanbelt. +// 3. Een vaste lijst met wat hij mag. Geen "voer dit uit"; alleen: rol de site uit, rol de +// SocketServer uit, draai de databasescripts. +// +// En hij draait alleen als jij hem start. Een service die dag en nacht luistert voor iets wat je +// een paar keer per week doet, is een deur die vooral openstaat als je er niet bent. + +// Twee werkwijzen, en welke je nodig hebt hangt af van hoe je bij deze server kunt: +// +// * ZELF UITROLLEN (--zip). Je staat al op de server - via RDP of aan de console - en het +// pakket staat ergens waar deze server bij kan, bijvoorbeeld op de doorgegeven schijf van je +// externe-bureaubladverbinding. Er komt geen netwerk aan te pas en dus ook geen poort, geen +// certificaat en geen gedeeld geheim: dat je hier bent ingelogd IS de toegangscontrole. +// +// * WACHTEN (--wacht). Je drukt op de knop in het uitrolvenster op je pc, en deze agent pakt +// de opdracht op uit de publicatiemap van die pc - over de doorgegeven schijf, of over een +// gedeelde map als er een VPN is. Geen poort, geen certificaat, en op de server hoef je +// niets te doen behalve dit programma starten. Zie Wachtstand. +// +// * LUISTEREN (--luister). Je pc stuurt het pakket erheen en drukt op de knop. Dat vraagt wel +// een poort, en dus TLS, een handtekening en een firewall die alleen je eigen netwerk +// toelaat. Pas zinvol als je de server over een VPN kunt bereiken; over RDP alleen kan je pc +// deze poort niet eens zien. +// +// Zonder argumenten vraagt hij het gewoon. + +// Dit venster staat vaak een tijd te wachten of te luisteren; --boven houdt het in beeld zonder +// dat je ernaartoe hoeft te wisselen om te zien of er al iets is gebeurd. Zie Consolevenster. +if (args.Contains("--boven")) Consolevenster.ZetAltijdBoven(); + +if (args.Contains("--wacht")) +{ + var wachtInstellingen = Agentinstellingen.Lees(Path.Combine(AppContext.BaseDirectory, "uitrolagent.json")); + + using var stopper = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopper.Cancel(); }; + + await new Wachtstand(wachtInstellingen).LoopAsync(stopper.Token); + return 0; +} + +if (args.Contains("--nieuwe-sleutel")) +{ + Console.WriteLine(Uitrolsleutel.NieuweSleutel()); + Console.WriteLine(); + Console.WriteLine("Zet deze in uitrolagent.json op de server EN in het uitrolvenster op je pc."); + Console.WriteLine("Niet in de projectmap, niet in een e-mail, niet in een schermafbeelding."); + return 0; +} + +var instellingenPad = Path.Combine(AppContext.BaseDirectory, "uitrolagent.json"); +var instellingen = Agentinstellingen.Lees(instellingenPad); + +// ---------------------------------------------------------------- zelf uitrollen + +var zipArgument = Waarde(args, "--zip"); +var soortArgument = Waarde(args, "--soort"); + +if (zipArgument is not null || (!args.Contains("--luister") && args.Length == 0 && !Console.IsInputRedirected)) +{ + if (zipArgument is null) + { + Console.WriteLine(); + Console.WriteLine(" MijnBesluit - uitrolagent"); + Console.WriteLine(); + Console.WriteLine(" 1) Wachten op een opdracht van je pc (geen VPN nodig)"); + Console.WriteLine(" 2) Een pakket uitrollen dat op deze server staat"); + Console.WriteLine(" 3) Luisteren op een poort (vraagt een VPN)"); + Console.WriteLine(" 4) Stoppen"); + Console.WriteLine(); + Console.Write(" Keuze: "); + + var keuze = Console.ReadLine()?.Trim(); + if (keuze == "4") return 0; + + // Wachten staat bovenaan omdat het het antwoord is op wat je meestal wilt: op je pc op + // een knop drukken en hier niets hoeven doen. Zie Wachtstand. + if (keuze is null or "" or "1") + { + using var stopper = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopper.Cancel(); }; + + await new Wachtstand(instellingen).LoopAsync(stopper.Token); + return 0; + } + + if (keuze != "3") + { + var gelukt = await ZelfUitrollenAsync(instellingen); + Console.WriteLine(); + Console.WriteLine(" Druk op Enter om te sluiten."); + Console.ReadLine(); + return gelukt ? 0 : 1; + } + } + else + { + var gelukt = await ZelfUitrollenAsync(instellingen, zipArgument, soortArgument); + return gelukt ? 0 : 1; + } +} + +// ---------------------------------------------------------------- luisteren + +var mankeert = instellingen.WatErMankeert(); +if (mankeert.Count > 0) +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("De agent start niet:"); + foreach (var regel in mankeert) Console.WriteLine(" - " + regel); + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine($"Instellingen: {instellingenPad}"); + return 1; +} + +var certificaat = Certificaat.Laad(instellingen, Console.Error.WriteLine); +if (certificaat is null) +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Zonder certificaat start de agent niet."); + Console.ResetColor(); + return 1; +} + +Directory.CreateDirectory(instellingen.Werkmap); + +var logboek = new Logboek(); +var ontvangst = new Ontvangst(instellingen, logboek); + +using var luisteraar = new SocketListener( + new SocketListenerOptions + { + // Luistert op alles; de VPN en de firewall bepalen wie erbij kan. Zet die firewall dicht + // voor alles behalve het VPN-bereik - dit is geen poort voor het open internet. + Address = IPAddress.Any, + Port = instellingen.Poort, + Certificate = certificaat, + MaxConcurrentConnections = 4, + IdleTimeout = TimeSpan.FromMinutes(30) + }, + ontvangst.BehandelAsync, + regel => Console.WriteLine(regel)); + +luisteraar.Start(); + +Console.WriteLine(); +Console.WriteLine(" MijnBesluit - uitrolagent"); +Console.WriteLine($" Luistert op poort {instellingen.Poort} met TLS ({certificaat.Subject})."); +Console.WriteLine($" Scripts: {instellingen.Scriptmap}"); +Console.WriteLine($" Pakketten: {instellingen.Werkmap}"); +Console.WriteLine(); +Console.WriteLine(" Laat dit venster openstaan zolang je uitrolt. Sluiten mag zodra je klaar bent."); +Console.WriteLine(); + +var stop = new ManualResetEventSlim(false); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Set(); }; +stop.Wait(); + +Console.WriteLine("Tot ziens."); +return 0; + +/// De waarde achter een argument, of null. +static string? Waarde(string[] args, string naam) +{ + var plek = Array.IndexOf(args, naam); + return plek >= 0 && plek + 1 < args.Length ? args[plek + 1] : null; +} + +/// +/// Uitrollen zonder netwerk: het pakket staat al op deze server (of op een schijf die je via je +/// externe-bureaubladverbinding hebt doorgegeven) en jij staat ervoor. +/// +/// Hier komt geen handtekening aan te pas, en dat is geen gat: wie dit draait, is al ingelogd op +/// de server. Wie zover komt heeft geen pakket nodig om schade aan te richten. +/// +static async Task ZelfUitrollenAsync(Agentinstellingen instellingen, string? zip = null, + string? soortnaam = null) +{ + if (zip is null) + { + Console.WriteLine(); + Console.WriteLine(" Wat wil je uitrollen?"); + Console.WriteLine(" 1) De website"); + Console.WriteLine(" 2) De SocketServer"); + Console.WriteLine(" 3) De databasescripts"); + Console.Write(" Keuze: "); + soortnaam = Console.ReadLine()?.Trim() switch + { + "2" => "socketserver", + "3" => "database", + _ => "site" + }; + + Console.WriteLine(); + Console.WriteLine(@" Waar staat het pakket? Bijvoorbeeld \tsclient\D\publish\mijnbesluit.zip"); + Console.Write(" Pad: "); + zip = Console.ReadLine()?.Trim().Trim('"'); + } + + var soort = (soortnaam ?? "site").ToLowerInvariant() switch + { + "socketserver" or "socket" => Uitrolsoort.SocketServer, + "database" or "db" => Uitrolsoort.Database, + _ => Uitrolsoort.Site + }; + + if (string.IsNullOrWhiteSpace(zip) || !File.Exists(zip)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" Het bestand {zip} staat er niet."); + Console.ResetColor(); + return false; + } + + if (!Directory.Exists(instellingen.Scriptmap)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" De scriptmap {instellingen.Scriptmap} bestaat niet. " + + "Pas uitrolagent.json aan."); + Console.ResetColor(); + return false; + } + + Console.WriteLine(); + var logboek = new Logboek(); + var werk = new Uitrolwerk(instellingen, logboek.Schrijf); + + var gelukt = await werk.VoerUitAsync(soort, zip!, CancellationToken.None); + + Console.WriteLine(); + Console.ForegroundColor = gelukt ? ConsoleColor.Green : ConsoleColor.Red; + Console.WriteLine(gelukt ? $" {soort} is uitgerold." : $" {soort} is NIET uitgerold."); + Console.ResetColor(); + + return gelukt; +} + +/// Het laden van het servercertificaat, op dezelfde manier als de SocketServer het doet. +static class Certificaat +{ + public static X509Certificate2? Laad(Agentinstellingen instellingen, Action fout) + { + var pad = instellingen.CertificateFile; + if (!string.IsNullOrWhiteSpace(pad)) + { + var volledig = Path.IsPathRooted(pad) ? pad : Path.Combine(AppContext.BaseDirectory, pad); + if (File.Exists(volledig)) + { + try { return new X509Certificate2(volledig, instellingen.CertificatePassword); } + catch (Exception ex) { fout($"Het certificaat {volledig} kon niet worden geladen: {ex.Message}"); } + } + else + { + fout($"Het ingestelde certificaat is niet gevonden: {volledig}"); + } + } + + var vingerafdruk = instellingen.CertificateThumbprint; + if (!string.IsNullOrWhiteSpace(vingerafdruk) && OperatingSystem.IsWindows()) + { + // Spaties en het onzichtbare teken dat het certificaatscherm van Windows meegeeft, + // eruit - anders zoek je je wezenloos naar een certificaat dat er gewoon is. + var schoon = new string(vingerafdruk.Where(char.IsLetterOrDigit).ToArray()); + + foreach (var naam in new[] { "My", "WebHosting" }) + { + using var store = new X509Store(naam, StoreLocation.LocalMachine); + try { store.Open(OpenFlags.ReadOnly); } catch (Exception) { continue; } + + var gevonden = store.Certificates.Find(X509FindType.FindByThumbprint, schoon, false); + if (gevonden.Count == 0) continue; + + if (!gevonden[0].HasPrivateKey) + { + fout($"Het certificaat {schoon} heeft geen privesleutel; TLS kan er niet mee."); + return null; + } + + return gevonden[0]; + } + + fout($"Certificaat {schoon} niet gevonden in de opslagen My en WebHosting."); + } + + return null; + } +} diff --git a/src/MijnBesluit.Uitrolagent/Uitrolwerk.cs b/src/MijnBesluit.Uitrolagent/Uitrolwerk.cs new file mode 100644 index 0000000..5c78e06 --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/Uitrolwerk.cs @@ -0,0 +1,211 @@ +using System.Diagnostics; +using MijnBesluit.Uitrol; + +namespace MijnBesluit.Uitrolagent; + +/// +/// Het werk zelf: een binnengekomen pakket uitrollen door het script te draaien dat daar al voor +/// bestond. +/// +/// Hier wordt met opzet niets nieuws bedacht. Uitrollen.ps1 en +/// SocketServer-uitrollen.ps1 doen dit werk al maanden: ze bewaren de vorige versie, zetten +/// de site of de service netjes stil, en zetten bij een fout alles terug. Die logica nog een keer +/// in C# schrijven zou betekenen dat er twee versies zijn van iets dat maar op één manier goed is. +/// +/// De agent kiest dus alleen WELK script, met het pakket als enige variabele. Wat er in dat script +/// staat, staat niet ter beschikking van de kant die verstuurt. +/// +public class Uitrolwerk +{ + private readonly Agentinstellingen _instellingen; + private readonly Action _meld; + + public Uitrolwerk(Agentinstellingen instellingen, Action meld) + { + _instellingen = instellingen; + _meld = meld; + } + + public async Task VoerUitAsync(Uitrolsoort soort, string zipPad, CancellationToken ct) + { + return soort switch + { + Uitrolsoort.Site => await ScriptAsync("Uitrollen.ps1", $"-Zip \"{zipPad}\"", ct), + Uitrolsoort.SocketServer => await ScriptAsync("SocketServer-uitrollen.ps1", $"-Zip \"{zipPad}\"", ct), + Uitrolsoort.Database => await DatabaseAsync(zipPad, ct), + _ => Mislukt($"Onbekende soort: {soort}") + }; + } + + private bool Mislukt(string reden) + { + _meld(reden, true); + return false; + } + + /// Draait een van de bekende scripts en stuurt elke regel door naar de ontwikkelmachine. + private async Task ScriptAsync(string script, string argumenten, CancellationToken ct) + { + var pad = Path.Combine(_instellingen.Scriptmap, script); + if (!File.Exists(pad)) return Mislukt($"Het script {pad} staat er niet."); + + _meld($"Start {script}", false); + + var start = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{pad}\" {argumenten}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = _instellingen.Scriptmap + }; + + return await DraaiAsync(start, ct); + } + + /// + /// De databasescripts, in volgorde, vanaf nummer 03. + /// + /// Waarom vanaf 03: 01 maakt het schema aan en 02 maakt de applicatiegebruiker met zijn + /// wachtwoord. Dat is inrichten, geen bijwerken, en dat hoort een mens één keer met de hand te + /// doen. Een agent die accounts kan aanmaken is een agent die meer kan dan hij moet. + /// + /// De scripts zijn zo geschreven dat opnieuw draaien niets doet (zie de kop van elk script), + /// dus ze gaan er allemaal overheen. Dat is eerlijker dan bijhouden wat er al gedraaid heeft: + /// zo'n administratie kan ernaast zitten, en dan mis je er precies één. + /// + private async Task DatabaseAsync(string zipPad, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_instellingen.DbGebruiker)) + return Mislukt("Er is geen databasegebruiker ingesteld in uitrolagent.json."); + + var mysql = VindMysql(); + if (mysql is null) return false; // VindMysql heeft al gemeld waar hij heeft gekeken + + var map = Path.Combine(Path.GetDirectoryName(zipPad)!, Path.GetFileNameWithoutExtension(zipPad)); + if (Directory.Exists(map)) Directory.Delete(map, true); + System.IO.Compression.ZipFile.ExtractToDirectory(zipPad, map); + + var scripts = Directory.GetFiles(map, "*.sql") + .Select(Path.GetFullPath) + .Where(p => !Path.GetFileName(p).StartsWith("01_", StringComparison.Ordinal) && + !Path.GetFileName(p).StartsWith("02_", StringComparison.Ordinal)) + .OrderBy(p => Path.GetFileName(p), StringComparer.Ordinal) + .ToList(); + + if (scripts.Count == 0) return Mislukt("Er zitten geen scripts in dit pakket."); + + foreach (var script in scripts) + { + _meld($"Draait {Path.GetFileName(script)}", false); + + var start = new ProcessStartInfo + { + FileName = mysql, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + start.ArgumentList.Add($"--user={_instellingen.DbGebruiker}"); + if (!string.IsNullOrEmpty(_instellingen.DbWachtwoord)) + start.ArgumentList.Add($"--password={_instellingen.DbWachtwoord}"); + start.ArgumentList.Add(_instellingen.DbNaam); + + if (!await DraaiAsync(start, ct, File.ReadAllText(script))) return false; + } + + _meld($"{scripts.Count} databasescript(s) gedraaid.", false); + return true; + } + + /// + /// Zoekt mysql.exe - de client, niet de server (dat is mysqld.exe). + /// + /// Eerst op het pad uit de instellingen, en anders zelf zoeken. Dat tweede is er omdat het + /// versienummer in die map staat: "MariaDB 11.3" vandaag, "MariaDB 11.5" na een update. Een + /// vast pad in een instellingenbestand is dus een pad dat een keer stilletjes verkeerd wordt, + /// en dan valt de uitrol om op iets wat niets met de uitrol te maken heeft. + /// + private string? VindMysql() + { + if (!string.IsNullOrWhiteSpace(_instellingen.MysqlExe) && File.Exists(_instellingen.MysqlExe)) + return _instellingen.MysqlExe; + + var gekeken = new List(); + + if (!string.IsNullOrWhiteSpace(_instellingen.MysqlExe)) gekeken.Add(_instellingen.MysqlExe); + + foreach (var map in new[] + { + @"C:\Program Files\MariaDB *", + @"C:\Program Files\MySQL\MySQL Server *", + @"C:\Program Files (x86)\MariaDB *", + @"C:\Program Files (x86)\MySQL\MySQL Server *" + }) + { + var ouder = Path.GetDirectoryName(map)!; + var patroon = Path.GetFileName(map); + + if (!Directory.Exists(ouder)) continue; + + // Nieuwste eerst: na een update wil je de nieuwe client, niet de oude die blijft staan. + foreach (var kandidaat in Directory.GetDirectories(ouder, patroon) + .OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase)) + { + var exe = Path.Combine(kandidaat, "bin", "mysql.exe"); + gekeken.Add(exe); + + if (File.Exists(exe)) + { + _meld($"mysql.exe gevonden: {exe}", false); + return exe; + } + } + } + + Mislukt("mysql.exe is nergens gevonden. Gekeken op:" + Environment.NewLine + + string.Join(Environment.NewLine, gekeken.Select(g => " " + g)) + Environment.NewLine + + "Zet het juiste pad in uitrolagent.json bij MysqlExe. Let op: mysql.exe is de " + + "client; mysqld.exe is de server en kan dit niet."); + return null; + } + + /// Start een proces, stuurt elke regel door, en zegt of het goed ging. + private async Task DraaiAsync(ProcessStartInfo start, CancellationToken ct, string? invoer = null) + { + using var proces = new Process { StartInfo = start, EnableRaisingEvents = true }; + + proces.OutputDataReceived += (_, e) => { if (e.Data is not null) _meld(e.Data, false); }; + proces.ErrorDataReceived += (_, e) => { if (e.Data is not null) _meld(e.Data, true); }; + + try + { + proces.Start(); + } + catch (Exception ex) + { + return Mislukt($"Kon {start.FileName} niet starten: {ex.Message}"); + } + + proces.BeginOutputReadLine(); + proces.BeginErrorReadLine(); + + if (invoer is not null) + { + await proces.StandardInput.WriteAsync(invoer); + proces.StandardInput.Close(); + } + + await proces.WaitForExitAsync(ct); + + if (proces.ExitCode == 0) return true; + + _meld($"Afgesloten met code {proces.ExitCode}.", true); + return false; + } +} diff --git a/src/MijnBesluit.Uitrolagent/Wachtstand.cs b/src/MijnBesluit.Uitrolagent/Wachtstand.cs new file mode 100644 index 0000000..b325c83 --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/Wachtstand.cs @@ -0,0 +1,331 @@ +using System.Text.Json; +using MijnBesluit.Uitrol; + +namespace MijnBesluit.Uitrolagent; + +/// +/// De agent staat te wachten tot er een opdracht in de map van je pc verschijnt, en voert die +/// dan uit. +/// +/// Waarom dit er is: over een externe-bureaubladverbinding kan je pc geen poort op deze server +/// bereiken. Je ziet het bureaublad, maar er loopt geen lijn naar een poort - dus de luisterstand +/// heeft daar niets te zoeken. Wat er wél is, is de doorgegeven schijf: deze agent kan bij de +/// publicatiemap op jouw pc. Het uitrolvenster legt daar een briefje neer, deze lus pakt het op. +/// +/// Daarmee is de handeling op de server teruggebracht tot één ding: de agent starten en laten +/// staan. Geen paden intikken, geen keuze maken, geen PowerShell. +/// +/// Wat deze lus NIET doet is uitrollen zodra er een nieuwe zip staat. Dan zou hij aan de gang +/// gaan omdat je om een heel andere reden een keer hebt gebouwd. Er moet een briefje liggen, en +/// dat ontstaat alleen doordat iemand op een knop drukt. +/// +/// Geen enkele regel hieronder gaat rechtstreeks naar de console. Alles loopt via , +/// en dat is niet netheid maar noodzaak: het agentvenster is een WPF-programma en heeft geen +/// console. Wat hier met Console.WriteLine zou worden geschreven, verdwijnt daar spoorloos - en +/// dan sta je naar een leeg venster te kijken terwijl de uitrol wel degelijk loopt. +/// +public class Wachtstand +{ + private readonly Agentinstellingen _instellingen; + private readonly Action _meld; + private readonly Action? _bezig; + + /// Waar elke regel heen gaat. Niets meegeven betekent: naar de console. + /// + /// Wordt aan gezet zodra er een opdracht wordt uitgevoerd en weer uit als die klaar is. Het + /// venster hangt daar zijn voortgangsbalk aan; de console doet er niets mee. + /// + public Wachtstand(Agentinstellingen instellingen, + Action? meld = null, + Action? bezig = null) + { + _instellingen = instellingen; + _meld = meld ?? Opscherm; + _bezig = bezig; + } + + /// Hoe vaak we kijken. Vaker heeft geen zin: je drukt niet twintig keer per seconde op die knop. + private static readonly TimeSpan Tussenpoos = TimeSpan.FromSeconds(2); + + public async Task LoopAsync(CancellationToken ct) + { + var map = _instellingen.Opdrachtmap; + + _meld("MijnBesluit - uitrolagent staat te wachten", false); + _meld($"Kijkt in : {map}", false); + _meld($"Scripts : {_instellingen.Scriptmap}", false); + _meld("Druk op de knop in het uitrolvenster op je pc. Hier hoef je niets te doen.", false); + + var gewaarschuwd = false; + + while (!ct.IsCancellationRequested) + { + try + { + // De map kan komen en gaan: een externe-bureaubladverbinding die even wegvalt + // neemt \\tsclient mee. Dat is geen fout om op af te breken - dan wachten we + // gewoon tot hij er weer is. Eén keer melden is genoeg; elke twee seconden + // hetzelfde roepen maakt het logboek onleesbaar. + // + // Bewust geen Directory.Exists: dat vraagt alleen de bestandskenmerken op, en + // die blijken na zo'n onderbreking soms een poosje een oude (foute) uitkomst uit + // de cache van Windows terug te geven. Een echte inhoudsopgave dwingt een verse + // controle af, en is meteen de plek waar we sowieso naar het briefje zoeken. + string[] inhoud; + try + { + inhoud = Directory.GetFiles(map); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + if (!gewaarschuwd) + { + _meld($"De map {map} is nu niet bereikbaar. Ik blijf kijken.", true); + gewaarschuwd = true; + } + + await Task.Delay(Tussenpoos, ct); + continue; + } + + if (gewaarschuwd) + { + _meld($"De map {map} is er weer. Ik kijk meteen of er iets klaarstaat.", false); + gewaarschuwd = false; + } + + var briefje = Path.Combine(map, Uitrolbrief.Bestandsnaam); + if (inhoud.Contains(briefje, StringComparer.OrdinalIgnoreCase)) await BehandelAsync(briefje, map, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _meld($"Onverwacht: {ex.Message}", true); + } + + try { await Task.Delay(Tussenpoos, ct); } + catch (OperationCanceledException) { break; } + } + + _meld("Tot ziens.", false); + } + + private async Task BehandelAsync(string briefje, string map, CancellationToken ct) + { + // Wachten tot het bestand niet meer verandert. Het venster schrijft het in één keer weg, + // maar over een netwerkschijf kan een lezer er middenin zitten - en een half briefje + // levert een onbegrijpelijke fout op in plaats van een uitrol. + if (!await StilAsync(briefje, ct)) return; + + Uitrolbrief? brief; + try + { + brief = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(briefje, ct), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (Exception ex) + { + // Opzij leggen en niet weggooien: er zit misschien iets in wat je wilt zien, en + // laten liggen zou betekenen dat we elke twee seconden op dezelfde fout stuiten. + _meld($"Het briefje is niet te lezen: {ex.Message}", true); + OpzijLeggen(briefje); + return; + } + + if (brief is null || brief.Pakketten.Count == 0) + { + _meld("Er staat niets in het briefje.", true); + OpzijLeggen(briefje); + return; + } + + var logboek = Path.Combine(map, Uitrolbrief.Logbestandsnaam); + var schrijver = new Logschrijver(logboek, _meld); + + // Vanaf hier is hij aan het werk. In een try/finally, want als er onderweg iets omvalt + // moet de balk stoppen met draaien - anders staat het venster tot in de eeuwigheid te + // suggereren dat er nog iets loopt. + _bezig?.Invoke(true); + + try + { + schrijver.Schrijf($"Opdracht van {brief.Van}, gemaakt {brief.Gemaakt.ToLocalTime():HH:mm:ss}.", false); + + var werk = new Uitrolwerk(_instellingen, schrijver.Schrijf); + var alles = true; + + Directory.CreateDirectory(_instellingen.Werkmap); + + foreach (var pakket in brief.InVolgorde) + { + var bron = Path.IsPathRooted(pakket.Bestandsnaam) + ? pakket.Bestandsnaam + : Path.Combine(map, pakket.Bestandsnaam); + + if (!File.Exists(bron)) + { + schrijver.Schrijf($"Het pakket {bron} staat er niet.", true); + alles = false; + break; + } + + // Eerst naar deze server kopiëren, en pas daarna uitrollen. Twee redenen, en ze zijn + // allebei in de praktijk tegengekomen: + // + // 1. Zolang wij dat bestand op zijn pc openhouden, kan hij er niet overheen bouwen. + // Het uitpakken duurt ruim een minuut; wie in die tijd op de knop drukt kreeg + // "the process cannot access the file". Met een kopie hier laten we het na een + // paar tellen weer los. + // 2. Het gaat sneller. Uitpakken leest honderden bestanden; over een doorgegeven + // schijf betekent dat honderden keren heen en weer. Eén keer kopiëren en daarna + // van de eigen schijf uitpakken scheelt het meeste van die minuut. + var zip = Path.Combine(_instellingen.Werkmap, Path.GetFileName(bron)); + + try + { + schrijver.Schrijf($"Pakket ophalen: {Path.GetFileName(bron)}", false); + File.Copy(bron, zip, overwrite: true); + } + catch (Exception ex) + { + schrijver.Schrijf($"Het pakket kon niet worden opgehaald: {ex.Message}", true); + alles = false; + break; + } + + // De hash van de kopie naast wat het briefje zegt. Dit vangt twee dingen tegelijk: + // een zip die nog half geschreven was toen wij begonnen te lezen, en een kopie die + // onderweg is afgebroken. Een verkeerde hash betekent hier dus meestal "te vroeg" + // en niet "kwaadwillend". + var hash = Uitrolsleutel.HashVanBestand(zip); + if (!string.Equals(hash, pakket.Sha256, StringComparison.OrdinalIgnoreCase)) + { + schrijver.Schrijf($"Het pakket {Path.GetFileName(zip)} is niet wat het briefje zegt. " + + "Bouw opnieuw en probeer het nog eens.", true); + alles = false; + break; + } + + schrijver.Schrijf($"— {pakket.Soort} uitrollen —", false); + + if (!await werk.VoerUitAsync(pakket.Soort, zip, ct)) + { + alles = false; + break; + } + } + + schrijver.Schrijf(alles ? "Alles is uitgerold." : "Gestopt; niet alles is uitgerold.", !alles); + + // De slotregel is een merkteken voor het uitrolvenster op de pc: daaraan ziet dat + // venster dat het klaar is en mag stoppen met meelezen. Op het scherm hier is het + // ruis - "[uitrol-klaar]" zegt een mens niets wat de regel erboven niet al zei. + schrijver.SchrijfStil(alles ? Uitrolbrief.Slotregel : Uitrolbrief.Slotregelmislukt); + + // Het briefje weg, zodat dezelfde opdracht niet nog een keer loopt. + try { File.Delete(briefje); } + catch (Exception ex) { _meld($"Het briefje kon niet worden opgeruimd: {ex.Message}", true); } + + _meld(alles ? "Klaar. Ik wacht weer." : "Er ging iets mis. Ik wacht weer.", !alles); + } + finally + { + _bezig?.Invoke(false); + } + } + + /// Wacht tot het bestand twee keer achter elkaar even groot is en niet meer verandert. + private static async Task StilAsync(string pad, CancellationToken ct) + { + try + { + var eerst = new FileInfo(pad); + var grootte = eerst.Length; + var moment = eerst.LastWriteTimeUtc; + + await Task.Delay(TimeSpan.FromMilliseconds(500), ct); + + var nu = new FileInfo(pad); + return nu.Exists && nu.Length == grootte && nu.LastWriteTimeUtc == moment; + } + catch (OperationCanceledException) { throw; } + catch (Exception) { return false; } + } + + private static void OpzijLeggen(string briefje) + { + try + { + var doel = briefje + $".fout-{DateTime.Now:yyyyMMdd-HHmmss}"; + File.Move(briefje, doel); + } + catch (Exception) { /* dan blijft hij liggen; de melding staat er al */ } + } + + /// De standaardluisteraar: het consolescherm, zoals de agent het altijd al deed. + private static void Opscherm(string tekst, bool isFout) + { + // Een lege regel krijgt geen tijdstempel. De scripts gebruiken witregels om hun uitvoer + // leesbaar te houden; met een klokje ervoor worden het juist zes losse regels ruis. + if (string.IsNullOrWhiteSpace(tekst)) { Console.WriteLine(); return; } // standaardluisteraar + + if (isFout) Console.ForegroundColor = ConsoleColor.DarkYellow; // standaardluisteraar + Console.WriteLine($" {DateTime.Now:HH:mm:ss} {tekst}"); // standaardluisteraar + Console.ResetColor(); // standaardluisteraar + } +} + +/// +/// Schrijft elke regel naar het scherm van de server én naar een logbestand in de map van je pc, +/// zodat het uitrolvenster daar kan meelezen. Zo zie je op je eigen scherm wat er ginds gebeurt. +/// +public class Logschrijver +{ + private readonly string _pad; + private readonly Action _meld; + private readonly object _slot = new(); + + public Logschrijver(string pad, Action? meld = null) + { + _pad = pad; + _meld = meld ?? ((tekst, isFout) => + { + if (isFout) Console.ForegroundColor = ConsoleColor.Red; // standaardluisteraar + Console.WriteLine(" " + tekst); // standaardluisteraar + Console.ResetColor(); // standaardluisteraar + }); + + // Schoon beginnen: het venster leest dit bestand van voren af aan, en regels van een + // vorige uitrol zouden daar bovenaan verschijnen alsof ze van nu zijn. + try { File.WriteAllText(_pad, string.Empty); } catch (Exception) { } + } + + public void Schrijf(string tekst, bool isFout) + { + _meld(tekst, isFout); + NaarBestand(tekst, isFout); + } + + /// + /// Alleen naar het logbestand, niet naar het scherm. Voor merktekens die het uitrolvenster op + /// de pc nodig heeft en die een mens alleen maar in de weg zitten. + /// + public void SchrijfStil(string tekst) => NaarBestand(tekst, false); + + private void NaarBestand(string tekst, bool isFout) + { + var regel = $"{DateTime.Now:HH:mm:ss}\t{(isFout ? "!" : " ")}\t{tekst}"; + + lock (_slot) + { + // Mislukt het schrijven naar de map van je pc - verbinding weg - dan gaat de uitrol + // gewoon door. Het werk is belangrijker dan het meekijken. + try { File.AppendAllText(_pad, regel + Environment.NewLine); } + catch (Exception) { } + } + } +} diff --git a/src/MijnBesluit.Uitrolagent/uitrolagent.json b/src/MijnBesluit.Uitrolagent/uitrolagent.json new file mode 100644 index 0000000..a14956d --- /dev/null +++ b/src/MijnBesluit.Uitrolagent/uitrolagent.json @@ -0,0 +1,29 @@ +{ + "//": "Vul Geheim met de uitkomst van: MijnBesluit.Uitrolagent.exe --nieuwe-sleutel", + "Geheim": "", + + "Poort": 5210, + + "//tls": "Hetzelfde certificaat als de SocketServer mag; zonder certificaat weigert de agent te starten.", + "CertificateFile": "", + "CertificatePassword": "", + "CertificateThumbprint": "", + + "//scripts": "Waar Uitrollen.ps1 en SocketServer-uitrollen.ps1 staan op deze server.", + "Scriptmap": "C:\\Beheer", + + "//werk": "Waar binnengekomen pakketten tijdelijk landen.", + "Werkmap": "C:\\Beheer\\uitrol", + + "//wachten": "Waar de agent naar een opdracht van je pc kijkt als hij staat te wachten. Over een externe-bureaubladverbinding is dat de publicatiemap op je pc; met een VPN kan het een gedeelde map zijn.", + "Opdrachtmap": "\\\\tsclient\\D\\publish", + + "//geheimen": "Wachtwoorden horen NIET in dit bestand: het wordt bij elke publicatie overschreven. Zet DbGebruiker en DbWachtwoord in het bestand hieronder; wat daarin staat gaat hier overheen en overleeft een nieuwe publicatie.", + "ExtraConfigFile": "C:\\MijnBesluitData\\uitrolagent.secrets.json", + + "//db": "Alleen nodig als je databasescripts wilt kunnen uitrollen. Neem een eigen account dat mag wijzigen; niet root als het niet hoeft.", + "MysqlExe": "C:\\Program Files\\MariaDB 11.4\\bin\\mysql.exe", + "DbNaam": "mijnbesluit", + "DbGebruiker": "", + "DbWachtwoord": "" +} diff --git a/src/MijnBesluit.Uitroller/App.xaml b/src/MijnBesluit.Uitroller/App.xaml new file mode 100644 index 0000000..77f30c2 --- /dev/null +++ b/src/MijnBesluit.Uitroller/App.xaml @@ -0,0 +1,5 @@ + + diff --git a/src/MijnBesluit.Uitroller/App.xaml.cs b/src/MijnBesluit.Uitroller/App.xaml.cs new file mode 100644 index 0000000..1441aac --- /dev/null +++ b/src/MijnBesluit.Uitroller/App.xaml.cs @@ -0,0 +1,7 @@ +using System.Windows; + +namespace MijnBesluit.Uitroller; + +public partial class App : Application +{ +} diff --git a/src/MijnBesluit.Uitroller/Bouwer.cs b/src/MijnBesluit.Uitroller/Bouwer.cs new file mode 100644 index 0000000..512e372 --- /dev/null +++ b/src/MijnBesluit.Uitroller/Bouwer.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.IO; +using System.IO.Compression; + +namespace MijnBesluit.Uitroller; + +/// +/// Het bouwen en inpakken op de ontwikkelmachine. Doet hetzelfde als Publiceren.ps1 en +/// SocketServer-publiceren.ps1, zodat het venster en de scripts niet uit elkaar kunnen lopen: +/// het roept die scripts gewoon aan. +/// +public class Bouwer +{ + private readonly Instellingen _instellingen; + private readonly Action _meld; + + public Bouwer(Instellingen instellingen, Action meld) + { + _instellingen = instellingen; + _meld = meld; + } + + /// Bouwt de website en geeft het zip-bestand terug, of null als het misging. + public async Task SiteAsync(CancellationToken ct) + { + var zip = Path.Combine(_instellingen.Publicatiemap, "mijnbesluit.zip"); + return await ScriptAsync("Publiceren.ps1", "", zip, ct); + } + + /// Bouwt de SocketServer en geeft het zip-bestand terug. + public async Task SocketServerAsync(CancellationToken ct) + { + var zip = Path.Combine(_instellingen.Publicatiemap, "mijnbesluit-socketserver.zip"); + return await ScriptAsync("SocketServer-publiceren.ps1", "", zip, ct); + } + + /// + /// Pakt de databasescripts in. Geen bouwstap: het zijn tekstbestanden. De scripts 01 en 02 + /// gaan niet mee - dat is inrichten, en dat hoort een mens één keer met de hand te doen. + /// + public Task DatabaseAsync(CancellationToken ct) + { + var bron = Path.Combine(_instellingen.Projectmap, "db"); + if (!Directory.Exists(bron)) + { + _meld($"De map {bron} bestaat niet.", true); + return Task.FromResult(null); + } + + Directory.CreateDirectory(_instellingen.Publicatiemap); + var zip = Path.Combine(_instellingen.Publicatiemap, "mijnbesluit-db.zip"); + + if (File.Exists(zip)) File.Delete(zip); + + using (var archief = ZipFile.Open(zip, ZipArchiveMode.Create)) + { + var scripts = Directory.GetFiles(bron, "*.sql") + .Where(p => !Path.GetFileName(p).StartsWith("01_", StringComparison.Ordinal) && + !Path.GetFileName(p).StartsWith("02_", StringComparison.Ordinal)) + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + .ToList(); + + foreach (var script in scripts) + { + archief.CreateEntryFromFile(script, Path.GetFileName(script)); + _meld($" {Path.GetFileName(script)}", false); + } + + _meld($"{scripts.Count} databasescript(s) ingepakt.", false); + } + + return Task.FromResult(zip); + } + + /// Bouwt de Android-APK. Blijft hier staan; die gaat niet naar de server. + public async Task ApkAsync(CancellationToken ct) => + await ScriptAsync("Apk-maken.ps1", "", null, ct); + + private async Task ScriptAsync(string script, string argumenten, string? verwachteZip, + CancellationToken ct) + { + var pad = Path.Combine(_instellingen.Projectmap, "tools", script); + if (!File.Exists(pad)) + { + _meld($"Het script {pad} staat er niet.", true); + return null; + } + + _meld($"Start {script} — dit duurt even.", false); + + var start = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{pad}\" {argumenten}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = _instellingen.Projectmap + }; + + // Zeggen wie er belt. Een script dat om iets moet vragen kan dan meteen stoppen met een + // melding, in plaats van te blijven wachten op invoer die hier nooit komt: dit venster + // heeft geen console. Zie de wachtwoordvraag in Apk-maken.ps1. + start.Environment["MIJNBESLUIT_UITROLVENSTER"] = "1"; + + using var proces = new Process { StartInfo = start, EnableRaisingEvents = true }; + proces.OutputDataReceived += (_, e) => { if (e.Data is not null) _meld(e.Data, false); }; + proces.ErrorDataReceived += (_, e) => { if (e.Data is not null) _meld(e.Data, true); }; + + try + { + proces.Start(); + } + catch (Exception ex) + { + _meld($"Kon powershell niet starten: {ex.Message}", true); + return null; + } + + proces.BeginOutputReadLine(); + proces.BeginErrorReadLine(); + await proces.WaitForExitAsync(ct); + + if (proces.ExitCode != 0) + { + _meld($"{script} is afgesloten met code {proces.ExitCode}.", true); + return null; + } + + if (verwachteZip is null) return ""; + + if (!File.Exists(verwachteZip)) + { + _meld($"Het script liep goed af, maar {verwachteZip} staat er niet.", true); + return null; + } + + var mb = new FileInfo(verwachteZip).Length / 1024 / 1024; + _meld($"Klaar: {Path.GetFileName(verwachteZip)} ({mb} MB).", false); + return verwachteZip; + } +} diff --git a/src/MijnBesluit.Uitroller/Briefwisseling.cs b/src/MijnBesluit.Uitroller/Briefwisseling.cs new file mode 100644 index 0000000..af606e0 --- /dev/null +++ b/src/MijnBesluit.Uitroller/Briefwisseling.cs @@ -0,0 +1,184 @@ +using System.IO; +using System.Text.Json; +using MijnBesluit.Uitrol; + +namespace MijnBesluit.Uitroller; + +/// +/// De kant van het venster bij de wachtende agent: een briefje neerleggen in de publicatiemap en +/// daarna meelezen wat de agent op de server doet. +/// +/// Waarom dit er naast het versturen over een poort is: over een externe-bureaubladverbinding kan +/// deze pc geen poort op de server bereiken. Wat er wél is, is de doorgegeven schijf - de agent +/// kan bij déze map. Dus leggen we het briefje hier neer en schrijft hij zijn logboek hier terug. +/// Zo zie je op je eigen scherm wat er ginds gebeurt, zonder VPN en zonder open poort. +/// +public class Briefwisseling +{ + private readonly Instellingen _instellingen; + private readonly Action _meld; + + public Briefwisseling(Instellingen instellingen, Action meld) + { + _instellingen = instellingen; + _meld = meld; + } + + /// + /// Hoe lang we wachten op het eerste teken van leven. Staat de agent niet te wachten, dan + /// hoor je dat liever na een halve minuut dan helemaal niet. + /// + private static readonly TimeSpan WachtOpAntwoord = TimeSpan.FromSeconds(45); + + /// + /// Hoe lang het stil mag zijn tijdens het werk voordat we het opgeven. Uitpakken over een + /// doorgegeven schijf duurde in de praktijk ruim een minuut zonder één regel ertussen, dus + /// dit moet ruim staan. + /// + private static readonly TimeSpan MagStilZijn = TimeSpan.FromMinutes(10); + + private string Briefpad => Path.Combine(_instellingen.Publicatiemap, Uitrolbrief.Bestandsnaam); + private string Logpad => Path.Combine(_instellingen.Publicatiemap, Uitrolbrief.Logbestandsnaam); + + /// + /// Ligt er nog een briefje van een vorige keer? + /// + /// De agent gooit het briefje weg zodra hij klaar is, dus als het er nog ligt is hij + /// waarschijnlijk nog bezig. Dat wil je weten vóór je gaat bouwen: hij houdt de zip open + /// terwijl hij hem ophaalt, en dan loopt het inpakken stuk op "the process cannot access + /// the file" - middenin een uitrol die verder gewoon goed gaat. + /// + public bool LooptErNogEen() => File.Exists(Briefpad); + + /// Legt het briefje neer. De pakketten staan er dan al. + public bool Neerleggen(IReadOnlyList<(Uitrolsoort Soort, string Zip)> pakketten) + { + try + { + // Het oude logboek weg: anders leest het venster zo meteen de regels van de vorige + // keer en denk je dat het al loopt. + if (File.Exists(Logpad)) File.Delete(Logpad); + + var brief = new Uitrolbrief( + Environment.MachineName, + DateTime.UtcNow, + pakketten.Select(p => new Uitrolpakket( + p.Soort, + Path.GetFileName(p.Zip), + Uitrolsleutel.HashVanBestand(p.Zip))).ToList()); + + // Eerst naar een tijdelijke naam en dan hernoemen. De agent kijkt naar één vaste + // naam; zou hij die zien terwijl we nog schrijven, dan leest hij een half briefje. + var tijdelijk = Briefpad + ".bezig"; + File.WriteAllText(tijdelijk, JsonSerializer.Serialize(brief, + new JsonSerializerOptions { WriteIndented = true })); + + if (File.Exists(Briefpad)) File.Delete(Briefpad); + File.Move(tijdelijk, Briefpad); + + return true; + } + catch (Exception ex) + { + _meld($"Het briefje kon niet worden neergelegd: {ex.Message}", true); + return false; + } + } + + /// + /// Leest mee met het logboek dat de agent schrijft, tot hij zegt dat hij klaar is. + /// Geeft terug of alles is gelukt. + /// + public async Task MeelezenAsync(CancellationToken ct) + { + // Bewust geen "het briefje ligt klaar": dat is al gemeld toen het werd neergelegd, en + // tussen die twee momenten zit de APK-build. Vaak is de agent dan allang begonnen en + // staan de regels hieronder meteen in beeld. + + + var begin = DateTime.UtcNow; + var laatsteRegel = DateTime.UtcNow; + var gelezen = 0; + var begonnen = false; + + while (!ct.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + + var regels = Lees(); + + for (var i = gelezen; i < regels.Count; i++) + { + var regel = regels[i]; + laatsteRegel = DateTime.UtcNow; + begonnen = true; + + if (regel.Contains(Uitrolbrief.Slotregel, StringComparison.Ordinal)) + { + _meld("server: klaar.", false); + return true; + } + + if (regel.Contains(Uitrolbrief.Slotregelmislukt, StringComparison.Ordinal)) + { + _meld("server: gestopt; niet alles is uitgerold.", true); + return false; + } + + // Vorm: tijd \t !-of-spatie \t tekst. Tijd staat al in ons eigen logvenster. + // "server:" ervoor, want deze regels lopen door de regels van het bouwen heen - + // dat gebeurt tegelijk. Zonder dat verschil sta je te raden welke kant wat zegt. + var stukken = regel.Split('\t'); + if (stukken.Length >= 3) _meld("server: " + stukken[2], stukken[1].Trim() == "!"); + else _meld("server: " + regel, false); + } + + gelezen = regels.Count; + + if (!begonnen && DateTime.UtcNow - begin > WachtOpAntwoord) + { + _meld("De agent op de server reageert niet. Staat hij aan, en kijkt hij naar de " + + $"map {_instellingen.Publicatiemap}?", true); + _meld("De pakketten staan er wel. Je kunt ze ook met de hand uitrollen; het " + + "briefje blijft liggen tot de agent hem oppakt.", false); + return false; + } + + if (begonnen && DateTime.UtcNow - laatsteRegel > MagStilZijn) + { + _meld("De agent zegt al een tijd niets meer. Kijk op het scherm van de server.", true); + return false; + } + } + + return false; + } + + /// + /// Het logboek lezen terwijl de agent erin schrijft. Vandaar met delen toegestaan: een + /// gewone ReadAllLines botst met de schrijver en levert dan een fout op in plaats van regels. + /// + private List Lees() + { + try + { + if (!File.Exists(Logpad)) return new List(); + + using var stroom = new FileStream(Logpad, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + using var lezer = new StreamReader(stroom); + + var regels = new List(); + while (lezer.ReadLine() is { } regel) + if (regel.Length > 0) regels.Add(regel); + + return regels; + } + catch (Exception) + { + // Een leesfout is hier geen ramp: de volgende ronde is over zevenhonderd + // milliseconden, en het werk op de server gaat toch door. + return new List(); + } + } +} diff --git a/src/MijnBesluit.Uitroller/Cockpitmodel.cs b/src/MijnBesluit.Uitroller/Cockpitmodel.cs new file mode 100644 index 0000000..f9b4485 --- /dev/null +++ b/src/MijnBesluit.Uitroller/Cockpitmodel.cs @@ -0,0 +1,31 @@ +using System.Windows.Media; + +namespace MijnBesluit.Uitroller; + +/// +/// Eén regel op het Cockpit-tabblad: de git-status van het project, zoals getoond in de lijst. +/// Puur voor de weergave; de gegevens komen uit . +/// +/// Geport van RenH.Uitroller. MijnBesluit is één repo/oplossing (geen site+kassa), dus deze +/// klasse wordt hier voor precies één rij gebruikt. +/// +public sealed class Cockpitrepo +{ + public string Naam { get; set; } = ""; + public string Tak { get; set; } = ""; + public string Commit { get; set; } = ""; + public string Status { get; set; } = ""; + public Brush StatusKleur { get; set; } = Brushes.Black; +} + +/// +/// Eén regel in de tabel "Laatste wijzigingen": een commit uit de repo, met korte hash, auteur, +/// wanneer en de eerste regel van het bericht. +/// +public sealed class Cockpitwijziging +{ + public string Commit { get; set; } = ""; + public string Auteur { get; set; } = ""; + public string Wanneer { get; set; } = ""; + public string Bericht { get; set; } = ""; +} diff --git a/src/MijnBesluit.Uitroller/Giteaclient.cs b/src/MijnBesluit.Uitroller/Giteaclient.cs new file mode 100644 index 0000000..0d7a24e --- /dev/null +++ b/src/MijnBesluit.Uitroller/Giteaclient.cs @@ -0,0 +1,142 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace MijnBesluit.Uitroller; + +/// Eén openstaande werk-claim = een Gitea-issue met een titel die met [bezig] begint. +public sealed record Werkclaim(int Nummer, string Titel, string Wie, DateTime Sinds, string Url) +{ + /// Regel voor in de lijst: onderdeel — wie — sinds. + public string Weergave => Wie.Length > 0 + ? $"{Titel} — {Wie} ({Sinds:dd-MM HH:mm})" + : $"{Titel} ({Sinds:dd-MM HH:mm})"; +} + +/// +/// Praat met de Gitea-API voor het claim-bord: openstaande "[bezig]"-issues tonen, er een +/// aanmaken (claimen) en sluiten (vrijgeven). Zo zie je van elkaar waar je mee bezig bent, +/// zonder elkaars werk te overschrijven. +/// +/// Geport van RenH.Uitroller: bewust met issues en niet met een bestand in de repo (geen +/// merge-conflicten op main, en ook in de browser zichtbaar). +/// +public sealed class Giteaclient +{ + public const string Prefix = "[bezig]"; + + private readonly HttpClient _http; + private readonly string _basis; + private readonly string _eigenaar; + private readonly string _repo; + + public Giteaclient(string basisUrl, string eigenaar, string repo, string token) + { + _basis = basisUrl.TrimEnd('/'); + _eigenaar = eigenaar; + _repo = repo; + _http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) }; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + private string Api(string pad) => $"{_basis}/api/v1{pad}"; + + /// De openstaande claims: open issues waarvan de titel met [bezig] begint. + public async Task> ClaimsAsync(CancellationToken ct = default) + { + using var antwoord = await _http.GetAsync( + Api($"/repos/{_eigenaar}/{_repo}/issues?state=open&type=issues&limit=50"), ct); + antwoord.EnsureSuccessStatusCode(); + + var tekst = await antwoord.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(tekst); + + var lijst = new List(); + foreach (var it in doc.RootElement.EnumerateArray()) + { + var titel = it.TryGetProperty("title", out var t) ? t.GetString() ?? "" : ""; + if (!titel.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) continue; + + var nummer = it.TryGetProperty("number", out var n) ? n.GetInt32() : 0; + var wie = ""; + if (it.TryGetProperty("user", out var u) && u.ValueKind == JsonValueKind.Object) + { + var volledig = u.TryGetProperty("full_name", out var fn) ? (fn.GetString() ?? "").Trim() : ""; + var login = u.TryGetProperty("login", out var l) ? (l.GetString() ?? "") : ""; + wie = volledig.Length > 0 ? volledig : login; + } + var sinds = it.TryGetProperty("created_at", out var c) && c.TryGetDateTime(out var d) + ? d.ToLocalTime() : DateTime.MinValue; + var url = it.TryGetProperty("html_url", out var h) ? h.GetString() ?? "" : ""; + + var schoon = titel.Substring(Prefix.Length).TrimStart(' ', ':', '-'); + lijst.Add(new Werkclaim(nummer, schoon, wie, sinds, url)); + } + return lijst; + } + + /// Een onderdeel claimen: maakt een issue "[bezig] <onderdeel>" aan. + public async Task ClaimAsync(string onderdeel, string toelichting = "", CancellationToken ct = default) + { + var body = JsonSerializer.Serialize(new + { + title = $"{Prefix} {onderdeel}".Trim(), + body = toelichting ?? "" + }); + using var inhoud = new StringContent(body, Encoding.UTF8, "application/json"); + using var antwoord = await _http.PostAsync(Api($"/repos/{_eigenaar}/{_repo}/issues"), inhoud, ct); + antwoord.EnsureSuccessStatusCode(); + + var tekst = await antwoord.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(tekst); + var el = doc.RootElement; + var nummer = el.TryGetProperty("number", out var n) ? n.GetInt32() : 0; + var url = el.TryGetProperty("html_url", out var h) ? h.GetString() ?? "" : ""; + return new Werkclaim(nummer, onderdeel, "", DateTime.Now, url); + } + + /// Een claim vrijgeven: sluit het issue. + public async Task VrijgeefAsync(int nummer, CancellationToken ct = default) + { + var body = JsonSerializer.Serialize(new { state = "closed" }); + using var inhoud = new StringContent(body, Encoding.UTF8, "application/json"); + using var verzoek = new HttpRequestMessage(HttpMethod.Patch, + Api($"/repos/{_eigenaar}/{_repo}/issues/{nummer}")) { Content = inhoud }; + using var antwoord = await _http.SendAsync(verzoek, ct); + antwoord.EnsureSuccessStatusCode(); + } + + /// + /// Basis-URL, eigenaar en repo uit een git-remote halen, bijvoorbeeld + /// https://git.example.nl/Org/Repo.git -> (https://git.example.nl, Org, Repo). + /// Null als de remote niet te lezen is. + /// + public static (string Basis, string Eigenaar, string Repo)? Ontleed(string? remoteUrl) + { + if (string.IsNullOrWhiteSpace(remoteUrl)) return null; + var u = remoteUrl.Trim(); + if (u.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) u = u[..^4]; + + try + { + if (u.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + var uri = new Uri(u); + var delen = uri.AbsolutePath.Trim('/').Split('/'); + if (delen.Length >= 2) + return ($"{uri.Scheme}://{uri.Host}", delen[^2], delen[^1]); + } + else if (u.Contains('@') && u.Contains(':')) + { + var host = u.Split('@', 2)[1].Split(':', 2)[0]; + var na = u.Split(':', 2)[1].Trim('/').Split('/'); + if (na.Length >= 2) + return ($"https://{host}", na[^2], na[^1]); + } + } + catch { } + return null; + } +} diff --git a/src/MijnBesluit.Uitroller/Gitstand.cs b/src/MijnBesluit.Uitroller/Gitstand.cs new file mode 100644 index 0000000..12ae4b1 --- /dev/null +++ b/src/MijnBesluit.Uitroller/Gitstand.cs @@ -0,0 +1,158 @@ +using System.Diagnostics; +using System.IO; + +namespace MijnBesluit.Uitroller; + +/// +/// Wat git van de projectmap vindt: ligt alles vast, en staat het op de server? +/// +/// Waarvoor dit er is: sinds dit venster bouwt, inpakt en uitrolt, hoeft Visual Studio bijna +/// niet meer open. Dat is prettig, maar het opent wel een gat - je kunt iets uitrollen dat +/// nergens is vastgelegd. Draait er dan iets raars op een server, dan is er geen versie om +/// naar terug te gaan en geen manier om te zien wat er precies op staat. +/// +/// Geport van RenH.Uitroller (functioneel ongewijzigd, alleen de naamruimte is anders). +/// +public sealed record Gitstand( + bool IsRepo, + bool Schoon, + bool Gepusht, + string Tak, + string Commit, + string Uitleg, + int Voor = 0, + int Achter = 0) +{ + /// Alles in orde: vastgelegd en weggeschreven naar de server. + public bool Volledig => IsRepo && Schoon && Gepusht; + + /// + /// Kijkt wat er van deze map te zeggen valt. + /// + /// Gaat er iets mis - geen git op deze machine, geen repo, een commando dat blijft hangen - + /// dan komt dat als uitleg terug en niet als uitzondering. + /// + public static Gitstand Bepaal(string map) + { + if (string.IsNullOrWhiteSpace(map) || !Directory.Exists(map)) + return new Gitstand(false, false, false, "", "", $"de map {map} bestaat niet"); + + var binnen = Draai(map, "rev-parse --is-inside-work-tree"); + + if (binnen is null) + return new Gitstand(false, false, false, "", "", + "git is niet gevonden op deze machine"); + + if (!string.Equals(binnen.Trim(), "true", StringComparison.OrdinalIgnoreCase)) + return new Gitstand(false, false, false, "", "", $"{map} is geen git-map"); + + var tak = Draai(map, "rev-parse --abbrev-ref HEAD")?.Trim() ?? ""; + var commit = Draai(map, "rev-parse --short HEAD")?.Trim() ?? ""; + + var status = Draai(map, "status --porcelain"); + var schoon = status is not null && status.Trim().Length == 0; + + var stroomop = Draai(map, "rev-parse --abbrev-ref --symbolic-full-name @{u}"); + + if (string.IsNullOrWhiteSpace(stroomop)) + return new Gitstand(true, schoon, false, tak, commit, + $"tak {tak} is nog nergens heen gepusht"); + + var voor = Draai(map, "rev-list --count @{u}..HEAD")?.Trim(); + var gepusht = voor == "0"; + int aantal = int.TryParse(voor, out var n) ? n : 0; + + var na = Draai(map, "rev-list --count HEAD..@{u}")?.Trim(); + int achter = int.TryParse(na, out var m2) ? m2 : 0; + + var uitleg = schoon switch + { + false when !gepusht => $"er staan wijzigingen open en {voor} commit(s) zijn nog niet gepusht", + false => "er staan wijzigingen open die nergens zijn vastgelegd", + _ when !gepusht => $"{voor} commit(s) staan nog niet op de server", + _ => $"{tak} {commit}, alles vastgelegd en gepusht" + }; + + return new Gitstand(true, schoon, gepusht, tak, commit, uitleg, aantal, achter); + } + + private static string? Draai(string map, string argumenten) + { + var start = new ProcessStartInfo + { + FileName = "git", + Arguments = argumenten, + WorkingDirectory = map, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + start.Environment["GIT_TERMINAL_PROMPT"] = "0"; + + try + { + using var proces = Process.Start(start); + if (proces is null) return null; + + var uitvoer = proces.StandardOutput.ReadToEnd(); + + if (!proces.WaitForExit(10_000)) + { + try { proces.Kill(true); } catch { } + return null; + } + + return proces.ExitCode == 0 ? uitvoer : ""; + } + catch + { + return null; + } + } + + /// De uitkomst van een schrijvend git-commando: gelukt of niet, met de uitvoer. + public readonly record struct Uitkomst(bool Ok, string Uitvoer); + + /// + /// Een git-commando dat iets wijzigt (add, commit, push), met de volledige uitvoer terug. + /// + public static Uitkomst Voer(string map, string argumenten, int seconden = 120) + { + var start = new ProcessStartInfo + { + FileName = "git", + Arguments = argumenten, + WorkingDirectory = map, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + start.Environment["GIT_TERMINAL_PROMPT"] = "0"; + + try + { + using var proces = Process.Start(start); + if (proces is null) return new Uitkomst(false, "git kon niet gestart worden"); + + var uit = proces.StandardOutput.ReadToEndAsync(); + var fout = proces.StandardError.ReadToEndAsync(); + + if (!proces.WaitForExit(seconden * 1000)) + { + try { proces.Kill(true); } catch { } + return new Uitkomst(false, "git bleef hangen en is afgebroken"); + } + + var tekst = (uit.Result + fout.Result).Trim(); + return new Uitkomst(proces.ExitCode == 0, tekst); + } + catch (Exception ex) + { + return new Uitkomst(false, ex.Message); + } + } +} diff --git a/src/MijnBesluit.Uitroller/Hoofdvenster.xaml b/src/MijnBesluit.Uitroller/Hoofdvenster.xaml new file mode 100644 index 0000000..15d3c1d --- /dev/null +++ b/src/MijnBesluit.Uitroller/Hoofdvenster.xaml @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inloggen + + + + + + + + + + + +
+ @Body +
+ diff --git a/src/MijnBesluit.Web/Components/Layout/NavMenu.razor b/src/MijnBesluit.Web/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..aa08b3b --- /dev/null +++ b/src/MijnBesluit.Web/Components/Layout/NavMenu.razor @@ -0,0 +1 @@ +@* De navigatie zit in MainLayout; dit bestand blijft leeg zodat de template-verwijzing niet breekt. *@ diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Bijlagenblok.razor b/src/MijnBesluit.Web/Components/Onderdelen/Bijlagenblok.razor new file mode 100644 index 0000000..12d7bf4 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Bijlagenblok.razor @@ -0,0 +1,126 @@ +@* Foto's, documenten en opnamen bij een kluisitem, een persoon of een bericht. + + Eén component voor alle drie, net als in de app, zodat ze er overal hetzelfde uitzien. Wat + er per plek verschilt zijn de knoppen die je meegeeft: bij een persoon kun je een foto + aanwijzen als profielfoto, bij een kluisitem niet. + + Het kiezen gaat via InputFile en niet via een eigen knop: dan doet de browser het + bestandsvenster, inclusief "meerdere tegelijk". *@ + +@using Microsoft.AspNetCore.Components.Forms +@using MijnBesluit.Domain.Entities + +
+ + + @if (Bijlagen.Count > 0) + { +
+ @foreach (var bijlage in Bijlagen) + { +
+ @* Een klein voorbeeld in de rij zelf. Eerder stond de foto op ware grootte + onder elke regel; in een wijzigpopup duwde dat de rest van het formulier + buiten beeld. Klikken opent hem groot, in een eigen tabblad. *@ + @if (IsAfbeelding(bijlage)) + { + + @bijlage.FileName + + } + else + { + @Teken(bijlage) + } + +
+ @bijlage.FileName +
+ @Grootte(bijlage.SizeBytes) + @if (bijlage.IsProfilePhoto) { · profielfoto } +
+
+ + @if (KanProfielfoto && IsAfbeelding(bijlage) && !bijlage.IsProfilePhoto) + { + + } + + +
+ + @* Verwijderen is onomkeerbaar - de bijlage is daarna echt weg - dus eerst + bevestigen, net als overal elders waar iets wordt weggehaald. *@ + @if (Weghalen == bijlage.Id) + { +
+ @bijlage.FileName verwijderen? +
+ + +
+
+ } + } +
+ } + + + + @if (Bezig) + { +
Bezig met opslaan…
+ } + + @if (Uitleg is not null) + { +
@Uitleg
+ } +
+ +@code { + [Parameter] public string Titel { get; set; } = "Foto's en documenten"; + [Parameter] public string? Uitleg { get; set; } + + [Parameter] public IReadOnlyList Bijlagen { get; set; } = Array.Empty(); + + /// Er loopt een overdracht; dan gaan de knoppen op slot. + [Parameter] public bool Bezig { get; set; } + + /// Mag hier een foto worden aangewezen als profielfoto? Alleen bij een persoon. + [Parameter] public bool KanProfielfoto { get; set; } + + [Parameter] public EventCallback OpKiezen { get; set; } + [Parameter] public EventCallback OpVerwijderen { get; set; } + [Parameter] public EventCallback OpProfielfoto { get; set; } + + /// Het id van de bijlage die op een "weet je het zeker?" staat te wachten. + private long? Weghalen; + + private async Task VerwijderBevestigdAsync(long bijlageId) + { + Weghalen = null; + await OpVerwijderen.InvokeAsync(bijlageId); + } + + private static bool IsAfbeelding(VaultFile f) => + f.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + + private static bool IsVideo(VaultFile f) => + f.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase); + + private static string Teken(VaultFile f) => + IsAfbeelding(f) ? "\U0001F4F7" : IsVideo(f) ? "\U0001F3AC" : "\U0001F4C4"; + + /// De grootte zoals je hem aan iemand laat zien: "1,4 MB". + private static string Grootte(long bytes) => bytes >= 1024 * 1024 + ? $"{bytes / 1024d / 1024d:0.#} MB" + : $"{Math.Max(1, bytes / 1024)} kB"; +} diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Groeigrafiek.razor b/src/MijnBesluit.Web/Components/Onderdelen/Groeigrafiek.razor new file mode 100644 index 0000000..508ed4a --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Groeigrafiek.razor @@ -0,0 +1,65 @@ +@* Een kleine lijngrafiek van de groei per dag: nieuwe profielen en nieuwe kluisitems. + + Puur SVG, geen library: het gaat om twee lijntjes over een dag of dertig, en dat is geen + reden om een grafiekbibliotheek naar de beheerpagina te trekken. Alleen op de website, en + alleen voor een beheerder - dit hoeft niet met de app mee. *@ + +
+ @if (Punten.Count < 2) + { +

Nog niet genoeg dagen om een lijn te tekenen.

+ } + else + { + + + + + + + +
+ @Punten[0].Datum.ToString("d MMM") + @Punten[^1].Datum.ToString("d MMM") +
+ } + +
+ + Nieuwe profielen (totaal @Punten.Sum(p => p.NieuweProfielen)) + + Nieuwe kluisitems (totaal @Punten.Sum(p => p.NieuweKluisItems)) +
+
+ +@code { + [Parameter] public IReadOnlyList Punten { get; set; } = Array.Empty(); + + private const int Breedte = 600; + private const int Hoogte = 160; + private const int Marge = 8; + + /// De hoogste dagwaarde over beide reeksen, zodat ze op dezelfde schaal staan. Nooit + /// nul: bij een lege of vlakke grafiek blijft de lijn dan gewoon plat onderin staan. + private int Max => Punten.Count == 0 + ? 1 + : Math.Max(1, Punten.Max(p => Math.Max(p.NieuweProfielen, p.NieuweKluisItems))); + + private string Lijn(Func waarde) + { + if (Punten.Count < 2) return ""; + + var stap = (double)Breedte / (Punten.Count - 1); + var bruikbareHoogte = Hoogte - Marge * 2; + + var punten = Punten.Select((p, i) => + { + var x = i * stap; + var y = Hoogte - Marge - (waarde(p) / (double)Max * bruikbareHoogte); + return FormattableString.Invariant($"{x:0.##},{y:0.##}"); + }); + + return string.Join(" ", punten); + } +} diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Kluisweergave.cs b/src/MijnBesluit.Web/Components/Onderdelen/Kluisweergave.cs new file mode 100644 index 0000000..2dd3156 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Kluisweergave.cs @@ -0,0 +1,54 @@ +using MijnBesluit.Application.Models; +using MijnBesluit.Domain.Enums; + +namespace MijnBesluit.Web.Components.Onderdelen; + +/// +/// Hoe een kluisitem er op het scherm uitziet: de naam van de categorie en de ingevulde velden +/// op een rij. +/// +/// Dit stond in Kluis.razor. Het staat hier omdat de nalatenschapspagina precies hetzelfde moet +/// tonen: een beheerder die na een overlijden een polis openslaat hoort dezelfde velden te zien +/// als de eigenaar zelf. Twee kopieën van deze lijst lopen vroeg of laat uit elkaar, en dan mist +/// er ergens stilletjes een veld. +/// +public static class Kluisweergave +{ + /// De ingevulde velden van een item, in de volgorde waarin je ze wilt lezen. + public static IEnumerable<(string Label, string Waarde)> Velden(VaultPayload p) + { + if (p.Website is not null) yield return ("Website", p.Website); + if (p.Username is not null) yield return ("Gebruikersnaam", p.Username); + if (p.Secret is not null) yield return ("Wachtwoord", p.Secret); + if (p.Pincode is not null) yield return ("Pincode", p.Pincode); + if (p.DeviceName is not null) yield return ("Apparaat", p.DeviceName); + if (p.RecoveryCodes is not null) yield return ("Herstelcodes", p.RecoveryCodes); + if (p.Platform is not null) yield return ("Medium", p.Platform); + if (p.PlatformAnders is not null) yield return ("Welk medium", p.PlatformAnders); + if (p.Insurer is not null) yield return ("Verzekeraar", p.Insurer); + if (p.PolicyNumber is not null) yield return ("Polisnummer", p.PolicyNumber); + if (p.ContactPhone is not null) yield return ("Telefoon", p.ContactPhone); + if (p.BurialOrCremation is not null) yield return ("Begraven of cremeren", p.BurialOrCremation); + if (p.Location is not null) yield return ("Locatie", p.Location); + if (p.Music is not null) yield return ("Muziek", p.Music); + if (p.Speakers is not null) yield return ("Sprekers", p.Speakers); + if (p.Wishes is not null) yield return ("Wensen", p.Wishes); + if (p.Notes is not null) yield return ("Toelichting", p.Notes); + } + + public static string Naam(VaultCategory c) => c switch + { + VaultCategory.Uitvaartwens => "Uitvaartwens", + VaultCategory.Donorcodicil => "Donorcodicil", + VaultCategory.Polis => "Polis", + VaultCategory.Wachtwoord => "Wachtwoord", + VaultCategory.Apparaat => "Apparaat", + VaultCategory.Pincode => "Pincode", + VaultCategory.Document => "Document", + VaultCategory.Notitie => "Notitie", + VaultCategory.SocialMedia => "Sociale media", + VaultCategory.Rijbewijs => "Rijbewijs", + VaultCategory.Paspoort => "Paspoort", + _ => "Overig" + }; +} diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Penning.razor b/src/MijnBesluit.Web/Components/Onderdelen/Penning.razor new file mode 100644 index 0000000..b47c62f --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Penning.razor @@ -0,0 +1,42 @@ +@* Het ronde penningetje voor een persoon: zijn profielfoto, en anders zijn beginletters. + + Eén component voor alle lijsten waar mensen in staan - Mijn mensen, je beheerders, de + nalatenschappen die aan jou zijn toevertrouwd. Ze horen er hetzelfde uit te zien, en dat + blijft alleen zo als het op één plek staat. + + Meegeven doe je alleen een naam en waar het plaatje vandaan komt; hoe dat plaatje eruit + ziet is aan het stijlblad. Zonder bron valt hij terug op de letters, even groot, zodat de + lijst niet verspringt zodra er ergens een foto bij komt. *@ + +@if (!string.IsNullOrEmpty(Bron)) +{ + @Naam +} +else +{ + @Beginletters(Naam) +} + +@code { + /// De naam van de persoon; alleen gebruikt voor de letters en de tekstballon. + [Parameter] public string Naam { get; set; } = ""; + + /// + /// Waar het plaatje vandaan komt. Op de website een adres van de server, in de app de + /// inhoud zelf. Leeg of null betekent: geen foto, dus de letters. + /// + [Parameter] public string? Bron { get; set; } + + /// + /// De eerste letter van de voornaam en van de achternaam. Bij één woord alleen de eerste, + /// bij een lege naam een vraagteken - er moet altijd iets in de penning staan. + /// + public static string Beginletters(string? naam) + { + var delen = (naam ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (delen.Length == 0) return "?"; + return delen.Length == 1 + ? delen[0][..1].ToUpperInvariant() + : (delen[0][..1] + delen[^1][..1]).ToUpperInvariant(); + } +} diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Popup.razor b/src/MijnBesluit.Web/Components/Onderdelen/Popup.razor new file mode 100644 index 0000000..1558fb6 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Popup.razor @@ -0,0 +1,59 @@ +@* Een venster over het scherm heen, voor wijzigen en bevestigen. + + Dezelfde opzet als in de app, zodat wijzigen en verwijderen op de site en op de telefoon + hetzelfde aanvoelen. Bewust geen JavaScript-dialoog: die is niet vorm te geven en ziet er + in elke browser anders uit. *@ + + + +@code { + [Parameter] public string Titel { get; set; } = ""; + [Parameter] public string Bevestigtekst { get; set; } = "Opslaan"; + + /// Kleurt de knop als waarschuwing; voor verwijderen. + [Parameter] public bool Gevaarlijk { get; set; } + + [Parameter] public bool Bezig { get; set; } + + /// + /// Er valt niets te bevestigen of af te breken, alleen dicht te doen. Dan is "Annuleren" + /// naast "Sluiten" twee knoppen voor hetzelfde. Voor een popup die alleen iets laat zien. + /// Staat net zo in de app-versie; die twee horen gelijk te blijven. + /// + [Parameter] public bool AlleenSluiten { get; set; } + + /// + /// Waarom Bevestigen niet is gelukt (bijvoorbeeld een verplicht veld dat leeg is). Staat + /// IN de popup, niet op de pagina erachter - die is aan het oog onttrokken zolang de popup + /// openstaat, dus een melding die daar verschijnt ziet niemand. + /// + [Parameter] public string? Foutmelding { get; set; } + + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public EventCallback OpBevestigen { get; set; } + [Parameter] public EventCallback OpSluiten { get; set; } + + private Task Bevestigen() => OpBevestigen.InvokeAsync(); + private Task Sluiten() => OpSluiten.InvokeAsync(); +} diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Privacytekst.razor b/src/MijnBesluit.Web/Components/Onderdelen/Privacytekst.razor new file mode 100644 index 0000000..e9aae0b --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Privacytekst.razor @@ -0,0 +1,146 @@ +@* De privacyverklaring zelf. Staat hier als onderdeel en niet in een pagina, omdat de app hem + ook laat zien - daar in een popup vanaf het overzicht, hier als pagina op /privacy. + + Dit bestand staat twee keer op schijf: de app en de site delen geen project. Een zelfcontrole + vergelijkt ze letterlijk, want twee versies van je privacyverklaring is erger dan twee versies + van een knop: het is het document waar iemand zich op beroept. + + Verandert er iets aan wat er wordt opgeslagen, wie het kan zien of wat er naar buiten gaat, dan + verandert deze tekst mee - en dan hoort de datum hieronder mee te veranderen. *@ + +

Laatst bijgewerkt op 19 september 2026.

+ +

+ MijnBesluit is een digitale kluis. Je legt er vast wat er geregeld moet worden, en je zet + berichten klaar voor de mensen om je heen. Dat zijn je meest persoonlijke gegevens, en + daarom staat hier zo precies mogelijk wat ermee gebeurt. +

+ +

Wie is verantwoordelijk

+

+ MijnBesluit is een dienst van Kwik-Bit Automatisering B.V.
+ Boulevard 1945 nr. 216, 7511 AG Enschede
+ KvK-nummer 97571792 +

+

+ Vragen over je gegevens kun je stellen via + privacy@@mijnbesluit.nl. +

+ +

Wat we vastleggen

+
    +
  • Je e-mailadres. Daarmee log je in; we sturen er een code naartoe.
  • +
  • Je naam en geboortedatum, als je die invult.
  • +
  • Wat je in je kluis zet: titels, notities, wachtwoorden, polissen, + wensen, en de bestanden die je toevoegt.
  • +
  • De mensen die je toevoegt: hun naam, en het e-mailadres dat je + invult om ze uit te nodigen.
  • +
  • De berichten die je voor iemand klaarzet, en wanneer ze bezorgd zijn.
  • +
  • Een logboek van handelingen: wie wanneer inlogde, wat er werd + gewijzigd, wanneer een bericht is bezorgd. Daar staat geen inhoud in.
  • +
+ +

Wat er versleuteld is

+

+ Alles wat in je kluis staat en alles wat in je berichten staat, is versleuteld met een + sleutel die bij jouw account hoort. Ook de bestanden die je toevoegt: die staan + versleuteld op schijf. Wie de database in handen krijgt zonder de sleutel, ziet er niets + van. +

+

+ Eén uitzondering, en die noemen we liever zelf: de kleine versie van een + profielfoto - het rondje van veertig pixels in een lijst - is niet versleuteld. Dat is een + afweging geweest: een profielfoto is juist bedoeld om aan anderen te laten zien, en hem + als kluismateriaal behandelen zou betekenen dat we voor elke kijker sleutels moeten + uitdelen en weer intrekken. De foto op ware grootte is wél versleuteld. +

+

+ Het verkeer tussen de app en onze server gaat over een versleutelde verbinding, net als + de website. +

+ +

Wie kan wat zien

+
    +
  • + Jij ziet je eigen kluis. Niemand anders, ook wij niet: wij kunnen je + kluisitems niet lezen zonder jouw account. +
  • +
  • + Mensen met wie je in een familie zit zien elkaars naam en + profielfoto. Niet elkaars kluis. +
  • +
  • + Iemand die je hebt aangewezen als nalatenschapsbeheerder krijgt + toegang tot wat je daarvoor hebt bestemd, en pas nadat je overlijden is bevestigd + volgens de procedure die in de app staat. +
  • +
  • + Iemand met wie je een kluisitem alvast "bespreekt". Dat kies je zelf, + per item: die persoon ziet het dan meteen, inclusief een eventuele foto of bijlage, + zonder dat daar een overlijden voor nodig is. Zonder die keuze ziet niemand het. +
  • +
  • + Een beheerder van MijnBesluit kan een profiel opzoeken op volledig + e-mailadres en een ten onrechte bevestigd overlijden terugdraaien. Hij kan géén kluis + openen. Elke handeling van een beheerder komt in het logboek. +
  • +
+ +

Wat er naar buiten gaat

+

+ Wij verkopen geen gegevens en er staan geen meetscripts of volgcookies op deze site. + Er zijn twee partijen die noodzakelijkerwijs iets te zien krijgen: +

+
    +
  • + Google (Firebase Cloud Messaging) bezorgt de meldingen op je + telefoon. Daarvoor gaat een kenmerk van je toestel naar Google, met de titel en de + korte tekst van de melding. De inhoud van een bericht of een kluisitem gaat daar + nooit in mee. +
  • +
  • + De verzender van onze e-mail, voor de inlogcodes en uitnodigingen. +
  • +
+

+ Adverteerders krijgen niets over jou te zien. Er wordt per advertentie geteld hoe vaak + hij is getoond en aangeklikt, per uur - meer niet. Geen profielen, geen kenmerken, geen + bezoeker die door de applicatie heen te volgen is. +

+ +

Hoe lang we het bewaren

+

+ Zolang je account bestaat. Hef je het op, dan wordt het verwijderd - zie hieronder. Het + logboek blijft staan, omdat je achteraf moet kunnen nagaan wat er is gebeurd; daar staat + geen inhoud in. Van gegevens die bij onze administratie horen, zoals facturen voor + adverteerders, houden we de wettelijke bewaartermijn aan. +

+ +

Je account opheffen

+

+ Dat kan zelf, op elk moment, en het hoeft niet via ons te lopen: + mijnbesluit.nl/account-verwijderen. Dat adres werkt ook + als je de app niet meer hebt. In de app staat dezelfde knop bij Mijn profiel. +

+

+ Wat er dan weggaat: je kluis, je bestanden, de berichten die nog klaarstonden, je lijst + met mensen, en je account zelf. Wat blijft: berichten die al bezorgd zijn - die zijn van + degene die ze heeft gekregen - en je naam in de lijst van mensen die jou hebben + toegevoegd. Dat laatste is hun aantekening, niet jouw gegeven; alles wat er aan jou + vastzat, gaat eraf. +

+ +

Je rechten

+

+ Je mag inzien wat we van je hebben, het laten corrigeren, en het laten verwijderen. Het + meeste doe je zelf in de app of op de website. Lukt iets niet, mail dan + privacy@@mijnbesluit.nl. Ben je het oneens + met hoe wij met je gegevens omgaan, dan kun je een klacht indienen bij de Autoriteit + Persoonsgegevens. +

+ +

Als er iets misgaat

+

+ Bij een datalek melden wij dat bij de Autoriteit Persoonsgegevens, en bij jou als het + jouw gegevens raakt. +

diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Productuitleg.razor b/src/MijnBesluit.Web/Components/Onderdelen/Productuitleg.razor new file mode 100644 index 0000000..b3fc106 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Productuitleg.razor @@ -0,0 +1,95 @@ +@* De uitleg over MijnBesluit, zonder kop en zonder knoppen eromheen. + + Dit staat als los onderdeel zodat de website en de app dezelfde tekst tonen: op de website als + pagina met knoppen om aan te melden, in de app als popup vanaf het overzicht. De app en de site + delen geen project, dus dit bestand staat twee keer op schijf; een zelfcontrole vergelijkt ze + letterlijk, zodat ze niet uit elkaar gaan lopen. + + Kop en knoppen staan er bewust niet in: op de website hoort er "Een account aanmaken" onder, en + in de app ben je al aangemeld. *@ + +

+ Er zit van alles in je hoofd waar niemand anders bij kan. Het wachtwoord van je e-mail, + waar de polissen liggen, hoe je je uitvaart voor je ziet, en de dingen die je nog tegen + iemand had willen zeggen. Zolang alles goed gaat maakt dat niet uit. Gaat het niet goed, + dan staan de mensen die van je houden met lege handen — op het slechtst denkbare moment. +

+

+ MijnBesluit is de plek waar dat wél ergens staat. Veilig opgeborgen zolang het van jou + alleen is, en beschikbaar op het moment dat jij hebt bepaald. +

+ +

Vier dingen

+
    +
  • + Je kluis. Wachtwoorden voor websites en apparaten, de pincode van + je telefoon, je sociale media, polissen, documenten, je uitvaartwensen. Alles gaat + versleuteld de opslag in. Per onderdeel kies je zelf of het alleen voor jou is, + pas na je overlijden vrijkomt, of op een datum die jij bepaalt. Je kunt een item ook + meteen — nog gewoon terwijl je er bent — met je nalatenschapsbeheerder + bespreken; die ziet het dan al, inclusief een eventuele foto of bijlage, zonder dat + daar een overlijden voor nodig is. +
  • +
  • + Je mensen. De familie en vrienden die erbij horen. Je maakt hun + profiel aan en nodigt ze uit. Tot ze die uitnodiging aannemen beheer jij dat + profiel; daarna is het van hen en vullen ze hun eigen MijnBesluit in. Zo groeit + het vanzelf verder. +
  • +
  • + Momenten. Een bericht of een video die je klaarzet voor iemand, + te bezorgen op een datum die jij kiest — of pas als jij er niet meer bent. + Een achttiende verjaardag, een huwelijk, of gewoon iets wat je nog wilde zeggen. + En je kunt zelf ook berichten ontvangen die een ander voor jou heeft klaargezet. +
  • +
  • + Je nalatenschap. Wie er straks mag regelen wat er geregeld moet + worden. Die persoon krijgt niet zomaar toegang: er moeten meerdere mensen + onafhankelijk melden dat je bent overleden — waaronder iemand die jij zelf hebt + aangewezen — en daarna heb jij nog een termijn om te laten weten dat je er gewoon + nog bent. Pas als die voorbij is, gaat het open. +
  • +
+ +

Hoe veilig is het?

+

+ Je legt hier je wachtwoorden en je laatste wensen vast. Dan moet je weten wat daarmee + gebeurt. +

+
    +
  • + Alles gaat versleuteld de opslag in. Elk onderdeel krijgt zijn eigen + sleutel, en die sleutel wordt zelf ook weer versleuteld. Wie de database in handen + zou krijgen, heeft daar niets aan: er staat geen leesbare regel in. +
  • +
  • + Bijlagen net zo. Een foto van je polis of een scan van je testament + wordt versleuteld opgeslagen en pas ontsleuteld op het moment dat jij hem opent. +
  • +
  • + Er is geen wachtwoord. Je vult je e-mailadres in en krijgt een code + die tien minuten geldig is en één keer werkt. Geen wachtwoord betekent: niets om te + stelen, te raden of te hergebruiken. De code zelf staat ook versleuteld opgeslagen, + dus zelfs wij kunnen hem niet teruglezen. +
  • +
  • + Niemand komt bij je kluis, ook wij niet. Wat je op "alleen voor + mijzelf" zet, blijft dat. Je nalatenschapsbeheerder krijgt alleen wat jij hebt + vrijgegeven, en pas op het moment dat jij hebt bepaald. +
  • +
  • + Er wordt bijgehouden wie wat doet. Elke keer dat er iets wordt + geopend, gewijzigd of vrijgegeven, wordt dat vastgelegd. Zo is achteraf na te gaan + wat er is gebeurd. +
  • +
+

+ Volledige zekerheid bestaat niet, en dat beweren we ook niet. Wat we wel doen is elke + keuze zo maken dat een fout of een inbraak zo min mogelijk oplevert. +

+ +

Wat kost het?

+

+ Een eigen MijnBesluit is gratis. Wat je vastlegt blijft van jou, en je kunt alles wat je + hebt ingevoerd altijd zelf wijzigen of verwijderen. +

diff --git a/src/MijnBesluit.Web/Components/Onderdelen/Versiehistorie.razor b/src/MijnBesluit.Web/Components/Onderdelen/Versiehistorie.razor new file mode 100644 index 0000000..d4bfd8d --- /dev/null +++ b/src/MijnBesluit.Web/Components/Onderdelen/Versiehistorie.razor @@ -0,0 +1,52 @@ +@* De versiehistorie: nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. + + Zelfde opzet als Productuitleg.razor en Privacytekst.razor: op de website een popup vanaf + het overzicht, in de app hetzelfde. Dit bestand staat twee keer op schijf - de app en de site + delen geen project - en een zelfcontrole vergelijkt ze letterlijk. + + Wat hier NIET in hoort: bugfixes, tekstuele correcties, opmaak die is rechtgezet. Dit is voor + "wat kan ik nu wat ik eerst niet kon", niet een logboek van elke wijziging in de code. *@ + +

+ Nieuwe of wezenlijk gewijzigde functies, nieuwste bovenaan. +

+ +

20 september 2026

+
    +
  • + Rijbewijs en paspoort in de kluis. Twee nieuwe categorieën, met een + knop om meteen de voor- en achterkant (rijbewijs) of de foto-pagina (paspoort) te maken + of te kiezen. +
  • +
  • + Mijn profiel als pop-up. Je eigen profiel bewerk je nu in dezelfde + pop-up met vaste "Opslaan"-knop onderin als bij de andere mensen in je familie, in de + app en op de website. +
  • +
  • + Duidelijker voor de nalatenschapsbeheerder. Bij "Aan mij toevertrouwd" + zie je nu meteen hoeveel meldingen er nodig zijn en hoe lang de bezwaartermijn is. +
  • +
  • + Muziek en verdere wensen groeien mee. Die velden bij een kluisitem + passen zich nu vanzelf aan de lengte van de tekst aan, ook op de website. +
  • +
+ +

19 september 2026

+
    +
  • + Met mij gedeeld, ook in de app. Heeft iemand een kluisitem rechtstreeks + met jou gedeeld om het vast te bespreken - bijvoorbeeld een uitvaartwens - dan zie je + dat voortaan ook op je telefoon, niet meer alleen op de website. +
  • +
  • + Een gedeeld of nagelaten kluisitem bekijken. Dat opent nu als pop-up in + dezelfde opmaak als het invulformulier, inclusief de foto's en documenten die erbij + horen. +
  • +
  • + Met de vinger naar beneden trekken ververst het scherm. Werkt op Kluis, + Mijn mensen, Momenten en Nalatenschap. +
  • +
diff --git a/src/MijnBesluit.Web/Components/Pages/AccountVerwijderen.razor b/src/MijnBesluit.Web/Components/Pages/AccountVerwijderen.razor new file mode 100644 index 0000000..3956ee4 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/AccountVerwijderen.razor @@ -0,0 +1,238 @@ +@page "/account-verwijderen" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.Cookies +@inject AccountService Accounts +@inject AccountOpheffenService Opheffen +@inject IHttpContextAccessor HttpContexten + +@* Je account opheffen. Deze pagina doet twee dingen tegelijk, en dat is met opzet: + + 1. Het is het pad vanuit je eigen profiel, voor wie is ingelogd. + 2. Het is het openbare adres waar iemand die de app niet meer heeft alsnog kan laten + verwijderen. Google Play eist dat apart, en terecht: een verwijderknop die alleen in de app + zit, helpt niet als je de app al hebt weggegooid. + + Statisch weergegeven, want er wordt een cookie gewist. Zie ook de inlogpagina: alle + formulieren worden altijd gerenderd en alleen verborgen. *@ + +Account opheffen - MijnBesluit + +
+

Je MijnBesluit opheffen

+ + @if (Klaar) + { +
+ Je account is opgeheven. Je kluis, je bestanden en de berichten die nog klaarstonden + zijn verwijderd. +
+

+ Wat al bezorgd was, blijft bij degene die het heeft gekregen. Je kunt op elk moment + opnieuw beginnen met hetzelfde e-mailadres. +

+

Terug naar MijnBesluit

+ } + else + { + @if (Fout is not null) {
@Fout
} + @if (Gelukt is not null) {
@Gelukt
} + + + + + +

Toch maar niet

+ } +
+ +@code { + [SupplyParameterFromForm(FormName = "opheffen-code")] private AanvraagGegevens Aanvraag { get; set; } = new(); + [SupplyParameterFromForm(FormName = "opheffen-bevestigen")] private BevestigGegevens Bevestig { get; set; } = new(); + + [CascadingParameter] private Task? AuthState { get; set; } + + private bool Ingelogd; + private AccountOpheffenService.Telling? Telling; + private bool _codeVerstuurd; + private bool Klaar; + private string? Fout, Gelukt; + + private bool ToonCodeveld => _codeVerstuurd || !string.IsNullOrWhiteSpace(Bevestig.Email); + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + + var gebruiker = (await AuthState).User; + if (gebruiker.UserId() is not { } gebruikerId) return; + + Ingelogd = true; + + // Voor wie is ingelogd vullen we het adres in en laten we zien wát er weggaat. Wie hier + // binnenkomt zonder in te loggen, krijgt hetzelfde verhaal maar zonder aantallen: die + // zouden we alleen kunnen tonen door op een ingetypt adres te zoeken, en dan vertelt + // deze pagina aan een vreemde hoeveel er in andermans kluis zit. + if (string.IsNullOrWhiteSpace(Aanvraag.Email)) + Aanvraag.Email = gebruiker.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? ""; + + var telling = await Opheffen.TelOpAsync(gebruikerId); + if (telling.Succeeded) Telling = telling.Value; + } + + private async Task CodeAanvragenAsync() + { + var context = HttpContexten.HttpContext!; + + var resultaat = await Accounts.VraagOpheffencodeAanAsync( + Aanvraag.Email, context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted); + + if (!resultaat.Succeeded) + { + Fout = resultaat.Error; + Gelukt = null; + return; + } + + _codeVerstuurd = true; + Bevestig = new BevestigGegevens { Email = resultaat.Value!.Email }; + Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in."; + Fout = null; + } + + private async Task VerwijderenAsync() + { + var context = HttpContexten.HttpContext!; + + var controle = await Accounts.ControleerOpheffencodeAsync( + Bevestig.Email, Bevestig.Code, context.RequestAborted); + + if (!controle.Succeeded) + { + _codeVerstuurd = true; + Fout = controle.Error; + Gelukt = null; + return; + } + + var weg = await Opheffen.VerwijderAsync(controle.Value!.Id, context.RequestAborted); + if (!weg.Succeeded) + { + _codeVerstuurd = true; + Fout = weg.Error; + return; + } + + // Het cookie eraf. Zonder dit loopt de volgende pagina vast op een account dat niet meer + // bestaat - en dat ziet eruit als een storing in plaats van als iets dat je zelf hebt + // gedaan. + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + Klaar = true; + Fout = null; + Gelukt = null; + } + + public class AanvraagGegevens + { + [Required(ErrorMessage = "Vul het e-mailadres van je account in.")] + [EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")] + public string Email { get; set; } = ""; + } + + public class BevestigGegevens + { + [Required] public string Email { get; set; } = ""; + + [Required(ErrorMessage = "Je moet de code invullen.")] + public string Code { get; set; } = ""; + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/AdverteerderCampagnes.razor b/src/MijnBesluit.Web/Components/Pages/AdverteerderCampagnes.razor new file mode 100644 index 0000000..f4be4c7 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/AdverteerderCampagnes.razor @@ -0,0 +1,197 @@ +@page "/adverteren/campagnes" +@using MijnBesluit.Domain.Entities +@using MijnBesluit.Domain.Enums +@inject AdvertentieService Adverteren +@inject IHttpContextAccessor HttpContexten +@inject NavigationManager Navigatie + +@* Het eigen scherm van een adverteerder: zijn campagnes, de stand ervan, en wat er werkelijk is + getoond. + + Bewust statisch weergegeven en niet interactief. Wie hier is ingelogd, zit in een eigen cookie + met een eigen schema (zie Adverteerderslogin) - en dat cookie lees je bij elk verzoek uit + HttpContext. Bij een interactief scherm bestaat die HttpContext na de eerste render niet meer + en zou het nummer van de adverteerder ergens moeten blijven staan. Dat is precies het soort + ding dat je een keer verkeerd doet. *@ + +Mijn advertenties - MijnBesluit + +
+ @if (Ik is null) + { +

Adverteren

+

Je bent niet (meer) ingelogd. Log opnieuw in.

+ } + else + { +
+

@Ik.Bedrijfsnaam

+
+ + + +
+ + @if (Melding is not null) {
@Melding
} + + @if (Ik.IsGeblokkeerd) + { +
+ Er kunnen met dit account geen nieuwe advertenties worden ingediend. Neem contact + met ons op als je denkt dat dat niet klopt. +
+ } + else + { +

Nieuwe advertentie

+ } + + @if (Campagnes.Count == 0) + { +
+

Je hebt nog geen advertenties. Met de knop hierboven stel je er een samen.

+
+ } + + @foreach (var campagne in Campagnes) + { + var totaal = Totalen.TryGetValue(campagne.Id, out var t) ? t : (0, 0); + var link = Adverteren.Betaallink(campagne); + +
+
+ @if (campagne.HeeftAfbeelding) + { + + } +
+ @campagne.Tekst +
@campagne.Link
+
+ @Standtekst(campagne) +
+ +
+ @campagne.VanafDatum.ToString("dd-MM-yyyy") tot en met + @campagne.TotEnMetDatum.ToString("dd-MM-yyyy") · + @campagne.PerUur keer per uur · @Euro(campagne.BedragCenten) +
+ + @if (campagne.Stand == CampagneStand.WachtOpBetaling) + { +
+

+ Te betalen: @Euro(campagne.BedragCenten), onder vermelding van + @campagne.Betaalkenmerk. +

+ @if (link is not null) + { + @* Een eigen tekst op de knop en niet het adres zelf: hier hoort te staan + wát je doet en voor hoeveel, niet hoe de link is opgebouwd. *@ + + Betaal @Euro(campagne.BedragCenten) via bunq + +
+ Je betaalt bij bunq; die betaling komt niet vanzelf bij ons binnen. + We vinken hem met de hand af, meestal dezelfde dag nog. +
+ } + else + { +
Neem contact met ons op voor de betaalgegevens.
+ } +
+ } + + @if (campagne.Stand == CampagneStand.Afgewezen && campagne.AfwijzingReden is { Length: > 0 } reden) + { +
Reden: @reden
+ } + + @if (campagne.Stand is CampagneStand.Loopt or CampagneStand.Gestopt) + { +
+
+ @totaal.Item1 +
keer getoond
+
+
+ @totaal.Item2 +
keer aangeklikt
+
+
+
+ Het geboekte aantal per uur is een maximum. Zijn er in een uur minder + bezoekers dan je hebt geboekt, dan wordt de rest niet ingehaald. +
+ } +
+ } + +

Uitleg over adverteren

+ } +
+ +@code { + [SupplyParameterFromQuery(Name = "nieuw")] private long? Nieuw { get; set; } + + private Adverteerder? Ik; + private IReadOnlyList Campagnes = Array.Empty(); + private readonly Dictionary Totalen = new(); + private string? Melding; + + protected override async Task OnInitializedAsync() + { + var context = HttpContexten.HttpContext; + if (context is null) return; + + // Het nummer komt uit het cookie van het adverteerdersschema, bij elk verzoek opnieuw. + var id = await Adverteerderslogin.AdverteerderIdAsync(context); + if (id is null) return; + + Ik = await Adverteren.AdverteerderAsync(id.Value, context.RequestAborted); + if (Ik is null) return; + + Campagnes = await Adverteren.CampagnesAsync(Ik.Id, context.RequestAborted); + + foreach (var campagne in Campagnes) + Totalen[campagne.Id] = await Adverteren.TotalenAsync(campagne.Id, context.RequestAborted); + + if (Nieuw is not null && Campagnes.Any(c => c.Id == Nieuw)) + Melding = "Je advertentie is ingediend. Hieronder staat wat er betaald moet worden."; + } + + // Eén plek die weet hoe een bedrag eruitziet; zie AdvertentieService.Euro. + private static string Euro(int centen) => AdvertentieService.Euro(centen); + + /// + /// Wat er in het labeltje staat. Een goedgekeurde campagne waarvan de einddatum voorbij is + /// heet hier "afgelopen" en niet "loopt": dat is geen aparte stand in de database - hij + /// verschijnt vanzelf niet meer - maar wel wat de adverteerder ziet als hij kijkt. + /// + private static string Standtekst(Campagne campagne) + { + if (campagne.Stand == CampagneStand.Loopt && + campagne.TotEnMetDatum < DateOnly.FromDateTime(DateTime.Today)) + return "afgelopen"; + + return campagne.Stand switch + { + CampagneStand.Concept => "concept", + CampagneStand.WachtOpBetaling => "wacht op betaling", + CampagneStand.WachtOpGoedkeuring => "wacht op goedkeuring", + CampagneStand.Loopt => "loopt", + CampagneStand.Afgewezen => "afgewezen", + CampagneStand.Gestopt => "gestopt", + _ => "" + }; + } + + private static string Kleur(CampagneStand stand) => stand switch + { + CampagneStand.Loopt => "goed", + CampagneStand.Afgewezen => "fout", + CampagneStand.Gestopt => "fout", + _ => "" + }; +} diff --git a/src/MijnBesluit.Web/Components/Pages/AdverteerderNieuw.razor b/src/MijnBesluit.Web/Components/Pages/AdverteerderNieuw.razor new file mode 100644 index 0000000..5995163 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/AdverteerderNieuw.razor @@ -0,0 +1,184 @@ +@page "/adverteren/nieuw" +@using System.ComponentModel.DataAnnotations +@using MijnBesluit.Domain.Entities +@inject AdvertentieService Adverteren +@inject IHttpContextAccessor HttpContexten +@inject NavigationManager Navigatie + +@* Een advertentie samenstellen. Statisch weergegeven, net als het overzicht ernaast: het nummer + van de adverteerder komt bij elk verzoek uit zijn eigen cookie. + + De prijs wordt twee keer uitgerekend. Eén keer hieronder in de pagina zelf, zodat je ziet wat + het kost terwijl je de datums verzet - dat is een gemak, verder niets. En één keer op de + server bij het indienen; dát is het bedrag dat geldt. Wie met de pagina knoeit, verandert + alleen wat hij zelf ziet staan. *@ + +Nieuwe advertentie - MijnBesluit + +
+

Nieuwe advertentie

+ + @if (Fout is not null) {
@Fout
} + + + + +
+
+ + +
+ Eén of twee regels. Hoogstens 300 tekens, en wat er staat moet kloppen: we + lezen hem voordat hij geplaatst wordt. +
+ +
+ +
+ + +
Waar iemand terechtkomt die op de balk klikt.
+ +
+ +
+ + @* Een gewoon bestandsveld en geen InputFile: die hoort bij een interactief + scherm. Dit formulier is een gewone POST, en de server pakt het bestand uit + Request.Form.Files. *@ + +
+ Niet verplicht. We verkleinen hem zelf; in beeld is hij ongeveer 48 bij 48. +
+
+
+ +
+
+ @* oninput en niet @onchange: dit is een statische pagina, dus er is geen + Blazor die meekijkt. Het zijn gewone HTML-kenmerken, en die overleven ook een + pagina die via een link binnenkomt. De functie staat in adverteren.js. *@ +
+ + +
+
+ + +
+
+ +
+ + +
+ Hoogstens @MaxPerUur keer per uur. Dit is een maximum en geen belofte: zijn er + in een uur minder bezoekers, dan wordt de rest niet ingehaald. +
+ +
+ + @* De tarieven staan als kenmerk op dit vak, zodat adverteren.js ze kan lezen zonder + dat er bedragen in dat bestand staan. Wat daar wordt uitgerekend is een gemak, geen + bedrag: bij het indienen rekent de server het zelf uit. *@ +
+ Dit kost @Euro(Bedrag) + (@Dagen dagen × @Boeking.PerUur per uur × @Adverteren.Tarieven.TariefCenten cent, + met een minimum van @Euro(Adverteren.Tarieven.MinimumCenten)) +
+ + +
+ Na het indienen krijg je een betaalverzoek. De advertentie gaat pas draaien nadat + de betaling binnen is en we naar de inhoud hebben gekeken. +
+
+
+ +

Terug naar mijn advertenties

+
+ +@code { + [SupplyParameterFromForm(FormName = "advertentie")] private Boekinggegevens Boeking { get; set; } = new(); + + private Adverteerder? Ik; + private string? Fout; + + private int MaxPerUur => Adverteren.Tarieven.MaxPerUur; + private int Dagen => Math.Max(Boeking.TotEnMet.DayNumber - Boeking.Vanaf.DayNumber + 1, 0); + private int Bedrag => Adverteren.BerekenBedrag(Boeking.Vanaf, Boeking.TotEnMet, Boeking.PerUur); + + protected override async Task OnInitializedAsync() + { + var context = HttpContexten.HttpContext; + if (context is null) return; + + if (await Adverteerderslogin.AdverteerderIdAsync(context) is not { } id) + { + Navigatie.NavigateTo("/adverteren", forceLoad: true); + return; + } + + Ik = await Adverteren.AdverteerderAsync(id, context.RequestAborted); + } + + private async Task IndienenAsync() + { + var context = HttpContexten.HttpContext!; + if (Ik is null) { Navigatie.NavigateTo("/adverteren", forceLoad: true); return; } + + byte[]? afbeelding = null; + string? soort = null; + + var bestand = context.Request.Form.Files["afbeelding"]; + if (bestand is { Length: > 0 }) + { + if (bestand.Length > Adverteren.Tarieven.MaxAfbeeldingBytes) + { + Fout = "De afbeelding is te groot."; + return; + } + + using var geheugen = new MemoryStream(); + await bestand.CopyToAsync(geheugen, context.RequestAborted); + afbeelding = geheugen.ToArray(); + soort = bestand.ContentType; + } + + var uitkomst = await Adverteren.DienInAsync( + Ik.Id, Boeking.Tekst, Boeking.Link, Boeking.Vanaf, Boeking.TotEnMet, Boeking.PerUur, + afbeelding, soort, context.RequestAborted); + + if (!uitkomst.Succeeded) + { + Fout = uitkomst.Error; + return; + } + + Navigatie.NavigateTo($"/adverteren/campagnes?nieuw={uitkomst.Value!.Id}", forceLoad: true); + } + + // Eén plek die weet hoe een bedrag eruitziet; zie AdvertentieService.Euro. + private static string Euro(int centen) => AdvertentieService.Euro(centen); + + public class Boekinggegevens + { + [Required(ErrorMessage = "Vul de tekst van de advertentie in.")] + [StringLength(300, ErrorMessage = "De tekst mag hoogstens 300 tekens lang zijn.")] + public string Tekst { get; set; } = ""; + + [Required(ErrorMessage = "Vul de link in.")] + public string Link { get; set; } = ""; + + public DateOnly Vanaf { get; set; } = DateOnly.FromDateTime(DateTime.Today).AddDays(1); + public DateOnly TotEnMet { get; set; } = DateOnly.FromDateTime(DateTime.Today).AddDays(30); + + [Range(1, 100, ErrorMessage = "Vul in hoe vaak de advertentie per uur mag worden getoond.")] + public int PerUur { get; set; } = 5; + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Adverteren.razor b/src/MijnBesluit.Web/Components/Pages/Adverteren.razor new file mode 100644 index 0000000..c1b4c28 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Adverteren.razor @@ -0,0 +1,262 @@ +@page "/adverteren" +@using System.ComponentModel.DataAnnotations +@inject AccountService Accounts +@inject AdvertentieService Advertenties +@inject IHttpContextAccessor HttpContexten +@inject NavigationManager Navigatie + +@* De voordeur van het adverteerdersgedeelte: wat het is, wat het kost, en inloggen. + + Zelfde opzet als de inlogpagina: de pagina wordt statisch weergegeven, want alleen zo kan er + een cookie worden gezet, en alle formulieren worden ALTIJD gerenderd en alleen verborgen met + hidden. Bij een statische pagina is elke POST een nieuwe pagina-instantie; een formulier dat + op dat moment niet gerenderd wordt, bestaat voor de server niet en levert + "Cannot submit the form ... because no form on the page currently has that name" op. *@ + +Adverteren op MijnBesluit + +
+

Adverteren op MijnBesluit

+ +

+ Eén balk boven het overzichtsscherm, met je logo, een regel tekst en een link naar je + eigen website. Je boekt zelf een periode en hoe vaak je per uur in beeld komt. +

+ + @if (Fout is not null) {
@Fout
} + @if (Gelukt is not null) {
@Gelukt
} + + + + + +

Terug naar MijnBesluit

+
+ +@code { + [SupplyParameterFromForm(FormName = "adverteerder-code")] private AanvraagGegevens Aanvraag { get; set; } = new(); + [SupplyParameterFromForm(FormName = "adverteerder-nieuw")] private NieuwGegevens Nieuw { get; set; } = new(); + [SupplyParameterFromForm(FormName = "adverteerder-inloggen")] private InlogGegevens Inlog { get; set; } = new(); + + private bool _codeVerstuurd; + private string? Fout, Gelukt; + + private int Tarief => Advertenties.Tarieven.TariefCenten; + private int Bodem => Advertenties.Tarieven.MinimumCenten; + private int Voorbeeld => Advertenties.BerekenBedrag(30, 5); + + // Eén plek die weet hoe een bedrag eruitziet; zie AdvertentieService.Euro. + private static string Euro(int centen) => AdvertentieService.Euro(centen); + private static string Centen(int centen) => centen + " cent"; + + /// + /// Staat er al een e-mailadres in het codeformulier, dan is die stap eerder bereikt en hoort + /// het codeveld te staan - ook bij de eerste render van een POST, vóórdat de afhandeling + /// heeft gedraaid. + /// + private bool ToonCodeveld => _codeVerstuurd || !string.IsNullOrWhiteSpace(Inlog.Email); + + private Task CodeAanvragenAsync() => StuurCodeAsync(Aanvraag.Email, null, null); + + private Task AanmeldenAsync() => StuurCodeAsync(Nieuw.Email, Nieuw.Bedrijfsnaam, Nieuw.Contactpersoon); + + private async Task StuurCodeAsync(string email, string? bedrijfsnaam, string? contactpersoon) + { + var context = HttpContexten.HttpContext!; + + // De bedrijfsnaam gaat mee met de code en niet in dit scherm: zo kan niemand zich een + // bedrijfsnaam aanmeten zonder eerst bij het e-mailadres te kunnen, en hoeft de pagina + // niets te onthouden tussen twee formulieren door. + var resultaat = await Accounts.VraagAdverteerderscodeAanAsync( + email, bedrijfsnaam, contactpersoon, + context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted); + + if (!resultaat.Succeeded) + { + Fout = resultaat.Error; + Gelukt = null; + return; + } + + _codeVerstuurd = true; + Inlog = new InlogGegevens { Email = resultaat.Value!.Email }; + Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in."; + Fout = null; + } + + private async Task BinnenAsync() + { + var context = HttpContexten.HttpContext!; + + // Eerst vastpakken: verderop wordt het formulier leeggemaakt, en dan is dit adres weg. + var adres = Inlog.Email; + + var controle = await Accounts.ControleerAdverteerderscodeAsync( + adres, Inlog.Code, context.RequestAborted); + + if (!controle.Succeeded) + { + _codeVerstuurd = true; + Fout = controle.Error; + Gelukt = null; + return; + } + + var adverteerder = await Advertenties.AdverteerderByEmailAsync(adres, context.RequestAborted); + + if (adverteerder is null) + { + // De code klopte, maar er is nog geen account en er is ook geen bedrijfsnaam + // meegegeven: dan heeft iemand het inlogformulier gebruikt terwijl hij nieuw is. + if (string.IsNullOrWhiteSpace(controle.Value!.Bedrijfsnaam)) + { + Fout = "We kennen dit e-mailadres nog niet. Vul hieronder je bedrijfsnaam in, " + + "dan maken we een account aan."; + Gelukt = null; + Inlog = new InlogGegevens(); + Nieuw = new NieuwGegevens { Email = adres }; + return; + } + + var aangemaakt = await Advertenties.MeldAanAsync( + controle.Value.Bedrijfsnaam, controle.Value.Contactpersoon, adres, + context.RequestAborted); + + if (!aangemaakt.Succeeded) + { + Fout = aangemaakt.Error; + Gelukt = null; + return; + } + + adverteerder = aangemaakt.Value!; + } + + await Adverteerderslogin.MeldAanAsync(context, adverteerder); + Navigatie.NavigateTo("/adverteren/campagnes", forceLoad: true); + } + + public class AanvraagGegevens + { + [Required(ErrorMessage = "Je moet een e-mailadres invullen!")] + [EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")] + public string Email { get; set; } = ""; + } + + public class NieuwGegevens + { + [Required(ErrorMessage = "Vul de naam van je bedrijf in.")] + public string Bedrijfsnaam { get; set; } = ""; + + public string Contactpersoon { get; set; } = ""; + + [Required(ErrorMessage = "Je moet een e-mailadres invullen!")] + [EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")] + public string Email { get; set; } = ""; + } + + public class InlogGegevens + { + [Required] public string Email { get; set; } = ""; + + [Required(ErrorMessage = "Je moet een code invullen!")] + public string Code { get; set; } = ""; + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Beheer.razor b/src/MijnBesluit.Web/Components/Pages/Beheer.razor new file mode 100644 index 0000000..4573a8c --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Beheer.razor @@ -0,0 +1,338 @@ +@page "/beheer" +@attribute [Authorize] +@inject BeheerService Beheerkant +@rendermode InteractiveServer + +Beheer - MijnBesluit + +@* De beheerpagina. Twee handelingen: een overlijden terugdraaien dat ten onrechte is bevestigd, + en een samenvoeging terugdraaien die niet had gemoeten. + + Zie BeheerService voor waarom er niet meer op staat. Kort: een beheerder kan geen kluis + openen en kan niet op naam zoeken. *@ + +@if (!Magdit) +{ +

Niet gevonden

+

Deze pagina bestaat niet, of hij is niet voor jou.

+} +else +{ +

Beheer

+

+ Hier draai je twee dingen terug die niemand anders kan: een overlijden dat ten onrechte + is bevestigd, en een samenvoeging van twee profielen die niet had gemoeten. Voor het + eerste is dit nodig als de bezwaarlink uit de waarschuwingsmail verlopen is; de + betrokkene kan dan niet meer inloggen en heeft zelf geen weg terug. +

+ + @* Adverteren heeft een eigen scherm: dat gaat niet over terugdraaien maar over betalingen + afvinken en advertenties beoordelen, en daar hoor je dagelijks te komen in plaats van bij + hoge uitzondering. *@ +

Advertenties beheren ›

+ + @if (Melding is not null) {
@Melding
} + + @* ------------------------------------------------------------ statistieken *@ +
+

Gebruik van MijnBesluit

+

+ Aantallen, niet inhoud: hier is niets van iemands kluis of berichten te zien. Bedoeld + om de belasting in te schatten, niet om iemand te volgen. +

+ + @if (Stats is null) + { +

Statistieken zijn hier niet beschikbaar.

+ } + else + { +
+
+ @Stats.Profielen + Profielen (@Stats.GeclaimdeProfielen geclaimd) +
+
+ @Stats.Families + Families +
+
+ @Stats.KluisItems + Kluisitems +
+
+ @Stats.KluisBestanden + Bijlagen +
+
+ @Stats.Momenten + Momenten (@Stats.BezorgdeMomenten bezorgd) +
+
+ @Stats.Nalatenschapbeheerders + Aangewezen beheerders +
+
+ + + + @if (Stats.ItemsPerCategorie.Count > 0) + { +

Kluisitems per categorie

+
+ @foreach (var (categorie, aantal) in Stats.ItemsPerCategorie.OrderByDescending(kv => kv.Value)) + { +
+ @Kluisweergave.Naam(categorie) +
+
+ +
+
+ @aantal +
+ } +
+ } + } +
+ +
+

Profiel opzoeken

+
+ + +
+ Alleen het volledige adres, niet een deel ervan en niet op naam. Je hoort al te + weten wie je zoekt. +
+
+ +
+ + @if (Gevonden is { } profiel) + { +
+

@profiel.DisplayName

+ +
+
+ E-mailadres + @(profiel.Email ?? "onbekend") +
+
+ Eigen account + @(profiel.IsGeclaimd ? "ja" : "nee, het profiel is nog niet geclaimd") +
+
+ Meldingen + @profiel.Meldingen van @profiel.MeldingenNodig +
+
+ Stand + @Stand(profiel.Stand) +
+
+ Kan inloggen + @(profiel.AccountGeblokkeerd ? "nee, het account staat op overleden" : "ja") +
+ @if (profiel.OpgegaanIn is { } opgegaan) + { +
+ Opgegaan in + profiel @opgegaan +
+ } +
+ + @if (profiel.OverlijdenBevestigdOp is { } bevestigd) + { +
+

+ Het overlijden is bevestigd op @Datums.Datum(bevestigd)@( + profiel.Overlijdensdatum is { } datum ? $", met {Datums.Datum(datum)} als overlijdensdatum" : ""). +

+

+ Terugdraaien betekent: alle meldingen vervallen, het account wordt weer + actief, en de toegang die de nalatenschapsbeheerder had gekregen wordt + ingetrokken. +

+ +
+ + +
Dit komt in het logboek te staan, bij jouw naam.
+
+ + +
+ } + else + { +

+ Voor dit profiel is geen overlijden bevestigd. Er valt daar dus niets terug te draaien. +

+ } +
+ + @* ------------------------------------------------------------ samenvoegingen *@ +
+

Samenvoegingen

+ + @if (profiel.Samenvoegingen.Count == 0) + { +

+ Dit profiel is nooit samengevoegd. Let op: samenvoegingen van vóór 15 september + 2026 zijn niet vastgelegd en kunnen daarom ook niet worden teruggedraaid. +

+ } + else + { +

+ Terugdraaien zet alles wat toen is verhuisd terug bij het profiel dat is + opgegaan. Wat er ná die tijd bij het andere profiel is bijgekomen, blijft + daar staan — dat is daar aangemaakt en hoort daar. +

+ +
+ @foreach (var samenvoeging in profiel.Samenvoegingen) + { +
+
+
+ + @samenvoeging.DuplicaatNaam (@samenvoeging.DuplicaatPersonId) + → @samenvoeging.DoelNaam (@samenvoeging.DoelPersonId) + +
+ @Datums.DatumTijd(samenvoeging.SamengevoegdOp) + · @samenvoeging.Stappen stap@(samenvoeging.Stappen == 1 ? "" : "pen") +
+
+ + @if (samenvoeging.IsTeruggedraaid) + { + Teruggedraaid + } +
+ + @if (!samenvoeging.IsTeruggedraaid) + { +
+ + +
Dit komt in het logboek te staan, bij jouw naam.
+
+ + + } +
+ } +
+ } +
+ } +} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId; + private bool Magdit, Bezig; + + private string Adres = ""; + private string Reden = ""; + private string Terugreden = ""; + private BeheerService.Profielbeeld? Gevonden; + private string? Melding, MeldingSoort; + private Statistieken? Stats; + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + _gebruikerId = (await AuthState).User.UserId() ?? 0; + Magdit = await Beheerkant.IsBeheerderAsync(_gebruikerId); + if (!Magdit) return; + + var statistieken = await Beheerkant.StatistiekenAsync(_gebruikerId); + if (statistieken.Succeeded) Stats = statistieken.Value; + } + + /// Breedte van de staaf voor deze categorie, ten opzichte van de grootste categorie. + private int CategoriePercentage(int aantal) + { + var grootste = Stats!.ItemsPerCategorie.Values.Max(); + return grootste == 0 ? 0 : Math.Max(2, aantal * 100 / grootste); + } + + private async Task Zoeken() + { + Bezig = true; + Melding = null; + Gevonden = null; + try + { + var resultaat = await Beheerkant.ZoekOpAdresAsync(Adres, _gebruikerId); + if (resultaat.Succeeded) Gevonden = resultaat.Value; + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + finally { Bezig = false; } + } + + private async Task Herstellen() + { + if (Gevonden is not { } profiel) return; + + Bezig = true; + Melding = null; + try + { + var resultaat = await Beheerkant.HerstelOverlijdenAsync(profiel.PersonId, _gebruikerId, Reden); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; } + + Melding = $"Het overlijden van {profiel.DisplayName} is teruggedraaid. " + + "Hij of zij kan weer inloggen, en de nalatenschapsbeheerder is zijn toegang kwijt."; + MeldingSoort = "goed"; + Reden = ""; + + // Opnieuw ophalen, zodat het scherm laat zien wat er nu staat. + var opnieuw = await Beheerkant.ZoekOpAdresAsync(Adres, _gebruikerId); + Gevonden = opnieuw.Succeeded ? opnieuw.Value : null; + } + finally { Bezig = false; } + } + + private async Task TerugdraaienAsync(long samenvoegingId) + { + Bezig = true; + Melding = null; + try + { + var resultaat = await Beheerkant.TerugdraaienAsync(samenvoegingId, _gebruikerId, Terugreden); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; } + + Melding = "De samenvoeging is teruggedraaid. De twee profielen staan weer los van elkaar."; + MeldingSoort = "goed"; + Terugreden = ""; + + var opnieuw = await Beheerkant.ZoekOpAdresAsync(Adres, _gebruikerId); + Gevonden = opnieuw.Succeeded ? opnieuw.Value : null; + } + finally { Bezig = false; } + } + + private static string Stand(DeathProcedureState stand) => stand switch + { + DeathProcedureState.Geen => "geen meldingen", + DeathProcedureState.Lopend => "procedure loopt", + DeathProcedureState.Betwist => "tegengesproken door de betrokkene", + DeathProcedureState.Bevestigd => "overlijden bevestigd", + _ => "onbekend" + }; +} diff --git a/src/MijnBesluit.Web/Components/Pages/BeheerAdverteren.razor b/src/MijnBesluit.Web/Components/Pages/BeheerAdverteren.razor new file mode 100644 index 0000000..f59d519 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/BeheerAdverteren.razor @@ -0,0 +1,372 @@ +@page "/beheer/adverteren" +@attribute [Authorize] +@inject BeheerService Beheerkant +@inject AdvertentieService Adverteren +@inject AdvertentietariefInstellingen TariefInstellingen +@rendermode InteractiveServer + +Adverteren - Beheer + +@* Het beheerscherm voor advertenties. Links de adverteerders, rechts hun campagnes. + + Twee handelingen per campagne, en dat zijn twee verschillende vragen: + 1. is het geld binnen (dat zie je in bunq, niet hier - er komt geen bericht terug); + 2. mag dit naast onze tekst over uitvaarten staan. + + Zie docs/Adverteren.md. *@ + +@if (!Magdit) +{ + @* Dezelfde melding als een pagina die niet bestaat. Wie geen beheerder is, hoort niet te + weten dat hier iets te beheren valt. *@ +

Niet gevonden

+

Deze pagina bestaat niet, of hij is niet voor jou.

+} +else +{ +

Adverteren

+ + @if (Melding is not null) {
@Melding
} + +
+

Tarief

+

+ Het bedrag is dagen × vertoningen per uur × dit tarief per vertoning. +

+
+
+ + +
+ +
+

+ Nu @HuidigTarief cent per vertoning per uur per dag. Dertig dagen en vijf keer per uur + komt daarmee op @AdvertentieService.Euro(30 * 5 * HuidigTarief). +

+
+ + @if (Wachtend.Count > 0) + { +
+

Er wacht iets op jou

+
+ @foreach (var campagne in Wachtend) + { +
+
+
@campagne.Adverteerder?.Bedrijfsnaam
+
+ @Bedrag(campagne.BedragCenten) · kenmerk @campagne.Betaalkenmerk + · @Standnaam(campagne.Stand) +
+
+ +
+ } +
+
+ } + +
+ +
+

Adverteerders

+ + @if (Adverteerders.Count == 0) + { +

Er is nog niemand aangemeld.

+ } + else + { +
+ @foreach (var adverteerder in Adverteerders) + { + + } +
+ } +
+ +
+ @if (Gekozen is null) + { +

Advertenties

+

Kies links een adverteerder.

+ } + else + { +

@Gekozen.Bedrijfsnaam

+

+ @Gekozen.Email + @if (!string.IsNullOrWhiteSpace(Gekozen.Contactpersoon)) { · @Gekozen.Contactpersoon } +

+ + @if (Campagnes.Count == 0) + { +

Deze adverteerder heeft nog geen advertenties.

+ } + +
+ @foreach (var campagne in Campagnes) + { +
+
+
+ @campagne.Tekst +
+ @campagne.VanafDatum.ToString("dd-MM-yyyy") t/m + @campagne.TotEnMetDatum.ToString("dd-MM-yyyy") · + @campagne.PerUur× per uur · @campagne.Dagen dagen +
+
+ @Standnaam(campagne.Stand) +
+ + @if (campagne.HeeftAfbeelding) + { + + } + +
+ Gaat naar @campagne.Link +
+ +
+ @Bedrag(campagne.BedragCenten) · kenmerk @campagne.Betaalkenmerk + @if (campagne.BetaaldOp is { } betaald) + { + · betaald op @betaald.ToLocalTime().ToString("dd-MM-yyyy") + } +
+ + @if (Getoond.TryGetValue(campagne.Id, out var tellers)) + { +
+ @tellers.Vertoningen getoond · @tellers.Kliks aangeklikt +
+ } + + @if (campagne.AfwijzingReden is { Length: > 0 } reden) + { +
Afgewezen: @reden
+ } + + @* De twee vinkjes. Goedkeuren kan pas als de betaling is afgevinkt; + de knop staat er daarom niet eerder. *@ +
+ @if (!campagne.IsBetaald) + { + + } + else if (campagne.Stand != CampagneStand.Loopt) + { + + } + else + { + + } + + @if (campagne.Stand != CampagneStand.Afgewezen) + { + + } +
+ + @if (Afwijzen == campagne.Id) + { +
+
+ + +
+
+ + +
+
+ } +
+ } +
+ } +
+
+} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId; + private bool Magdit, Bezig; + + private IReadOnlyList Adverteerders = Array.Empty(); + private IReadOnlyList Wachtend = Array.Empty(); + private IReadOnlyList Campagnes = Array.Empty(); + private Adverteerder? Gekozen; + + /// Wat er per campagne werkelijk is getoond en aangeklikt. + private Dictionary Getoond = new(); + + private long? Afwijzen; + private string Reden = ""; + private string? Melding, MeldingSoort; + + private int HuidigTarief; + private int NieuwTarief; + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + _gebruikerId = (await AuthState).User.UserId() ?? 0; + Magdit = await Beheerkant.IsBeheerderAsync(_gebruikerId); + if (Magdit) + { + HuidigTarief = Adverteren.Tarieven.TariefCenten; + NieuwTarief = HuidigTarief; + await Verversen(); + } + } + + /// + /// Schrijft het tarief naar de instellingen. Deze pagina rekent zelf al met het oude tarief + /// verder tot een herlaad; nieuwe boekingen elders pakken het nieuwe tarief op zodra + /// ASP.NET Core het gewijzigde bestand heeft opgemerkt, meestal binnen enkele seconden. + /// + private async Task TariefOpslaanAsync() + { + Melding = null; + + var probleem = AdvertentietariefInstellingen.Controleer(NieuwTarief); + if (probleem is not null) + { + Melding = probleem; + MeldingSoort = "fout"; + return; + } + + Bezig = true; + try + { + TariefInstellingen.SchrijfTarief(NieuwTarief); + HuidigTarief = NieuwTarief; + Melding = "Tarief opgeslagen. Nieuwe boekingen rekenen hiermee zodra dit is opgepikt."; + MeldingSoort = "goed"; + } + catch (Exception ex) + { + Melding = ex.Message; + MeldingSoort = "fout"; + } + finally { Bezig = false; } + + await Task.CompletedTask; + } + + private async Task Verversen() + { + Adverteerders = await Adverteren.AdverteerdersAsync(); + Wachtend = await Adverteren.WachtOpBeheerderAsync(); + + if (Gekozen is not null) + { + Gekozen = await Adverteren.AdverteerderAsync(Gekozen.Id); + Campagnes = Gekozen is null ? Array.Empty() + : await Adverteren.CampagnesAsync(Gekozen.Id); + + Getoond = new Dictionary(); + foreach (var campagne in Campagnes) + Getoond[campagne.Id] = await Adverteren.TotalenAsync(campagne.Id); + } + } + + private async Task Openen(long adverteerderId) + { + Afwijzen = null; + Melding = null; + Gekozen = await Adverteren.AdverteerderAsync(adverteerderId); + await Verversen(); + } + + private Task Betaald(long campagneId) => + DoenAsync(() => Adverteren.BetaaldAsync(campagneId, _gebruikerId), "De betaling staat genoteerd."); + + private Task Goedkeuren(long campagneId) => + DoenAsync(() => Adverteren.GoedkeurenAsync(campagneId, _gebruikerId), "Goedgekeurd. Hij draait binnen zijn periode."); + + private Task Stopzetten(long campagneId) => + DoenAsync(() => Adverteren.StopzettenAsync(campagneId, _gebruikerId), "Stopgezet."); + + private async Task AfwijzenNu(long campagneId) + { + var reden = Reden; + if (await DoenAsync(() => Adverteren.AfwijzenAsync(campagneId, _gebruikerId, reden), "Afgewezen; de adverteerder heeft bericht.")) + { + Afwijzen = null; + Reden = ""; + } + } + + /// + /// Eén plek voor het patroon "doe iets, toon de uitkomst, ververs". Zo staat de afhandeling + /// van fouten hier één keer en blijft er geen knop hangen als er iets misgaat. + /// + private async Task DoenAsync(Func> werk, string gelukt) + { + Bezig = true; + Melding = null; + try + { + var uitkomst = await werk(); + Melding = uitkomst.Succeeded ? gelukt : uitkomst.Error; + MeldingSoort = uitkomst.Succeeded ? "goed" : "fout"; + await Verversen(); + return uitkomst.Succeeded; + } + finally { Bezig = false; } + } + + private static string Bedrag(int centen) => AdvertentieService.Euro(centen); + + private static string Standnaam(CampagneStand stand) => stand switch + { + CampagneStand.Concept => "Concept", + CampagneStand.WachtOpBetaling => "Wacht op betaling", + CampagneStand.WachtOpGoedkeuring => "Wacht op goedkeuring", + CampagneStand.Loopt => "Loopt", + CampagneStand.Afgewezen => "Afgewezen", + _ => "Gestopt" + }; + + private static string Standkleur(CampagneStand stand) => stand switch + { + CampagneStand.Loopt => "geclaimd", + CampagneStand.WachtOpBetaling => "uitgenodigd", + CampagneStand.WachtOpGoedkeuring => "uitgenodigd", + _ => "" + }; +} diff --git a/src/MijnBesluit.Web/Components/Pages/Error.razor b/src/MijnBesluit.Web/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/MijnBesluit.Web/Components/Pages/Familie.razor b/src/MijnBesluit.Web/Components/Pages/Familie.razor new file mode 100644 index 0000000..a01a0a8 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Familie.razor @@ -0,0 +1,593 @@ +@page "/familie" +@attribute [Authorize] +@using MijnBesluit.Application.Abstractions +@inject FamilyService Families +@inject IFamilyRepository FamilieOpslag +@inject VaultService Kluis +@inject IJSRuntime JS +@rendermode InteractiveServer + +Mijn mensen - MijnBesluit + +@* Dezelfde indeling als in de app: een brede knop om iemand toe te voegen, daaronder + compacte kaarten met naam en wat het van je is. Wijzigen en verwijderen gaan via een + popup, zodat je op de kaart zelf alleen de drie knoppen ziet. + + Wijzigen en verwijderen mag zolang het profiel niet geclaimd is - ook na een uitnodiging, + want een tikfout in een e-mailadres merk je pas als de mail nergens aankomt. *@ + +

Mijn mensen

+

+ Je familie en je vrienden — iedereen die erbij hoort. Voeg ze toe en nodig ze uit. + Zolang iemand de uitnodiging niet heeft aangenomen, beheer jij dat profiel. Daarna is het + van hem of haar. +

+ +@if (Melding is not null) {
@Melding
} + +@if (Leden is null) +{ +

Bezig met laden…

+} +else +{ + + + @* Jezelf bovenaan. Je staat niet in je eigen ledenlijst - je bent geen familielid van + jezelf - maar dit is wel de plek waar mensen je zoeken als ze hun eigen gegevens willen + wijzigen. De kaart doet zelf niets; hij brengt je naar Mijn profiel. *@ + +
+ +
+ @_mijnNaam +
+ Je eigen gegevens en foto + Jijzelf +
+
+
+
+ +
+ @foreach (var lid in Leden) + { +
+
+ @* De profielfoto als die er is, anders de beginletters. Even groot, zodat de + lijst niet verspringt zodra er ergens een foto bij komt. *@ + +
+ @lid.DisplayName + + @* De status staat hier en niet naast de naam. Daar hield de naam op een + telefoon te weinig ruimte over; op deze regel past hij wel, en de + status staat nog steeds altijd rechts op de tegel. *@ +
+ @Onderregel(lid) + + @switch (lid.State) + { + case ProfileState.Geclaimd: + Geclaimd + break; + case ProfileState.Uitgenodigd: + Uitgenodigd + break; + default: + Door jou + break; + } +
+
+
+ + @* De prullenbak staat op ELKE tegel, ook bij een profiel dat iemand zelf beheert. + Dat je Karin uit je lijst haalt zegt niets over haar eigen MijnBesluit: haar + profiel blijft van haar, het verdwijnt alleen hier. Wijzigen en uitnodigen + horen daar niet bij - dat kán niet meer zodra iemand heeft geclaimd. + + Is het profiel geclaimd, dan staat "Geclaimd op ..." links op deze regel, + naast Verwijderen - zo blijft de tegel net zo laag als de andere. *@ +
+ @if (lid.State == ProfileState.Geclaimd) + { + + Geclaimd@(lid.ClaimedAt is { } sinds ? $" op {KortDatum(sinds)}" : ""). + + } + + @if (lid.State != ProfileState.Geclaimd) + { + + + @if (!string.IsNullOrWhiteSpace(lid.Email)) + { + + } + } + + +
+ + @if (lid.State != ProfileState.Geclaimd && string.IsNullOrWhiteSpace(lid.Email)) + { +
+ Vul een e-mailadres in om @(lid.DisplayName) te kunnen uitnodigen. +
+ } +
+ } +
+ + @if (Leden.Count == 0) + { +
+ 👥 + Er staat hier nog niemand. Voeg je partner of je kinderen toe; + zij krijgen een uitnodiging en kunnen daarna hun eigen MijnBesluit invullen. +
+ } +} + +@* ---------------------------------------------------------------- toevoegen en wijzigen *@ +@if (Bewerken is not null) +{ + + +
+
+
+
+ +
+ + +
+ +
+ + @* Op de site gewoon een datumveld: de agendaknop van de browser werkt hier wel. + In de app staat een keuzelijstje, want in een webview doet die knop niets. *@ + +
Niet verplicht. Met een datum staat je familie bovenaan op leeftijd.
+
+ +
+ + +
Met een adres kun je hem uitnodigen. Zonder adres beheer je het profiel zelf.
+
+ + @if (Bewerken.PersoonId == 0) + { +
+ +
+ Die kun je toevoegen zodra deze persoon is opgeslagen; ze horen bij hem en + hij moet er eerst zijn. +
+
+ } + else + { + + } + + @if (BewerkLid is { } lid) + { +
+
Profiel aangemaakt op @Datum(lid.CreatedAt)
+ + @if (lid.InvitedAt is { } verstuurd) + { +
+ Uitgenodigd op @Datum(verstuurd)@(lid.InvitationResends > 0 + ? $" ({lid.InvitationResends + 1}× verstuurd)" : "") +
+ + @if (lid.InvitationExpiresAt is { } tot) + { +
+ @(tot > DateTime.UtcNow + ? $"Uitnodiging geldig tot {Datum(tot)}" + : $"Uitnodiging verlopen op {Datum(tot)}") +
+ } + } + else + { +
Nog niet uitgenodigd.
+ } +
+ } +
+} + +@* ---------------------------------------------------------------- verwijderen *@ +@if (Weghalen is not null) +{ + @* Drie gevallen, zoals in de app. + + 1. Hij beheert je nalatenschap. Dan kan het niet, en dit scherm zegt waar je dat weghaalt. + De server weigert het ook; dit is er zodat je niet eerst op "verwijderen" hoeft te + drukken om erachter te komen. + 2. Er staan momenten voor hem klaar. Die verdwijnen mee, en dat is het soort ding dat je + wilt weten vóór je op "ja" drukt - niet erna. + 3. Gewoon weghalen. *@ + @if (Weghalen.IsBeheerder) + { + + +

+ @Weghalen.DisplayName is aangewezen om je nalatenschap te beheren. Zolang dat zo + is, hoort hij bij je mensen te staan. +

+

+ Haal hem eerst weg bij Nalatenschap. Dat is met opzet een aparte + handeling: wie je nalatenschap beheert, is een besluit dat je niet per ongeluk + terugdraait door iemand uit een lijst te verwijderen. +

+
+ } + else + { + + +

+ @Weghalen.DisplayName verdwijnt uit je lijst. Een uitnodiging die nog openstaat + vervalt daarmee. +

+ + @if (Weghalen.MomentenKlaar > 0) + { +
+ Er @(Weghalen.MomentenKlaar == 1 ? "staat nog 1 moment" : $"staan nog {Weghalen.MomentenKlaar} momenten") + voor @Weghalen.DisplayName klaar. @(Weghalen.MomentenKlaar == 1 ? "Dat verdwijnt" : "Die verdwijnen") mee + en @(Weghalen.MomentenKlaar == 1 ? "wordt" : "worden") dus nooit bezorgd. + Wat al bezorgd is blijft staan. +
+ } + +

+ @if (Weghalen.State == ProfileState.Geclaimd) + { + + @Weghalen.DisplayName beheert dit profiel zelf. Het verdwijnt alleen bij + jou; zijn eigen MijnBesluit blijft zoals het is. + + } + else + { + + Het profiel zelf blijft bestaan; hij kan in andere families staan of zijn + eigen MijnBesluit hebben. + + } +

+
+ } +} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId, _persoonId, _familieId; + private string _mijnNaam = "Mijn profiel"; + private IReadOnlyList? Leden; + private string? Melding, MeldingSoort; + private bool Bezig; + + /// Het formulier voor toevoegen en wijzigen; null als er niets openstaat. + private Formulier? Bewerken; + + /// Het lid dat bij hoort; null bij toevoegen. Nodig omdat het + /// formulier alleen de te wijzigen velden bevat, niet de geschiedenis van het profiel. + private FamilyMemberView? BewerkLid; + + /// Wie er op verwijderen staat te wachten. + private FamilyMemberView? Weghalen; + + /// De foto's en documenten bij de persoon die in het wijzigscherm staat. + private IReadOnlyList Bijlagen = Array.Empty(); + private bool Uploaden; + + /// Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens. + private const int MaxTegelijk = 10; + + /// Per persoon het nummer van zijn profielfoto, voor wie er een heeft. + private IReadOnlyDictionary Profielfotos = new Dictionary(); + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + var user = (await AuthState).User; + _gebruikerId = user.UserId() ?? 0; + _persoonId = user.PersonId() ?? 0; + _mijnNaam = user.Naam() is { Length: > 0 } naam ? naam : "Mijn profiel"; + + var families = await FamilieOpslag.ListOwnedByPersonAsync(_persoonId); + _familieId = families.FirstOrDefault()?.Id ?? 0; + + await Verversen(); + } + + private async Task Verversen() + { + if (_familieId == 0) + { + Leden = Array.Empty(); + } + else + { + var alle = await Families.ListMembersAsync(_familieId, _gebruikerId); + Leden = alle.Where(l => l.PersonId != _persoonId).ToList(); + } + + // De profielfoto's in één vraag erbij. Alleen het nummer; de browser haalt het plaatje + // zelf op via /bestand/{id}/klein en bewaart het daarna in zijn eigen buffer. + Profielfotos = (await Kluis.ProfielfotosAsync(Leden.Select(l => l.PersonId).Append(_persoonId), + _gebruikerId)) + .ToDictionary(f => f.PersoonId, f => f.BestandId); + } + + /// Waar de profielfoto van deze persoon staat; null als hij er geen heeft. + private string? Foto(long persoonId) => + Profielfotos.TryGetValue(persoonId, out var bestandId) ? $"/bestand/{bestandId}/klein" : null; + + private void Nieuw() + { + Melding = null; + Weghalen = null; + BewerkLid = null; + Bijlagen = Array.Empty(); + Bewerken = new Formulier(); + } + + private async Task Wijzigen(FamilyMemberView lid) + { + Melding = null; + Weghalen = null; + BewerkLid = lid; + Bewerken = new Formulier + { + PersoonId = lid.PersonId, + Voornaam = lid.FirstName, + Achternaam = lid.LastName, + Email = lid.Email ?? "", + Relatie = lid.Relation, + Geboortedatum = lid.BirthDate?.ToDateTime(TimeOnly.MinValue) + }; + + await BijlagenLaden(); + } + + // ------------------------------------------------------------------ foto's en documenten + + private async Task BijlagenLaden() + { + if (Bewerken is not { PersoonId: > 0 } formulier) { Bijlagen = Array.Empty(); return; } + + var resultaat = await Kluis.ListPersonFilesAsync(formulier.PersoonId, _gebruikerId); + Bijlagen = resultaat.Succeeded ? resultaat.Value! : Array.Empty(); + } + + private async Task BijlageKiezen(InputFileChangeEventArgs e) + { + if (Bewerken is not { PersoonId: > 0 } formulier) return; + + Uploaden = true; + Melding = null; + try + { + foreach (var bestand in e.GetMultipleFiles(MaxTegelijk)) + { + if (bestand.Size > VaultService.MaxBestandsgrootte) + { + Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB."; + MeldingSoort = "fout"; + continue; + } + + using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte); + using var buffer = new MemoryStream(); + await stroom.CopyToAsync(buffer); + + var resultaat = await Kluis.StoreFileAsync( + _gebruikerId, bestand.Name, + string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType, + buffer.ToArray(), subjectPersonId: formulier.PersoonId); + + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + await BijlagenLaden(); + } + catch (Exception ex) + { + Melding = $"Het bestand kon niet worden gelezen: {ex.Message}"; + MeldingSoort = "fout"; + } + finally { Uploaden = false; } + } + + private async Task BijlageWeg(long bestandId) + { + var resultaat = await Kluis.DeleteFileAsync(bestandId, _gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + await BijlagenLaden(); + } + + private async Task Profielfoto(long bestandId) + { + var resultaat = await Kluis.SetProfilePhotoAsync(bestandId, _gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + await BijlagenLaden(); + } + + private async Task Opslaan() + { + var formulier = Bewerken!; + if (string.IsNullOrWhiteSpace(formulier.Voornaam) && string.IsNullOrWhiteSpace(formulier.Achternaam)) + { + Melding = "Vul minstens een voor- of achternaam in."; + MeldingSoort = "fout"; + await JS.InvokeVoidAsync("mijnbesluit.springNaarVeld", "vn"); + return; + } + + var email = string.IsNullOrWhiteSpace(formulier.Email) ? null : formulier.Email.Trim(); + + Bezig = true; + try + { + // AddMemberAsync geeft de nieuwe persoon terug, WijzigLidAsync alleen goed of fout. + // Daarom hier twee aanroepen in plaats van een ternaire: de uitkomsttypen verschillen. + bool gelukt; + string? fout; + + var geboren = formulier.Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null; + + if (formulier.PersoonId == 0) + { + var uitkomst = await Families.AddMemberAsync( + _familieId, _gebruikerId, formulier.Voornaam.Trim(), formulier.Achternaam.Trim(), + email, formulier.Relatie, null, geboren); + gelukt = uitkomst.Succeeded; + fout = uitkomst.Error; + } + else + { + var uitkomst = await Families.WijzigLidAsync( + _familieId, formulier.PersoonId, _gebruikerId, formulier.Voornaam.Trim(), + formulier.Achternaam.Trim(), email, formulier.Relatie, null, geboren); + gelukt = uitkomst.Succeeded; + fout = uitkomst.Error; + } + + if (gelukt) + { + Melding = formulier.PersoonId == 0 ? "Toegevoegd." : "De wijziging is opgeslagen."; + MeldingSoort = "goed"; + Sluiten(); + await Verversen(); + } + else + { + Melding = fout; + MeldingSoort = "fout"; + } + } + finally { Bezig = false; } + } + + private async Task Verwijderen(long persoonId) + { + Bezig = true; + try + { + var resultaat = await Families.VerwijderLidAsync(_familieId, persoonId, _gebruikerId); + Melding = resultaat.Succeeded ? "De persoon staat hier niet meer bij." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + if (resultaat.Succeeded) { Weghalen = null; await Verversen(); } + } + finally { Bezig = false; } + } + + private async Task Uitnodigen(long persoonId) + { + Bezig = true; + try + { + var resultaat = await Families.InviteAsync(_familieId, persoonId, _gebruikerId); + Melding = resultaat.Succeeded ? "De uitnodiging is verstuurd." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + await Verversen(); + } + finally { Bezig = false; } + } + + private void Sluiten() + { + Bewerken = null; + BewerkLid = null; + Bijlagen = Array.Empty(); + } + + /// + /// De regel onder de naam: "Partner (59)". Kort gehouden, want de status staat ernaast en + /// die heeft ook ruimte nodig. Is iemand overleden, dan is dat belangrijker dan zijn leeftijd. + /// + private static string Onderregel(FamilyMemberView lid) + { + var relatie = Relatie(lid); + + if (lid.DateOfDeath is { } overleden) + return $"{relatie} · overleden {Datums.Datum(overleden)}"; + + return lid.BirthDate is { } geboren ? $"{relatie} ({Leeftijd(geboren)})" : relatie; + } + + /// Leeftijd in hele jaren, zoals je hem zelf zou uitrekenen. + private static int Leeftijd(DateOnly geboren) + { + var vandaag = DateOnly.FromDateTime(DateTime.Today); + var jaren = vandaag.Year - geboren.Year; + if (geboren.AddYears(jaren) > vandaag) jaren--; + return jaren; + } + + /// Een moment in UTC leesbaar op het scherm zetten, in lokale tijd. + private static string Datum(DateTime moment) => Datums.Datum(moment); + private static string KortDatum(DateTime moment) => Datums.Kort(moment); + + private static string Relatie(FamilyMemberView lid) => + !string.IsNullOrWhiteSpace(lid.RelationLabel) ? lid.RelationLabel! : Omschrijving(lid.Relation); + + // De lijst en de namen staan in het domein, zodat de website en de app er niet ieder een + // eigen versie van hebben. Zie MijnBesluit.Domain.Rules.Relaties. + private static string Omschrijving(RelationType r) => MijnBesluit.Domain.Rules.Relaties.Naam(r); + + /// Eén formulier voor toevoegen en wijzigen; PersoonId 0 betekent nieuw. + private class Formulier + { + public long PersoonId { get; set; } + public string Voornaam { get; set; } = ""; + public string Achternaam { get; set; } = ""; + public string Email { get; set; } = ""; + // Partner staat bovenaan de lijst; een keuzelijst hoort te openen op wat je ziet staan. + // Kind stond hier eerst, en dat is sinds de uitsplitsing geen keuze meer. + public RelationType Relatie { get; set; } = RelationType.Partner; + + /// Null als er geen geboortedatum is ingevuld; dat mag. + public DateTime? Geboortedatum { get; set; } + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Home.razor b/src/MijnBesluit.Web/Components/Pages/Home.razor new file mode 100644 index 0000000..2f1eae6 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Home.razor @@ -0,0 +1,359 @@ +@page "/" +@attribute [Authorize] +@inject OverzichtService Overzichten +@inject LegacyService NalatenschapService +@inject AdvertentieService Adverteren +@inject AccountService Accounts +@inject SamenvoegService Samenvoegen +@rendermode InteractiveServer + +MijnBesluit + +@* Het beginscherm, met dezelfde indeling als in de app: eerst wat er speelt, dan de tegels. + De aantallen staan achter de titel in plaats van op een regel eronder, zodat de tegels + laag blijven. *@ + + + +

@Begroeting(auth.User.Naam())

+ + @if (Fout is not null) + { +
@Fout
+ } + + @if (Melding is not null) + { +
@Melding
+ } + + @* Heeft iemand een profiel voor je aangemaakt op jouw adres? Dan staat die vraag hier, + bovenaan, tot je hem beantwoordt. Alleen jij kunt zeggen of dat over jou gaat; + koppelen op e-mailadres doet MijnBesluit niet. Zie docs/Identiteit.md. *@ + @foreach (var aantekening in Aantekeningen) + { +
+

Ben jij dit?

+

+ @aantekening.Van heeft een profiel voor je aangemaakt op jouw + e-mailadres@(aantekening.Relatie is { Length: > 0 } r ? $", als {r.ToLowerInvariant()}" : ""). +

+

+ Ben jij dat, dan voegen we het bij je eigen profiel: wat hij over je heeft + ingevuld komt bij jou te staan, en vanaf dat moment bepaal jij wat er staat. + Ben jij het niet, dan blijft alles zoals het is en vragen we het niet nog eens. +

+
+ + +
+
+ } + + @if (Gegevens is { Regels.Count: > 0 }) + { +
+ @foreach (var regel in Gegevens.Regels) + { +
+ @Teken(regel.Soort) +
+
@regel.Tekst
+ @if (regel.Datum is { } datum) + { +
@Datums.MetDag(datum)
+ } +
+
+ } +
+ + @if (Gegevens.Regels.Any(r => r.Soort == "overlijden")) + { + + } + } + + @* De advertentie staat hier en niet bovenaan: "wat er speelt" gaat voor. Iemand die net + leest dat er een overlijden op zijn naam is gemeld, hoort daar geen reclame boven te + zien. Zie docs/Adverteren.md. *@ + @if (Reclame is not null) + { + + @if (Reclame.HeeftAfbeelding) + { + + } + + Advertentie + @Reclame.Tekst + + + } + +
+ + Mijn kluis @(Gegevens?.AantalKluis ?? 0) + + + + Mijn mensen @(Gegevens?.AantalFamilie ?? 0) + + + @* Twee richtingen, dus twee aantallen; die staan achter de titel in hetzelfde + rondje als de andere tegels, met hun naam erbij zodat je niet hoeft te raden + welke welke is. Zo past de tegel op één regel, even hoog als de andere drie. *@ + + + Momenten + + ↓@(Gegevens?.AantalVoorMij ?? 0) · ↑@(Gegevens?.AantalVanMij ?? 0) + + @if (Gegevens is { AantalNieuweBerichten: > 0 }) + { + @Gegevens.AantalNieuweBerichten + } + + + + + Nalatenschap @(Gegevens?.AantalBeheerders ?? 0) + +
+ + @if (Gegevens is not null) + { +
+ Hoe ver vooruit wil je kijken? +

+ Hierboven zie je wat er de komende tijd voor je klaarstaat. Zet het op 0 als je + liever verrast wordt. +

+
+ +
+
+ } + + @* Dezelfde uitleg als in de app en op de pagina /watismijnbesluit, maar hier als popup: + je hoeft dan niet weg van je overzicht en weer terug. + + De privacyverklaring staat er om dezelfde reden bij, en op dezelfde plek als in de app. + Er is ook een pagina /privacy - die is er voor wie hem los wil kunnen delen en voor + Google Play, dat een adres wil dat je kunt aanklikken. Maar wie hier zit, hoort hem te + kunnen lezen zonder zijn overzicht kwijt te raken. De tekst zelf staat in + Privacytekst.razor, hetzelfde onderdeel dat /privacy en de app gebruiken. *@ +

+ +
+ +
+ +

+ + @if (Uitleg) + { + + + + } + + @if (Privacy) + { + + + + } + + @* Zelfde tekst als in de app: Versiehistorie.razor staat twee keer op schijf en een + zelfcontrole vergelijkt ze letterlijk. *@ + @if (VersiehistorieOpen) + { + + + + } + +
+

Waar is MijnBesluit voor?

+

+ Voor alles wat je zou willen doorgeven maar wat nu alleen in jouw hoofd zit. +

+
    +
  • + Je kluis — wachtwoorden, de pincode van je telefoon, + polissen, documenten, je uitvaartwensen. Alles versleuteld, en jij bepaalt per + onderdeel of het alleen voor jou is, pas na je overlijden vrijkomt, of op een + datum die je zelf kiest. +
  • +
  • + Je mensen — familie en vrienden die je toevoegt en + uitnodigt. Zolang iemand de uitnodiging niet heeft aangenomen, beheer jij dat + profiel; daarna is het van hem of haar. +
  • +
  • + Momenten — een tekst- of videobericht dat je klaarzet + voor iemand. Te bezorgen op een datum die jij kiest, of pas na je overlijden. + Een verjaardag die je niet meer meemaakt, of gewoon iets wat je nog wilt zeggen. +
  • +
  • + Je nalatenschap — wie er straks mag regelen wat er + geregeld moet worden. Die persoon krijgt pas toegang als je overlijden is + bevestigd door meerdere mensen én jij er zelf niet op hebt gereageerd. +
  • +
+
+
+
+ +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId, _persoonId; + private Overzicht? Gegevens; + private int Dagen = 30; + + /// Profielen die anderen op mijn adres hebben aangemaakt; alleen ik kan zeggen of ik dat ben. + private IReadOnlyList Aantekeningen = + Array.Empty(); + + /// Staat de uitleg open? + private bool Uitleg; + + /// Staat de privacyverklaring open? + private bool Privacy; + private bool VersiehistorieOpen; + + /// De advertentie voor deze keer, of null als er geen is. + private Campagne? Reclame; + private bool Bezig; + private string? Fout, Melding; + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + var user = (await AuthState).User; + _gebruikerId = user.UserId() ?? 0; + _persoonId = user.PersonId() ?? 0; + + await Laden(); + } + + private async Task Laden() + { + if (_gebruikerId == 0) return; + + var uitkomst = await Overzichten.VoorAsync(_gebruikerId); + if (!uitkomst.Succeeded) { Fout = uitkomst.Error; return; } + + Fout = null; + Gegevens = uitkomst.Value; + Dagen = Gegevens!.VooruitblikDagen; + + // De keuze en het tellen horen bij elkaar: wat hier wordt uitgekozen komt ook echt in + // beeld. Eén advertentie per keer dat het overzicht wordt geopend; zie docs/Adverteren.md. + // + // Gaat er hier iets mis, dan hoort het overzicht er gewoon te staan. Reclame is het + // minst belangrijke op dit scherm. + try + { + Reclame = await Adverteren.KiesAsync(); + if (Reclame is not null) await Adverteren.TelVertoningAsync(Reclame.Id); + } + catch (Exception) + { + Reclame = null; + } + + // Heeft iemand een profiel voor mij aangemaakt? Apart opgehaald, en een fout hier hoort + // het overzicht niet weg te houden: dit is een vraag die best een keer later mag komen. + try { Aantekeningen = await Accounts.AantekeningenOverMijAsync(_gebruikerId); } + catch (Exception) { Aantekeningen = Array.Empty(); } + } + + /// "Ja, dat ben ik." Daarna staat alles wat die ander over je had bij jou. + private async Task Overnemen(long persoonId) + { + Bezig = true; + try + { + var uitkomst = await Accounts.NeemAantekeningOverAsync(persoonId, _gebruikerId, Samenvoegen); + Fout = uitkomst.Succeeded ? null : uitkomst.Error; + Melding = uitkomst.Succeeded ? "Klaar. Het profiel staat nu bij jou." : null; + if (uitkomst.Succeeded) await Laden(); + } + finally { Bezig = false; } + } + + /// "Nee, dat ben ik niet." De vraag komt niet terug. + private async Task Afwijzen(long persoonId) + { + Bezig = true; + try + { + var uitkomst = await Accounts.WijsAantekeningAfAsync(persoonId, _gebruikerId); + Fout = uitkomst.Succeeded ? null : uitkomst.Error; + if (uitkomst.Succeeded) + Aantekeningen = Aantekeningen.Where(a => a.PersoonId != persoonId).ToList(); + } + finally { Bezig = false; } + } + + private async Task VooruitblikOpslaan() + { + Bezig = true; + try + { + var uitkomst = await Overzichten.VooruitblikAsync(_gebruikerId, Dagen); + if (!uitkomst.Succeeded) { Fout = uitkomst.Error; return; } + await Laden(); + } + finally { Bezig = false; } + } + + private async Task IkBenErNog() + { + Bezig = true; + try + { + var uitkomst = await NalatenschapService.ObjectAsync(_persoonId, _gebruikerId); + Melding = uitkomst.Succeeded ? "Genoteerd. De melding is ingetrokken." : null; + Fout = uitkomst.Succeeded ? null : uitkomst.Error; + if (uitkomst.Succeeded) await Laden(); + } + finally { Bezig = false; } + } + + private static string Teken(string soort) => soort switch + { + "beheerder" => "\U0001F33F", // blad: iemand beheert jouw nalatenschap + "beheer-voor" => "\U0001F91D", // handdruk: jij beheert die van een ander + "moment" => "✉", // envelop + "overlijden" => "⚠", // waarschuwing + _ => "•" + }; + + /// + /// "Dag Jonathan" als we de naam kennen, anders alleen "Welkom". Nooit het e-mailadres: + /// dat is geen naam, en het staat raar boven een scherm over je nalatenschap. + /// + private static string Begroeting(string naam) + { + var voornaam = naam.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + return string.IsNullOrWhiteSpace(voornaam) ? "Welkom" : $"Dag {voornaam}"; + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Inloggen.razor b/src/MijnBesluit.Web/Components/Pages/Inloggen.razor new file mode 100644 index 0000000..307f056 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Inloggen.razor @@ -0,0 +1,216 @@ +@page "/inloggen" +@using System.ComponentModel.DataAnnotations +@using System.Security.Claims +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.Cookies +@inject AccountService Accounts +@inject IHttpContextAccessor HttpContexten +@inject NavigationManager Navigatie + +@* Deze pagina wordt statisch weergegeven: alleen zo kan er bij het inloggen een cookie worden + gezet. Het gaat in twee stappen, net als in de WinDev-versie: eerst het e-mailadres, dan de + code die per e-mail binnenkomt. + + Let op bij het aanpassen: alle drie de formulieren worden ALTIJD gerenderd, en alleen + verborgen met het hidden-attribuut. Bij een statische pagina is elke POST een nieuwe + pagina-instantie; een formulier dat op dat moment niet gerenderd wordt, bestaat voor de + server niet en levert "Cannot submit the form ... because no form on the page currently has + that name" op. *@ + +Inloggen - MijnBesluit + +@* "inlog-scherm": een merkteken voor app.css, zodat de kopbalk op een telefoon zijn logo kan + laten vallen. Bij het inloggen mist er nog een cookie met de naam van de gebruiker; het + logo is dan het enige dat de balk vult, en op een telefoon duwt dat het inlogblok een heel + stuk lager dan nodig. Op een groter scherm is er ruimte genoeg en blijft het logo gewoon + staan - zie de media-query bij ".inlog-scherm" in app.css. *@ +
+

Log in

+ + @if (Fout is not null) {
@Fout
} + @if (Gelukt is not null) {
@Gelukt
} + + + + + +

Wat is MijnBesluit?

+

Ik ben nieuw en wil een account aanmaken

+

Ik wil adverteren op MijnBesluit

+
+ +@code { + [SupplyParameterFromForm(FormName = "code-aanvragen")] private AanvraagGegevens Aanvraag { get; set; } = new(); + [SupplyParameterFromForm(FormName = "inloggen")] private InlogGegevens Inlog { get; set; } = new(); + [SupplyParameterFromForm(FormName = "nieuwe-code")] private AanvraagGegevens Opnieuw { get; set; } = new(); + [SupplyParameterFromQuery(Name = "terug")] private string? Terug { get; set; } + + private bool _codeVerstuurd; + private string? AfgeschermdAdres; + private string? Fout, Gelukt; + + /// + /// Staat er al een e-mailadres in een van de formulieren van stap twee, dan is die stap + /// eerder bereikt en horen we het codeveld te tonen — ook bij de eerste render van een POST, + /// vóórdat de afhandeling heeft gedraaid. + /// + private bool ToonCodeveld => + _codeVerstuurd || !string.IsNullOrWhiteSpace(Inlog.Email) || !string.IsNullOrWhiteSpace(Opnieuw.Email); + + protected override void OnParametersSet() + { + if (AfgeschermdAdres is null && ToonCodeveld) + { + var adres = !string.IsNullOrWhiteSpace(Inlog.Email) ? Inlog.Email : Opnieuw.Email; + AfgeschermdAdres = Afschermen(adres); + } + } + + private Task CodeAanvragen() => StuurCodeAsync(Aanvraag.Email); + + private Task OpnieuwAanvragen() => StuurCodeAsync(Opnieuw.Email); + + private async Task StuurCodeAsync(string email) + { + var context = HttpContexten.HttpContext!; + var resultaat = await Accounts.VraagInlogcodeAanAsync( + email, context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted); + + if (!resultaat.Succeeded) + { + Fout = resultaat.Error; + Gelukt = null; + return; + } + + _codeVerstuurd = true; + AfgeschermdAdres = resultaat.Value!.AfgeschermdAdres; + Inlog = new InlogGegevens { Email = resultaat.Value.Email }; + Opnieuw = new AanvraagGegevens { Email = resultaat.Value.Email }; + Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in."; + Fout = null; + } + + private async Task Aanmelden() + { + var context = HttpContexten.HttpContext!; + var resultaat = await Accounts.LogInMetCodeAsync(Inlog.Email, Inlog.Code, context.RequestAborted); + + if (!resultaat.Succeeded) + { + // Het codeveld blijft staan, met de melding erboven. + _codeVerstuurd = true; + Opnieuw = new AanvraagGegevens { Email = Inlog.Email }; + AfgeschermdAdres = Afschermen(Inlog.Email); + Fout = resultaat.Error; + Gelukt = null; + return; + } + + await MeldAanAsync(context, resultaat.Value!); + Navigatie.NavigateTo(VeiligeBestemming(Terug), forceLoad: true); + } + + /// Zet het inlogcookie met wie er is ingelogd. + internal static async Task MeldAanAsync(HttpContext context, User gebruiker) + { + // DisplayName is een lege tekst als er geen naam is ingevuld, niet null - de oude + // terugval met ?? greep daar dus niet op en liet het e-mailadres staan. Een adres is + // geen naam; staat er niets, dan laat het scherm de begroeting gewoon weg. + var naam = gebruiker.Person?.DisplayName; + if (string.IsNullOrWhiteSpace(naam)) naam = ""; + + var claims = new List + { + new(ClaimTypes.NameIdentifier, gebruiker.Id.ToString()), + new(ClaimTypes.Email, gebruiker.Email), + new(ClaimTypes.Name, naam), + new(HuidigeGebruiker.PersonIdClaim, gebruiker.PersonId.ToString()), + new("stamp", gebruiker.SecurityStamp.ToString()), + // Zonder deze claim toont MainLayout het menu-item "Beheer" nooit, ook niet voor + // wie in de database wel IsBeheerder = true heeft staan - dat is precies wat hij + // moet aansturen, zie HuidigeGebruiker.BeheerClaim. + new(HuidigeGebruiker.BeheerClaim, gebruiker.IsBeheerder ? "ja" : "nee") + }; + + await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme))); + } + + /// Accepteert alleen paden binnen de site, zodat een link niet naar buiten kan doorsturen. + private static string VeiligeBestemming(string? terug) => + string.IsNullOrWhiteSpace(terug) || terug.Contains("//") || terug.StartsWith('/') ? "/" : "/" + terug; + + private static string Afschermen(string email) => new CodeVerstuurd(email, DateTime.UtcNow).AfgeschermdAdres; + + public class AanvraagGegevens + { + [Required(ErrorMessage = "Je moet een e-mailadres invullen!")] + [EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")] + public string Email { get; set; } = ""; + } + + public class InlogGegevens + { + [Required] public string Email { get; set; } = ""; + + [Required(ErrorMessage = "Je moet een code invullen!")] + public string Code { get; set; } = ""; + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Kluis.razor b/src/MijnBesluit.Web/Components/Pages/Kluis.razor new file mode 100644 index 0000000..ba247ea --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Kluis.razor @@ -0,0 +1,665 @@ +@page "/kluis" +@attribute [Authorize] +@inject VaultService KluisService +@inject LegacyService BeheerService +@inject IJSRuntime JS +@rendermode InteractiveServer + +Mijn kluis - MijnBesluit + +

Mijn kluis

+

+ Wat je hier vastlegt staat versleuteld opgeslagen. Per item bepaal je zelf of het privé blijft, + of dat het vrijkomt na je overlijden of op een datum die jij kiest. +

+ +@if (Melding is not null) {
@Melding
} + +
+
+
+ + +
+ @* Geen "Sluiten" meer: het formulier staat nu in een popup en die heeft zijn eigen + Annuleren. Deze knop begint altijd met een leeg item. *@ + +
+
+ +@if (ToonFormulier) +{ + @* De EditForm staat om de popup heen en niet erin: de invoervelden hebben zijn context + nodig, en de knoppen zitten in de popup zelf. Enter in een veld slaat nog steeds op. *@ + + +
+ + + @foreach (var c in Categorieen) { } + +
+
+ + +
De titel is niet versleuteld, zodat je kunt zoeken. Zet er dus niets geheims in.
+
+ + @switch (Nieuw.Categorie) + { + case VaultCategory.Wachtwoord: +
+
+
+
+
+ break; + + case VaultCategory.SocialMedia: +
+
+ + + + @foreach (var naam in SocialeMedia.Namen) + { + + } + +
+
+
+ @if (Nieuw.Platform == SocialeMedia.Anders) + { +
+ } +
+
+ + +
De codes die je krijgt bij tweestapsverificatie. Zonder die codes komt niemand er nog in.
+
+ break; + + case VaultCategory.Apparaat: +
+
+
+ break; + + case VaultCategory.Pincode: +
+
+ break; + + case VaultCategory.Polis: +
+
+
+
+
+ break; + + case VaultCategory.Uitvaartwens: +
+
+ + + + + + + + +
+
+
+
+
+
+ break; + + case VaultCategory.Donorcodicil: + break; + } + + @if (Nieuw.Categorie is VaultCategory.Uitvaartwens or VaultCategory.Donorcodicil) + { +
+ + + + @if (Beheerders is not null) + { + @foreach (var b in Beheerders) + { + + } + } + +

+ Kies je hier iemand, dan kan diegene dit item meteen lezen en met je bespreken - + je hoeft niet te wachten tot na je overlijden. Kies je niemand, dan blijft dit item + zoals altijd alleen voor jezelf. +

+
+ } + +
+ + +
+ + @if (BewerkId == 0) + { +
+ +
+ Die kun je toevoegen zodra dit is opgeslagen; ze horen bij het item en + dat moet er eerst zijn. +
+
+ } + else + { + + } + +
+ +
+ + +
+
+ + +
+
+ + +
+ @if (Nieuw.Vrijgave == ReleasePolicy.OpDatum) + { + + } +
+ +
+
+} + +@if (Items is null) +{ +

Bezig met laden…

+} +else if (Items.Count == 0) +{ +
+ 🔒 + Je kluis is nog leeg. Begin bijvoorbeeld met je uitvaartwensen. +
+} +else +{ +
+ @foreach (var item in Items) + { +
+
+
+
@item.Title
+
@Naam(item.Category) · @Vrijgave(item)
+ @if (item.SharedManagerPersonId is { } gedeeldMet) + { +
💬 Wordt besproken met @BeheerderNaam(gedeeldMet)
+ } +
+ +
+ + @if (Weghalen == item.Id) + { +
+ @item.Title verwijderen? Wat erin staat is daarna weg. +
+ + +
+
+ } + else + { +
+ + +
+ } + + @if (Geopend == item.Id && Inhoud is not null) + { +
+
+ @foreach (var (label, waarde) in Velden(Inhoud)) + { +
+ @label + @waarde +
+ } +
+ +
+

Bijlagen

+ + @if (Bijlagen is { Count: > 0 }) + { +
+ @foreach (var bijlage in Bijlagen) + { +
+
+ @bijlage.FileName +
@Grootte(bijlage.SizeBytes) · @Datums.Datum(bijlage.CreatedAt)
+
+ +
+ + @if (WeghalenBijlage == bijlage.Id) + { +
+ @bijlage.FileName verwijderen? +
+ + +
+
+ } + + @if (bijlage.ContentType.StartsWith("image/")) + { + @bijlage.FileName + } + else if (bijlage.ContentType.StartsWith("video/")) + { + + } + } +
+ } + else + { +

Nog geen bijlagen. Denk aan een foto van je polis, of een scan van je testament.

+ } + +
+ + +
+ Maximaal @(VaultService.MaxBestandsgrootte / 1024 / 1024) MB per bestand. Alles wordt + versleuteld opgeslagen, net als de rest van je kluis. +
+
+ + @if (Uploaden) + { +

Bezig met opslaan…

+ } + } +
+ } +
+} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId, _persoonId; + private IReadOnlyList? Items; + private VaultPayload? Inhoud; + private long? Geopend; + private bool ToonFormulier; + private string Filter = ""; + private string? Melding, MeldingSoort; + private NieuwItem Nieuw = new(); + private IReadOnlyList? Beheerders; + + /// Het item dat bewerkt wordt; 0 betekent een nieuw item. + private long BewerkId; + + /// Het item dat op verwijderen staat te wachten. + private long? Weghalen; + + /// Welke bijlage bij het bekeken item op een "weet je het zeker?" staat te wachten. + private long? WeghalenBijlage; + + /// De bijlagen bij het item dat openstaat of dat in het wijzigscherm staat. + private IReadOnlyList? Bijlagen; + private bool Uploaden; + + /// Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens. + private const int MaxTegelijk = 10; + + private static readonly VaultCategory[] Categorieen = + { + VaultCategory.Uitvaartwens, VaultCategory.Donorcodicil, VaultCategory.Polis, VaultCategory.Wachtwoord, + VaultCategory.SocialMedia, VaultCategory.Apparaat, VaultCategory.Pincode, + VaultCategory.Rijbewijs, VaultCategory.Paspoort, + VaultCategory.Document, VaultCategory.Notitie, VaultCategory.Overig + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (ToonFormulier) + { + await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "muziek"); + await JS.InvokeVoidAsync("mijnbesluit.laatMeegroeien", "wensen"); + } + } + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + var user = (await AuthState).User; + _gebruikerId = user.UserId() ?? 0; + _persoonId = user.PersonId() ?? 0; + Beheerders = await BeheerService.ListManagersAsync(_persoonId); + await Verversen(); + } + + private async Task Verversen() + { + VaultCategory? categorie = int.TryParse(Filter, out var c) ? (VaultCategory)c : null; + Items = await KluisService.ListAsync(_persoonId, categorie); + Geopend = null; + Inhoud = null; + } + + private async Task Openen(long id) + { + if (Geopend == id) { Geopend = null; Inhoud = null; Bijlagen = null; return; } + + var resultaat = await KluisService.OpenAsync(id, _gebruikerId); + if (resultaat.Succeeded) + { + Geopend = id; + Inhoud = resultaat.Value; + Melding = null; + await BijlagenLaden(); + } + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + /// + /// Bij welk item een bijlage hoort: het item dat in het wijzigscherm staat, of het item dat + /// je hebt opengeklapt. Zo werken de knoppen op allebei de plekken. + /// + private long? BijlageDoel => BewerkId != 0 ? BewerkId : Geopend; + + private async Task BijlagenLaden() + { + if (BijlageDoel is not { } id) { Bijlagen = null; return; } + var resultaat = await KluisService.ListFilesAsync(id, _gebruikerId); + Bijlagen = resultaat.Succeeded ? resultaat.Value : Array.Empty(); + } + + private async Task BijlageKiezen(InputFileChangeEventArgs e) + { + if (BijlageDoel is not { } itemId) return; + + Uploaden = true; + Melding = null; + try + { + foreach (var bestand in e.GetMultipleFiles(MaxTegelijk)) + { + if (bestand.Size > VaultService.MaxBestandsgrootte) + { + Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB."; + MeldingSoort = "fout"; + continue; + } + + using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte); + using var buffer = new MemoryStream(); + await stroom.CopyToAsync(buffer); + + var resultaat = await KluisService.StoreFileAsync(_gebruikerId, bestand.Name, + string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType, + buffer.ToArray(), itemId); + + if (resultaat.Succeeded) { Melding = $"{bestand.Name} is toegevoegd."; MeldingSoort = "goed"; } + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + await BijlagenLaden(); + } + catch (Exception ex) + { + Melding = $"Het bestand kon niet worden gelezen: {ex.Message}"; + MeldingSoort = "fout"; + } + finally + { + Uploaden = false; + } + } + + private async Task BijlageWeg(long bestandId) + { + var resultaat = await KluisService.DeleteFileAsync(bestandId, _gebruikerId); + Melding = resultaat.Succeeded ? "De bijlage is verwijderd." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + await BijlagenLaden(); + } + + private async Task BijlageWegBevestigd(long bestandId) + { + WeghalenBijlage = null; + await BijlageWeg(bestandId); + } + + private static string Grootte(long bytes) => bytes >= 1024 * 1024 + ? $"{bytes / 1024d / 1024d:0.#} MB" + : $"{Math.Max(1, bytes / 1024)} kB"; + + private async Task Wijzigen(VaultItem item) + { + // De inhoud staat versleuteld; die halen we op om het formulier te vullen. + var resultaat = await KluisService.OpenAsync(item.Id, _gebruikerId); + if (!resultaat.Succeeded) + { + Melding = resultaat.Error; + MeldingSoort = "fout"; + return; + } + + var p = resultaat.Value!; + Nieuw = new NieuwItem + { + Categorie = item.Category, + Titel = item.Title, + Notitie = p.Notes ?? "", + Website = p.Website ?? "", + Gebruikersnaam = p.Username ?? "", + Geheim = p.Secret ?? "", + Herstelcodes = p.RecoveryCodes ?? "", + Platform = p.Platform ?? "", + PlatformAnders = p.PlatformAnders ?? "", + Pincode = p.Pincode ?? "", + Apparaat = p.DeviceName ?? "", + Verzekeraar = p.Insurer ?? "", + Polisnummer = p.PolicyNumber ?? "", + Telefoon = p.ContactPhone ?? "", + BegravenOfCremeren = p.BurialOrCremation ?? "", + Locatie = p.Location ?? "", + Muziek = p.Music ?? "", + Sprekers = p.Speakers ?? "", + Wensen = p.Wishes ?? "", + BesprekenMetPersonId = item.SharedManagerPersonId, + Vrijgave = item.ReleasePolicy, + VrijgaveDatum = item.ReleaseAt is { } r ? DateOnly.FromDateTime(r) : null + }; + + BewerkId = item.Id; + ToonFormulier = true; + Weghalen = null; + Geopend = null; + Inhoud = null; + Melding = null; + + await BijlagenLaden(); + } + + /// Opent het formulier met een leeg item, ook als er net iets anders openstond. + private void Beginnen() + { + BewerkId = 0; + Nieuw = new NieuwItem(); + Bijlagen = null; + ToonFormulier = true; + } + + private void Annuleren() + { + BewerkId = 0; + Nieuw = new NieuwItem(); + Bijlagen = null; + ToonFormulier = false; + } + + private async Task Verwijderen(long id) + { + var resultaat = await KluisService.DeleteAsync(id, _gebruikerId); + Melding = resultaat.Succeeded ? "Het item is verwijderd." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + Weghalen = null; + if (resultaat.Succeeded) await Verversen(); + } + + private async Task Opslaan() + { + var payload = new VaultPayload + { + Notes = Leeg(Nieuw.Notitie), + Website = Leeg(Nieuw.Website), + Username = Leeg(Nieuw.Gebruikersnaam), + Secret = Leeg(Nieuw.Geheim), + RecoveryCodes = Leeg(Nieuw.Herstelcodes), + Platform = Leeg(Nieuw.Platform), + PlatformAnders = Leeg(Nieuw.PlatformAnders), + Pincode = Leeg(Nieuw.Pincode), + DeviceName = Leeg(Nieuw.Apparaat), + Insurer = Leeg(Nieuw.Verzekeraar), + PolicyNumber = Leeg(Nieuw.Polisnummer), + ContactPhone = Leeg(Nieuw.Telefoon), + BurialOrCremation = Leeg(Nieuw.BegravenOfCremeren), + Location = Leeg(Nieuw.Locatie), + Music = Leeg(Nieuw.Muziek), + Speakers = Leeg(Nieuw.Sprekers), + Wishes = Leeg(Nieuw.Wensen) + }; + + DateTime? datum = Nieuw.Vrijgave == ReleasePolicy.OpDatum && Nieuw.VrijgaveDatum is { } d + ? d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) : null; + + // Nieuw geeft het item terug, wijzigen alleen goed of fout; hier maken we er één uitkomst van. + Result resultaat; + if (BewerkId == 0) + { + var gemaakt = await KluisService.CreateAsync(_gebruikerId, Nieuw.Categorie, Nieuw.Titel, payload, + Nieuw.Vrijgave, datum, Nieuw.BesprekenMetPersonId); + resultaat = gemaakt.Succeeded ? Result.Ok() : Result.Fail(gemaakt.Error!); + } + else + { + resultaat = await KluisService.UpdateAsync(BewerkId, _gebruikerId, Nieuw.Categorie, Nieuw.Titel, + payload, Nieuw.Vrijgave, datum, Nieuw.BesprekenMetPersonId); + } + + if (resultaat.Succeeded) + { + Melding = BewerkId == 0 ? "Opgeslagen in je kluis." : "De wijziging is opgeslagen."; + MeldingSoort = "goed"; + BewerkId = 0; + Nieuw = new NieuwItem(); + Bijlagen = null; + ToonFormulier = false; + await Verversen(); + } + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + private static string? Leeg(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + private string BeheerderNaam(long personId) => + Beheerders?.FirstOrDefault(b => b.PersonId == personId)?.DisplayName ?? "een beheerder"; + + // Velden() en Naam() staan in Onderdelen/Kluisweergave.cs: de nalatenschapspagina toont + // precies hetzelfde, en twee kopieën van die lijst lopen vroeg of laat uit elkaar. + private static IEnumerable<(string, string)> Velden(VaultPayload p) => Kluisweergave.Velden(p); + private static string Naam(VaultCategory c) => Kluisweergave.Naam(c); + + private static string Voorbeeld(VaultCategory c) => c switch + { + VaultCategory.Uitvaartwens => "Mijn uitvaart", + VaultCategory.Donorcodicil => "Mijn donorcodicil", + VaultCategory.Polis => "Uitvaartverzekering", + VaultCategory.Wachtwoord => "Inloggen bij de bank", + VaultCategory.Apparaat => "Laptop", + VaultCategory.Pincode => "Telefoon", + VaultCategory.SocialMedia => "Mijn Facebook", + _ => "" + }; + + private static string Vrijgave(VaultItem item) => item.ReleasePolicy switch + { + ReleasePolicy.NaOverlijden => "vrij na mijn overlijden", + ReleasePolicy.OpDatum => $"vrij op {Datums.Datum(item.ReleaseAt)}", + _ => "alleen voor mij" + }; + + public class NieuwItem + { + public VaultCategory Categorie { get; set; } = VaultCategory.Uitvaartwens; + public string Titel { get; set; } = ""; + public string? Notitie { get; set; } + public string? Website { get; set; } + public string? Gebruikersnaam { get; set; } + public string? Geheim { get; set; } + public string? Herstelcodes { get; set; } + public string? Platform { get; set; } + public string? PlatformAnders { get; set; } + public string? Pincode { get; set; } + public string? Apparaat { get; set; } + public string? Verzekeraar { get; set; } + public string? Polisnummer { get; set; } + public string? Telefoon { get; set; } + public string? BegravenOfCremeren { get; set; } + public string? Locatie { get; set; } + public string? Muziek { get; set; } + public string? Sprekers { get; set; } + public string? Wensen { get; set; } + public long? BesprekenMetPersonId { get; set; } + public ReleasePolicy Vrijgave { get; set; } = ReleasePolicy.NaOverlijden; + public DateOnly? VrijgaveDatum { get; set; } + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/LeefNog.razor b/src/MijnBesluit.Web/Components/Pages/LeefNog.razor new file mode 100644 index 0000000..ffeb874 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/LeefNog.razor @@ -0,0 +1,78 @@ +@page "/leefnog/{Token}" +@inject LegacyService Nalatenschap +@inject NavigationManager Navigatie + +Ik ben er nog - MijnBesluit + +@* Bezwaar maken tegen een overlijdensmelding, zonder in te loggen. + + Dat "zonder inloggen" is geen gemak maar noodzaak. Zodra een overlijden is bevestigd staat het + account op "overleden" en kun je er niet meer in. Wie dan ten onrechte dood is verklaard, zou + zonder deze pagina vastzitten - en juist diegene heeft het het hardst nodig. + + De link uit de e-mail is het bewijs, net als bij een uitnodiging. Hij komt alleen aan op het + adres van de betrokkene zelf. *@ + +
+ @if (Naam is not null) + { +

Fijn dat je er nog bent

+
+

+ We hebben genoteerd dat @Naam gewoon in leven is. Alle meldingen zijn ingetrokken + en niemand heeft toegang gekregen tot wat er in de kluis staat. +

+

+ Is er eerder al toegang verleend, dan is die weer ingetrokken. Je kunt gewoon + weer inloggen. +

+
+

Naar het inloggen

+ } + else + { +

Ben je er nog?

+

+ Er is bij MijnBesluit gemeld dat je bent overleden. Klopt dat niet, laat het dan + hieronder weten. Alle meldingen vervallen dan, en als er al iemand toegang had + gekregen tot je kluis, wordt die weer ingetrokken. +

+ + @if (Fout is not null) {
@Fout
} + + +
+

+ Je hoeft hier niet voor in te loggen. Deze link uit je e-mail is genoeg. +

+ +
+
+ } +
+ +@code { + [Parameter] public string Token { get; set; } = ""; + + /// + /// Het model van het formulier. Bewust GEEN [SupplyParameterFromForm]: er zijn geen + /// invoervelden, er valt niets te binden, en met dat kenmerk erop zonder FormName zet de + /// binder het model op null. Zie Uitnodiging.razor, waar dat een harde fout opleverde. + /// + private LeegFormulier Invoer { get; } = new(); + + /// De naam van wie er nog is; null zolang er nog niets is bevestigd. + private string? Naam; + private string? Fout; + + private async Task Bezwaar() + { + var resultaat = await Nalatenschap.BezwaarMetTokenAsync(Token); + + if (resultaat.Succeeded) Naam = resultaat.Value; + else Fout = resultaat.Error; + } + + /// De link is zelf het bewijs; er hoeft niets ingevuld te worden. + public class LeegFormulier { } +} diff --git a/src/MijnBesluit.Web/Components/Pages/MomentBekijken.razor b/src/MijnBesluit.Web/Components/Pages/MomentBekijken.razor new file mode 100644 index 0000000..04be834 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/MomentBekijken.razor @@ -0,0 +1,88 @@ +@page "/moment/{Token}" +@using MijnBesluit.Application.Services +@inject MessageService Berichten + +@* De landingspagina achter een kijklink: waar iemand zonder MijnBesluit-account een voor hem + klaargezet bericht kan bekijken, foto's/video's/documenten inbegrepen, zonder in te loggen. + Bewust GEEN [Authorize] en GEEN @rendermode InteractiveServer - dit is een gewone, statisch + gerenderde pagina die alleen leest, precies zoals Uitnodiging.razor voor een uitnodigingslink. + + De link zelf is het bewijs: wie hem heeft, mag zien wat erachter zit. Zie + MessageService.ViewByTokenAsync voor hoe dat versleuteld blijft tot dit moment. *@ + +Een bericht voor jou - MijnBesluit + +
+ @if (Fout is not null) + { +

Deze link werkt niet (meer)

+
@Fout
+

+ Vraag degene die het bericht klaarzette om het opnieuw te versturen. Heb je zelf al + een MijnBesluit, log dan in - daar staan al je berichten gewoon bij elkaar. +

+ } + else if (Weergave is not null) + { +

@Weergave.Subject

+

Van @Weergave.AfzenderNaam.

+ +
+

@Weergave.Text

+ + @if (Weergave.VaultFileId is not null) + { +
+ @if (IsAfbeelding(Weergave.BestandsType)) + { + + @Weergave.BestandsNaam + + } + else if (IsVideo(Weergave.BestandsType)) + { + + } + else + { + 📄 + } + +
+ } +
+ + @if (Weergave.KanNogClaimen) + { +

+ Wil je zelf ook een MijnBesluit beginnen - bijvoorbeeld om je eigen berichten aan + anderen klaar te zetten? Vraag degene die je toevoegde om een uitnodiging, of + kijk eerst rond op de website. +

+ } + +

Deze link blijft nog 30 dagen werken vanaf het versturen.

+ } +
+ +@code { + [Parameter] public string Token { get; set; } = ""; + + private MessageService.MomentWeergave? Weergave; + private string? Fout; + + protected override async Task OnInitializedAsync() + { + var resultaat = await Berichten.ViewByTokenAsync(Token); + if (resultaat.Succeeded) Weergave = resultaat.Value; + else Fout = resultaat.Error; + } + + private static bool IsAfbeelding(string? contentType) => + contentType is not null && contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + + private static bool IsVideo(string? contentType) => + contentType is not null && contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/MijnBesluit.Web/Components/Pages/Momenten.razor b/src/MijnBesluit.Web/Components/Pages/Momenten.razor new file mode 100644 index 0000000..1c823ea --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Momenten.razor @@ -0,0 +1,526 @@ +@page "/momenten" +@attribute [Authorize] +@using MijnBesluit.Application.Abstractions +@inject MessageService Berichten +@inject FamilyService Families +@inject IFamilyRepository FamilieOpslag +@inject VaultService Kluis +@rendermode InteractiveServer + +Momenten - MijnBesluit + +

Momenten

+

+ Berichten die voor jou klaarstaan, en berichten die jij hebt klaargezet voor een ander — + nu, op een datum, of pas als je er niet meer bent. +

+ +@if (Melding is not null) {
@Melding
} + +
+
+ + +
+ @* Zie Kluis.razor: de popup heeft zijn eigen Annuleren, dus hier geen "Sluiten". *@ + +
+
+ +@if (ToonFormulier) +{ + @* Zie Kluis.razor: de EditForm staat om de popup heen omdat de invoervelden zijn context + nodig hebben, terwijl de knoppen onderin de popup horen. *@ + + +
+ + + + @if (Leden is not null) + { + @foreach (var lid in Leden.Where(l => l.PersonId != _persoonId)) + { + + } + } + +
+
+ + +
+
+ + +
De tekst wordt versleuteld opgeslagen en is pas leesbaar zodra het bericht bezorgd is.
+
+ + @if (BewerkId == 0) + { +
+ +
+ Die kun je toevoegen zodra het bericht klaarstaat; ze horen bij het + bericht en dat moet er eerst zijn. +
+
+ } + else + { + + } +
+ +
+ + +
+
+ + +
+
+ + +
+ @if (Nieuw.Wanneer == "datum") + { + + } +
+
+
+} + +@if (Tab == "in") +{ + @if (Inkomend is null) {

Bezig met laden…

} + else if (Inkomend.Count == 0) + { +
🎬 Er staan nog geen berichten voor je klaar.
+ } + else + { +
+ @foreach (var bericht in Inkomend) + { +
+
+
+
@bericht.Subject
+
+ Bezorgd @Datums.Datum(bericht.DeliveredAt) + @if (bericht.Status == MessageStatus.Bezorgd) { Nieuw } +
+
+ +
+ + @if (Verwijderen == bericht.Id) + { +
+ Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @NaamVan(bericht.FromPersonId) + houdt het eigen exemplaar gewoon. +
+ + +
+
+ } + else + { +
+ +
+ } +
+ } +
+ } +} + +@if (Tab == "uit") +{ + @if (Uitgaand is null) {

Bezig met laden…

} + else if (Uitgaand.Count == 0) + { +
🎬 Je hebt nog niets klaargezet.
+ } + else + { +
+ @foreach (var bericht in Uitgaand) + { +
+
+
+
@bericht.Subject
+
Voor @NaamVan(bericht.ToPersonId) · @Bezorging(bericht)
+
+ @Stand(bericht.Status) +
+ + @if (bericht.Status is MessageStatus.Bezorgd or MessageStatus.Gelezen) + { + @if (Verwijderen == bericht.Id) + { +
+ Dit bericht verwijderen? Het verdwijnt dan alleen bij jou; @NaamVan(bericht.ToPersonId) + houdt het eigen exemplaar gewoon. +
+ + +
+
+ } + else + { +
+ + +
+ } + } + else if (Weghalen == bericht.Id) + { +
+ Dit bericht terugtrekken? Het wordt dan nooit bezorgd. +
+ + +
+
+ } + else + { +
+ + +
+ } +
+ } +
+ } +} + +@* Het bericht dat openstaat, in een popup - zoals overal elders op de website. Er stond hier + eerder een tekst die onder de tegel zelf uitklapte; dat was de enige plek waar lezen niet in + een popup ging. *@ +@if (Open is { } openId && Inkomend is not null && Inkomend.FirstOrDefault(b => b.Id == openId) is { } openBericht) +{ + +

Bezorgd @Datums.Datum(openBericht.DeliveredAt)

+

@Tekst

+
+} + +@* Je eigen verzonden bericht ter inzage, in dezelfde opmaak als hierboven. *@ +@if (OpenUit is { } openUitId && Uitgaand is not null && Uitgaand.FirstOrDefault(b => b.Id == openUitId) is { } openUitBericht) +{ + +

Voor @NaamVan(openUitBericht.ToPersonId) · @Bezorging(openUitBericht)

+ + @* Alleen zolang het bericht al bezorgd is: daarvoor staat er nog niets om te tonen. *@ + @if (openUitBericht.DeliveredAt is not null) + { +

+ Verzonden: @Datums.Datum(openUitBericht.DeliveredAt)
+ Gelezen: @(openUitBericht.ReadAt is { } gelezenOp ? Datums.Datum(gelezenOp) : "nog niet") +

+ } + +

@TekstUit

+
+} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId, _persoonId, _familieId; + private IReadOnlyList? Inkomend, Uitgaand; + private IReadOnlyList? Leden; + private string Tab = "in"; + private long? Open; + private string? Tekst, Melding, MeldingSoort; + + /// Welk eigen verzonden bericht ter inzage openstaat; los van Open hierboven, dat + /// is voor "Voor mij". + private long? OpenUit; + private string? TekstUit; + private bool ToonFormulier; + private NieuwBericht Nieuw = new(); + + /// Het bericht dat bewerkt wordt; 0 betekent een nieuw bericht. + private long BewerkId; + + /// Het bericht dat op terugtrekken staat te wachten. + private long? Weghalen; + + /// Welke tegel om bevestiging vraagt om te verwijderen - bij "Voor mij" of bij + /// "Van mij", ook als het bericht al bezorgd is (in tegenstelling tot Weghalen hierboven, + /// dat alleen voor terugtrekken vóór bezorging is). + private long? Verwijderen; + + /// De bijlagen bij het bericht dat in het wijzigscherm staat. + private IReadOnlyList Bijlagen = Array.Empty(); + private bool Uploaden; + + /// Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens. + private const int MaxTegelijk = 10; + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + var user = (await AuthState).User; + _gebruikerId = user.UserId() ?? 0; + _persoonId = user.PersonId() ?? 0; + + _familieId = (await FamilieOpslag.ListOwnedByPersonAsync(_persoonId)).FirstOrDefault()?.Id ?? 0; + if (_familieId != 0) Leden = await Families.ListMembersAsync(_familieId, _gebruikerId); + + await Verversen(); + } + + private async Task Verversen() + { + Inkomend = await Berichten.InboxAsync(_persoonId); + Uitgaand = await Berichten.OutboxAsync(_persoonId); + Open = null; Tekst = null; + } + + private async Task Lezen(long id) + { + var resultaat = await Berichten.ReadAsync(id, _gebruikerId); + if (resultaat.Succeeded) { Open = id; Tekst = resultaat.Value; } + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + /// Doet de popup met het gelezen bericht weer dicht. + private void Dichtdoen() + { + Open = null; + Tekst = null; + } + + /// + /// Je eigen verzonden bericht nog eens bekijken, ook als het al bezorgd is. Anders dan + /// Lezen hierboven (voor "Voor mij", dat meteen ook als gelezen markeert): hier verandert + /// er niets aan de status. + /// + private async Task BekijkenUit(long id) + { + var resultaat = await Berichten.ReadSentAsync(id, _gebruikerId); + if (resultaat.Succeeded) { OpenUit = id; TekstUit = resultaat.Value; } + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + /// Doet de popup met het eigen verzonden bericht weer dicht. + private void DichtdoenUit() + { + OpenUit = null; + TekstUit = null; + } + + private async Task Wijzigen(Message bericht) + { + // De tekst staat versleuteld; die halen we op om het formulier te vullen. Let op: + // ConceptAsync en niet ReadAsync - dat laatste is voor de ontvanger van een bezorgd bericht. + var resultaat = await Berichten.ConceptAsync(bericht.Id, _gebruikerId); + if (!resultaat.Succeeded) + { + Melding = resultaat.Error; + MeldingSoort = "fout"; + return; + } + + Nieuw = new NieuwBericht + { + AanPersoonId = bericht.ToPersonId, + Onderwerp = bericht.Subject, + Tekst = resultaat.Value ?? "", + Wanneer = bericht.DeliverAfterDeath ? "overlijden" : bericht.DeliverAt is null ? "nu" : "datum", + Datum = bericht.DeliverAt is { } at ? DateOnly.FromDateTime(at.ToLocalTime()) : null + }; + + BewerkId = bericht.Id; + ToonFormulier = true; + Weghalen = null; + Tab = "uit"; + Melding = null; + + await BijlagenLaden(); + } + + // ------------------------------------------------------------------ foto's en documenten + + private async Task BijlagenLaden() + { + if (BewerkId == 0) { Bijlagen = Array.Empty(); return; } + + var resultaat = await Kluis.ListMessageFilesAsync(BewerkId, _gebruikerId); + Bijlagen = resultaat.Succeeded ? resultaat.Value! : Array.Empty(); + } + + private async Task BijlageKiezen(InputFileChangeEventArgs e) + { + if (BewerkId == 0) return; + + Uploaden = true; + Melding = null; + try + { + foreach (var bestand in e.GetMultipleFiles(MaxTegelijk)) + { + if (bestand.Size > VaultService.MaxBestandsgrootte) + { + Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB."; + MeldingSoort = "fout"; + continue; + } + + using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte); + using var buffer = new MemoryStream(); + await stroom.CopyToAsync(buffer); + + var resultaat = await Kluis.StoreFileAsync( + _gebruikerId, bestand.Name, + string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType, + buffer.ToArray(), messageId: BewerkId); + + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + await BijlagenLaden(); + } + catch (Exception ex) + { + Melding = $"Het bestand kon niet worden gelezen: {ex.Message}"; + MeldingSoort = "fout"; + } + finally { Uploaden = false; } + } + + private async Task BijlageWeg(long bestandId) + { + var resultaat = await Kluis.DeleteFileAsync(bestandId, _gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + await BijlagenLaden(); + } + + /// Opent het formulier met een leeg bericht, ook als er net iets anders openstond. + private void Beginnen() + { + BewerkId = 0; + Nieuw = new NieuwBericht(); + Bijlagen = Array.Empty(); + ToonFormulier = true; + } + + private void Annuleren() + { + BewerkId = 0; + Nieuw = new NieuwBericht(); + Bijlagen = Array.Empty(); + ToonFormulier = false; + } + + private async Task Intrekken(long id) + { + var resultaat = await Berichten.WithdrawAsync(id, _gebruikerId); + Melding = resultaat.Succeeded ? "Het bericht is teruggetrokken." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + Weghalen = null; + if (resultaat.Succeeded) await Verversen(); + } + + /// + /// Een bericht uit je eigen lijst halen - bij "Voor mij" of bij "Van mij", bezorgd of niet. + /// Anders dan Intrekken hierboven (dat een bericht terugtrekt vóór het is bezorgd, zodat de + /// ander het nooit krijgt): dit ruimt alleen je eigen exemplaar op, de ander houdt het zijne. + /// + private async Task VerwijderenAsync(long id) + { + var resultaat = await Berichten.DeleteForMeAsync(id, _gebruikerId); + Melding = resultaat.Succeeded ? "Het bericht is verwijderd." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + Verwijderen = null; + if (resultaat.Succeeded) await Verversen(); + } + + private async Task Versturen() + { + if (BewerkId == 0 && Nieuw.AanPersoonId == 0) + { + Melding = "Kies eerst voor wie het bericht is."; + MeldingSoort = "fout"; + return; + } + + DateTime? datum = Nieuw.Wanneer == "datum" && Nieuw.Datum is { } d + ? d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) : null; + + Result resultaat; + if (BewerkId == 0) + { + var gemaakt = await Berichten.ComposeAsync(_gebruikerId, Nieuw.AanPersoonId, Nieuw.Onderwerp, + Nieuw.Tekst, datum, Nieuw.Wanneer == "overlijden"); + resultaat = gemaakt.Succeeded ? Result.Ok() : Result.Fail(gemaakt.Error!); + } + else + { + resultaat = await Berichten.UpdateAsync(BewerkId, _gebruikerId, Nieuw.Onderwerp, Nieuw.Tekst, + datum, Nieuw.Wanneer == "overlijden"); + } + + if (resultaat.Succeeded) + { + Melding = BewerkId == 0 ? "Je bericht staat klaar." : "De wijziging is opgeslagen."; + MeldingSoort = "goed"; + BewerkId = 0; + Nieuw = new NieuwBericht(); + ToonFormulier = false; + await Verversen(); + } + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + private string NaamVan(long persoonId) => + Leden?.FirstOrDefault(l => l.PersonId == persoonId)?.DisplayName ?? "onbekend"; + + private static string Bezorging(Message m) => + m.DeliverAfterDeath ? "na mijn overlijden" + : m.DeliverAt is { } at ? $"op {Datums.Datum(at)}" + : "meteen"; + + private static string Stand(MessageStatus s) => s switch + { + MessageStatus.Gepland => "Klaargezet", + MessageStatus.Bezorgd => "Bezorgd", + MessageStatus.Gelezen => "Gelezen", + MessageStatus.Ingetrokken => "Ingetrokken", + _ => "Concept" + }; + + public class NieuwBericht + { + public long AanPersoonId { get; set; } + public string Onderwerp { get; set; } = ""; + public string Tekst { get; set; } = ""; + public string Wanneer { get; set; } = "overlijden"; + public DateOnly? Datum { get; set; } + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Nalatenschap.razor b/src/MijnBesluit.Web/Components/Pages/Nalatenschap.razor new file mode 100644 index 0000000..b509124 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Nalatenschap.razor @@ -0,0 +1,557 @@ +@page "/nalatenschap" +@attribute [Authorize] +@using MijnBesluit.Application.Abstractions +@inject LegacyService NalatenschapService +@inject FamilyService Families +@inject IFamilyRepository FamilieOpslag +@inject ILegacyRepository LegacyOpslag +@inject IPersonRepository Personen +@inject VaultService Kluis +@rendermode InteractiveServer + +Nalatenschap - MijnBesluit + +

Nalatenschap

+

+ Wie mag straks regelen wat jij hebt vastgelegd? En hoeveel mensen moeten melden dat je er + niet meer bent voordat dat gebeurt? +

+ +@if (Melding is not null) {
@Melding
} + +@if (Stand.State == DeathProcedureState.Lopend && Stand.Missing == 0) +{ +
+ Er is gemeld dat je zou zijn overleden. + @Stand.ActiveDeclarations mensen hebben dat doorgegeven. Klopt het niet, laat het dan + weten vóór @Datums.Datum(Stand.ObjectionDeadline). +
+ +
+
+} +else if (Stand.WachtOpBeheerder) +{ +
+ Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen") + binnengekomen over jouw profiel — genoeg in aantal, maar er zit nog geen melding bij + van iemand die jij als beheerder hebt aangewezen. Zolang die er niet is gebeurt er niets. +
+ +
+
+} +else if (Stand.State == DeathProcedureState.Lopend) +{ +
+ Er @(Stand.ActiveDeclarations == 1 ? "is één melding" : $"zijn {Stand.ActiveDeclarations} meldingen") + binnengekomen over jouw profiel. Er @(Stand.Missing == 1 ? "is er nog één" : $"zijn er nog {Stand.Missing}") + nodig voordat de procedure begint. +
+ +
+
+} + +@* ---------------------------------------------------------------- aan mij toevertrouwd *@ +@if (Toevertrouwd is { Count: > 0 }) +{ +
+

Aan mij toevertrouwd

+

+ Deze mensen hebben jou aangewezen om hun nalatenschap te regelen. Zolang zij er zijn + valt er niets in te zien; dat verandert pas als de procedure is afgerond. +

+ +
+ @foreach (var nalatenschap in Toevertrouwd) + { +
+ @* De naam en de knop op één regel, de toelichting daaronder over de volle + breedte. Naast de knop bleef er op een telefoon zo weinig over dat + "Overleden 3 maart 2026" over vier regels ging en de naam werd afgekapt. *@ +
+ + @nalatenschap.DisplayName +
+ +
+ @if (!nalatenschap.Afgerond) + { + Je bent aangewezen · volgorde @nalatenschap.Volgorde + } + else + { + @if (nalatenschap.DateOfDeath is { } overleden) + { + Overleden @Datums.Datum(overleden) · + } + @nalatenschap.AantalItems item@(nalatenschap.AantalItems == 1 ? "" : "s") vrijgegeven + } +
+ + @* Wat deze persoon zelf heeft ingesteld: zo weet je bij leven al waar je op + moet rekenen, en kun je het er nog met diegene over hebben. Alleen zolang + de procedure nog niet is afgerond - daarna is het geschiedenis, geen + vooruitzicht meer. *@ + @if (!nalatenschap.Afgerond) + { +
+ @nalatenschap.RequiredDeclarations + meldingen nodig · @nalatenschap.ObjectionDays dagen bezwaartermijn +
+ } + + @if (nalatenschap.Afgerond) + { + + } + + @if (nalatenschap.Afgerond && Bekeken == nalatenschap.PersonId) + { + @if (Nagelaten is { Count: 0 }) + { +

+ Er is niets vrijgegeven. @nalatenschap.DisplayName heeft alles op + "alleen voor mijzelf" laten staan; dat blijft ook nu dicht. +

+ } + +
+ @foreach (var item in Nagelaten ?? new List()) + { +
+
+ @item.Title +
@Kluisweergave.Naam(item.Category)
+
+ +
+ } +
+ } +
+ } +
+
+} + +@* ---------------------------------------------------------------- met mij gedeeld *@ +@if (GedeeldMetMij is { Count: > 0 }) +{ +
+

Met mij gedeeld

+

+ Deze uitvaartwens is met jou gedeeld om alvast te bespreken - je hoeft niet te + wachten op een overlijden om hem te lezen. +

+ +
+ @foreach (var item in GedeeldMetMij) + { +
+
+ @item.Title +
@Kluisweergave.Naam(item.Category)
+
+ +
+ } +
+
+} + +@* ---------------------------------------------------------------- item bekijken *@ +@* Dezelfde opmaak als het invulformulier in de kluis (.veld-blokken onder elkaar), maar + zonder invoervelden: dit is een ander item dan het jouwe, dus er valt hier niets te + wijzigen - alleen te lezen en, als er iets bij hangt, te bekijken. *@ +@if (GeopendItem is not null && Inhoud is not null) +{ + +
+ @foreach (var (label, waarde) in Kluisweergave.Velden(Inhoud)) + { +
+ +

@waarde

+
+ } +
+ + @if (GeopendBijlagen is { Count: > 0 }) + { +
+

Bijlagen

+
+ @foreach (var bijlage in GeopendBijlagen) + { + + + @if (bijlage.ContentType.StartsWith("image/")) + { + @bijlage.FileName + } + else if (bijlage.ContentType.StartsWith("video/")) + { + + } + } +
+ } +
+} + +
+

Mijn beheerders

+ @if (Beheerders is null) {

Bezig met laden…

} + else if (Beheerders.Count == 0) + { +

Je hebt nog niemand aangewezen. Zonder beheerder blijft je kluis dicht.

+ } + else + { +
+ @foreach (var beheerder in Beheerders) + { +
+ +
+
@NaamVan(beheerder.ManagerPersonId)
+
+ Volgorde @beheerder.Priority + @if (beheerder.AccessGrantedAt is not null) { · heeft toegang gekregen } +
+
+ + @* Wie je aanwijst moet je ook weer kunnen weghalen - als hij zelf overlijdt, + of als je het anders wilt regelen. Behalve als hij je kluis al heeft + gekregen: dan valt er niets meer terug te nemen. *@ + @if (beheerder.AccessGrantedAt is null) + { + + + + } +
+ + @if (Weghalen == beheerder.ManagerPersonId) + { +
+ @NaamVan(beheerder.ManagerPersonId) niet meer laten beheren? + Je kluis gaat dan niet meer naar hem of haar. Wijs wel iemand anders aan: + zonder beheerder blijft je kluis ook na je overlijden dicht. +
+ + +
+
+ } + } +
+ } + +
+ +
+
+ + + + @if (Leden is not null) + { + @foreach (var lid in Leden.Where(l => l.PersonId != _persoonId)) + { + + } + } + +
+
+ + +
1 is de eerste keus.
+
+
+ +
+
+ +
+

Hoeveel meldingen zijn er nodig?

+

+ Standaard moeten drie verschillende mensen melden dat je bent overleden. Daarna krijg je + zelf nog een termijn om dat tegen te spreken. Pas als die verstrijkt, krijgt je beheerder toegang. +

+
+
+ + +
+
+ + +
+
+ @* Zonder beheerder komt de procedure niet op gang, hoeveel mensen er ook melden. Dat hoort + hier te staan en niet pas als het zover is. *@ + @if (Beheerders is { Count: 0 }) + { +
+ Je hebt nog niemand aangewezen. Melden mag iedereen uit Mijn mensen, maar de procedure + begint pas te lopen als er een melding van een van jouw beheerders bij zit. Zonder + beheerder gebeurt er dus niets — en blijft je kluis ook na je overlijden dicht. +
+ } + + +
+ +@* Melden mag over iedereen uit je eigen mensen. Staat daar niemand in, dan staat dit blok er + niet: een knop met een lege lijst is geen knop. *@ +@if (Meldbaar.Count > 0) +{ +
+

Iemand als overleden melden

+

+ Is iemand uit Mijn mensen overleden? Geef het hier door. Er zijn meerdere + onafhankelijke meldingen nodig voordat er iets gebeurt, waaronder één van iemand die + hij zelf als beheerder heeft aangewezen. Daarna krijgt de betrokkene eerst bericht. +

+ +
+
+ + + + @foreach (var wie in Meldbaar) + { + + } + +
+
+ + +
+
+ +
+
+} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId, _persoonId, _familieId; + private IReadOnlyList? Beheerders; + private IReadOnlyList? Leden; + private DeathProcedureResult Stand; + private int Aantal = 3, Dagen = 14; + private string? Melding, MeldingSoort; + private NieuweBeheerder Nieuw = new(); + private Overlijdensmelding Overlijden = new(); + + /// De nalatenschappen die aan mij zijn toevertrouwd, afgerond of nog niet. + private IReadOnlyList? Toevertrouwd; + + /// Kluisitems die iemand rechtstreeks met mij heeft gedeeld (nog geen overlijden nodig). + private IReadOnlyList? GedeeldMetMij; + + /// Per persoon het nummer van zijn profielfoto, voor wie er een heeft. + private IReadOnlyDictionary Profielfotos = new Dictionary(); + + /// + /// Over wie jij een overlijdensmelding mag doen: iedereen uit je eigen mensen die nog niet + /// als overleden staat. Dezelfde kring die de server aanhoudt, zodat het scherm en de regel + /// het over dezelfde mensen hebben. + /// + private IReadOnlyList Meldbaar => + Leden?.Where(l => l.PersonId != _persoonId && l.DateOfDeath is null).ToList() + ?? (IReadOnlyList)Array.Empty(); + + /// Welke nalatenschap openstaat, en welk item daarbinnen. + private long? Bekeken, GeopendItem; + private IReadOnlyList? Nagelaten; + private VaultPayload? Inhoud; + private string? GeopendTitel; + private IReadOnlyList? GeopendBijlagen; + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + var user = (await AuthState).User; + _gebruikerId = user.UserId() ?? 0; + _persoonId = user.PersonId() ?? 0; + + _familieId = (await FamilieOpslag.ListOwnedByPersonAsync(_persoonId)).FirstOrDefault()?.Id ?? 0; + if (_familieId != 0) Leden = await Families.ListMembersAsync(_familieId, _gebruikerId); + + var ik = await Personen.FindByIdAsync(_persoonId); + if (ik is not null) { Aantal = ik.RequiredDeathDeclarations; Dagen = ik.DeathObjectionDays; } + + await Verversen(); + } + + private async Task Verversen() + { + Beheerders = await LegacyOpslag.ListManagersForAsync(_persoonId); + Stand = await NalatenschapService.StatusAsync(_persoonId); + Toevertrouwd = await NalatenschapService.ToevertrouwdAsync(_gebruikerId); + + var gedeeld = await Kluis.ListSharedWithMeAsync(_gebruikerId); + GedeeldMetMij = gedeeld.Succeeded ? gedeeld.Value : Array.Empty(); + + // De profielfoto's van iedereen die op dit scherm staat, in één vraag. Alleen het + // nummer; het plaatje zelf haalt de browser op via /bestand/{id}/klein. + var wie = Beheerders.Select(b => b.ManagerPersonId) + .Concat(Toevertrouwd.Select(t => t.PersonId)) + .Distinct(); + + Profielfotos = (await Kluis.ProfielfotosAsync(wie, _gebruikerId)) + .ToDictionary(f => f.PersoonId, f => f.BestandId); + } + + /// Waar de profielfoto van deze persoon staat; null als hij er geen heeft. + private string? Foto(long persoonId) => + Profielfotos.TryGetValue(persoonId, out var bestandId) ? $"/bestand/{bestandId}/klein" : null; + + // ------------------------------------------------------------------ aan mij toevertrouwd + + private async Task Openen(long persoonId) + { + if (Bekeken == persoonId) { Sluiten(); return; } + + var resultaat = await NalatenschapService.NalatenschapItemsAsync(persoonId, _gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; } + + Bekeken = persoonId; + Nagelaten = resultaat.Value; + ItemDichtdoen(); + } + + private void Sluiten() + { + Bekeken = null; + Nagelaten = null; + ItemDichtdoen(); + } + + /// + /// Opent één nagelaten item. De sleutel daarvoor is bij het bevestigen van het overlijden + /// voor jou klaargezet; VaultService gebruikt die vanzelf als het item niet van jou is. + /// + private async Task ItemOpenen(VaultItem item) + { + if (GeopendItem == item.Id) { ItemDichtdoen(); return; } + + var resultaat = await Kluis.OpenAsync(item.Id, _gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; } + + GeopendItem = item.Id; + Inhoud = resultaat.Value; + GeopendTitel = item.Title; + + // Bijlagen zijn optioneel: als ophalen mislukt (bv. geen toegang) tonen we het + // item gewoon zonder bijlagenlijst, in plaats van de hele popup te blokkeren. + var bijlagen = await Kluis.ListFilesAsync(item.Id, _gebruikerId); + GeopendBijlagen = bijlagen.Succeeded ? bijlagen.Value : Array.Empty(); + } + + private void ItemDichtdoen() + { + GeopendItem = null; + Inhoud = null; + GeopendTitel = null; + GeopendBijlagen = null; + } + + /// Van wie de vraag "weghalen?" openstaat. + private long? Weghalen; + + private async Task BeheerderWeg(long persoonId) + { + var resultaat = await NalatenschapService.RemoveManagerAsync(_gebruikerId, persoonId); + + Melding = resultaat.Succeeded + ? $"{NaamVan(persoonId)} beheert je nalatenschap niet meer." + : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + + Weghalen = null; + await Verversen(); + } + + private async Task Aanwijzen() + { + if (Nieuw.PersoonId == 0) { Melding = "Kies eerst iemand."; MeldingSoort = "fout"; return; } + var resultaat = await NalatenschapService.AppointManagerAsync(_gebruikerId, Nieuw.PersoonId, Nieuw.Volgorde); + Melding = resultaat.Succeeded ? "Aangewezen." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + Nieuw = new NieuweBeheerder(); + await Verversen(); + } + + private async Task Bezwaar() + { + var resultaat = await NalatenschapService.ObjectAsync(_persoonId, _gebruikerId); + Melding = resultaat.Succeeded + ? "Goed dat je het laat weten. De meldingen zijn ingetrokken." + : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + await Verversen(); + } + + private async Task Melden() + { + if (Overlijden.PersoonId == 0) { Melding = "Kies eerst iemand."; MeldingSoort = "fout"; return; } + var resultaat = await NalatenschapService.DeclareDeathAsync(Overlijden.PersoonId, _gebruikerId, Overlijden.Datum); + if (resultaat.Succeeded) + { + var stand = resultaat.Value; + Melding = stand.Missing > 0 + ? $"Je melding is genoteerd. Er zijn nog {stand.Missing} melding(en) nodig." + : "Je melding is genoteerd. De bezwaartermijn is ingegaan."; + MeldingSoort = "goed"; + Overlijden = new Overlijdensmelding(); + } + else { Melding = resultaat.Error; MeldingSoort = "fout"; } + await Verversen(); + } + + private void AantalGewijzigd(ChangeEventArgs e) + { if (int.TryParse(e.Value?.ToString(), out var v)) Aantal = Math.Clamp(v, 1, 10); } + + private void DagenGewijzigd(ChangeEventArgs e) + { if (int.TryParse(e.Value?.ToString(), out var v)) Dagen = Math.Clamp(v, 0, 90); } + + private async Task InstellingenOpslaan() + { + var resultaat = await Families.UpdatePersonAsync(_persoonId, _gebruikerId, p => + { + p.RequiredDeathDeclarations = Aantal; + p.DeathObjectionDays = Dagen; + }); + Melding = resultaat.Succeeded ? "Je instellingen zijn opgeslagen." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + } + + private string NaamVan(long persoonId) => + Leden?.FirstOrDefault(l => l.PersonId == persoonId)?.DisplayName ?? "onbekend"; + + public class NieuweBeheerder { public long PersoonId { get; set; } public int Volgorde { get; set; } = 1; } + public class Overlijdensmelding { public long PersoonId { get; set; } public DateOnly? Datum { get; set; } } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Privacy.razor b/src/MijnBesluit.Web/Components/Pages/Privacy.razor new file mode 100644 index 0000000..13e4971 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Privacy.razor @@ -0,0 +1,20 @@ +@page "/privacy" +@attribute [AllowAnonymous] + +@* De privacyverklaring. Openbaar bereikbaar, want Google Play vraagt er een adres van, en wie + overweegt zijn wachtwoorden hier neer te leggen hoort het te kunnen lezen vóórdat hij een + account maakt. + + Let op bij het aanpassen: wat hier staat moet waar zijn. Verandert er iets aan wat er wordt + opgeslagen, wie het kan zien of wat er naar buiten gaat, dan verandert deze pagina mee. Een + privacyverklaring die niet klopt is erger dan geen. *@ + +Privacy - MijnBesluit + +
+

Privacy

+ + + +

Terug naar MijnBesluit

+
diff --git a/src/MijnBesluit.Web/Components/Pages/Profiel.razor b/src/MijnBesluit.Web/Components/Pages/Profiel.razor new file mode 100644 index 0000000..b4d5155 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Profiel.razor @@ -0,0 +1,368 @@ +@page "/profiel" +@attribute [Authorize] +@inject AccountService Accounts +@inject VaultService Kluis +@inject MeldingService Meldingen +@inject NavigationManager Navigatie +@rendermode InteractiveServer + +Mijn profiel - MijnBesluit + +@* Je eigen gegevens. Dit is het enige profiel dat niet bij Mijn mensen staat - daar staat + iedereen behalve jij - dus zonder deze pagina kon je je eigen naam of adres nergens kwijt. + + Het e-mailadres staat bewust apart van de rest. Dat is je inlog: wie dat zomaar kan + verzetten, kan zichzelf buitensluiten of een ander account overnemen. Daarom gaat er eerst + een code naar het nieuwe adres. *@ + +@* Net als overal elders op de site: aantikken van je eigen kaart bij Mijn mensen opent dit als + popup, niet als eigen pagina. "Vast onderin" (.knop-vast-onder stond hier eerder) is precies + wat een Popup toch al gratis geeft via .popup-knoppen. De route /profiel blijft bestaan - de + kop en het menu linken er nog naar - maar toont nu meteen deze popup. *@ +@if (Melding is not null && Mijn is null) {
@Melding
} + +@if (Mijn is null) +{ +

Bezig met laden…

+} +else +{ + + + @if (Melding is not null) {
@Melding
} + + @if (Gewisseld is not null) + { +
+

+ Je e-mailadres is gewijzigd. Vanaf nu log je in met @Gewisseld. + Je huidige aanmelding loopt nog op je oude adres, dus log even opnieuw in. +

+ + @* Uitloggen gaat via een formulier en niet vanuit deze component: het cookie kan alleen + gewist worden terwijl het antwoord nog niet onderweg is. Zie Program.cs. *@ +
+ + + +
+ } + +
+
+
+
+ +
+ + +
Niet verplicht.
+
+ +

Je bent hier sinds @Datum(Mijn.LidSinds).

+ +
+

E-mailadres

+

+ Hiermee log je in. Je huidige adres is @Mijn.Email. +

+ + +
+ + @* ---------------------------------------------------------------- meldingen *@ +
+

Meldingen op je telefoon

+ + @if (Toestellen.Count == 0) + { +

+ Er is nog geen telefoon aangemeld. Installeer de app, log in en geef toestemming + voor meldingen; daarna staat je toestel hier. +

+ } + else + { +

Meldingen gaan naar:

+
    + @foreach (var toestel in Toestellen) + { +
  • + @(string.IsNullOrWhiteSpace(toestel.Naam) ? toestel.Platform : toestel.Naam) + · laatst gezien @Datum(toestel.LaatstGezien) +
  • + } +
+ } + + @if (Proef is not null) + { +
@Proef.Uitleg
+ } + + +
+ Komt hij niet aan, dan zegt deze knop waar het op vastloopt. +
+
+ +
+

Mijn foto's en documenten

+ +
+ + @* Een link en geen knop: het opheffen zelf gebeurt op een eigen pagina. Die pagina wordt + statisch weergegeven, en dat is nodig ook - er wordt een inlogcookie gewist, en dat kan + niet tijdens het renderen van een interactief scherm. Diezelfde pagina is ook het + openbare adres waar iemand zonder app terechtkan; zie AccountVerwijderen.razor. *@ +
+

Je MijnBesluit opheffen

+

+ Je kluis, je bestanden en de berichten die nog klaarstaan worden verwijderd. Dat is + niet terug te draaien. +

+ Mijn account opheffen +
+
+} + +@* ---------------------------------------------------------------- e-mailadres wijzigen *@ +@if (Adres is not null) +{ + + + @if (!Adres.CodeGevraagd) + { +
+ + +
+ We sturen er een code naartoe. Pas als je die invult verandert je adres, + dus een tikfout kan je hier niet buitensluiten. +
+
+ } + else + { +

Er is een code gestuurd naar @Adres.Nieuw.

+ +
+ + +
+ } +
+} + + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private long _gebruikerId; + private EigenProfiel? Mijn; + + private string Voornaam = ""; + private string Achternaam = ""; + private DateTime? Geboortedatum; + + private string? Melding, MeldingSoort; + private bool Bezig; + + /// Het adreswijzigingsformulier; null als het niet openstaat. + private Adreswissel? Adres; + + /// Het nieuwe adres, zodra de wijziging rond is. Dan volgt alleen nog uitloggen. + private string? Gewisseld; + + private IReadOnlyList Bijlagen = Array.Empty(); + private bool Uploaden; + + /// De toestellen waar meldingen heen gaan, en de uitkomst van een proefmelding. + private IReadOnlyList Toestellen = Array.Empty(); + private MeldingService.Proefuitslag? Proef; + + /// Zoveel bestanden tegelijk kiezen mag. Blazor wil hier een bovengrens. + private const int MaxTegelijk = 10; + + protected override async Task OnInitializedAsync() + { + if (AuthState is null) return; + _gebruikerId = (await AuthState).User.UserId() ?? 0; + await Verversen(); + } + + private async Task Verversen() + { + var resultaat = await Accounts.EigenProfielAsync(_gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; return; } + + Mijn = resultaat.Value; + Voornaam = Mijn!.Voornaam; + Achternaam = Mijn.Achternaam; + Geboortedatum = Mijn.Geboortedatum?.ToDateTime(TimeOnly.MinValue); + + Toestellen = (await Meldingen.ToestellenAsync(_gebruikerId)).Where(t => t.IsActief).ToList(); + + await BijlagenLaden(); + } + + /// + /// Stuurt een proefmelding naar je eigen telefoon. Blijft er een melding uit, dan zijn er + /// drie mogelijke oorzaken die er van buiten hetzelfde uitzien; deze knop zegt welke het is. + /// + private async Task ProefmeldingAsync() + { + Bezig = true; + Melding = null; + Proef = null; + try + { + Proef = await Meldingen.ProefAsync(_gebruikerId); + + // Een verlopen kenmerk wordt bij het versturen afgemeld; dan klopt de lijst niet meer. + Toestellen = (await Meldingen.ToestellenAsync(_gebruikerId)).Where(t => t.IsActief).ToList(); + } + finally { Bezig = false; } + } + + private async Task Opslaan() + { + Bezig = true; + Melding = null; + try + { + var geboren = Geboortedatum is { } g ? DateOnly.FromDateTime(g) : (DateOnly?)null; + var resultaat = await Accounts.WijzigEigenProfielAsync( + _gebruikerId, Voornaam.Trim(), Achternaam.Trim(), geboren); + + Melding = resultaat.Succeeded ? "Je gegevens zijn opgeslagen." : resultaat.Error; + MeldingSoort = resultaat.Succeeded ? "goed" : "fout"; + if (resultaat.Succeeded) await Verversen(); + } + finally { Bezig = false; } + } + + /// Annuleren, of de popup gewoon dichttikken: terug naar waar de tegel vandaan kwam. + private void Sluiten() => Navigatie.NavigateTo("/familie"); + + // ------------------------------------------------------------------ e-mailadres + + private void AdresWijzigen() + { + Melding = null; + Adres = new Adreswissel(); + } + + /// Eén knop, twee stappen: eerst de code aanvragen, daarna hem bevestigen. + private async Task AdresStap() + { + var wissel = Adres!; + Bezig = true; + Melding = null; + try + { + if (!wissel.CodeGevraagd) + { + var gevraagd = await Accounts.VraagAdreswijzigingAanAsync(_gebruikerId, wissel.Nieuw.Trim()); + if (!gevraagd.Succeeded) { Melding = gevraagd.Error; MeldingSoort = "fout"; return; } + + wissel.Nieuw = wissel.Nieuw.Trim().ToLowerInvariant(); + wissel.CodeGevraagd = true; + return; + } + + var bevestigd = await Accounts.BevestigAdreswijzigingAsync( + _gebruikerId, wissel.Nieuw, wissel.Code.Trim()); + if (!bevestigd.Succeeded) { Melding = bevestigd.Error; MeldingSoort = "fout"; return; } + + Gewisseld = wissel.Nieuw; + Adres = null; + } + finally { Bezig = false; } + } + + // ------------------------------------------------------------------ foto's en documenten + + private async Task BijlagenLaden() + { + if (Mijn is null) { Bijlagen = Array.Empty(); return; } + + var resultaat = await Kluis.ListPersonFilesAsync(Mijn.PersoonId, _gebruikerId); + Bijlagen = resultaat.Succeeded ? resultaat.Value! : Array.Empty(); + } + + private async Task BijlageKiezen(InputFileChangeEventArgs e) + { + if (Mijn is null) return; + + Uploaden = true; + Melding = null; + try + { + foreach (var bestand in e.GetMultipleFiles(MaxTegelijk)) + { + if (bestand.Size > VaultService.MaxBestandsgrootte) + { + Melding = $"{bestand.Name} is te groot. Maximaal {VaultService.MaxBestandsgrootte / 1024 / 1024} MB."; + MeldingSoort = "fout"; + continue; + } + + using var stroom = bestand.OpenReadStream(VaultService.MaxBestandsgrootte); + using var buffer = new MemoryStream(); + await stroom.CopyToAsync(buffer); + + var resultaat = await Kluis.StoreFileAsync( + _gebruikerId, bestand.Name, + string.IsNullOrWhiteSpace(bestand.ContentType) ? "application/octet-stream" : bestand.ContentType, + buffer.ToArray(), subjectPersonId: Mijn.PersoonId); + + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + } + + await BijlagenLaden(); + } + catch (Exception ex) + { + Melding = $"Het bestand kon niet worden gelezen: {ex.Message}"; + MeldingSoort = "fout"; + } + finally { Uploaden = false; } + } + + private async Task BijlageWeg(long bestandId) + { + var resultaat = await Kluis.DeleteFileAsync(bestandId, _gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + await BijlagenLaden(); + } + + private async Task Profielfoto(long bestandId) + { + var resultaat = await Kluis.SetProfilePhotoAsync(bestandId, _gebruikerId); + if (!resultaat.Succeeded) { Melding = resultaat.Error; MeldingSoort = "fout"; } + await BijlagenLaden(); + } + + private static string Datum(DateTime moment) => Datums.Datum(moment); + + /// Het adreswijzigingsformulier: eerst een adres, daarna de code die daarheen ging. + private class Adreswissel + { + public string Nieuw { get; set; } = ""; + public string Code { get; set; } = ""; + public bool CodeGevraagd { get; set; } + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Registreren.razor b/src/MijnBesluit.Web/Components/Pages/Registreren.razor new file mode 100644 index 0000000..9023d48 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Registreren.razor @@ -0,0 +1,175 @@ +@page "/registreren" +@using System.ComponentModel.DataAnnotations +@inject AccountService Accounts +@inject IHttpContextAccessor HttpContexten +@inject NavigationManager Navigatie + +@* Zelfde opzet als de inlogpagina: alle formulieren worden altijd gerenderd en alleen + verborgen. Bij een statische pagina is elke POST een nieuwe pagina-instantie, dus een + formulier dat op dat moment niet gerenderd wordt, bestaat voor de server niet. *@ + +Account aanmaken - MijnBesluit + +
+

Begin je eigen MijnBesluit

+

+ Je legt vast wat je wilt regelen, en zet berichten klaar voor de mensen om je heen. + Daarna nodig je je familie uit. +

+ + @if (Fout is not null) {
@Fout
} + @if (Gelukt is not null) {
@Gelukt
} + + + + + +

Heb je al een account? Inloggen.

+
+ +@code { + [SupplyParameterFromForm(FormName = "registratiecode")] private AanvraagGegevens Aanvraag { get; set; } = new(); + [SupplyParameterFromForm(FormName = "registreren")] private BevestigGegevens Bevestig { get; set; } = new(); + [SupplyParameterFromForm(FormName = "nieuwe-registratiecode")] private AanvraagGegevens Opnieuw { get; set; } = new(); + + private bool _codeVerstuurd; + private string? AfgeschermdAdres; + private string? Fout, Gelukt; + + private bool ToonCodeveld => + _codeVerstuurd || !string.IsNullOrWhiteSpace(Bevestig.Email) || !string.IsNullOrWhiteSpace(Opnieuw.Email); + + protected override void OnParametersSet() + { + if (AfgeschermdAdres is null && ToonCodeveld) + { + var adres = !string.IsNullOrWhiteSpace(Bevestig.Email) ? Bevestig.Email : Opnieuw.Email; + AfgeschermdAdres = new CodeVerstuurd(adres, DateTime.UtcNow).AfgeschermdAdres; + } + } + + private Task CodeAanvragen() => StuurCodeAsync(Aanvraag.Email, Aanvraag.Voornaam, Aanvraag.Achternaam); + + private Task OpnieuwAanvragen() => StuurCodeAsync(Opnieuw.Email, Opnieuw.Voornaam, Opnieuw.Achternaam); + + private async Task StuurCodeAsync(string email, string voornaam, string achternaam) + { + var context = HttpContexten.HttpContext!; + var resultaat = await Accounts.VraagRegistratiecodeAanAsync( + email, voornaam, achternaam, context.Connection.RemoteIpAddress?.ToString(), context.RequestAborted); + + if (!resultaat.Succeeded) { Fout = resultaat.Error; Gelukt = null; return; } + + _codeVerstuurd = true; + AfgeschermdAdres = resultaat.Value!.AfgeschermdAdres; + Bevestig = new BevestigGegevens { Email = resultaat.Value.Email }; + Opnieuw = new AanvraagGegevens { Email = resultaat.Value.Email, Voornaam = voornaam, Achternaam = achternaam }; + Gelukt = "We hebben je een code gestuurd. Vul hem hieronder in."; + Fout = null; + } + + private async Task Aanmaken() + { + var context = HttpContexten.HttpContext!; + var resultaat = await Accounts.RegistreerMetCodeAsync(Bevestig.Email, Bevestig.Code, context.RequestAborted); + + if (!resultaat.Succeeded) + { + _codeVerstuurd = true; + AfgeschermdAdres = new CodeVerstuurd(Bevestig.Email, DateTime.UtcNow).AfgeschermdAdres; + Fout = resultaat.Error; + Gelukt = null; + return; + } + + await Inloggen.MeldAanAsync(context, resultaat.Value!); + Navigatie.NavigateTo("/", forceLoad: true); + } + + public class AanvraagGegevens + { + [Required(ErrorMessage = "Vul je voornaam in.")] + public string Voornaam { get; set; } = ""; + + public string Achternaam { get; set; } = ""; + + [Required(ErrorMessage = "Je moet een e-mailadres invullen!")] + [EmailAddress(ErrorMessage = "Dit is geen geldig e-mailadres!")] + public string Email { get; set; } = ""; + } + + public class BevestigGegevens + { + [Required] public string Email { get; set; } = ""; + + [Required(ErrorMessage = "Je moet een code invullen!")] + public string Code { get; set; } = ""; + } +} diff --git a/src/MijnBesluit.Web/Components/Pages/Uitloggen.razor b/src/MijnBesluit.Web/Components/Pages/Uitloggen.razor new file mode 100644 index 0000000..17b2062 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Uitloggen.razor @@ -0,0 +1,8 @@ +@* Uitloggen zit niet meer in een pagina maar in een eigen endpoint; zie Program.cs. + + Waarom: een Blazor-pagina rendert pas als het antwoord al onderweg is. Het Set-Cookie + waarmee de aanmelding wordt gewist, kan dan niet meer worden meegestuurd - het uitloggen + leek te lukken, maar je bleef gewoon ingelogd. + + Dit bestand is leeg gelaten in plaats van verwijderd, zodat het bij een volgende publicatie + niet per ongeluk terugkomt uit een oude map. *@ diff --git a/src/MijnBesluit.Web/Components/Pages/Uitnodiging.razor b/src/MijnBesluit.Web/Components/Pages/Uitnodiging.razor new file mode 100644 index 0000000..e8aa9d9 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/Uitnodiging.razor @@ -0,0 +1,88 @@ +@page "/uitnodiging/{Token}" +@inject FamilyService Families +@inject AccountService Accounts +@inject SamenvoegService Samenvoegen +@inject IHttpContextAccessor HttpContexten +@inject NavigationManager Navigatie + +Uitnodiging - MijnBesluit + +
+

Je bent uitgenodigd

+

+ Iemand heeft in MijnBesluit een profiel voor je aangemaakt. Neem je de uitnodiging aan, dan + is dat profiel vanaf dat moment van jou: alleen jij kunt het dan nog wijzigen. +

+ + @if (Fout is not null) + { +
+ @Fout + @if (LogEerstIn) + { +

+ Inloggen +

+ } +
+ } + + +
+

+ Je hoeft geen wachtwoord te bedenken. Bij MijnBesluit log je voortaan in met een code + die we je per e-mail sturen — naar hetzelfde adres waarop je deze uitnodiging kreeg. +

+ +
+
+
+ +@code { + [Parameter] public string Token { get; set; } = ""; + + /// + /// Het model van het formulier. Bewust GEEN [SupplyParameterFromForm]: dit formulier heeft + /// geen invoervelden, er valt dus niets te binden. + /// + /// Met dat kenmerk erop ging het juist mis. Het kenmerk stond er zonder FormName, terwijl + /// het formulier FormName="uitnodiging" heeft. Die twee horen bij elkaar; kloppen ze niet, + /// dan zet de binder de waarde op null en is de "= new()" hierboven weg. EditForm kreeg dan + /// een Model van null en gooide "EditForm requires either a Model parameter" - een harde + /// fout bij het openen van de uitnodigingslink, nog voordat er iets te accepteren viel. + /// + private LeegFormulier Invoer { get; } = new(); + + private string? Fout; + + /// Staat er een knop naar het inlogscherm bij de melding? + private bool LogEerstIn; + + private async Task Aannemen() + { + var context = HttpContexten.HttpContext!; + + // Ben je al ingelogd, dan neem je deze uitnodiging aan als jezelf en gaat het profiel + // op in dat van jou. Dat is hoe twee mensen die allebei een profiel voor je hebben + // aangemaakt tot één persoon worden. + var ingelogd = context.User.UserId(); + + var resultaat = await Families.AcceptInvitationAsync( + Token, Accounts, context.RequestAborted, ingelogd, Samenvoegen); + + if (!resultaat.Succeeded) + { + Fout = resultaat.Error; + LogEerstIn = ingelogd is null && Fout is not null && Fout.Contains("Log eerst in"); + return; + } + + // Al ingelogd? Dan is er niets aan je aanmelding veranderd en gaan we gewoon verder. + if (ingelogd is null) await Inloggen.MeldAanAsync(context, resultaat.Value!); + + Navigatie.NavigateTo("/", forceLoad: true); + } + + /// De uitnodigingslink is zelf het bewijs; er hoeft niets ingevuld te worden. + public class LeegFormulier { } +} diff --git a/src/MijnBesluit.Web/Components/Pages/WatIsMijnBesluit.razor b/src/MijnBesluit.Web/Components/Pages/WatIsMijnBesluit.razor new file mode 100644 index 0000000..abd5bb7 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Pages/WatIsMijnBesluit.razor @@ -0,0 +1,19 @@ +@page "/wat-is-mijnbesluit" +@attribute [AllowAnonymous] + +Wat is MijnBesluit? + +
+

Wat is MijnBesluit?

+ + @* De tekst zelf staat in Productuitleg, want de app toont hem ook - daar als popup vanaf + het overzicht. Kop en knoppen staan hier omdat ze per plek verschillen. *@ + + +

+ Een account aanmaken + Inloggen +

+ +

Terug naar inloggen

+
diff --git a/src/MijnBesluit.Web/Components/Routes.razor b/src/MijnBesluit.Web/Components/Routes.razor new file mode 100644 index 0000000..34f5128 --- /dev/null +++ b/src/MijnBesluit.Web/Components/Routes.razor @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/MijnBesluit.Web/Components/_Imports.razor b/src/MijnBesluit.Web/Components/_Imports.razor new file mode 100644 index 0000000..8b5da2a --- /dev/null +++ b/src/MijnBesluit.Web/Components/_Imports.razor @@ -0,0 +1,20 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Http +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using MijnBesluit.Web +@using MijnBesluit.Web.Components +@using MijnBesluit.Web.Components.Account +@using MijnBesluit.Web.Components.Onderdelen +@using MijnBesluit.Application.Models +@using MijnBesluit.Application.Services +@using MijnBesluit.Domain.Entities +@using MijnBesluit.Domain.Enums +@using MijnBesluit.Domain.Rules diff --git a/src/MijnBesluit.Web/HuidigeGebruiker.cs b/src/MijnBesluit.Web/HuidigeGebruiker.cs new file mode 100644 index 0000000..2e55b92 --- /dev/null +++ b/src/MijnBesluit.Web/HuidigeGebruiker.cs @@ -0,0 +1,35 @@ +using System.Security.Claims; + +namespace MijnBesluit.Web; + +/// Leest wie er is ingelogd uit de claims van het cookie. +public static class HuidigeGebruiker +{ + public const string PersonIdClaim = "mijnbesluit:person"; + + /// + /// Staat er bij het inloggen in, en zegt alleen of het menu-item Beheer getoond wordt. + /// De beheerpagina's kijken zelf nog een keer in de database of je het echt bent; deze + /// claim is dus geen sleutel, alleen een lampje. Zie MainLayout voor waarom het niet + /// live uit de database komt. + /// + public const string BeheerClaim = "mijnbesluit:beheer"; + + public static long? UserId(this ClaimsPrincipal? user) + { + var value = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return long.TryParse(value, out var id) ? id : null; + } + + public static long? PersonId(this ClaimsPrincipal? user) + { + var value = user?.FindFirst(PersonIdClaim)?.Value; + return long.TryParse(value, out var id) ? id : null; + } + + public static bool IsBeheerder(this ClaimsPrincipal? user) => + user?.FindFirst(BeheerClaim)?.Value == "ja"; + + public static string Naam(this ClaimsPrincipal? user) => + user?.FindFirst(ClaimTypes.Name)?.Value ?? ""; +} diff --git a/src/MijnBesluit.Web/Logging/BestandLogger.cs b/src/MijnBesluit.Web/Logging/BestandLogger.cs new file mode 100644 index 0000000..fa37e55 --- /dev/null +++ b/src/MijnBesluit.Web/Logging/BestandLogger.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace MijnBesluit.Web.Logging; + +/// +/// Schrijft de log naar een bestand per dag. Bewust zelfgeschreven en klein: onder IIS is de +/// console nergens te zien, en de stdout-log van IIS staat standaard uit en loopt vol. +/// Zonder dit is een mislukte e-mail of databasefout op de server onzichtbaar. +/// +public sealed class BestandLoggerProvider : ILoggerProvider +{ + private readonly string _map; + private readonly object _slot = new(); + private readonly ConcurrentDictionary _loggers = new(); + + public BestandLoggerProvider(string map) + { + _map = map; + Directory.CreateDirectory(map); + } + + public ILogger CreateLogger(string categoryName) => + _loggers.GetOrAdd(categoryName, naam => new BestandLogger(this, naam)); + + public void Dispose() => _loggers.Clear(); + + internal void Schrijf(string regel) + { + var pad = Path.Combine(_map, $"mijnbesluit-{DateTime.Now:yyyy-MM-dd}.log"); + // Eén schrijver tegelijk; de log is geen hot path en zo raken regels niet door elkaar. + lock (_slot) + { + try { File.AppendAllText(pad, regel, Encoding.UTF8); } + catch { /* Een log die zelf de applicatie omlegt is erger dan geen log. */ } + } + } + + private sealed class BestandLogger : ILogger + { + private readonly BestandLoggerProvider _provider; + private readonly string _categorie; + + public BestandLogger(BestandLoggerProvider provider, string categorie) + { + _provider = provider; + _categorie = categorie; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) return; + + var tekst = new StringBuilder() + .Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")) + .Append(' ').Append(Niveau(logLevel)) + .Append(' ').Append(_categorie) + .Append(' ').Append(formatter(state, exception)) + .AppendLine(); + + if (exception is not null) tekst.AppendLine(exception.ToString()); + + _provider.Schrijf(tekst.ToString()); + } + + private static string Niveau(LogLevel niveau) => niveau switch + { + LogLevel.Trace => "TRACE", + LogLevel.Debug => "DEBUG", + LogLevel.Information => "INFO ", + LogLevel.Warning => "WAARSCHUWING", + LogLevel.Error => "FOUT ", + LogLevel.Critical => "ERNSTIG", + _ => "?" + }; + } +} diff --git a/src/MijnBesluit.Web/MijnBesluit.Web.csproj b/src/MijnBesluit.Web/MijnBesluit.Web.csproj new file mode 100644 index 0000000..acb8170 --- /dev/null +++ b/src/MijnBesluit.Web/MijnBesluit.Web.csproj @@ -0,0 +1,12 @@ + + + net8.0 + enable + enable + mijnbesluit-web + + + + + + diff --git a/src/MijnBesluit.Web/Program.cs b/src/MijnBesluit.Web/Program.cs new file mode 100644 index 0000000..010e1b0 --- /dev/null +++ b/src/MijnBesluit.Web/Program.cs @@ -0,0 +1,193 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using MijnBesluit.Infrastructure; +using MijnBesluit.Infrastructure.Security; +using MijnBesluit.Web; +using MijnBesluit.Web.Components; + +// De website van MijnBesluit. Roept de Application-laag rechtstreeks aan: geen tussenliggende +// webservice, dus ook geen extra laag om te onderhouden of te beveiligen. + +// Eenmalig een sleutelbestand aanmaken, bijvoorbeeld bij het inrichten van een server: +// MijnBesluit.Web.exe --maak-sleutel D:\MijnBesluitData\keys\masterkey.json +// Zonder dit bestand is geen enkele kluis te openen; mét dit bestand en een databasekopie is +// alles leesbaar. Bewaar het dus apart van de database-back-up. +if (args.Length > 0 && args[0] == "--maak-sleutel") +{ + var pad = args.Length > 1 ? args[1] : "keys/masterkey.json"; + try + { + FileMasterKeyProtector.CreateKeyFile(pad); + Console.WriteLine($"Sleutelbestand aangemaakt: {Path.GetFullPath(pad)}"); + Console.WriteLine("Maak hier NU een back-up van, op een andere plek dan de database-back-up."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } +} + +var builder = WebApplication.CreateBuilder(args); + +// Geheimen horen niet in de publicatiemap, want die wordt bij elke nieuwe publicatie +// overschreven. Wijs met "ExtraConfigFile" naar een bestand daarbuiten (zie +// appsettings.Production.json); wat daarin staat overschrijft de gewone instellingen. +// +// Bewust NIET optional: staat er een pad ingesteld en ontbreekt dat bestand, dan valt de +// applicatie stilletjes terug op de voorbeeldwaarden uit appsettings.json en krijg je even +// verderop een onbegrijpelijke "Access denied" van de database. Beter meteen hard stoppen +// met een melding die zegt wat er aan de hand is. +var extraInstellingen = builder.Configuration["ExtraConfigFile"]; +if (!string.IsNullOrWhiteSpace(extraInstellingen)) +{ + if (!File.Exists(extraInstellingen)) + { + throw new FileNotFoundException( + $"Het instellingenbestand '{extraInstellingen}' is niet gevonden. Het pad staat in " + + "appsettings.Production.json bij \"ExtraConfigFile\". Maak het bestand aan (zie " + + "docs/Uitrollen-naar-IIS.md, stap 6) of haal de instelling weg.", + extraInstellingen); + } + + builder.Configuration.AddJsonFile(extraInstellingen, optional: false, reloadOnChange: true); +} + +// Het advertentietarief wordt vanuit het beheerscherm gewijzigd (zie BeheerAdverteren.razor). +// Dat schrijft naar hetzelfde bestand als waar de instelling ook vandaan komt: het aparte +// productiebestand als dat is ingesteld, anders de gewone appsettings.json naast het programma. +builder.Services.AddSingleton(new MijnBesluit.Web.AdvertentietariefInstellingen( + !string.IsNullOrWhiteSpace(extraInstellingen) + ? extraInstellingen! + : Path.Combine(AppContext.BaseDirectory, "appsettings.json"))); + +// Log naar een bestand, zodat je op de server kunt zien wát er misging. Staat er geen map +// ingesteld, dan verandert er niets (tijdens ontwikkelen is de console genoeg). +var logmap = builder.Configuration["Logging:Bestandsmap"]; +if (!string.IsNullOrWhiteSpace(logmap)) + builder.Logging.AddProvider(new MijnBesluit.Web.Logging.BestandLoggerProvider(logmap)); + +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddMijnBesluit(builder.Configuration); + +// De sleutels waarmee ASP.NET cookies en formuliertokens beschermt. Standaard staan die in het +// profiel van het account waaronder de app draait; onder IIS bestaat dat profiel vaak niet, +// waardoor ze bij elke herstart van de app-pool weg zijn en iedereen ineens uitgelogd is of +// een antiforgery-fout krijgt. Daarom een vaste map, buiten de publicatiemap zodat een nieuwe +// publicatie ze niet weggooit. +var sleutelring = builder.Configuration["Security:DataProtectionPath"]; +if (!string.IsNullOrWhiteSpace(sleutelring)) +{ + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(sleutelring)) + .SetApplicationName("MijnBesluit"); +} + +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/inloggen"; + options.LogoutPath = "/uitloggen"; + options.AccessDeniedPath = "/inloggen"; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + options.Cookie.Name = "mijnbesluit"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + // Alleen over https, zodat het cookie nooit over een open verbinding meegaat. + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }) + // Adverteerders hebben een eigen cookie, los van de gebruikers van MijnBesluit. Niet uit + // netheid: een adverteerder is geen gebruiker en hoort nooit per ongeluk als gebruiker te + // gelden. Met één cookie en een claim erin is dat één vergeten controle bij elkaar; met twee + // schema's kan het niet. Het cookie gaat ook alleen mee naar /adverteren. + .AddCookie(Adverteerderslogin.Schema, options => + { + options.LoginPath = "/adverteren"; + options.AccessDeniedPath = "/adverteren"; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + options.Cookie.Name = "mijnbesluit-adverteren"; + options.Cookie.Path = "/adverteren"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddAntiforgery(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +// Een paar kopteksten die weinig kosten en veel schelen. +app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + await next(); +}); + +app.UseStaticFiles(); + +// De volgorde is hier geen smaak. UseAntiforgery hoort NA UseAuthentication en +// UseAuthorization: het formuliertoken wordt aan de aangemelde gebruiker gekoppeld, en draait +// de controle voordat die gebruiker bekend is, dan gebeurt dat tegen een anonieme identiteit. +// Het gevolg is een formulier dat bij het opsturen wordt geweigerd - een kale HTTP 400 zonder +// verdere uitleg. Stond hier eerder andersom. +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +// Uitloggen is bewust een eigen endpoint en geen Blazor-pagina. In een pagina gebeurt het +// uitloggen tijdens het renderen, en dan is het antwoord vaak al onderweg; het Set-Cookie +// waarmee de aanmelding wordt gewist komt dan niet meer mee en je blijft gewoon ingelogd. +app.MapPost("/uitloggen", async (HttpContext context, IAntiforgery antiforgery) => +{ + try + { + await antiforgery.ValidateRequestAsync(context); + } + catch (AntiforgeryValidationException) + { + // Verlopen formuliertoken, bijvoorbeeld na een herstart. Dan is er niets te doen + // behalve opnieuw beginnen; het cookie wissen we voor de zekerheid ook. + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Redirect("/inloggen"); + } + + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Redirect("/inloggen"); +}); + +// Uitloggen uit het adverteerdersgedeelte. Eigen endpoint om dezelfde reden als hierboven: het +// wissen van het cookie hoort niet tijdens het renderen van een pagina te gebeuren. +app.MapPost("/adverteren/uitloggen", async (HttpContext context, IAntiforgery antiforgery) => +{ + try { await antiforgery.ValidateRequestAsync(context); } + catch (AntiforgeryValidationException) { /* verlopen token; wissen doen we toch. */ } + + await context.SignOutAsync(Adverteerderslogin.Schema); + return Results.Redirect("/adverteren"); +}); + +app.MapBijlagen(); +app.MapAdvertenties(); + +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +app.Run(); +return 0; diff --git a/src/MijnBesluit.Web/Properties/launchSettings.json b/src/MijnBesluit.Web/Properties/launchSettings.json new file mode 100644 index 0000000..5b872cd --- /dev/null +++ b/src/MijnBesluit.Web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7014;http://localhost:5009", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5009", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/MijnBesluit.Web/appsettings.Development.json b/src/MijnBesluit.Web/appsettings.Development.json new file mode 100644 index 0000000..6d49f6c --- /dev/null +++ b/src/MijnBesluit.Web/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Security": { + // Maakt bij de eerste start keys/masterkey.json aan als dat er nog niet is. + // Alleen voor ontwikkelen; zie docs/Beveiliging.md. + "MaakSleutelbestandAlsHetOntbreekt": true + }, + "Smtp": { + // Uitnodigingen worden als html-bestand in de map mail-uitgaand gezet in plaats van verstuurd. + "WriteToDiskInsteadOfSending": true + } +} diff --git a/src/MijnBesluit.Web/appsettings.Production.json b/src/MijnBesluit.Web/appsettings.Production.json new file mode 100644 index 0000000..f21f43e --- /dev/null +++ b/src/MijnBesluit.Web/appsettings.Production.json @@ -0,0 +1,39 @@ +{ + // Instellingen voor de server. Hier staan GEEN geheimen: de connectiereeks en de + // e-mailinstellingen komen uit het bestand waar "ExtraConfigFile" naar wijst, dat buiten + // de publicatiemap staat en dus niet overschreven wordt bij een nieuwe publicatie. + "ExtraConfigFile": "C:\\MijnBesluitData\\appsettings.secrets.json", + + "Security": { + // Zonder dit bestand is geen enkele kluis te openen. Eenmalig aanmaken met: + // MijnBesluit.Web.exe --maak-sleutel C:\MijnBesluitData\keys\masterkey.json + "MasterKeyFile": "C:\\MijnBesluitData\\keys\\masterkey.json", + + // De sleutels waarmee ASP.NET cookies en formuliertokens beschermt. Zonder vaste map + // is iedereen uitgelogd zodra IIS de app-pool herstart. + "DataProtectionPath": "C:\\MijnBesluitData\\dataprotection" + }, + + "Storage": { + "Path": "C:\\MijnBesluitData\\kluisbestanden" + }, + + "Site": { + "Url": "https://mijnbesluit.nl" + }, + + "Logging": { + // Hierheen schrijft de applicatie zelf haar log, één bestand per dag. Zonder deze regel + // zie je op de server niet wat er misgaat, want de console van IIS is nergens zichtbaar. + "Bestandsmap": "C:\\MijnBesluitData\\logs", + "LogLevel": { + "Default": "Warning", + "MijnBesluit": "Information", + // Anders zie je niet eens dat de applicatie is opgestart en op welke poort. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + + "AllowedHosts": "mijnbesluit.nl;www.mijnbesluit.nl" +} diff --git a/src/MijnBesluit.Web/appsettings.json b/src/MijnBesluit.Web/appsettings.json new file mode 100644 index 0000000..5fcc4c8 --- /dev/null +++ b/src/MijnBesluit.Web/appsettings.json @@ -0,0 +1,27 @@ +{ + "ConnectionStrings": { + "MijnBesluit": "Server=localhost;Port=3306;Database=mijnbesluit;User Id=mijnbesluit_app;Password=VERVANG_DIT;" + }, + "Security": { + "MasterKeyFile": "keys/masterkey.json" + }, + "Storage": { + "Path": "kluisbestanden" + }, + "Site": { + "Url": "https://localhost:7014" + }, + "Smtp": { + "Host": "localhost", + "Port": 587, + "UseStartTls": true, + "FromAddress": "noreply@mijnbesluit.nl", + "FromName": "MijnBesluit", + "WriteToDiskInsteadOfSending": true, + "DropFolder": "mail-uitgaand" + }, + "Logging": { + "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "AllowedHosts": "*" +} diff --git a/src/MijnBesluit.Web/wwwroot/adverteren.js b/src/MijnBesluit.Web/wwwroot/adverteren.js new file mode 100644 index 0000000..a58ab97 --- /dev/null +++ b/src/MijnBesluit.Web/wwwroot/adverteren.js @@ -0,0 +1,50 @@ +// Laat de prijs meelopen terwijl een adverteerder datums en aantallen invult. +// +// Waarom dit een apart bestand is en niet een