forked from GNUsocial/gnu-social
Compare commits
1096 Commits
v3
...
experiment
Author | SHA1 | Date |
---|---|---|
Eliseu Amaro | 010f70e432 | |
Eliseu Amaro | fc310a0b4e | |
Hugo Sales | d398456be8 | |
Hugo Sales | 3288d48b8a | |
Hugo Sales | 94edde001c | |
Hugo Sales | 41d759428f | |
Diogo Peralta Cordeiro | 469cd97b9b | |
Diogo Peralta Cordeiro | 8a01224feb | |
Diogo Peralta Cordeiro | 61d558b371 | |
Diogo Peralta Cordeiro | 8c5486ba13 | |
Diogo Peralta Cordeiro | a7d4a56b14 | |
Diogo Peralta Cordeiro | bef23f20bc | |
Diogo Peralta Cordeiro | 22ad2bd5cc | |
Diogo Peralta Cordeiro | 968a425459 | |
Diogo Peralta Cordeiro | 30975111d9 | |
Diogo Peralta Cordeiro | 7c85d312ed | |
Diogo Peralta Cordeiro | c50e3324ef | |
Diogo Peralta Cordeiro | aebc5358b5 | |
Diogo Peralta Cordeiro | 24b3e22f73 | |
Diogo Peralta Cordeiro | 6e9cde8a5c | |
Diogo Peralta Cordeiro | 4089fc692d | |
Diogo Peralta Cordeiro | f25759d60b | |
Hugo Sales | a383021992 | |
Hugo Sales | d6e6e56814 | |
Hugo Sales | 9afa265c30 | |
Hugo Sales | 027c9a9324 | |
Hugo Sales | 2c10ce5cfc | |
Hugo Sales | c03c6f1bb5 | |
Hugo Sales | be5328cdc5 | |
Hugo Sales | 4a781d483a | |
Hugo Sales | 5bcabbb025 | |
Hugo Sales | b6cd58d501 | |
Hugo Sales | 2ba6f66b7f | |
Hugo Sales | 152beb5798 | |
Hugo Sales | 96612fcd43 | |
Hugo Sales | 4cda3fc645 | |
Hugo Sales | b72fcd2a05 | |
Hugo Sales | 9018b1301a | |
Hugo Sales | 20901d26df | |
Hugo Sales | 0b3ebf841d | |
Hugo Sales | fd1bd9838d | |
Hugo Sales | 7320c6834f | |
Hugo Sales | 9c533a54a7 | |
Diogo Peralta Cordeiro | 5be4c6a22e | |
Diogo Peralta Cordeiro | 61500c5223 | |
Diogo Peralta Cordeiro | f7c426e81c | |
Diogo Peralta Cordeiro | 40f2f5f977 | |
Diogo Peralta Cordeiro | b7b54b8a07 | |
Diogo Peralta Cordeiro | a6e41d3bd8 | |
Diogo Peralta Cordeiro | d4ad0cc3d4 | |
Diogo Peralta Cordeiro | 3af33d1317 | |
Eliseu Amaro | 2448d83ace | |
Diogo Peralta Cordeiro | 923ff309fe | |
Diogo Peralta Cordeiro | f039c86578 | |
Diogo Peralta Cordeiro | e76e3b710b | |
Eliseu Amaro | 022a9476cc | |
Hugo Sales | 4e5f9a51f0 | |
Eliseu Amaro | 44593f2ab4 | |
Eliseu Amaro | 2ae1198704 | |
Eliseu Amaro | 9e52bd127f | |
Eliseu Amaro | 41b45435ff | |
Hugo Sales | e9fa41c5a8 | |
Diogo Peralta Cordeiro | 48c11a3fda | |
Diogo Peralta Cordeiro | fa1585bd00 | |
Diogo Peralta Cordeiro | f5918d8d5c | |
Diogo Peralta Cordeiro | ff4d31404b | |
Hugo Sales | ac6f2bed5e | |
Hugo Sales | 5cb45fcd66 | |
Hugo Sales | dd22894f66 | |
Hugo Sales | 11178289fa | |
Hugo Sales | 1e8beefb07 | |
Hugo Sales | f68a2ce481 | |
Hugo Sales | b0f5352a53 | |
Hugo Sales | 69ff8c2750 | |
Hugo Sales | 355b26221d | |
Hugo Sales | d4c3e26f50 | |
Hugo Sales | 5bd5c25dcf | |
Hugo Sales | e30ae79eb7 | |
Hugo Sales | fb861ed41f | |
Hugo Sales | 33bf99cfda | |
Hugo Sales | 4d883d1011 | |
Hugo Sales | 1d95080f9a | |
Hugo Sales | bb57d7dc10 | |
Hugo Sales | f3972abb70 | |
Diogo Peralta Cordeiro | 2e3ab5bdfb | |
Diogo Peralta Cordeiro | d23312aff9 | |
Diogo Peralta Cordeiro | a43f1a641a | |
Diogo Peralta Cordeiro | 31c5fd6da7 | |
Diogo Peralta Cordeiro | 7b3ca428e9 | |
Diogo Peralta Cordeiro | df5e7b139a | |
Diogo Peralta Cordeiro | 4c1fc40c43 | |
Diogo Peralta Cordeiro | c381e58d33 | |
Diogo Peralta Cordeiro | 333567c6a1 | |
Diogo Peralta Cordeiro | 632a54208d | |
Diogo Peralta Cordeiro | daaf7ea236 | |
Diogo Peralta Cordeiro | 3019048585 | |
Diogo Peralta Cordeiro | 9781ddc8e0 | |
Diogo Peralta Cordeiro | c12eacc758 | |
Diogo Peralta Cordeiro | d13da61d30 | |
Diogo Peralta Cordeiro | f64436771c | |
Diogo Peralta Cordeiro | 91666f7d61 | |
Diogo Peralta Cordeiro | b20a4c89fb | |
Diogo Peralta Cordeiro | 6453593b0d | |
Diogo Peralta Cordeiro | f72cfd1c2b | |
Diogo Peralta Cordeiro | c0a404c640 | |
Diogo Peralta Cordeiro | aec8521e4b | |
Diogo Peralta Cordeiro | eb6ff68f7a | |
Eliseu Amaro | c86cac2095 | |
Eliseu Amaro | c14718e8dd | |
Diogo Peralta Cordeiro | ae7516c893 | |
Diogo Peralta Cordeiro | 32ad5dbd74 | |
Diogo Peralta Cordeiro | 2ea739ef61 | |
Hugo Sales | 420b3f4aeb | |
Hugo Sales | 6cea2b1d00 | |
Hugo Sales | 9c99c11790 | |
Hugo Sales | ecbfba1b1a | |
Hugo Sales | 66b39d3607 | |
Hugo Sales | 8e627f2c18 | |
Hugo Sales | 7cace2051f | |
Hugo Sales | a4cb90ba12 | |
Hugo Sales | cb0093bd4a | |
Hugo Sales | c804892672 | |
Hugo Sales | e053ee451b | |
Hugo Sales | 9a6fddb004 | |
Hugo Sales | 06b9bd9910 | |
Hugo Sales | 47daf6169a | |
Hugo Sales | 71b1ee7796 | |
Hugo Sales | 4266b361c0 | |
Hugo Sales | 504c8f8935 | |
Hugo Sales | c38bbed7df | |
Eliseu Amaro | 7308e66981 | |
Eliseu Amaro | 2590ea7b67 | |
Eliseu Amaro | 6aa61abd81 | |
Eliseu Amaro | 96abf53e22 | |
Eliseu Amaro | b7d205465f | |
Eliseu Amaro | d19c990acf | |
Eliseu Amaro | 38abbc14b9 | |
Eliseu Amaro | 0eb9575534 | |
Eliseu Amaro | a02093e848 | |
Diogo Peralta Cordeiro | 9343d00110 | |
Diogo Peralta Cordeiro | 67f5421691 | |
Eliseu Amaro | bbaeaad052 | |
Eliseu Amaro | 5236278f45 | |
Diogo Peralta Cordeiro | 289eef5cf7 | |
Diogo Peralta Cordeiro | c155f4e30e | |
Diogo Peralta Cordeiro | 5896f5bb82 | |
Diogo Peralta Cordeiro | 1556b3e019 | |
Hugo Sales | c58b9fb5b1 | |
Hugo Sales | 97a3c067d9 | |
Hugo Sales | 92db61a975 | |
Hugo Sales | 05e10589c3 | |
Hugo Sales | a590ddd85e | |
Hugo Sales | 0bead1c58a | |
Hugo Sales | 0845224188 | |
Hugo Sales | 1da1f0918e | |
Diogo Peralta Cordeiro | b075ab610b | |
Diogo Peralta Cordeiro | 5b858a7bc1 | |
Hugo Sales | f760de43b0 | |
Hugo Sales | 960675b459 | |
Hugo Sales | f9c1d14c7a | |
Hugo Sales | ed21290ef4 | |
Hugo Sales | 6b098a26f7 | |
Hugo Sales | 19a966f1a9 | |
Hugo Sales | f5f11b6e54 | |
Hugo Sales | 9077403f65 | |
Hugo Sales | bbdad515a2 | |
Hugo Sales | 7034476cc7 | |
Hugo Sales | 927472cf06 | |
Hugo Sales | b2456d8cd2 | |
Hugo Sales | d1e92a80e5 | |
Hugo Sales | af951685ed | |
Hugo Sales | 9c61e92257 | |
Hugo Sales | 4297eb71a0 | |
Hugo Sales | b89368bf6a | |
Hugo Sales | 5fc5df68f5 | |
Hugo Sales | a83d506d6c | |
Hugo Sales | 56481c8289 | |
Hugo Sales | 6d2f8daeae | |
Hugo Sales | bdbd588de9 | |
Hugo Sales | 176d604abb | |
Hugo Sales | bff65afe5d | |
Hugo Sales | 6479b698f8 | |
Hugo Sales | a01914ddac | |
Hugo Sales | 2e3ec15827 | |
Hugo Sales | 6deac21960 | |
Hugo Sales | c4de4cab32 | |
Hugo Sales | 75af2232dc | |
Hugo Sales | 988d384654 | |
Hugo Sales | 14db5d9864 | |
Hugo Sales | e0ebef594f | |
Hugo Sales | 63d26d1295 | |
Hugo Sales | ed850a7763 | |
Hugo Sales | e196a3577d | |
Hugo Sales | c0e4dec674 | |
Hugo Sales | 88ab76c480 | |
Hugo Sales | 1f9acaf4ef | |
Hugo Sales | 55710aa33d | |
Hugo Sales | 8e743eabb9 | |
Hugo Sales | d34155c743 | |
Hugo Sales | 0d6b4093fe | |
Hugo Sales | 400716c1b2 | |
Hugo Sales | 75c9ffde31 | |
Hugo Sales | 4258148a03 | |
Hugo Sales | 6956e6907c | |
Hugo Sales | 7ee908f4dc | |
Hugo Sales | b2d72673c7 | |
Hugo Sales | b51d43e6e2 | |
Hugo Sales | 5777cdeaf9 | |
Hugo Sales | b0ef7599b2 | |
Hugo Sales | 2c74bd7fb4 | |
Hugo Sales | b9fdaa1401 | |
Hugo Sales | 88bb0c6b38 | |
Hugo Sales | df956a5f90 | |
Hugo Sales | aa66263b92 | |
Hugo Sales | ae27d95509 | |
Hugo Sales | 2beda0dd44 | |
Hugo Sales | b79629b6d2 | |
Hugo Sales | 4f6f4aa512 | |
Hugo Sales | 3554a5c369 | |
Hugo Sales | 5ca8842308 | |
Eliseu Amaro | 5bf4a68454 | |
Eliseu Amaro | 903e6b33ff | |
Eliseu Amaro | 0a0ead3081 | |
Eliseu Amaro | efeb4b4ffe | |
Eliseu Amaro | 780d341939 | |
Eliseu Amaro | 5e012c39ab | |
Eliseu Amaro | 787afb9b41 | |
Eliseu Amaro | 0e7c657301 | |
Eliseu Amaro | 95f92d34db | |
Eliseu Amaro | 3cd33fb83a | |
Diogo Peralta Cordeiro | c2fc2300c7 | |
Diogo Peralta Cordeiro | 7fd4149695 | |
Diogo Peralta Cordeiro | 0273c8ca24 | |
Diogo Peralta Cordeiro | 477518abf7 | |
Diogo Peralta Cordeiro | 47171069c2 | |
Diogo Peralta Cordeiro | e8f57e8380 | |
Diogo Peralta Cordeiro | 7f3a9bc880 | |
Diogo Peralta Cordeiro | 263a5f67f3 | |
Diogo Peralta Cordeiro | df1f12470f | |
Diogo Peralta Cordeiro | 0cf53a4163 | |
Diogo Peralta Cordeiro | 12f3e1f406 | |
Diogo Peralta Cordeiro | 15f2514aa2 | |
Diogo Peralta Cordeiro | 900d538e26 | |
Diogo Peralta Cordeiro | 8f8b66c938 | |
Diogo Peralta Cordeiro | 3c0f6b294f | |
Eliseu Amaro | cfd771283a | |
Eliseu Amaro | 88eea554fc | |
Eliseu Amaro | 5c8735ebea | |
Eliseu Amaro | d2a7281a4d | |
Eliseu Amaro | 0ce686d8bb | |
Eliseu Amaro | b4bf720b06 | |
Eliseu Amaro | 0d83bbff23 | |
Eliseu Amaro | a9d710f189 | |
Eliseu Amaro | 4b095961d8 | |
Eliseu Amaro | 14ecf913bf | |
Eliseu Amaro | 2b4bf6c31f | |
Eliseu Amaro | 6437e68132 | |
Eliseu Amaro | 37a2db3706 | |
Eliseu Amaro | 362be17aba | |
Eliseu Amaro | 3a5ba9b6c4 | |
Eliseu Amaro | dd7d412e83 | |
Eliseu Amaro | 593d5bf96e | |
Eliseu Amaro | d6cf812707 | |
Eliseu Amaro | 2d7b201e71 | |
Eliseu Amaro | ac5df2f6b3 | |
Eliseu Amaro | 375d0097f3 | |
Eliseu Amaro | a5eb231196 | |
Eliseu Amaro | aa6886a62a | |
Eliseu Amaro | aa1fd8ea40 | |
Eliseu Amaro | f47fedcfd4 | |
Eliseu Amaro | f206b55869 | |
Eliseu Amaro | 69f5c1e312 | |
Eliseu Amaro | 8b4148a00d | |
Eliseu Amaro | df44d92bb2 | |
Eliseu Amaro | 021583ea05 | |
Eliseu Amaro | 4afaa6858b | |
Eliseu Amaro | ab7d1b0370 | |
Eliseu Amaro | 38225dfa6e | |
Eliseu Amaro | 154025090c | |
Eliseu Amaro | 8ec17086a0 | |
Eliseu Amaro | 18f2823e14 | |
Eliseu Amaro | 7dd23f3f2c | |
Eliseu Amaro | a34f0c2534 | |
Eliseu Amaro | 7d15ec1620 | |
Eliseu Amaro | 2d253ee5ad | |
Eliseu Amaro | c18879b02f | |
Eliseu Amaro | a48e699133 | |
Eliseu Amaro | 77675ea8c4 | |
Eliseu Amaro | b7b69b549e | |
Eliseu Amaro | 681f001f4e | |
Eliseu Amaro | c5e5708915 | |
Diogo Peralta Cordeiro | 86e92fedc2 | |
Diogo Peralta Cordeiro | 8ef6aceb6a | |
Hugo Sales | bf01e97533 | |
Hugo Sales | 835a3c6701 | |
Diogo Peralta Cordeiro | cc0ef73799 | |
Diogo Peralta Cordeiro | c3eda07521 | |
Diogo Peralta Cordeiro | 832a5c0bd9 | |
Diogo Peralta Cordeiro | 143ecea376 | |
Diogo Peralta Cordeiro | 0eebcdbd51 | |
Diogo Peralta Cordeiro | aada96beb7 | |
Diogo Peralta Cordeiro | 218bec1826 | |
Diogo Peralta Cordeiro | 4d2131808a | |
Diogo Peralta Cordeiro | 4ef400f509 | |
Diogo Peralta Cordeiro | 086754d95b | |
Diogo Peralta Cordeiro | 5e9cd21db5 | |
Eliseu Amaro | 65c2c42790 | |
Eliseu Amaro | d6f31ad4b4 | |
Diogo Peralta Cordeiro | 8f7e0f2131 | |
Diogo Peralta Cordeiro | 3af3526b5c | |
Hugo Sales | a46140fc00 | |
Hugo Sales | eecef99372 | |
Hugo Sales | 5543f65ce9 | |
Hugo Sales | 818a31a690 | |
Hugo Sales | 9b862d6a26 | |
Hugo Sales | f8107c86c5 | |
Hugo Sales | ce98e80836 | |
Hugo Sales | 75adf2e59f | |
Hugo Sales | 31518f97ee | |
Hugo Sales | dab822037c | |
Hugo Sales | 79644d1e2b | |
Hugo Sales | 5f9b61f4bf | |
Hugo Sales | 3a6a1b71d6 | |
Hugo Sales | f25494cd83 | |
Hugo Sales | b79c0595d5 | |
Hugo Sales | 33cdea87ee | |
Hugo Sales | c532fdb4c8 | |
Hugo Sales | 5cc82785c6 | |
Hugo Sales | 05fbcdefa8 | |
Hugo Sales | dd218b04e9 | |
Hugo Sales | 059ed1fa76 | |
Hugo Sales | f946da6f29 | |
Hugo Sales | 9e2037e086 | |
Hugo Sales | 84399a76e3 | |
Hugo Sales | 4f0bdade45 | |
Hugo Sales | d5db350595 | |
Hugo Sales | f5fcfe628e | |
Hugo Sales | fde7b87c65 | |
Hugo Sales | f841e5e0dd | |
Hugo Sales | 39ac043d59 | |
Hugo Sales | 041d19a22d | |
Hugo Sales | b99fab00e9 | |
Hugo Sales | 88e84f2dc5 | |
Hugo Sales | 16055c7055 | |
Hugo Sales | 15c406a348 | |
Hugo Sales | eff703ca21 | |
Diogo Peralta Cordeiro | 2e943293e6 | |
Diogo Peralta Cordeiro | 6aea20db05 | |
Diogo Peralta Cordeiro | a5a2032e75 | |
Diogo Peralta Cordeiro | c948ca6178 | |
Diogo Peralta Cordeiro | 676210f76a | |
Diogo Peralta Cordeiro | af4b0113ba | |
Diogo Peralta Cordeiro | 3f565442d2 | |
Diogo Peralta Cordeiro | 4397d12fa4 | |
Diogo Peralta Cordeiro | c58d7e470a | |
Diogo Peralta Cordeiro | 5a40d1f3e3 | |
Diogo Peralta Cordeiro | ced6e236ce | |
Diogo Peralta Cordeiro | d5a7f2122a | |
Diogo Peralta Cordeiro | d0d98a611d | |
Hugo Sales | 650bfec699 | |
Hugo Sales | 6d842d60c5 | |
Hugo Sales | e0e1dca0f0 | |
Diogo Peralta Cordeiro | 6374e30475 | |
Diogo Peralta Cordeiro | 0086d8dec4 | |
Diogo Peralta Cordeiro | 6910620d59 | |
Diogo Peralta Cordeiro | 0629c1434d | |
Hugo Sales | 120571fa42 | |
Hugo Sales | d9a3ecb116 | |
Hugo Sales | 1bf5e9d117 | |
Diogo Peralta Cordeiro | aa28251c11 | |
Diogo Peralta Cordeiro | c2f6665cce | |
Diogo Peralta Cordeiro | b196af5f36 | |
Hugo Sales | ebfa0e2240 | |
Hugo Sales | 365a7b436f | |
Hugo Sales | 93e1e4b7a9 | |
Hugo Sales | e5ee31a2fe | |
Hugo Sales | e32d8711d6 | |
Hugo Sales | 78a17425f9 | |
Diogo Peralta Cordeiro | 94b100dc06 | |
Diogo Peralta Cordeiro | 75c494dca1 | |
Diogo Peralta Cordeiro | f95b8ab226 | |
Diogo Peralta Cordeiro | 6819dd9fb7 | |
Hugo Sales | c57a8481b1 | |
Hugo Sales | ec0c551bb3 | |
Hugo Sales | f17d4d2d92 | |
Diogo Peralta Cordeiro | 255055d149 | |
Diogo Peralta Cordeiro | 55c4ad40cd | |
Diogo Peralta Cordeiro | 5fbc079c55 | |
Diogo Peralta Cordeiro | 22c79db540 | |
Hugo Sales | bb56b24d8f | |
Hugo Sales | b2841cb5fc | |
Hugo Sales | f264cd6125 | |
Hugo Sales | d49de9d35e | |
Hugo Sales | 7f765c530e | |
Hugo Sales | e699824b1d | |
Hugo Sales | 6da8cf7f14 | |
Hugo Sales | e08767cec0 | |
Hugo Sales | 83415b7aa6 | |
Hugo Sales | 495e66f4ae | |
Hugo Sales | 17ea4ecce1 | |
Hugo Sales | 1d7375b9cb | |
Hugo Sales | 0a69f6de8c | |
Hugo Sales | 72cd2e7a30 | |
Hugo Sales | c6389c63b8 | |
Hugo Sales | f388554166 | |
Hugo Sales | b4ad396cd1 | |
Hugo Sales | c3473e45d2 | |
Hugo Sales | 075b495f5a | |
Hugo Sales | 9b3ccac246 | |
Hugo Sales | 82d9326343 | |
Hugo Sales | 464406cccc | |
Hugo Sales | 2782aa9924 | |
Hugo Sales | 1df7be7e8a | |
Hugo Sales | 792a9f097c | |
Hugo Sales | 4649ee9e71 | |
Hugo Sales | c1db9bd0a3 | |
Hugo Sales | cc47cda3d1 | |
Hugo Sales | 1503c98f26 | |
Hugo Sales | b82658e345 | |
Hugo Sales | 1bad2fa050 | |
Hugo Sales | 926d0af663 | |
Hugo Sales | 0a7496de1e | |
Diogo Peralta Cordeiro | 9814baf192 | |
Hugo Sales | 5ec7717fa1 | |
Hugo Sales | d316f9dd6f | |
Hugo Sales | 529ec19801 | |
Hugo Sales | e105889a59 | |
Hugo Sales | c37a75cf7b | |
Hugo Sales | a33a25983e | |
Diogo Peralta Cordeiro | 2f137f8b44 | |
Diogo Peralta Cordeiro | 0f52638a80 | |
Hugo Sales | bbc2fe1b5a | |
Hugo Sales | 45a894c953 | |
Hugo Sales | c8915df31e | |
Hugo Sales | f6dea6e162 | |
Hugo Sales | ec8ad1888a | |
Hugo Sales | 8a280c349f | |
Hugo Sales | cbb36c9531 | |
Hugo Sales | acf5bd1ff5 | |
Hugo Sales | 6dd6491bee | |
Hugo Sales | 2f65311ae6 | |
Hugo Sales | cadd48922d | |
Hugo Sales | 2232f28283 | |
Hugo Sales | b639ce906c | |
Hugo Sales | d6414e51a2 | |
Hugo Sales | 1fda65bc3d | |
Hugo Sales | a5505bf848 | |
Hugo Sales | 678d62781b | |
Diogo Peralta Cordeiro | b5ffe8a52b | |
Diogo Peralta Cordeiro | 8e9da452c6 | |
Hugo Sales | 8fc2a83e3c | |
Hugo Sales | f4e40002a4 | |
Hugo Sales | 8c6881f526 | |
Hugo Sales | 0802f7a9e3 | |
Hugo Sales | d95e51a030 | |
Hugo Sales | 085a98cea3 | |
Hugo Sales | f7af76a1ba | |
Hugo Sales | c5b26bcffb | |
Hugo Sales | 244cc8dae1 | |
Hugo Sales | 520733888d | |
Diogo Peralta Cordeiro | a1cac40f6a | |
Hugo Sales | 6bfea8a0df | |
Hugo Sales | ae29a9c00a | |
Diogo Peralta Cordeiro | 5ddc551fd9 | |
Diogo Peralta Cordeiro | 764ff60c34 | |
Hugo Sales | ae91f75aeb | |
Hugo Sales | 5d4f544a03 | |
Hugo Sales | efd2719481 | |
Hugo Sales | 66ed6fb658 | |
Hugo Sales | 6606a72e67 | |
Hugo Sales | fc019d6a6e | |
Hugo Sales | 6be1622fd0 | |
Hugo Sales | d0fd0e6c6c | |
Hugo Sales | 079d230959 | |
Hugo Sales | 637c25d5fe | |
Hugo Sales | 051720a686 | |
Hugo Sales | f3c2048c62 | |
Hugo Sales | 988c5af6d3 | |
Hugo Sales | aa58c3520c | |
Hugo Sales | cafd9a39a0 | |
Hugo Sales | 120011a2d0 | |
Hugo Sales | c8b2ce6694 | |
Hugo Sales | b855dd00ac | |
Hugo Sales | d082f4249c | |
Hugo Sales | f11f9040b1 | |
Hugo Sales | 27dbd5521a | |
Hugo Sales | 4f3b797c80 | |
Hugo Sales | f5df7edc6c | |
Hugo Sales | 99c4e8ded5 | |
Hugo Sales | a8b599d213 | |
Hugo Sales | ffaf5da984 | |
Hugo Sales | 49fa11ba07 | |
Hugo Sales | a1546a51cd | |
Hugo Sales | 0f0851dbf3 | |
Hugo Sales | ef617819e0 | |
Hugo Sales | 636f8d1be9 | |
Hugo Sales | b8c73d2d2a | |
Hugo Sales | b2aff4c75e | |
Hugo Sales | f912236114 | |
Hugo Sales | 349df02f78 | |
Hugo Sales | 0a15ccab9b | |
Hugo Sales | cfbb28f1ea | |
Hugo Sales | e008bf1863 | |
Hugo Sales | 297d30706f | |
Hugo Sales | 2f570fcc2a | |
Angelo D. Moura | ea6623f029 | |
up201706832 | ccf4480395 | |
up201706832 | 98f072bc12 | |
Daniel | 03aa46cf4e | |
Daniel | 4d0f87b91b | |
Daniel | a4fdb193bc | |
Daniel | 9ea47c5385 | |
Daniel | 79f0615441 | |
Daniel | 7e215d9f9e | |
Daniel | 04b9c736a6 | |
João Brandão | 5e26359783 | |
Daniel | 493476f408 | |
Angelo D. Moura | 54fd7eda06 | |
Angelo D. Moura | f838dbe5f3 | |
Angelo D. Moura | 793e1b0417 | |
Angelo D. Moura | 66875e93f8 | |
Angelo D. Moura | 08fe5fb23f | |
Angelo D. Moura | d4038cd520 | |
Angelo D. Moura | f29b15924c | |
Angelo D. Moura | f621e521f9 | |
Angelo D. Moura | b9622e4512 | |
Angelo D. Moura | f67c41a7ac | |
Angelo D. Moura | 19be786da8 | |
Daniel | e14efe86a4 | |
Daniel | a19b51f91e | |
Daniel | 35d2bdfd5e | |
Daniel | 485607169f | |
Daniel | ee039ab2e9 | |
Daniel | e2df8aec10 | |
Daniel | 0d18615fd8 | |
Daniel | 4f69686968 | |
Daniel | 777b8b55fd | |
Daniel | 7688cc39a8 | |
Daniel | 4fd33bf37f | |
Daniel | 3d3c560516 | |
Diogo Machado | 1abc3e3e7d | |
Pastilhas | f1f4ad7ba7 | |
margarida | 66670ff220 | |
margarida | a5eca9f110 | |
margarida | f9b98f87a4 | |
margarida | 28b337f793 | |
margarida | b02564e575 | |
João Brandão | 850f1b327e | |
João Brandão | b98db96c27 | |
up201706832 | 49b0494f28 | |
João Brandão | 27137b4762 | |
Pastilhas | 81109c88c7 | |
Pastilhas | 748d86d6d3 | |
Pastilhas | dde68b1d22 | |
Pastilhas | a27e3593fa | |
Pastilhas | aaa6585a1e | |
Pastilhas | a3908a22ae | |
Pastilhas | 0a5ac7cf7e | |
Pastilhas | 7e99d5faa8 | |
Pastilhas | b43cc4f742 | |
Pastilhas | e99d8481b5 | |
Pastilhas | 5950986a6f | |
Pastilhas | c37f5a59b3 | |
Pastilhas | 92ffc5644f | |
Pastilhas | 439ea2c182 | |
Pastilhas | 27065e5ead | |
Pastilhas | 12cfb5006a | |
Daniel | ac16b3eff1 | |
Daniel | 696ebe60e0 | |
Daniel | 168b7d313a | |
Daniel | 3a51d3ef89 | |
Daniel | 7c8dbccee2 | |
Daniel | 7a925cd9a6 | |
Daniel | 0a1ea8749b | |
Daniel | 8543c8c68e | |
Daniel | 8bbeb79233 | |
Daniel | 4fcde940ff | |
Daniel | a98e3a32f9 | |
Daniel | a9c35def3f | |
Daniel | b860c6bbb0 | |
margarida | 03007194c8 | |
margarida | b600dc0902 | |
Daniel | 0868880d45 | |
Angelo D. Moura | 5ec7e2e092 | |
Angelo D. Moura | b60185a97c | |
Angelo D. Moura | cacd9a574d | |
Diogo Machado | 5a7b895476 | |
Daniel | 630ef3e826 | |
Hugo Sales | bb4f5b88e7 | |
Hugo Sales | 23904f326d | |
Hugo Sales | 9b42f525e8 | |
Hugo Sales | 9d12dde7c1 | |
Hugo Sales | adb5cfbb72 | |
Hugo Sales | f8c47387c4 | |
Hugo Sales | b337d6b2eb | |
Hugo Sales | f486656756 | |
Hugo Sales | 2c9bd3575b | |
Hugo Sales | e1941b6612 | |
Hugo Sales | 256169a3c4 | |
Hugo Sales | f51a772826 | |
Hugo Sales | 0d2cf6eaa6 | |
Hugo Sales | 8a14222d51 | |
Hugo Sales | 9c2a911dab | |
Hugo Sales | cfc8af675f | |
Hugo Sales | b1cb923036 | |
Hugo Sales | ff1d6d9df8 | |
Hugo Sales | 5c1b3b99f4 | |
Diogo Peralta Cordeiro | 7e9ffbe033 | |
Hugo Sales | 38deea85e2 | |
Hugo Sales | 91eb3354e3 | |
Hugo Sales | 5cced1c9ed | |
Hugo Sales | 9cc7b6adf5 | |
Hugo Sales | 1f4f080bd2 | |
Hugo Sales | 8eb32add3a | |
Hugo Sales | fdaa89e3c9 | |
Hugo Sales | dd8fe29a98 | |
Hugo Sales | ed9e4be6b2 | |
Hugo Sales | 69202ce7a0 | |
Hugo Sales | 88ce4cbf80 | |
Hugo Sales | 4c021a2838 | |
Tiago Magalhaes | 4050222bc8 | |
Hugo Sales | 4b4da170f2 | |
Hugo Sales | 8ef85e90e9 | |
Hugo Sales | 06e92344cc | |
Hugo Sales | a9944592c4 | |
Hugo Sales | 02c7bdf4f0 | |
Hugo Sales | b34307b74c | |
Hugo Sales | c43f25f4b8 | |
Hugo Sales | 8547c54103 | |
Hugo Sales | db608ca3c1 | |
Hugo Sales | abc32ecc0e | |
Hugo Sales | d7ff38fe24 | |
Hugo Sales | 4b84ef5183 | |
Hugo Sales | a7b7d487d7 | |
Hugo Sales | 7e7bfd1958 | |
Hugo Sales | 9f4a53dbbd | |
Hugo Sales | 96415f8523 | |
Hugo Sales | e0672e559a | |
Hugo Sales | c8b6db650a | |
Hugo Sales | 9ae31501cc | |
Hugo Sales | 1330c96681 | |
Hugo Sales | 774d7ffdf9 | |
Hugo Sales | 0492d71294 | |
Hugo Sales | 1c37eb7c72 | |
Hugo Sales | 34fab45b6b | |
Hugo Sales | b1e49f67f4 | |
Hugo Sales | 6926d70543 | |
Hugo Sales | 09a1342588 | |
Hugo Sales | ff96c2bb59 | |
Hugo Sales | 4ab7da32ce | |
Hugo Sales | 749bec5d52 | |
Hugo Sales | 7a68ba4f05 | |
Hugo Sales | 3affbc3c78 | |
Hugo Sales | 5663e5e58d | |
Hugo Sales | 922c435e28 | |
Hugo Sales | 5c4be9d29e | |
Hugo Sales | 13fb9b4698 | |
rainydaysavings | 8e17dd1829 | |
rainydaysavings | 3a4d3fc1e2 | |
rainydaysavings | 1f7e3a1d90 | |
rainydaysavings | 40aa4fa60e | |
rainydaysavings | aa4418e71a | |
Hugo Sales | 76b8b29776 | |
Hugo Sales | d862457623 | |
Hugo Sales | f6a40390e0 | |
Hugo Sales | 1387eab434 | |
Hugo Sales | ca576981a3 | |
Hugo Sales | 5cf7050008 | |
rainydaysavings | af3ed18d48 | |
Hugo Sales | 54e8852fb7 | |
Hugo Sales | 109b17b1f9 | |
Hugo Sales | a129a6e368 | |
Hugo Sales | 736fb672a5 | |
Hugo Sales | 7b467091d6 | |
Hugo Sales | b364a51f80 | |
Hugo Sales | 8f68d7deb4 | |
rainydaysavings | b5b39b5f68 | |
rainydaysavings | 49cd0af021 | |
rainydaysavings | c423101c00 | |
rainydaysavings | ebf6f8d735 | |
rainydaysavings | ce94d50043 | |
rainydaysavings | 0ed0d0470c | |
rainydaysavings | 153c8d0d64 | |
Hugo Sales | 75bc71f473 | |
Hugo Sales | 29f30a6932 | |
rainydaysavings | f4e52f5e11 | |
rainydaysavings | 902a57d10f | |
rainydaysavings | 928064c5ee | |
rainydaysavings | 492f32c555 | |
rainydaysavings | b0566e7b8c | |
rainydaysavings | 6d3dba17d2 | |
rainydaysavings | 2d1200e2e6 | |
rainydaysavings | a2c40163f5 | |
rainydaysavings | a51c546f8c | |
rainydaysavings | b2c2e6b6c6 | |
rainydaysavings | fa0612c0d1 | |
rainydaysavings | 4d0028d95f | |
rainydaysavings | 54c54990a4 | |
rainydaysavings | 930a9a99f2 | |
rainydaysavings | 36aff803c6 | |
rainydaysavings | b609932726 | |
rainydaysavings | 8f7790fa3c | |
Hugo Sales | e13e763d5c | |
Hugo Sales | c0caf520b8 | |
Hugo Sales | 1c1ef7a572 | |
Hugo Sales | fefee324b4 | |
rainydaysavings | 07178e6ffa | |
rainydaysavings | d96e4f9076 | |
rainydaysavings | 6445931493 | |
rainydaysavings | 11d6c19d65 | |
rainydaysavings | b9355b49f3 | |
rainydaysavings | be86a05ddb | |
rainydaysavings | 79be38992f | |
rainydaysavings | 6d92230c32 | |
rainydaysavings | f5267a1975 | |
rainydaysavings | 5dbde32f01 | |
rainydaysavings | 655b5e36a4 | |
rainydaysavings | a4c6fbbbd8 | |
rainydaysavings | 1f89f3298c | |
rainydaysavings | f16fcb0200 | |
rainydaysavings | 253705f704 | |
rainydaysavings | cf8c85f6ba | |
rainydaysavings | dec35a6aa1 | |
rainydaysavings | 9442556c3e | |
rainydaysavings | a761c4e11a | |
rainydaysavings | fa40bfb8dc | |
rainydaysavings | 27d292affd | |
rainydaysavings | dc5992bebd | |
rainydaysavings | 3c06a1e24f | |
Hugo Sales | 84cfa65bc6 | |
Hugo Sales | 43665749bb | |
Hugo Sales | 57297aba56 | |
Hugo Sales | 9204213dbc | |
Hugo Sales | 1b0cab6dc8 | |
Hugo Sales | 9a0c64c3d1 | |
Hugo Sales | 4b8e6bb198 | |
Hugo Sales | 624aef0a8e | |
Hugo Sales | d66ec9d85c | |
rainydaysavings | fc6bb1ddf6 | |
rainydaysavings | 475e78e13f | |
Hugo Sales | 8ceeb6be80 | |
Hugo Sales | f76bfca921 | |
Hugo Sales | 0758b84d2c | |
Hugo Sales | aab9212ffa | |
Hugo Sales | b3c5fe9e96 | |
Hugo Sales | 8ca49478ab | |
Hugo Sales | e142b90653 | |
Hugo Sales | 8276baecab | |
Hugo Sales | b678ab2191 | |
Hugo Sales | 5ed2abaf64 | |
Hugo Sales | 513a1e58b8 | |
Hugo Sales | d86636ebd4 | |
Hugo Sales | 6d1fa10965 | |
Hugo Sales | 86bd1dbbbf | |
Hugo Sales | 96aa98cbcf | |
Hugo Sales | 30deeaf4ef | |
Hugo Sales | fe50909549 | |
Hugo Sales | 651af27674 | |
Hugo Sales | 75958fc9b4 | |
Hugo Sales | 036b4480f3 | |
Hugo Sales | d4813b4ce9 | |
Hugo Sales | 21be5199cc | |
Hugo Sales | 8f43c12e22 | |
Hugo Sales | a752a5a07c | |
Hugo Sales | 4945a1342f | |
Hugo Sales | fd7e06bf18 | |
Hugo Sales | e2960aebcb | |
Hugo Sales | 5b11c26e79 | |
Hugo Sales | a60c79c35d | |
Hugo Sales | 0508886fc4 | |
Hugo Sales | e0af29fd5e | |
rainydaysavings | 9fa363d9bf | |
rainydaysavings | c3a0b08c40 | |
rainydaysavings | b52d4faca7 | |
rainydaysavings | 7d593366c7 | |
rainydaysavings | 4d00a0f6dd | |
rainydaysavings | 648a911055 | |
rainydaysavings | 0d3a1cc14e | |
rainydaysavings | c9b36b6030 | |
rainydaysavings | 52caf7cab1 | |
rainydaysavings | 46d946f381 | |
rainydaysavings | 4568578e16 | |
rainydaysavings | c0b6d8807f | |
rainydaysavings | c25d33e38a | |
rainydaysavings | 488247119a | |
rainydaysavings | 08c792fac7 | |
Hugo Sales | e96c273351 | |
Hugo Sales | a20e95fd38 | |
Hugo Sales | 37f21b516d | |
Alexei Sorokin | 690b8750c6 | |
Hugo Sales | 459a60d789 | |
Hugo Sales | 56c4309cb8 | |
Hugo Sales | d21d4f5cb1 | |
Hugo Sales | a498134b13 | |
Hugo Sales | d91ab6f277 | |
Hugo Sales | a5c97762e0 | |
Hugo Sales | d5e41ec099 | |
Hugo Sales | 9e45641b7b | |
Hugo Sales | b7300c6457 | |
Hugo Sales | 04258b6072 | |
Hugo Sales | 480904a4e3 | |
Hugo Sales | 7635f455ab | |
Hugo Sales | c06346ef31 | |
Hugo Sales | 65d6204a01 | |
Hugo Sales | bdacd638c7 | |
Hugo Sales | b9a2badc31 | |
Hugo Sales | be49bfa0c1 | |
Hugo Sales | c1963438bc | |
rainydaysavings | 19e4f120c0 | |
rainydaysavings | cc4a95fbd5 | |
rainydaysavings | 246bf30c41 | |
rainydaysavings | a71f54c6bf | |
rainydaysavings | e4db0eb9b9 | |
rainydaysavings | e1ff2a0ef1 | |
rainydaysavings | 3f98f8fecf | |
rainydaysavings | 10010552e1 | |
rainydaysavings | d38bf8ff4c | |
rainydaysavings | caab08b017 | |
rainydaysavings | 85d8d9b268 | |
rainydaysavings | a64a099d7d | |
rainydaysavings | 2a75237c70 | |
Hugo Sales | d7801737f6 | |
Hugo Sales | b2b0990bf6 | |
Hugo Sales | ed84c1f8bf | |
Hugo Sales | 6567f10e69 | |
Hugo Sales | b2dbf9bc20 | |
Hugo Sales | 78929629f0 | |
Hugo Sales | 7945a9c825 | |
Hugo Sales | 4c60aac8f8 | |
Hugo Sales | d394f6fc9c | |
Hugo Sales | 605a8919a7 | |
Hugo Sales | bfa3095137 | |
Hugo Sales | 0e401edac2 | |
Hugo Sales | 468d00d393 | |
Hugo Sales | f5f10890b6 | |
Hugo Sales | 9b2db7608b | |
Hugo Sales | 0ca169aad2 | |
Hugo Sales | 9291bfbecb | |
Hugo Sales | e620c20bb4 | |
Hugo Sales | 4b73024a57 | |
Hugo Sales | b4e42d6562 | |
Hugo Sales | 3934d403ef | |
Hugo Sales | e571c62319 | |
Hugo Sales | 9dffd1c93e | |
Hugo Sales | 34890aff90 | |
Hugo Sales | 503fa2e537 | |
Hugo Sales | e10e6644e3 | |
Hugo Sales | 4d2770319e | |
Hugo Sales | 0cba00ebbb | |
rainydaysavings | 6e52fd4c95 | |
rainydaysavings | 9a2ac34ba3 | |
rainydaysavings | e334ce9a55 | |
rainydaysavings | b8a0d14fd5 | |
Hugo Sales | 7b0f5ab576 | |
Hugo Sales | e7f541219d | |
Hugo Sales | 0bc59f1b9a | |
Hugo Sales | 8088b78a24 | |
Hugo Sales | b98d01bd06 | |
Hugo Sales | d0c999199b | |
Hugo Sales | f907843d43 | |
Hugo Sales | 8aa1a3d05e | |
Hugo Sales | c91c385dec | |
Hugo Sales | 2838aaad14 | |
Hugo Sales | d6a7843240 | |
Hugo Sales | 51f65edb55 | |
Alexei Sorokin | 256d57adaa | |
rainydaysavings | 6d6b1447f8 | |
rainydaysavings | 801399218f | |
rainydaysavings | c2e69a06b0 | |
rainydaysavings | ae49f82580 | |
rainydaysavings | 879666fab7 | |
Hugo Sales | 81e45e3ace | |
Hugo Sales | 107351a6b5 | |
Hugo Sales | 292d98a33c | |
Hugo Sales | 72ee91a8da | |
Hugo Sales | 2eb61543d9 | |
Hugo Sales | 60002df680 | |
Hugo Sales | f081d58e2b | |
Hugo Sales | 13244c1e37 | |
Hugo Sales | 71c9462d2e | |
Hugo Sales | c410f9b67a | |
Hugo Sales | b4fb1569ce | |
Hugo Sales | fdcedb8295 | |
Hugo Sales | 39e3e8a04e | |
Hugo Sales | 7bb3717673 | |
Hugo Sales | 8dcf563674 | |
Hugo Sales | a582cfe4f2 | |
Hugo Sales | 0af82054ff | |
Hugo Sales | f812d9142f | |
Hugo Sales | 92ecb50cff | |
Hugo Sales | 1b2c308808 | |
Hugo Sales | 0c448ee83f | |
Hugo Sales | a075d35c8c | |
Hugo Sales | f26b488045 | |
Hugo Sales | 2fd81e218a | |
Hugo Sales | fc4d8bcf65 | |
Hugo Sales | 0ef9223803 | |
Hugo Sales | 9bc186a072 | |
Hugo Sales | 70cb6d5d94 | |
Hugo Sales | 162a955f41 | |
Hugo Sales | aadb4832bc | |
Hugo Sales | 0e96ffe287 | |
Hugo Sales | 958d5bfe22 | |
Hugo Sales | c0ba6250aa | |
Hugo Sales | 0a6b134f23 | |
Hugo Sales | a7715fc9c3 | |
Hugo Sales | b508fbe3b1 | |
Hugo Sales | 9a9ac8b55f | |
Hugo Sales | adda4caea4 | |
Hugo Sales | a7ff0ef506 | |
Hugo Sales | ee1c1bce80 | |
Hugo Sales | 7b00ab4699 | |
Hugo Sales | df60e72fb3 | |
Hugo Sales | d5b5d97bc1 | |
Hugo Sales | 640c4b2ca8 | |
Hugo Sales | 11822cbed0 | |
Hugo Sales | 9fb74c2f27 | |
rainydaysavings | f361a64ab5 | |
rainydaysavings | a4934a4ef3 | |
rainydaysavings | 46c63b3240 | |
rainydaysavings | f77f56e1f2 | |
rainydaysavings | 2f05f05dc9 | |
rainydaysavings | 02b17049e3 | |
rainydaysavings | ee6791fe97 | |
rainydaysavings | de5554f1e2 | |
rainydaysavings | e1f9143cf5 | |
rainydaysavings | 4fd69b684a | |
rainydaysavings | c133565780 | |
rainydaysavings | 099be93420 | |
rainydaysavings | 43b7076ff8 | |
rainydaysavings | 49e33557e1 | |
rainydaysavings | f16789f10e | |
rainydaysavings | 2b4540952e | |
Hugo Sales | 91ff4dbdec | |
Hugo Sales | 4cc196a69a | |
Hugo Sales | 7cedbcd63f | |
Hugo Sales | ba7ad5fd28 | |
Hugo Sales | 7ca22ecc1d | |
Hugo Sales | 02a23a2aff | |
Hugo Sales | 47af6e85b8 | |
Hugo Sales | 04b0d63d43 | |
Hugo Sales | 62c9b56b3f | |
Hugo Sales | 155038a5c0 | |
Hugo Sales | 89ce298a3b | |
Hugo Sales | 9563fb0af3 | |
Hugo Sales | 2c4fcaaf07 | |
Hugo Sales | 3aaad123de | |
Hugo Sales | 7a07b95240 | |
Hugo Sales | e5babcd36e | |
Hugo Sales | 1134fec173 | |
Hugo Sales | d2b44f4400 | |
Hugo Sales | aaba304ca8 | |
Hugo Sales | 288f8363ae | |
Hugo Sales | b09e1525eb | |
Hugo Sales | 9fadb73ea5 | |
Hugo Sales | cf1483e6b5 | |
Hugo Sales | b579842eb6 | |
Hugo Sales | 47ab835549 | |
Hugo Sales | 69341880d3 | |
Hugo Sales | e146ebc05b | |
Hugo Sales | ceb5092b34 | |
Hugo Sales | 17da1f7fb5 | |
Hugo Sales | 43e56c08f7 | |
Hugo Sales | 6f9c70398b | |
Hugo Sales | 8b9a1dd535 | |
Hugo Sales | c2d9d5b75b | |
Hugo Sales | 4fa6295fde | |
Hugo Sales | bf4c06295a | |
rainydaysavings | 443a5438be | |
rainydaysavings | 9b88f93cad | |
rainydaysavings | d6196a5e69 | |
rainydaysavings | b39d43a700 | |
rainydaysavings | 20497bf905 | |
rainydaysavings | fe20ed08d0 | |
rainydaysavings | a87653860b | |
rainydaysavings | 9f2977bfd1 | |
rainydaysavings | 1e8efe180c | |
rainydaysavings | 29712edbd3 | |
rainydaysavings | 0211771d5f | |
rainydaysavings | f100d33d94 | |
rainydaysavings | 9a05f11b65 | |
rainydaysavings | b28f3ffa19 | |
rainydaysavings | 168d138481 | |
rainydaysavings | 3daa764d87 | |
rainydaysavings | ff06671cd5 | |
rainydaysavings | 5736bd1408 | |
rainydaysavings | 999b31b615 | |
rainydaysavings | e925c566ac | |
rainydaysavings | 8faf299a23 | |
rainydaysavings | 2985284f2b | |
Hugo Sales | 2d1d697498 | |
Hugo Sales | 57310dcb15 | |
Hugo Sales | a574971f0b | |
Hugo Sales | 68a5551f36 | |
Hugo Sales | 3a6b4cca1e | |
Hugo Sales | 6fe35833e7 | |
Hugo Sales | 4b4f235481 | |
Hugo Sales | 6c0c84c284 | |
Hugo Sales | 26c966084a | |
Hugo Sales | bf92c44d81 | |
Hugo Sales | 07422c4e1a | |
Hugo Sales | 6e6e50939b | |
Hugo Sales | 0ecb164e2e | |
Hugo Sales | 15cf498e75 | |
Hugo Sales | 377965d100 | |
Hugo Sales | c9b0e994c1 | |
Hugo Sales | daf4f0727d | |
Hugo Sales | 652c3b5d62 | |
Hugo Sales | 95764a0c48 | |
Hugo Sales | 97e9991d85 | |
Hugo Sales | 26be897578 | |
Hugo Sales | b15fb50194 | |
Hugo Sales | d7218535dd | |
Hugo Sales | 3108d82a4d | |
Hugo Sales | d5fa31a6f5 | |
Hugo Sales | a8cd9034ff | |
Hugo Sales | d73840352b | |
Hugo Sales | 766eac8467 | |
Hugo Sales | 0fe5ae7675 | |
Hugo Sales | 66c4ab7e24 | |
Hugo Sales | 34f49edf2c | |
Hugo Sales | 27bb76706c | |
Hugo Sales | ce00acdb39 | |
Hugo Sales | e5a97611d0 | |
Hugo Sales | d2f49e56bc | |
Hugo Sales | 520989bc59 | |
Hugo Sales | 2407853970 | |
Hugo Sales | ccc0d7d401 | |
Hugo Sales | ac68436b0b | |
Hugo Sales | b0fece57ea | |
Hugo Sales | 7dda377a79 | |
Hugo Sales | 4a754553f7 | |
Hugo Sales | 074797384d | |
Hugo Sales | 7718b167c3 | |
Hugo Sales | a2f5b77ff0 | |
Hugo Sales | 40ec37bd27 | |
Hugo Sales | 0d5f66e8b8 | |
Diogo Cordeiro | c67cf336d4 | |
Diogo Cordeiro | 4efbf9361c | |
Hugo Sales | 87a768ac8d | |
Hugo Sales | 26ee98a224 | |
Hugo Sales | 5c1851028a | |
Hugo Sales | f0b8f91a75 | |
Hugo Sales | 70fac546da | |
Hugo Sales | 80755fc6e2 | |
Hugo Sales | feb3c16b3f | |
Hugo Sales | cef20e1332 | |
Hugo Sales | 3b50815422 | |
Hugo Sales | 33270dabf3 | |
Hugo Sales | a97c511c7a | |
Hugo Sales | 3b5789639b | |
Hugo Sales | 04a59d22a6 | |
Hugo Sales | cb1944aca9 | |
Hugo Sales | c2add5e1d1 | |
Hugo Sales | a72e0a53e7 | |
Hugo Sales | 3ad81ab730 | |
Hugo Sales | 2afb15ee02 | |
Hugo Sales | 160b811669 | |
Hugo Sales | fe603928e2 | |
Hugo Sales | 57298da60e | |
Hugo Sales | d14ac1edf6 | |
Hugo Sales | 8ca49ab511 | |
Hugo Sales | 04202b59ef | |
Hugo Sales | 4bc3eabd29 | |
Hugo Sales | 36bc871c65 | |
Hugo Sales | 1589f6e26f | |
Hugo Sales | b52f0c795e | |
Hugo Sales | 8f13d331ad | |
Hugo Sales | b46e3d5bf4 | |
Hugo Sales | 8a8d0f1dcd | |
Hugo Sales | e3ef58bd8e | |
Hugo Sales | 57f7f40fa9 | |
Hugo Sales | 7e4aacd342 | |
Hugo Sales | 1caab62200 | |
Hugo Sales | 6c8da48efa | |
Hugo Sales | 10a304ab83 | |
Hugo Sales | a1d83bd2a8 | |
Hugo Sales | df6da4d941 | |
Hugo Sales | d8d2ad3e10 | |
Hugo Sales | 2f5bdeed62 | |
Hugo Sales | 40b0812d9b | |
Hugo Sales | 26ea268fed | |
Hugo Sales | 04a5d2bfef | |
Hugo Sales | 06d76a649f | |
Hugo Sales | b0960c5345 | |
Hugo Sales | 6546c088d9 | |
Hugo Sales | 69550a1036 | |
Hugo Sales | 027726205d | |
Hugo Sales | 4d2b8c26fa | |
Hugo Sales | cc47efe4b5 | |
Hugo Sales | c0e53ae658 | |
Hugo Sales | 5ec23f2200 | |
Hugo Sales | 51d1ea4f8f | |
Hugo Sales | fff0ecd1cd | |
Hugo Sales | 86b5bfe075 | |
Hugo Sales | f01331671c |
|
@ -1,3 +0,0 @@
|
|||
((nil . ((php-project-root . auto)
|
||||
(phpstan-executable . (root . "bin/phpstan"))
|
||||
)
|
|
@ -16,9 +16,8 @@
|
|||
###< symfony/phpunit-bridge ###
|
||||
|
||||
###> friendsofphp/php-cs-fixer ###
|
||||
!.php-cs-fixer.php
|
||||
!.php_cs
|
||||
/.php_cs.cache
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
|
|
|
@ -1,46 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This document has been generated with
|
||||
* https://mlocati.github.io/php-cs-fixer-configurator/#version:3.2.1|configurator
|
||||
* https://mlocati.github.io/php-cs-fixer-configurator/#version:2.16.1|configurator
|
||||
* you can change this configuration by importing this file.
|
||||
*/
|
||||
$config = new PhpCsFixer\Config();
|
||||
return $config
|
||||
return PhpCsFixer\Config::create()
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
// Each line of multi-line DocComments must have an asterisk [PSR-5] and must be aligned with the first one.
|
||||
'align_multiline_comment' => ['comment_type' => 'phpdocs_like'],
|
||||
// Each element of an array must be indented exactly once.
|
||||
'array_indentation' => true,
|
||||
// Converts simple usages of `array_push($x, $y);` to `$x[] = $y;`.
|
||||
'array_push' => true,
|
||||
// PHP arrays should be declared using the configured syntax.
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
// Use the null coalescing assignment operator `??=` where possible.
|
||||
'assign_null_coalescing_to_coalesce_equal' => true,
|
||||
// Binary operators should be surrounded by space as configured.
|
||||
'binary_operator_spaces' => ['default' => 'align_single_space_minimal', 'operators' => ['??' => 'align']],
|
||||
'binary_operator_spaces' => [
|
||||
'default' => 'align_single_space_minimal',
|
||||
'operators' => ['??' => 'align'],
|
||||
],
|
||||
// There MUST be one blank line after the namespace declaration.
|
||||
'blank_line_after_namespace' => true,
|
||||
// Each element of an array must be indented exactly once.
|
||||
'array_indentation' => true,
|
||||
// Ensure there is no code on the same line as the PHP open tag and it is followed by a blank line.
|
||||
'blank_line_after_opening_tag' => true,
|
||||
'blank_line_after_opening_tag' => false,
|
||||
// The body of each structure MUST be enclosed by braces. Braces should be properly placed. Body of braces should be properly indented.
|
||||
'braces' => ['allow_single_line_anonymous_class_with_empty_body' => true, 'allow_single_line_closure' => true, 'position_after_functions_and_oop_constructs' => 'next'],
|
||||
'braces' => ['allow_single_line_closure' => true, 'position_after_functions_and_oop_constructs' => 'next',
|
||||
// 'allow_single_line_functions' => true, // Awaiting PR merge...
|
||||
],
|
||||
// A single space or none should be between cast and variable.
|
||||
'cast_spaces' => true,
|
||||
// Class, trait and interface elements must be separated with one blank line.
|
||||
'class_attributes_separation' => false,
|
||||
// Whitespace around the keywords of a class, trait or interfaces definition should be one space.
|
||||
'class_definition' => ['single_item_single_line' => true, 'single_line' => true],
|
||||
// Using `isset($var) &&` multiple times should be done in one call.
|
||||
'combine_consecutive_issets' => true,
|
||||
// Calling `unset` on multiple items should be done in one call.
|
||||
'combine_consecutive_unsets' => true,
|
||||
// Replace multiple nested calls of `dirname` by only one call with second `$level` parameter. Requires PHP >= 7.0.
|
||||
'combine_nested_dirname' => true,
|
||||
// Comments with annotation should be docblock when used on structural elements.
|
||||
'comment_to_phpdoc' => false,
|
||||
// Remove extra spaces in a nullable typehint.
|
||||
'compact_nullable_typehint' => true,
|
||||
// Concatenation should be spaced according configuration.
|
||||
|
@ -49,20 +45,8 @@ return $config
|
|||
'constant_case' => true,
|
||||
// Equal sign in declare statement should be surrounded by spaces or not following configuration.
|
||||
'declare_equal_normalize' => ['space' => 'single'],
|
||||
// There must not be spaces around `declare` statement parentheses.
|
||||
'declare_parentheses' => true,
|
||||
// Force strict types declaration in all files. Requires PHP >= 7.0.
|
||||
'declare_strict_types' => true,
|
||||
// Replaces `dirname(__FILE__)` expression with equivalent `__DIR__` constant.
|
||||
'dir_constant' => true,
|
||||
// Replaces short-echo `<?=` with long format `<?php echo`/`<?php print` syntax, or vice-versa.
|
||||
'echo_tag_syntax' => true,
|
||||
// The keyword `elseif` should be used instead of `else if` so that all control keywords look like single words.
|
||||
'elseif' => true,
|
||||
// Empty loop-body must be in configured style.
|
||||
'empty_loop_body' => true,
|
||||
// Empty loop-condition must be in configured style.
|
||||
'empty_loop_condition' => true,
|
||||
// PHP code MUST use only UTF-8 without BOM (remove BOM).
|
||||
'encoding' => true,
|
||||
// Replace deprecated `ereg` regular expression functions with `preg`.
|
||||
|
@ -81,30 +65,14 @@ return $config
|
|||
'function_declaration' => ['closure_function_spacing' => 'one'],
|
||||
// Ensure single space between function's argument and its typehint.
|
||||
'function_typehint_space' => true,
|
||||
// Imports or fully qualifies global classes/functions/constants.
|
||||
'global_namespace_import' => true,
|
||||
// Heredoc/nowdoc content must be properly indented. Requires PHP >= 7.3.
|
||||
'heredoc_indentation' => true,
|
||||
// Convert `heredoc` to `nowdoc` where possible.
|
||||
'heredoc_to_nowdoc' => true,
|
||||
// Function `implode` must be called with 2 arguments in the documented order.
|
||||
'implode_call' => true,
|
||||
// Pre- or post-increment and decrement operators should be used if possible.
|
||||
'increment_style' => true,
|
||||
// Code MUST use configured indentation type.
|
||||
'indentation_type' => true,
|
||||
// Lambda must not import variables it doesn't use.
|
||||
'lambda_not_used_import' => true,
|
||||
// All PHP files must use same line ending.
|
||||
'line_ending' => true,
|
||||
// Ensure there is no code on the same line as the PHP open tag.
|
||||
'linebreak_after_opening_tag' => true,
|
||||
// List (`array` destructuring) assignment should be declared using the configured syntax. Requires PHP >= 7.1.
|
||||
'list_syntax' => ['syntax' => 'short'],
|
||||
// Use `&&` and `||` logical operators instead of `and` and `or`.
|
||||
'logical_operators' => true,
|
||||
// Cast should be written in lower case.
|
||||
'lowercase_cast' => true,
|
||||
// PHP keywords MUST be in lower case.
|
||||
'lowercase_keywords' => true,
|
||||
// Class static references `self`, `static` and `parent` MUST be in lower case.
|
||||
|
@ -113,14 +81,6 @@ return $config
|
|||
'magic_constant_casing' => true,
|
||||
// Magic method definitions and calls must be using the correct casing.
|
||||
'magic_method_casing' => true,
|
||||
// Replace non multibyte-safe functions with corresponding mb function.
|
||||
'mb_str_functions' => true,
|
||||
// In method arguments and method call, there MUST NOT be a space before each comma and there MUST be one space after each comma. Argument lists MAY be split across multiple lines, where each subsequent line is indented once. When doing so, the first item in the list MUST be on the next line, and there MUST be only one argument per line.
|
||||
'method_argument_space' => ['after_heredoc' => true, 'on_multiline' => 'ensure_fully_multiline'],
|
||||
// Method chaining MUST be properly indented. Method chaining with different levels of indentation is not supported.
|
||||
'method_chaining_indentation' => true,
|
||||
// Replace `strpos()` calls with `str_starts_with()` or `str_contains()` if possible.
|
||||
'modernize_strpos' => true,
|
||||
// Replaces `intval`, `floatval`, `doubleval`, `strval` and `boolval` function calls with according type casting operator.
|
||||
'modernize_types_casting' => true,
|
||||
// DocBlocks must start with two asterisks, multiline comments must start with a single asterisk, after the opening slash. Both must end with a single asterisk before the closing slash.
|
||||
|
@ -128,17 +88,13 @@ return $config
|
|||
// Forbid multi-line whitespace before the closing semicolon or move the semicolon to the new line for chained calls.
|
||||
'multiline_whitespace_before_semicolons' => true,
|
||||
// Add leading `\` before constant invocation of internal constant to speed up resolving. Constant name match is case-sensitive, except for `null`, `false` and `true`.
|
||||
'native_constant_invocation' => true,
|
||||
'native_constant_invocation' => false,
|
||||
// Function defined by PHP should be called using the correct casing.
|
||||
'native_function_casing' => true,
|
||||
// Add leading `\` before function invocation to speed up resolving.
|
||||
'native_function_invocation' => true,
|
||||
'native_function_invocation' => false,
|
||||
// Native type hints for functions should use the correct case.
|
||||
'native_function_type_declaration_casing' => true,
|
||||
// Master language constructs shall be used instead of aliases.
|
||||
'no_alias_language_construct_call' => true,
|
||||
// Replace control structure alternative syntax to use braces.
|
||||
'no_alternative_syntax' => true,
|
||||
// There should be no empty lines after class opening brace.
|
||||
'no_blank_lines_after_class_opening' => true,
|
||||
// There should not be blank lines between docblock and the documented element.
|
||||
|
@ -151,7 +107,7 @@ return $config
|
|||
'no_empty_comment' => true,
|
||||
// There should not be empty PHPDoc blocks.
|
||||
'no_empty_phpdoc' => true,
|
||||
// Remove useless (semicolon) statements.
|
||||
// Remove useless semicolon statements.
|
||||
'no_empty_statement' => true,
|
||||
// Removes extra blank lines and/or blank lines following configuration.
|
||||
'no_extra_blank_lines' => true,
|
||||
|
@ -167,18 +123,18 @@ return $config
|
|||
'no_null_property_initialization' => true,
|
||||
// Short cast `bool` using double exclamation mark should not be used.
|
||||
'no_short_bool_cast' => true,
|
||||
// Replace short-echo `<?=` with long format `<?php echo` syntax.
|
||||
'no_short_echo_tag' => true,
|
||||
// Single-line whitespace before closing semicolon are prohibited.
|
||||
'no_singleline_whitespace_before_semicolons' => true,
|
||||
// There must be no space around double colons (also called Scope Resolution Operator or Paamayim Nekudotayim).
|
||||
'no_space_around_double_colon' => true,
|
||||
// When making a method or function call, there MUST NOT be a space between the method or function name and the opening parenthesis.
|
||||
'no_spaces_after_function_name' => true,
|
||||
// There MUST NOT be spaces around offset braces.
|
||||
'no_spaces_around_offset' => true,
|
||||
// There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis.
|
||||
'no_spaces_inside_parenthesis' => true,
|
||||
// Removes `@param`, `@return` and `@var` tags that don't provide any useful information.
|
||||
'no_superfluous_phpdoc_tags' => true,
|
||||
// Replaces superfluous `elseif` with `if`.
|
||||
'no_superfluous_elseif' => false,
|
||||
// Remove trailing commas in list function calls.
|
||||
'no_trailing_comma_in_list_call' => true,
|
||||
// PHP single-line arrays should not have trailing comma.
|
||||
|
@ -194,9 +150,13 @@ return $config
|
|||
// In function arguments there must not be arguments with default values before non-default ones.
|
||||
'no_unreachable_default_argument_value' => true,
|
||||
// Variables must be set `null` instead of using `(unset)` casting.
|
||||
'no_unset_cast' => true,
|
||||
'no_unset_cast' => false,
|
||||
// Properties should be set to `null` instead of using `unset`.
|
||||
'no_unset_on_property' => true,
|
||||
// Unused `use` statements must be removed.
|
||||
'no_unused_imports' => true,
|
||||
// There should not be useless `else` cases.
|
||||
'no_useless_else' => false,
|
||||
// There should not be an empty `return` statement at the end of a function.
|
||||
'no_useless_return' => true,
|
||||
// In array declaration, there MUST NOT be a whitespace before each comma.
|
||||
|
@ -207,32 +167,22 @@ return $config
|
|||
'non_printable_character' => true,
|
||||
// Array index should always be written by using square braces.
|
||||
'normalize_index_brace' => true,
|
||||
// Adds or removes `?` before type declarations for parameters with a default `null` value.
|
||||
'nullable_type_declaration_for_default_null_value' => true,
|
||||
// There should not be space before or after object operators `->` and `?->`.
|
||||
// There should not be space before or after object `T_OBJECT_OPERATOR` `->`.
|
||||
'object_operator_without_whitespace' => true,
|
||||
// Operators - when multiline - must always be at the beginning or at the end of the line.
|
||||
'operator_linebreak' => true,
|
||||
// Ordering `use` statements.
|
||||
'ordered_imports' => true,
|
||||
// Orders the elements of classes/interfaces/traits.
|
||||
'ordered_class_elements' => false,
|
||||
// PHPUnit assertion method calls like `->assertSame(true, $foo)` should be written with dedicated method like `->assertTrue($foo)`.
|
||||
'php_unit_construct' => true,
|
||||
// PHPUnit assertions like `assertInternalType`, `assertFileExists`, should be used over `assertTrue`.
|
||||
'php_unit_dedicate_assert' => true,
|
||||
// PHPUnit assertions like `assertIsArray` should be used over `assertInternalType`.
|
||||
'php_unit_dedicate_assert_internal_type' => true,
|
||||
// Usages of `->setExpectedException*` methods MUST be replaced by `->expectException*` methods.
|
||||
'php_unit_expectation' => true,
|
||||
// PHPUnit annotations should be a FQCNs including a root namespace.
|
||||
'php_unit_fqcn_annotation' => true,
|
||||
// Enforce camel (or snake) case for PHPUnit test methods, following configuration.
|
||||
'php_unit_method_casing' => true,
|
||||
// Usage of PHPUnit's mock e.g. `->will($this->returnValue(..))` must be replaced by its shorter equivalent such as `->willReturn(...)`.
|
||||
'php_unit_mock_short_will_return' => true,
|
||||
// PHPUnit classes MUST be used in namespaced version, e.g. `\PHPUnit\Framework\TestCase` instead of `\PHPUnit_Framework_TestCase`.
|
||||
'php_unit_namespaced' => true,
|
||||
// Usages of `@expectedException*` annotations MUST be replaced by `->setExpectedException*` methods.
|
||||
'php_unit_no_expectation_annotation' => true,
|
||||
// Order `@covers` annotation of PHPUnit tests.
|
||||
'php_unit_ordered_covers' => true,
|
||||
// Changes the visibility of the `setUp()` and `tearDown()` functions of PHPUnit to `protected`, to match the PHPUnit TestCase.
|
||||
'php_unit_set_up_tear_down_visibility' => true,
|
||||
// PHPUnit methods like `assertSame` should be used instead of `assertEquals`.
|
||||
|
@ -243,26 +193,16 @@ return $config
|
|||
'phpdoc_add_missing_param_annotation' => true,
|
||||
// All items of the given phpdoc tags must be either left-aligned or (by default) aligned vertically.
|
||||
'phpdoc_align' => true,
|
||||
// PHPDoc annotation descriptions should not be a sentence.
|
||||
'phpdoc_annotation_without_dot' => true,
|
||||
// Docblocks should have the same indentation as the documented subject.
|
||||
'phpdoc_indent' => true,
|
||||
// Fixes PHPDoc inline tags.
|
||||
'phpdoc_inline_tag_normalizer' => true,
|
||||
// Changes doc blocks from single to multi line, or reversed. Works for class constants, properties and methods only.
|
||||
'phpdoc_line_span' => true,
|
||||
// Fix PHPDoc inline tags, make `@inheritdoc` always inline.
|
||||
'phpdoc_inline_tag' => true,
|
||||
// `@access` annotations should be omitted from PHPDoc.
|
||||
'phpdoc_no_access' => true,
|
||||
// No alias PHPDoc tags should be used.
|
||||
'phpdoc_no_alias_tag' => true,
|
||||
// `@return void` and `@return null` annotations should be omitted from PHPDoc.
|
||||
'phpdoc_no_empty_return' => true,
|
||||
// Classy that does not inherit must not have `@inheritdoc` tags.
|
||||
'phpdoc_no_useless_inheritdoc' => true,
|
||||
// Annotations in PHPDoc should be ordered so that `@param` annotations come first, then `@throws` annotations, then `@return` annotations.
|
||||
'phpdoc_order' => true,
|
||||
// Order phpdoc tags by value.
|
||||
'phpdoc_order_by_value' => ['annotations' => ['covers', 'throws']],
|
||||
// The type of `@return` annotations of methods returning a reference to itself must the configured one.
|
||||
'phpdoc_return_self_reference' => true,
|
||||
// Scalar types should always be written in the same form. `int` not `integer`, `bool` not `boolean`, `float` not `real` or `double`.
|
||||
|
@ -270,18 +210,7 @@ return $config
|
|||
// Annotations in PHPDoc should be grouped together so that annotations of the same type immediately follow each other, and annotations of a different type are separated by a single blank line.
|
||||
'phpdoc_separation' => true,
|
||||
// Single line `@var` PHPDoc should have proper spacing.
|
||||
'phpdoc_single_line_var_spacing' => true,
|
||||
// Fixes casing of PHPDoc tags.
|
||||
'phpdoc_tag_casing' => true,
|
||||
// Would be neat, but breaks cases where the parents don't have annotations
|
||||
// // EXPERIMENTAL: Takes `@param` annotations of non-mixed types and adjusts accordingly the function signature. Requires PHP >= 7.0.
|
||||
// 'phpdoc_to_param_type' => true,
|
||||
// // EXPERIMENTAL: Takes `@var` annotation of non-mixed types and adjusts accordingly the property signature. Requires PHP >= 7.4.
|
||||
// 'phpdoc_to_property_type' => true,
|
||||
// // EXPERIMENTAL: Takes `@return` annotation of non-mixed types and adjusts accordingly the function signature. Requires PHP >= 7.0.
|
||||
// 'phpdoc_to_return_type' => true,
|
||||
// PHPDoc should start and end with content, excluding the very first and last line of the docblocks.
|
||||
'phpdoc_trim' => true,
|
||||
'phpdoc_single_line_var_spacing' => false,
|
||||
// Removes extra blank lines after summary and after description in PHPDoc.
|
||||
'phpdoc_trim_consecutive_blank_line_separation' => true,
|
||||
// The correct case must be used for standard PHP types in PHPDoc.
|
||||
|
@ -290,14 +219,8 @@ return $config
|
|||
'phpdoc_types_order' => true,
|
||||
// `@var` and `@type` annotations must have type and name in the correct order.
|
||||
'phpdoc_var_annotation_correct_order' => true,
|
||||
// Converts `pow` to the `**` operator.
|
||||
'pow_to_exponentiation' => true,
|
||||
// Replaces `rand`, `srand`, `getrandmax` functions calls with their `mt_*` analogs or `random_int`.
|
||||
'random_api_migration' => true,
|
||||
// Callables must be called without using `call_user_func*` when possible.
|
||||
'regular_callable_call' => true,
|
||||
// Local, dynamic and directly referenced variables should not be assigned and directly returned by a function or method.
|
||||
'return_assignment' => true,
|
||||
// Class names should match the file name.
|
||||
'psr4' => true,
|
||||
// There should be one or no space before colon, and one space after it in return type declarations, according to configuration.
|
||||
'return_type_declaration' => true,
|
||||
// Inside class or interface element `self` should be preferred to the class name itself.
|
||||
|
@ -310,26 +233,18 @@ return $config
|
|||
'short_scalar_cast' => true,
|
||||
// Converts explicit variables in double-quoted strings and heredoc syntax from simple to complex format (`${` to `{$`).
|
||||
'simple_to_complex_string_variable' => true,
|
||||
// Simplify `if` control structures that return the boolean result of their condition.
|
||||
'simplified_if_return' => true,
|
||||
// A return statement wishing to return `void` should not return `null`.
|
||||
'simplified_null_return' => true,
|
||||
// A PHP file without end tag must always end with a single empty line feed.
|
||||
'single_blank_line_at_eof' => true,
|
||||
// There should be exactly one blank line before a namespace declaration.
|
||||
'single_blank_line_before_namespace' => true,
|
||||
// There MUST NOT be more than one property or constant declared per statement.
|
||||
'single_class_element_per_statement' => true,
|
||||
// There MUST be one use keyword per declaration.
|
||||
'single_import_per_statement' => true,
|
||||
// There MUST NOT be more than one property or constant declared per statement.
|
||||
'single_class_element_per_statement' => true,
|
||||
// Each namespace use MUST go on its own line and there MUST be one blank line after the use statements block.
|
||||
'single_line_after_imports' => true,
|
||||
// Single-line comments and multi-line comments with only one line of actual content should use the `//` syntax.
|
||||
'single_line_comment_style' => true,
|
||||
// Convert double quotes to single quotes for simple strings.
|
||||
'single_quote' => true,
|
||||
// Ensures a single space after language constructs.
|
||||
'single_space_after_construct' => true,
|
||||
// Each trait `use` must be done as single statement.
|
||||
'single_trait_insert_per_statement' => true,
|
||||
// Fix whitespace after a semicolon.
|
||||
|
@ -338,41 +253,26 @@ return $config
|
|||
'standardize_increment' => true,
|
||||
// Replace all `<>` with `!=`.
|
||||
'standardize_not_equals' => true,
|
||||
// String tests for empty must be done against `''`, not with `strlen`.
|
||||
'string_length_to_empty' => true,
|
||||
// A case should be followed by a colon and not a semicolon.
|
||||
'switch_case_semicolon_to_colon' => true,
|
||||
// Removes extra spaces between colon and case value.
|
||||
'switch_case_space' => true,
|
||||
// Switch case must not be ended with `continue` but with `break`.
|
||||
'switch_continue_to_break' => true,
|
||||
// Standardize spaces around ternary operator.
|
||||
'ternary_operator_spaces' => true,
|
||||
// Use the Elvis operator `?:` where possible.
|
||||
'ternary_to_elvis_operator' => true,
|
||||
// Use `null` coalescing operator `??` where possible. Requires PHP >= 7.0.
|
||||
'ternary_to_null_coalescing' => true,
|
||||
// Multi-line arrays, arguments list and parameters list must have a trailing comma.
|
||||
'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arguments', 'arrays', 'parameters']],
|
||||
// Arrays should be formatted like function/method arguments, without leading or trailing single line space.
|
||||
'trim_array_spaces' => true,
|
||||
// A single space or none should be around union type operator.
|
||||
'types_spaces' => true,
|
||||
// PHP multi-line arrays should have a trailing comma.
|
||||
'trailing_comma_in_multiline_array' => true,
|
||||
// Unary operators should be placed adjacent to their operands.
|
||||
'unary_operator_spaces' => true,
|
||||
// Anonymous functions with one-liner return statement must use arrow functions.
|
||||
'use_arrow_functions' => true,
|
||||
// Visibility MUST be declared on all properties and methods; `abstract` and `final` MUST be declared before the visibility; `static` MUST be declared after the visibility.
|
||||
'visibility_required' => true,
|
||||
// In array declaration, there MUST be a whitespace after each comma.
|
||||
'whitespace_after_comma_in_array' => true,
|
||||
])
|
||||
->setFinder(
|
||||
PhpCsFixer\Finder::create()
|
||||
->exclude('vendor')
|
||||
->exclude('var')
|
||||
->exclude('docker')
|
||||
->exclude('src/Entity')
|
||||
->notPath('src/Core/DB/DefaultSettings.php')
|
||||
->in(__DIR__),
|
||||
->setFinder(PhpCsFixer\Finder::create()
|
||||
->exclude('vendor')
|
||||
->exclude('var')
|
||||
->exclude('docker')
|
||||
->exclude('src/Entity')
|
||||
->notPath('src/Core/DB/DefaultSettings.php')
|
||||
->in(__DIR__)
|
||||
);
|
12
CREDITS.md
12
CREDITS.md
|
@ -3,21 +3,15 @@ Credits for GNU social
|
|||
The following is an incomplete list of developers
|
||||
who've worked on GNU social, or its predecessors
|
||||
StatusNet and Free Social. Apologies for any
|
||||
oversight; please let mail@diogo.site know if
|
||||
oversight; please let mattl@gnu.org know if
|
||||
anyone's been overlooked in error.
|
||||
|
||||
Current team
|
||||
------------
|
||||
* Alexei Sorokin
|
||||
* Diogo Cordeiro
|
||||
* Eliseu Amaro
|
||||
* Hugo Sales
|
||||
|
||||
V2 team
|
||||
-------
|
||||
* Diogo Cordeiro
|
||||
* Alexei Sorokin
|
||||
* Bruno Casteleiro
|
||||
* Diogo Cordeiro
|
||||
* Hugo Sales
|
||||
|
||||
Additional Contributors
|
||||
-----------------------
|
||||
|
|
|
@ -491,7 +491,7 @@ For configuring the public stream.
|
|||
some kind of automated poster, testing bots, etc.
|
||||
|
||||
* `exclude_sources` (array, default []): Sources of notices that should be kept off of
|
||||
the public feed (because they're from automatic posters, for instance).
|
||||
the public timeline (because they're from automatic posters, for instance).
|
||||
|
||||
|
||||
throttle
|
||||
|
|
109
Makefile
109
Makefile
|
@ -1,12 +1,5 @@
|
|||
|
||||
DIR=$(strip $(notdir $(CURDIR))) # Seems a bit hack-ish, but `basename` works differently
|
||||
|
||||
translate-container-name = $$(if docker container inspect $(1) > /dev/null 2>&1; then echo $(1); else echo $(1) | sed 'y/_/-/' ; fi)
|
||||
args = `arg="$(filter-out $@,$(MAKECMDGOALS))" && echo $${arg:-${1}}`
|
||||
|
||||
%:
|
||||
@:
|
||||
|
||||
.PHONY:
|
||||
@if ! docker info > /dev/null; then echo "Docker does not seem to be running"; exit 1; fi
|
||||
|
||||
|
@ -17,106 +10,22 @@ down: .PHONY
|
|||
docker-compose down
|
||||
|
||||
redis-shell:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli'
|
||||
docker exec -it $(strip $(DIR))_redis_1 sh -c 'redis-cli'
|
||||
|
||||
php-repl: .PHONY
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c '/var/www/social/bin/console psysh'
|
||||
docker exec -it $(strip $(DIR))_php_1 sh -c '/var/www/social/bin/console psysh'
|
||||
|
||||
php-shell: .PHONY
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social; sh'
|
||||
docker exec -it $(strip $(DIR))_php_1 sh -c 'cd /var/www/social; sh'
|
||||
|
||||
psql-shell: .PHONY
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) sh -c "psql -U postgres social"
|
||||
|
||||
database-force-nuke:
|
||||
docker stop $(call translate-container-name,$(strip $(DIR))_worker_1) \
|
||||
&& docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "cd /var/www/social; bin/console doctrine:database:drop --force && bin/console doctrine:database:create && bin/console doctrine:schema:update --dump-sql --force && bin/console app:populate_initial_values" \
|
||||
&& docker-compose up -d
|
||||
docker exec -it $(strip $(DIR))_db_1 sh -c "psql -U postgres social"
|
||||
|
||||
database-force-schema-update:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
|
||||
docker exec -it $(strip $(DIR))_php_1 sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
|
||||
|
||||
tooling-docker: .PHONY
|
||||
@cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1
|
||||
test: .PHONY
|
||||
cd docker/testing && docker-compose run php; docker-compose down
|
||||
|
||||
stop-tooling: .PHONY
|
||||
cd docker/tooling && docker-compose down
|
||||
|
||||
tooling-php-shell: tooling-docker
|
||||
docker exec -it $(call translate-container-name,tooling_php_1) sh
|
||||
|
||||
test-accesibility: tooling-docker
|
||||
cd docker/tooling && docker-compose run pa11y /accessibility.sh
|
||||
|
||||
test: tooling-docker
|
||||
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
|
||||
|
||||
cs-fixer: tooling-docker
|
||||
@bin/php-cs-fixer $${CS_FIXER_FILE}
|
||||
|
||||
doc-check: tooling-docker
|
||||
bin/php-doc-check
|
||||
|
||||
phpstan: tooling-docker
|
||||
bin/phpstan
|
||||
|
||||
remove-var:
|
||||
rm -rf var/*
|
||||
|
||||
remove-file:
|
||||
sudo rm -rf file/*
|
||||
|
||||
flush-redis-cache:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
|
||||
|
||||
install-plugins:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) /var/www/social/bin/install_plugins.sh
|
||||
|
||||
update-dependencies:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && composer update'
|
||||
|
||||
update-autocode:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && bin/update_autocode'
|
||||
|
||||
backup-actors:
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) \
|
||||
sh -c 'su postgres -c "mkdir -p /tmp/backup"' && \
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
|
||||
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
|
||||
copy actor to '/tmp/backup/actor.csv';\
|
||||
copy local_user to '/tmp/backup/local_user.csv';\
|
||||
copy local_group to '/tmp/backup/local_group.csv';\
|
||||
\
|
||||
copy activitypub_actor to '/tmp/backup/ap_actor.csv';\
|
||||
copy activitypub_rsa to '/tmp/backup/ap_rsa.csv';\
|
||||
\
|
||||
copy actor_subscription to '/tmp/backup/actor_subscription.csv';\
|
||||
copy group_member to '/tmp/backup/group_member.csv';\
|
||||
\
|
||||
copy feed to '/tmp/backup/feed.csv';\
|
||||
copy (SELECT 'ALTER SEQUENCE ' || c.relname || ' RESTART WITH ' || nextval(c.relname::regclass) || ';'\
|
||||
FROM pg_class c WHERE c.relkind = 'S') to '/tmp/backup/sequences';\"" && \
|
||||
mkdir -p /tmp/social-sql-backup && \
|
||||
docker cp $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup/. /tmp/social-sql-backup
|
||||
|
||||
restore-actors:
|
||||
docker cp /tmp/social-sql-backup/. $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) sh -c 'chown postgres /tmp/backup' && \
|
||||
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
|
||||
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
|
||||
copy actor from '/tmp/backup/actor.csv';\
|
||||
copy local_user from '/tmp/backup/local_user.csv';\
|
||||
copy local_group from '/tmp/backup/local_group.csv';\
|
||||
\
|
||||
copy activitypub_actor from '/tmp/backup/ap_actor.csv';\
|
||||
copy activitypub_rsa from '/tmp/backup/ap_rsa.csv';\
|
||||
\
|
||||
copy actor_subscription from '/tmp/backup/actor_subscription.csv';\
|
||||
copy group_member from '/tmp/backup/group_member.csv';\
|
||||
\
|
||||
copy feed from '/tmp/backup/feed.csv';\
|
||||
`cat /tmp/social-sql-backup/sequences`\""
|
||||
|
||||
force-nuke-everything: down remove-var remove-file up flush-redis-cache database-force-nuke install-plugins
|
||||
|
||||
force-delete-content: backup-actors force-nuke-everything restore-actors
|
||||
stop-test: .PHONY
|
||||
cd docker/testing && docker-compose down
|
||||
|
|
|
@ -47,7 +47,7 @@ Choose whether you prefer social to handle all the services it needs though dock
|
|||
3>&1 1>&2 2>&3)
|
||||
validate_exit $?
|
||||
case ${SERVICES} in
|
||||
'docker') DOCKER='"nginx" "certbot" "php" "db" "redis" "worker"' ;; # TODO enable and configure "mail"
|
||||
'docker') DOCKER='"nginx" "certbot" "php" "db" "redis"' ;; # TODO enable and configure "mail"
|
||||
'mixed')
|
||||
DOCKER=$(${WHIPTAIL} --title 'GNU social Docker services' --clear --backtitle 'GNU social' \
|
||||
--checklist "\nPick which of the following services you'd like to add to docker-compose.\n* indicates a service that has extra configuration" 0 0 0 \
|
||||
|
@ -57,7 +57,6 @@ case ${SERVICES} in
|
|||
db 'Configure a DBMS*' on \
|
||||
redis 'Configure Redis (optional, recommended)' on \
|
||||
mail 'Confugure a mail server*' on \
|
||||
worker 'Confugure container with worker queues' on \
|
||||
3>&1 1>&2 2>&3)
|
||||
validate_exit $?
|
||||
;;
|
||||
|
@ -142,7 +141,6 @@ fi
|
|||
if echo "${DOCKER}" | grep -Fq '"php"'; then
|
||||
${WHIPTAIL} --title "Build PHP container locally?" --clear --backtitle 'GNU social' \
|
||||
--yesno "\nDo you want to compile the needed PHP extensions and build the container locally? (May provide better performance but requires more than 1GiB of RAM)" 0 0 \
|
||||
--defaultno \
|
||||
3>&1 1>&2 2>&3
|
||||
BUILD_PHP=$((1-$?)) # Invert output
|
||||
fi
|
||||
|
@ -228,7 +226,7 @@ PROFILE=$(${WHIPTAIL} --title 'GNU social site profile' --clear --backtitle 'GNU
|
|||
public 'Make this node publicly accessible, with open registration' \
|
||||
community 'Make this node publicly accessible, but with invite-only registration' \
|
||||
isolated 'Make this node publicly accessible, with open registration but do not federate' \
|
||||
private 'Make this node publicly accessible, but with invite-only registration, only registered users can see feeds' \
|
||||
private 'Make this node publicly accessible, but with invite-only registration, only registered users can see timelines' \
|
||||
single_user 'Like public, but only allows registering one user' \
|
||||
3>&1 1>&2 2>&3)
|
||||
validate_exit $?
|
||||
|
@ -352,8 +350,8 @@ SOCIAL_DBMS=${DBMS}
|
|||
SOCIAL_DB=${DB_NAME}
|
||||
SOCIAL_USER=${DB_USER}
|
||||
SOCIAL_PASSWORD=${DB_PASSWORD}
|
||||
CONFIG_DOMAIN=${DOMAIN}
|
||||
CONFIG_NODE_NAME=${NODE_NAME}
|
||||
SOCIAL_DOMAIN=${DOMAIN}
|
||||
SOCIAL_NODE_NAME=${NODE_NAME}
|
||||
SOCIAL_ADMIN_EMAIL=${EMAIL}
|
||||
SOCIAL_SITE_PROFILE=${PROFILE}
|
||||
MAILER_DSN=${MAILER_DSN}
|
||||
|
|
|
@ -8,9 +8,11 @@ require INSTALLDIR . '/vendor/autoload.php';
|
|||
|
||||
use Functional as F;
|
||||
|
||||
use App\Util\Functional;
|
||||
|
||||
$filenames = glob(INSTALLDIR . '/src/*/*.php');
|
||||
|
||||
$files = F\map($filenames, F\ary('file_get_contents', 1));
|
||||
$files = F\map($filenames, Functional::arity('file_get_contents', 1));
|
||||
|
||||
$old_licenses = ['/* {{{ License
|
||||
* This file is part of GNU social - https://www.gnu.org/software/social
|
||||
|
|
|
@ -51,7 +51,7 @@ foreach ($files as $file) {
|
|||
foreach ($schema['fields'] as $field => $opts) {
|
||||
if (isset($opts['foreign key'])) {
|
||||
[$foreign_entity, $foreign_key] = explode('.', $opts['target']);
|
||||
$foreign_table = Formatting::camelCaseToSnakeCase(preg_replace('/Actor/', 'actor', $foreign_entity));
|
||||
$foreign_table = Formatting::camelCaseToSnakeCase(preg_replace('/GSActor/', 'gsactor', $foreign_entity));
|
||||
$edges[] = "{$table}:{$field} -- {$foreign_table}:{$foreign_key}";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/env php
|
||||
#!/usr/local/bin/php
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
@ -20,23 +20,25 @@ const types = [
|
|||
'text' => 'string',
|
||||
'varchar' => 'string',
|
||||
'phone_number' => 'PhoneNumber',
|
||||
'float' => 'float', // TODO REMOVE THIS
|
||||
];
|
||||
|
||||
$files = array_merge(glob(ROOT . '/src/Entity/*.php'),
|
||||
array_merge(glob(ROOT . '/components/*/Entity/*.php'),
|
||||
glob(ROOT . '/plugins/*/Entity/*.php')));
|
||||
|
||||
$nullable_no_defaults_warning = [];
|
||||
$classes = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
|
||||
require_once $file;
|
||||
|
||||
$class = str_replace(['/', 'src', 'components', 'plugins'], ['\\', 'App', 'Component', 'Plugin'], substr($file, strlen(ROOT) + 1, -4));
|
||||
|
||||
if (!method_exists($class, 'schemaDef')) {
|
||||
continue;
|
||||
$declared = get_declared_classes();
|
||||
foreach ($declared as $dc) {
|
||||
if (preg_match('/(App|(Component|Plugin)\\\\[^\\\\]+)\\\\Entity/', $dc) && !in_array($dc, $classes)) {
|
||||
$class = $dc;
|
||||
$classes[] = $class;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$no_ns_class = preg_replace('/.*?\\\\/', '', $class);
|
||||
|
@ -45,32 +47,16 @@ foreach ($files as $file) {
|
|||
$fields_code = [];
|
||||
$methods_code = [];
|
||||
foreach ($fields as $field) {
|
||||
$field_schema = $schema['fields'][$field];
|
||||
$nullable = ($field_schema['not null'] ?? false) ? '' : '?';
|
||||
$type = types[$field_schema['type']];
|
||||
$nullable = !@$schema['fields'][$field]['not null'] ? '?' : '';
|
||||
$type = types[$schema['fields'][$field]['type']];
|
||||
$type = $type !== '' ? $nullable . $type : $type;
|
||||
$method_name = str_replace([' ', 'actor'], ['', 'Actor'], ucwords(str_replace('_', ' ', $field)));
|
||||
$length = $field_schema['length'] ?? null;
|
||||
$method_name = str_replace([' ', 'Gsactor'], ['', 'GSActor'], ucwords(str_replace('_', ' ', $field)));
|
||||
$default = @$schema['fields'][$field]['default'];
|
||||
|
||||
$field_setter = "\${$field}";
|
||||
if (\is_int($length)) {
|
||||
if ($nullable === '?') {
|
||||
$field_setter = "\is_null(\${$field}) ? null : \mb_substr(\${$field}, 0, $length)";
|
||||
} else {
|
||||
$field_setter = "\mb_substr(\${$field}, 0, $length)";
|
||||
}
|
||||
}
|
||||
|
||||
if (($nullable === '?' || \array_key_exists('default', $field_schema)) && $type != '\DateTimeInterface') {
|
||||
if (!\array_key_exists('default', $field_schema)) {
|
||||
$nullable_no_defaults_warning[] = "{$class}::{$field}";
|
||||
}
|
||||
$default = $field_schema['default'] ?? null;
|
||||
if (\is_string($default)) {
|
||||
if (isset($default) && $nullable != '?' && $type != '\DateTimeInterface') {
|
||||
if (is_string($default)) {
|
||||
$default = "'{$default}'";
|
||||
} elseif (\is_null($default)) {
|
||||
$default = "null";
|
||||
} elseif ($type === 'bool' || $type === '?bool') {
|
||||
} elseif ($type == 'bool') {
|
||||
$default = $default ? 'true' : 'false';
|
||||
}
|
||||
|
||||
|
@ -80,13 +66,13 @@ foreach ($files as $file) {
|
|||
}
|
||||
|
||||
$methods_code[] = " public function set{$method_name}({$type} \${$field}): self" .
|
||||
"\n {\n \$this->{$field} = {$field_setter};\n return \$this;\n }" . "\n\n" .
|
||||
"\n {\n \$this->{$field} = \${$field};\n return \$this;\n }" . "\n\n" .
|
||||
" public function get{$method_name}()" . ($type !== '' ? ": {$type}" : '') .
|
||||
"\n {\n return \$this->{$field};\n }" . "\n";
|
||||
}
|
||||
|
||||
$fields_code = implode("\n", $fields_code);
|
||||
$methods_code = implode("\n", $methods_code);
|
||||
$methods_code = implode("\n", $methods_code) . "\n";
|
||||
|
||||
$begin = '// {{{ Autocode';
|
||||
$end = '// }}} Autocode';
|
||||
|
@ -105,13 +91,7 @@ foreach ($files as $file) {
|
|||
}
|
||||
|
||||
$in_file = file_get_contents($file);
|
||||
$out_file = preg_replace("%\\s*{$begin}.*{$end}%smu", $code, $in_file);
|
||||
$out_file = preg_replace("/\\s*{$begin}[^\\/]*{$end}/m", $code, $in_file);
|
||||
|
||||
file_put_contents($file, $out_file);
|
||||
}
|
||||
|
||||
if (!empty($nullable_no_defaults_warning)) {
|
||||
echo "Warning: The following don't have a default value, but we're assigning it `null`. Doctrine might not like this, so update it\n";
|
||||
foreach ($nullable_no_defaults_warning as $n) {
|
||||
echo " {$n}\n";
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
for plugin in plugins/*; do
|
||||
install="${plugin}/bin/install.sh"
|
||||
if [ -x "${install}" ]; then
|
||||
( # subshell, to clear options/environment
|
||||
set -x
|
||||
"${install}"
|
||||
)
|
||||
fi
|
||||
done
|
|
@ -1,9 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
. bin/translate_container_name.sh
|
||||
|
||||
if [ "$#" -eq 0 ] || [ -z "$*" ]; then
|
||||
docker exec "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer -n --config=".php-cs-fixer.php" fix
|
||||
else
|
||||
docker exec "$(translate_container_name tooling_php_1)" sh -c "/var/www/social/vendor/bin/php-cs-fixer -q -n --config=\".php-cs-fixer.php\" fix --path-mode=intersection -- $*"
|
||||
fi
|
|
@ -0,0 +1 @@
|
|||
../vendor/bin/php-cs-fixer
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
. bin/translate_container_name.sh
|
||||
|
||||
docker exec -it "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-doc-check src components plugins
|
|
@ -0,0 +1 @@
|
|||
../vendor/bin/php-doc-check
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
. bin/translate_container_name.sh
|
||||
|
||||
docker exec "$(translate_container_name tooling_php_1)" /var/tooling/phpstan.sh "$@"
|
|
@ -1,32 +1,28 @@
|
|||
#!/usr/bin/env sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# get the list of changed files that didn't get only partially added
|
||||
# get the list of changed files
|
||||
staged_files="$(git status --porcelain | sed -rn "s/^[^ ][ ] (.*)/\1/p")"
|
||||
|
||||
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_CS_FIX?}") 2>/dev/null); then
|
||||
echo "Running php-cs-fixer on edited files"
|
||||
for staged in ${staged_files}; do
|
||||
# work only with existing files
|
||||
if [ -f "${staged}" ] && expr "${staged}" : '^.*\.php$' > /dev/null; then
|
||||
# use php-cs-fixer and get flag of correction
|
||||
CS_FIXER_FILE="${staged}" make cs-fixer
|
||||
git add "${staged}"
|
||||
echo "Running php-cs-fixer on edited files"
|
||||
|
||||
for staged in ${staged_files}; do
|
||||
# work only with existing files
|
||||
if [ -f "${staged}" ] && [[ "${staged}" = *.php ]]
|
||||
then
|
||||
# use php-cs-fixer and get flag of correction
|
||||
if "${root}/bin/php-cs-fixer" -q fix "${staged}"
|
||||
then
|
||||
git add "${staged}" # execute git add directly
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_DOC_CHECK?}") 2>/dev/null); then
|
||||
if echo "${staged_files}" | grep -F ".php" > /dev/null 2>&1; then
|
||||
echo "Running php-doc-checker"
|
||||
make doc-check < /dev/tty
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_PHPSTAN?}") 2>/dev/null); then
|
||||
echo "Running phpstan"
|
||||
make phpstan
|
||||
echo "Running php-doc-checker"
|
||||
|
||||
if echo "${staged_files}" | grep -F ".php"; then
|
||||
"${root}/bin/php-doc-check" src plugins components
|
||||
fi
|
||||
|
||||
# Only commit if there wasn't an error
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
translate_container_name () {
|
||||
if docker container inspect "$1" > /dev/null 2>&1; then
|
||||
echo "$1"
|
||||
else
|
||||
echo "$1" | sed 'y/_/-/'
|
||||
fi
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Raise/update static version numbers in composer.json.
|
||||
*
|
||||
* Run on the CLI: "composer outdated --direct > outdated.txt"
|
||||
*/
|
||||
$composerJson = json_decode(file_get_contents('composer.json'), true);
|
||||
|
||||
system('composer outdated --direct > outdated.txt');
|
||||
$listOfOutdatedPackages = file('outdated.txt');
|
||||
|
||||
foreach($listOfOutdatedPackages as $line) {
|
||||
|
||||
$regexp = '/(?P<package>[\w]+\/[\w]+).*(?P<currentVersion>\d.\d.\d).*(?P<latestVersion>\d.\d.\d)/';
|
||||
preg_match($regexp, $line, $matches);
|
||||
$matches = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||
|
||||
if(isset($matches['package']))
|
||||
{
|
||||
$package = $matches['package'];
|
||||
|
||||
if(isset($composerJson['require'][$package]))
|
||||
{
|
||||
$currentVersion = $composerJson['require'][$package];
|
||||
echo sprintf('Updating %s from %s to %s', $package, $currentVersion, $matches['latestVersion']);
|
||||
$composerJson['require'][$package] = $matches['latestVersion'];
|
||||
}
|
||||
if(isset($composerJson['require-dev'][$package]))
|
||||
{
|
||||
$currentVersion = $composerJson['require-dev'][$package];
|
||||
echo sprintf('Updating %s from %s to %s', $package, $currentVersion, $matches['latestVersion']);
|
||||
$composerJson['require-dev'][$package] = $matches['latestVersion'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents('composer.json', json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
@ -1,102 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace Component\Attachment;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Formatting;
|
||||
use Component\Attachment\Controller as C;
|
||||
use Component\Attachment\Entity as E;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class Attachment extends Component
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']);
|
||||
$r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']);
|
||||
$r->connect('note_attachment_download', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/download', [C\Attachment::class, 'attachmentDownloadWithNote']);
|
||||
$r->connect('note_attachment_thumbnail', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/thumbnail/{size<big|medium|small>}', [C\Attachment::class, 'attachmentThumbnailWithNote']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique representation of a file on disk
|
||||
*
|
||||
* This can be used in the future to deduplicate images by visual content
|
||||
*/
|
||||
public function onHashFile(string $filename, ?string &$out_hash): bool
|
||||
{
|
||||
$out_hash = hash_file(E\Attachment::FILEHASH_ALGO, $filename);
|
||||
return Event::stop;
|
||||
}
|
||||
|
||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
||||
{
|
||||
Cache::delete("note-attachments-{$note->getId()}");
|
||||
foreach ($note->getAttachments() as $attachment) {
|
||||
$attachment->kill();
|
||||
}
|
||||
DB::wrapInTransaction(fn () => E\AttachmentToNote::removeWhereNoteId($note->getId()));
|
||||
Cache::delete("note-attachments-{$note->getId()}");
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
if (!\in_array('attachment_to_note', $note_qb->getAllAliases())) {
|
||||
$note_qb->leftJoin(
|
||||
join: E\AttachmentToNote::class,
|
||||
alias: 'attachment_to_note',
|
||||
conditionType: Expr\Join::WITH,
|
||||
condition: 'note.id = attachment_to_note.note_id',
|
||||
);
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate $note_expr with the criteria for looking for notes with attachments
|
||||
*/
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
{
|
||||
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
|
||||
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {
|
||||
if (\is_null($note_expr)) {
|
||||
$note_expr = [];
|
||||
}
|
||||
if (array_intersect(explode(',', $include_term), ['media', 'image', 'images', 'attachment']) !== []) {
|
||||
$note_expr[] = $eb->neq('attachment_to_note.note_id', null);
|
||||
} else {
|
||||
$note_expr[] = $eb->eq('attachment_to_note.note_id', null);
|
||||
}
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
}
|
|
@ -1,180 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Attachment\Controller;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\GSFile;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NoSuchFileException;
|
||||
use App\Util\Exception\NotFoundException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use Component\Attachment\Entity\AttachmentToNote;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
|
||||
class Attachment extends Controller
|
||||
{
|
||||
/**
|
||||
* Generic function that handles getting a representation for an attachment
|
||||
*/
|
||||
private function attachment(int $attachment_id, Note|int $note, callable $handle)
|
||||
{
|
||||
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
|
||||
$note = \is_int($note) ? Note::getById($note) : $note;
|
||||
|
||||
// Before anything, two very important things!
|
||||
// first: ensure this attachment is associated with this note
|
||||
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
|
||||
throw new ClientException(_m('No such attachment.'), 404);
|
||||
}
|
||||
// second: ensure proper scope
|
||||
if (!$note->isVisibleTo(Common::actor())) {
|
||||
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
|
||||
}
|
||||
|
||||
$res = null;
|
||||
if (Event::handle('AttachmentFileInfo', [$attachment, $note, &$res]) !== Event::stop) {
|
||||
// If no one else claims this attachment, use the default representation
|
||||
try {
|
||||
$res = GSFile::getAttachmentFileInfo($attachment_id);
|
||||
} catch (NoSuchFileException $e) {
|
||||
// Continue below
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($res)) {
|
||||
throw new ClientException(_m('No such attachment'), 404);
|
||||
} else {
|
||||
if (!\array_key_exists('filepath', $res)) {
|
||||
// @codeCoverageIgnoreStart
|
||||
throw new ServerException('This attachment is not stored locally.');
|
||||
// @codeCoverageIgnoreEnd
|
||||
} else {
|
||||
$res['attachment'] = $attachment;
|
||||
$res['note'] = $note;
|
||||
$res['title'] = $attachment->getBestTitle($note);
|
||||
return $handle($res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page where the attachment and it's info is shown
|
||||
*/
|
||||
public function attachmentShowWithNote(Request $request, int $note_id, int $attachment_id)
|
||||
{
|
||||
try {
|
||||
return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) {
|
||||
return [
|
||||
'_template' => 'attachment/view.html.twig',
|
||||
'download' => $res['attachment']->getDownloadUrl(note: $note_id),
|
||||
'title' => $res['title'],
|
||||
'attachment' => $res['attachment'],
|
||||
'note' => $res['note'],
|
||||
'right_panel_vars' => ['attachment_id' => $attachment_id, 'note_id' => $note_id],
|
||||
];
|
||||
});
|
||||
} catch (NotFoundException) {
|
||||
throw new ClientException(_m('No such attachment.'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the attachment inline
|
||||
*/
|
||||
public function attachmentViewWithNote(Request $request, int $note_id, int $attachment_id)
|
||||
{
|
||||
return $this->attachment(
|
||||
$attachment_id,
|
||||
$note_id,
|
||||
fn (array $res) => GSFile::sendFile(
|
||||
$res['filepath'],
|
||||
$res['mimetype'],
|
||||
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
|
||||
HeaderUtils::DISPOSITION_INLINE,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function attachmentDownloadWithNote(Request $request, int $note_id, int $attachment_id)
|
||||
{
|
||||
return $this->attachment(
|
||||
$attachment_id,
|
||||
$note_id,
|
||||
fn (array $res) => GSFile::sendFile(
|
||||
$res['filepath'],
|
||||
$res['mimetype'],
|
||||
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
|
||||
HeaderUtils::DISPOSITION_ATTACHMENT,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller to produce a thumbnail for a given attachment id
|
||||
*
|
||||
* @param int $attachment_id Attachment ID
|
||||
*
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws ClientException
|
||||
* @throws NotFoundException
|
||||
* @throws ServerException
|
||||
*/
|
||||
public function attachmentThumbnailWithNote(Request $request, int $note_id, int $attachment_id, string $size = 'small'): Response
|
||||
{
|
||||
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
|
||||
$note = Note::getById($note_id);
|
||||
|
||||
// Before anything, two very important things!
|
||||
// first: ensure this attachment is associated with this note
|
||||
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
|
||||
throw new ClientException(_m('No such attachment.'), 404);
|
||||
}
|
||||
// second: ensure proper scope
|
||||
if (!$note->isVisibleTo(Common::actor())) {
|
||||
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
|
||||
}
|
||||
|
||||
$crop = Common::config('thumbnail', 'smart_crop');
|
||||
|
||||
$thumbnail = AttachmentThumbnail::getOrCreate(attachment: $attachment, size: $size, crop: $crop);
|
||||
if (\is_null($thumbnail)) {
|
||||
throw new ClientException(_m('Can not generate thumbnail for attachment with id={id}', ['id' => $attachment->getId()]));
|
||||
}
|
||||
|
||||
$filename = $thumbnail->getFilename();
|
||||
$path = $thumbnail->getPath();
|
||||
$mimetype = $thumbnail->getMimetype();
|
||||
|
||||
return GSFile::sendFile(filepath: $path, mimetype: $mimetype, output_filename: $filename . '.' . MimeTypes::getDefault()->getExtensions($mimetype)[0], disposition: HeaderUtils::DISPOSITION_INLINE);
|
||||
}
|
||||
}
|
|
@ -1,177 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace Component\Attachment\tests\Entity;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Util\Exception\NotStoredLocallyException;
|
||||
use App\Util\GNUsocialTestCase;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use Functional as F;
|
||||
use Jchook\AssertThrows\AssertThrows;
|
||||
use SplFileInfo;
|
||||
|
||||
class AttachmentThumbnailTest extends GNUsocialTestCase
|
||||
{
|
||||
use AssertThrows;
|
||||
|
||||
public function testAttachmentThumbnailLifecycle()
|
||||
{
|
||||
parent::bootKernel();
|
||||
|
||||
// Data fixture already loaded this file, but we need to get its hash to find it
|
||||
$file = new SplFileInfo(INSTALLDIR . '/tests/sample-uploads/attachment-lifecycle-target.jpg');
|
||||
$hash = null;
|
||||
Event::handle('HashFile', [$file->getPathname(), &$hash]);
|
||||
$attachment = DB::findOneBy(Attachment::class, ['filehash' => $hash]);
|
||||
|
||||
$expected = [
|
||||
AttachmentThumbnail::getOrCreate($attachment, 'small', crop: false),
|
||||
AttachmentThumbnail::getOrCreate($attachment, 'medium', crop: false),
|
||||
$thumb = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false),
|
||||
];
|
||||
|
||||
static::assertSame($attachment, $thumb->getAttachment());
|
||||
$thumb->setAttachment(null);
|
||||
static::assertSame($attachment, $thumb->getAttachment());
|
||||
|
||||
$actual = $attachment->getThumbnails();
|
||||
static::assertSame(\count($expected), \count($actual));
|
||||
foreach ($expected as $e) {
|
||||
$a = array_shift($actual);
|
||||
static::assertObjectEquals($e, $a);
|
||||
}
|
||||
|
||||
array_pop($expected);
|
||||
$thumb->delete();
|
||||
$actual = $attachment->getThumbnails();
|
||||
static::assertSame(\count($expected), \count($actual));
|
||||
foreach ($expected as $e) {
|
||||
$a = array_shift($actual);
|
||||
static::assertObjectEquals($e, $a);
|
||||
}
|
||||
|
||||
$attachment->deleteStorage();
|
||||
|
||||
foreach (array_reverse($attachment->getThumbnails()) as $t) {
|
||||
// Since we still have thumbnails, those will be used as the new thumbnail, even though we don't have the original
|
||||
$new = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false);
|
||||
static::assertSame([$t->getFilename(), $t->getSize()], [$new->getFilename(), $new->getSize()]);
|
||||
$t->delete();
|
||||
}
|
||||
|
||||
// Since the backed storage was deleted and we don't have any more previous thumnbs, we can't generate another thumbnail
|
||||
static::assertThrows(NotStoredLocallyException::class, fn () => AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false));
|
||||
|
||||
$attachment->kill();
|
||||
// static::assertThrows(NotStoredLocallyException::class, fn () => AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false));
|
||||
}
|
||||
|
||||
public function testInvalidThumbnail()
|
||||
{
|
||||
parent::bootKernel();
|
||||
$file = new SplFileInfo(INSTALLDIR . '/tests/sample-uploads/spreadsheet.ods');
|
||||
$hash = null;
|
||||
Event::handle('HashFile', [$file->getPathname(), &$hash]);
|
||||
$attachment = DB::findOneBy('attachment', ['filehash' => $hash]);
|
||||
static::assertNull(AttachmentThumbnail::getOrCreate($attachment, 'small', crop: false));
|
||||
}
|
||||
|
||||
public function testPredictScalingValues()
|
||||
{
|
||||
parent::bootKernel();
|
||||
// TODO test with cropping
|
||||
|
||||
$inputs = [
|
||||
[100, 100],
|
||||
[400, 200],
|
||||
[800, 400],
|
||||
[1600, 800],
|
||||
[1600, 1600],
|
||||
// 16:9 video
|
||||
[854, 480],
|
||||
[1280, 720],
|
||||
[1920, 1080],
|
||||
[2560, 1440],
|
||||
[3840, 2160],
|
||||
];
|
||||
|
||||
$outputs = [
|
||||
'small' => [
|
||||
[100, 100],
|
||||
[400, 200],
|
||||
[32, 14],
|
||||
[32, 14],
|
||||
[32, 32],
|
||||
// 16:9 video
|
||||
[32, 21],
|
||||
[32, 21],
|
||||
[32, 21],
|
||||
[32, 21],
|
||||
[32, 21],
|
||||
],
|
||||
'medium' => [
|
||||
[100, 100],
|
||||
[400, 200],
|
||||
[256, 116],
|
||||
[256, 116],
|
||||
[256, 256],
|
||||
// 16:9 video
|
||||
[256, 170],
|
||||
[256, 170],
|
||||
[256, 170],
|
||||
[256, 170],
|
||||
[256, 170],
|
||||
],
|
||||
'big' => [
|
||||
[100, 100],
|
||||
[400, 200],
|
||||
[496, 225],
|
||||
[496, 225],
|
||||
[496, 496],
|
||||
// 16:9 video
|
||||
[496, 330],
|
||||
[496, 330],
|
||||
[496, 330],
|
||||
[496, 330],
|
||||
[496, 330],
|
||||
],
|
||||
];
|
||||
|
||||
foreach (['small', 'medium', 'big'] as $size) {
|
||||
foreach (F\zip($inputs, $outputs[$size]) as [$existing, $results]) {
|
||||
static::assertSame($results, AttachmentThumbnail::predictScalingValues(existing_width: $existing[0], existing_height: $existing[1], requested_size: $size, crop: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetUrl()
|
||||
{
|
||||
parent::bootKernel();
|
||||
$attachment = DB::findBy(Attachment::class, ['mimetype' => 'image/png'], limit: 1)[0];
|
||||
$thumb = AttachmentThumbnail::getOrCreate($attachment, 'big', crop: false);
|
||||
$id = $attachment->getId();
|
||||
$expected = "/object/note/42/attachment/{$id}/thumbnail/big";
|
||||
static::assertSame($expected, $thumb->getUrl(note: 42));
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
|
@ -26,122 +24,100 @@ use App\Core\DB\DB;
|
|||
use App\Core\Event;
|
||||
use App\Core\GSFile;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Core\Router\Router;
|
||||
use App\Util\Common;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use Component\Avatar\Controller as C;
|
||||
use Component\Avatar\Exception\NoAvatarException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Avatar extends Component
|
||||
{
|
||||
public function onInitializeComponent()
|
||||
public function onAddRoute($r): bool
|
||||
{
|
||||
}
|
||||
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']);
|
||||
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']);
|
||||
$r->connect('avatar_settings', '/settings/avatar', [Controller\Avatar::class, 'settings_avatar']);
|
||||
$r->connect('avatar', '/{gsactor_id<\d+>}/avatar/{size<full|big|medium|small>?full}', [Controller\Avatar::class, 'avatar_view']);
|
||||
$r->connect('settings_avatar', '/settings/avatar', [Controller\Avatar::class, 'settings_avatar']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \App\Util\Exception\ClientException
|
||||
*/
|
||||
public function onPopulateSettingsTabs(Request $request, string $section, &$tabs): bool
|
||||
public function onPopulateProfileSettingsTabs(Request $request, &$tabs): bool
|
||||
{
|
||||
if ($section === 'profile') {
|
||||
$tabs[] = [
|
||||
'title' => 'Avatar',
|
||||
'desc' => 'Change your avatar.',
|
||||
'id' => 'settings-avatar',
|
||||
'controller' => C\Avatar::settings_avatar($request),
|
||||
];
|
||||
// TODO avatar template shouldn't be on settings folder
|
||||
$tabs[] = [
|
||||
'title' => 'Avatar',
|
||||
'desc' => 'Change your avatar.',
|
||||
'controller' => C\Avatar::settings_avatar($request),
|
||||
];
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onStartTwigPopulateVars(array &$vars): bool
|
||||
{
|
||||
if (Common::user() != null) {
|
||||
$vars['user_avatar'] = self::getAvatarUrl();
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onAvatarUpdate(int $actor_id): bool
|
||||
public function onGetAvatarUrl(int $gsactor_id, ?string &$url): bool
|
||||
{
|
||||
Cache::delete("avatar-{$actor_id}");
|
||||
foreach (['full', 'big', 'medium', 'small'] as $size) {
|
||||
foreach ([Router::ABSOLUTE_PATH, Router::ABSOLUTE_URL] as $type) {
|
||||
Cache::delete("avatar-url-{$actor_id}-{$size}-{$type}");
|
||||
}
|
||||
Cache::delete("avatar-file-info-{$actor_id}-{$size}");
|
||||
}
|
||||
$url = self::getAvatarUrl($gsactor_id);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onAvatarUpdate(int $gsactor_id): bool
|
||||
{
|
||||
Cache::delete('avatar-' . $gsactor_id);
|
||||
Cache::delete('avatar-url-' . $gsactor_id);
|
||||
Cache::delete('avatar-file-info-' . $gsactor_id);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// UTILS ----------------------------------
|
||||
|
||||
/**
|
||||
* Get the avatar associated with the given Actor id
|
||||
* Get the avatar associated with the given GSActor id
|
||||
*/
|
||||
public static function getAvatar(?int $actor_id = null): Entity\Avatar
|
||||
public static function getAvatar(?int $gsactor_id = null): Entity\Avatar
|
||||
{
|
||||
$actor_id = $actor_id ?: Common::userId();
|
||||
return GSFile::error(
|
||||
NoAvatarException::class,
|
||||
$actor_id,
|
||||
Cache::get(
|
||||
"avatar-{$actor_id}",
|
||||
function () use ($actor_id) {
|
||||
return DB::dql(
|
||||
'select a from Component\Avatar\Entity\Avatar a '
|
||||
. 'where a.actor_id = :actor_id',
|
||||
['actor_id' => $actor_id],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
$gsactor_id = $gsactor_id ?: Common::userId();
|
||||
return GSFile::error(NoAvatarException::class,
|
||||
$gsactor_id,
|
||||
Cache::get("avatar-{$gsactor_id}",
|
||||
function () use ($gsactor_id) {
|
||||
return DB::dql('select a from Component\Avatar\Entity\Avatar a ' .
|
||||
'where a.gsactor_id = :gsactor_id',
|
||||
['gsactor_id' => $gsactor_id]);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached avatar associated with the given Actor id, or the current user if not given
|
||||
* Get the cached avatar associated with the given GSActor id, or the current user if not given
|
||||
*/
|
||||
public static function getUrl(int $actor_id, string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
|
||||
public static function getAvatarUrl(?int $gsactor_id = null, string $size = 'full'): string
|
||||
{
|
||||
try {
|
||||
return self::getAvatar($actor_id)->getUrl($size, $type);
|
||||
} catch (NoAvatarException) {
|
||||
return Router::url('avatar_default', ['size' => $size], $type);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getDimensions(int $actor_id, string $size = 'medium')
|
||||
{
|
||||
try {
|
||||
$attachment = self::getAvatar($actor_id)->getAttachment();
|
||||
return ['width' => (int) $attachment->getWidth(), 'height' => (int) $attachment->getHeight()];
|
||||
} catch (NoAvatarException) {
|
||||
return ['width' => (int) (Common::config('thumbnail', 'small')), 'height' => (int) (Common::config('thumbnail', 'small'))];
|
||||
}
|
||||
$gsactor_id = $gsactor_id ?: Common::userId();
|
||||
return Cache::get("avatar-url-{$gsactor_id}", function () use ($gsactor_id) {
|
||||
return Router::url('avatar', ['gsactor_id' => $gsactor_id, 'size' => 'full']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached avatar file info associated with the given Actor id
|
||||
* Get the cached avatar file info associated with the given GSActor id
|
||||
*
|
||||
* Returns the avatar file's hash, mimetype, title and path.
|
||||
* Ensures exactly one cached value exists
|
||||
*/
|
||||
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
|
||||
public static function getAvatarFileInfo(int $gsactor_id): array
|
||||
{
|
||||
$res = Cache::get(
|
||||
"avatar-file-info-{$actor_id}-{$size}",
|
||||
function () use ($actor_id) {
|
||||
return DB::dql(
|
||||
'select f.id, f.filename, a.title, f.mimetype '
|
||||
. 'from Component\Attachment\Entity\Attachment f '
|
||||
. 'join Component\Avatar\Entity\Avatar a with f.id = a.attachment_id '
|
||||
. 'where a.actor_id = :actor_id',
|
||||
['actor_id' => $actor_id],
|
||||
);
|
||||
},
|
||||
$res = Cache::get("avatar-file-info-{$gsactor_id}",
|
||||
function () use ($gsactor_id) {
|
||||
return DB::dql('select f.id, f.filename, a.filename title, f.mimetype ' .
|
||||
'from App\Entity\Attachment f ' .
|
||||
'join Component\Avatar\Entity\Avatar a with f.id = a.attachment_id ' .
|
||||
'where a.gsactor_id = :gsactor_id',
|
||||
['gsactor_id' => $gsactor_id]);
|
||||
}
|
||||
);
|
||||
if ($res === []) { // Avatar not found
|
||||
$filepath = INSTALLDIR . '/public/assets/default-avatar.svg';
|
||||
|
@ -153,12 +129,8 @@ class Avatar extends Component
|
|||
'title' => 'default_avatar.svg',
|
||||
];
|
||||
} else {
|
||||
$res = $res[0]; // A user must always only have one avatar.
|
||||
if ($size === 'full') {
|
||||
$res['filepath'] = Attachment::getByPK(['id' => $res['id']])->getPath();
|
||||
} else {
|
||||
$res['filepath'] = AttachmentThumbnail::getOrCreate(Attachment::getByPK(['id' => $res['id']]), $size)->getPath();
|
||||
}
|
||||
$res = $res[0]; // A user must always only have one avatar.
|
||||
$res['filepath'] = DB::findOneBy('attachment', ['id' => $res['id']])->getPath();
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
|
@ -33,6 +31,7 @@ use function App\Core\I18n\_m;
|
|||
use App\Core\Log;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NotFoundException;
|
||||
use App\Util\TemporaryFile;
|
||||
use Component\Avatar\Entity\Avatar as AvatarEntity;
|
||||
use Exception;
|
||||
|
@ -46,18 +45,18 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
class Avatar extends Controller
|
||||
{
|
||||
public function default_avatar_view(Request $request, string $size): Response
|
||||
{
|
||||
return $this->avatar_view($request, 0, $size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function avatar_view(Request $request, int $actor_id, string $size): Response
|
||||
public function avatar_view(Request $request, int $gsactor_id, string $size): Response
|
||||
{
|
||||
$res = \Component\Avatar\Avatar::getAvatarFileInfo($actor_id, $size);
|
||||
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
|
||||
switch ($size) {
|
||||
case 'full':
|
||||
$res = \Component\Avatar\Avatar::getAvatarFileInfo($gsactor_id);
|
||||
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
|
||||
default:
|
||||
throw new Exception('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,54 +74,52 @@ class Avatar extends Controller
|
|||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$data = $form->getData();
|
||||
$user = Common::user();
|
||||
$actor_id = $user->getId();
|
||||
$data = $form->getData();
|
||||
$user = Common::user();
|
||||
$gsactor_id = $user->getId();
|
||||
if ($data['remove'] == true) {
|
||||
if (\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
|
||||
$form->addError(new FormError(_m('No avatar set, so cannot delete.')));
|
||||
} else {
|
||||
try {
|
||||
$avatar = DB::findOneBy('avatar', ['gsactor_id' => $gsactor_id]);
|
||||
$avatar->delete();
|
||||
Event::handle('AvatarUpdate', [$user->getId()]);
|
||||
} catch (NotFoundException) {
|
||||
$form->addError(new FormError(_m('No avatar set, so cannot delete')));
|
||||
}
|
||||
} else {
|
||||
$attachment = null;
|
||||
$title = $data['avatar']?->getClientOriginalName() ?? null;
|
||||
if (isset($data['hidden'])) {
|
||||
// Cropped client side
|
||||
$matches = [];
|
||||
if (!empty(preg_match('/data:([^;]*)(;(base64))?,(.*)/', $data['hidden'], $matches))) {
|
||||
[, , , $encoding_user, $data_user] = $matches;
|
||||
list(, , , $encoding_user, $data_user) = $matches;
|
||||
if ($encoding_user === 'base64') {
|
||||
$data_user = base64_decode($data_user);
|
||||
$tempfile = new TemporaryFile(['prefix' => 'gs-avatar']);
|
||||
$tempfile->write($data_user);
|
||||
$attachment = GSFile::storeFileAsAttachment($tempfile);
|
||||
} else {
|
||||
Log::info('Avatar upload got an invalid encoding, something\'s fishy and/or wrong');
|
||||
}
|
||||
}
|
||||
} elseif (isset($data['avatar'])) {
|
||||
// Cropping failed (e.g. disabled js), use file as uploaded
|
||||
$file = $data['avatar'];
|
||||
$attachment = GSFile::storeFileAsAttachment($file);
|
||||
$file = $data['avatar'];
|
||||
} else {
|
||||
throw new ClientException(_m('Invalid form.'));
|
||||
throw new ClientException('Invalid form');
|
||||
}
|
||||
$attachment = GSFile::sanitizeAndStoreFileAsAttachment(
|
||||
$file
|
||||
);
|
||||
// Delete current avatar if there's one
|
||||
if (!\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
|
||||
$avatar->delete();
|
||||
}
|
||||
$avatar = DB::find('avatar', ['gsactor_id' => $gsactor_id]);
|
||||
$avatar?->delete();
|
||||
DB::persist($attachment);
|
||||
DB::persist(AvatarEntity::create([
|
||||
'actor_id' => $actor_id,
|
||||
'attachment_id' => $attachment->getId(),
|
||||
'title' => $title,
|
||||
]));
|
||||
// Can only get new id after inserting
|
||||
DB::flush();
|
||||
DB::persist(AvatarEntity::create(['gsactor_id' => $gsactor_id, 'attachment_id' => $attachment->getId(), 'filename' => $file->getClientOriginalName()]));
|
||||
DB::flush();
|
||||
Event::handle('AvatarUpdate', [$user->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['_template' => 'avatar/settings.html.twig', 'avatar' => $form->createView()];
|
||||
return ['_template' => 'settings/avatar.html.twig', 'avatar' => $form->createView()];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
|
@ -23,14 +21,11 @@ declare(strict_types = 1);
|
|||
|
||||
namespace Component\Avatar\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Event;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Attachment;
|
||||
use App\Util\Common;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use Component\Attachment\Entity\AttachmentThumbnail;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
|
@ -51,21 +46,21 @@ class Avatar extends Entity
|
|||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $actor_id;
|
||||
private int $gsactor_id;
|
||||
private int $attachment_id;
|
||||
private ?string $title = null;
|
||||
private ?string $filename;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setActorId(int $actor_id): self
|
||||
public function setGSActorId(int $gsactor_id): self
|
||||
{
|
||||
$this->actor_id = $actor_id;
|
||||
$this->gsactor_id = $gsactor_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActorId(): int
|
||||
public function getGSActorId(): int
|
||||
{
|
||||
return $this->actor_id;
|
||||
return $this->gsactor_id;
|
||||
}
|
||||
|
||||
public function setAttachmentId(int $attachment_id): self
|
||||
|
@ -79,15 +74,20 @@ class Avatar extends Entity
|
|||
return $this->attachment_id;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): self
|
||||
/**
|
||||
* @return null|string
|
||||
*/
|
||||
public function getFilename(): ?string
|
||||
{
|
||||
$this->title = \is_null($title) ? null : mb_substr($title, 0, 191);
|
||||
return $this;
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
/**
|
||||
* @param null|string $filename
|
||||
*/
|
||||
public function setFilename(?string $filename): void
|
||||
{
|
||||
return $this->title;
|
||||
$this->filename = $filename;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
|
@ -117,23 +117,17 @@ class Avatar extends Entity
|
|||
|
||||
private ?Attachment $attachment = null;
|
||||
|
||||
public function getUrl(string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
|
||||
public function getUrl(): string
|
||||
{
|
||||
$actor_id = $this->getActorId();
|
||||
return Cache::get("avatar-url-{$actor_id}-{$size}-{$type}", fn () => Router::url('avatar_actor', ['actor_id' => $actor_id, 'size' => $size], $type));
|
||||
return Router::url('avatar', ['gsactor_id' => $this->gsactor_id]);
|
||||
}
|
||||
|
||||
public function getAttachment(): Attachment
|
||||
{
|
||||
$this->attachment ??= DB::findOneBy('attachment', ['id' => $this->getAttachmentId()]);
|
||||
$this->attachment = $this->attachment ?: DB::findOneBy('attachment', ['id' => $this->attachment_id]);
|
||||
return $this->attachment;
|
||||
}
|
||||
|
||||
public function getAttachmentThumbnail(string $size): ?AttachmentThumbnail
|
||||
{
|
||||
return AttachmentThumbnail::getOrCreate($this->getAttachment(), $size);
|
||||
}
|
||||
|
||||
public static function getFilePathStatic(string $filename): string
|
||||
{
|
||||
return Common::config('avatar', 'dir') . $filename;
|
||||
|
@ -146,14 +140,15 @@ class Avatar extends Entity
|
|||
|
||||
/**
|
||||
* Delete this avatar and kill corresponding attachment
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function delete(): bool
|
||||
{
|
||||
$actor_id = $this->getActorId();
|
||||
DB::remove($this);
|
||||
$attachment = $this->getAttachment();
|
||||
DB::wrapInTransaction(fn () => DB::removeBy(static::class, ['actor_id' => $actor_id]));
|
||||
$attachment->kill();
|
||||
Event::handle('AvatarUpdate', [$actor_id]);
|
||||
DB::flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -162,13 +157,13 @@ class Avatar extends Entity
|
|||
return [
|
||||
'name' => 'avatar',
|
||||
'fields' => [
|
||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to actor table'],
|
||||
'gsactor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to gsactor table'],
|
||||
'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to attachment table'],
|
||||
'title' => ['type' => 'varchar', 'length' => 191, 'description' => 'file name of resource when available'],
|
||||
'filename' => ['type' => 'varchar', 'length' => 191, 'description' => 'file name of resource when available'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'description' => 'date this record was created', 'default' => 'CURRENT_TIMESTAMP'],
|
||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified', 'default' => 'CURRENT_TIMESTAMP'],
|
||||
],
|
||||
'primary key' => ['actor_id'],
|
||||
'primary key' => ['gsactor_id'],
|
||||
'indexes' => [
|
||||
'avatar_attachment_id_idx' => ['attachment_id'],
|
||||
],
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
parameters:
|
||||
avatar:
|
||||
use_avatar: true
|
|
@ -1,166 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Blog\Controller;
|
||||
|
||||
use App\Core\ActorLocalRoles;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Event;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\VisibilityScope;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Form\FormFields;
|
||||
use Component\Posting\Posting;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
class Post extends Controller
|
||||
{
|
||||
/**
|
||||
* Creates and handles Blog post creation form
|
||||
*
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws \App\Util\Exception\NoLoggedInUser
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
* @throws ClientException
|
||||
* @throws RedirectException
|
||||
*/
|
||||
public function makePost(Request $request)
|
||||
{
|
||||
$actor = Common::ensureLoggedIn()->getActor();
|
||||
|
||||
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
|
||||
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
|
||||
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
|
||||
|
||||
$initial_content = '';
|
||||
Event::handle('PostingInitialContent', [&$initial_content]);
|
||||
|
||||
$available_content_types = [
|
||||
_m('Plain Text') => 'text/plain',
|
||||
];
|
||||
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
|
||||
|
||||
if (!\is_int($this->int('in'))) {
|
||||
throw new InvalidArgumentException('You must specify an In group/org.');
|
||||
}
|
||||
$context_actor = Actor::getById($this->int('in'));
|
||||
if (!$context_actor->isGroup()) {
|
||||
throw new InvalidArgumentException('Only group blog posts are supported for now.');
|
||||
}
|
||||
$in_targets = ["!{$context_actor->getNickname()}" => $context_actor->getId()];
|
||||
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
|
||||
|
||||
$visibility_options = [
|
||||
_m('Public') => VisibilityScope::EVERYWHERE->value,
|
||||
_m('Local') => VisibilityScope::LOCAL->value,
|
||||
_m('Addressee') => VisibilityScope::ADDRESSEE->value,
|
||||
];
|
||||
if (!\is_null($context_actor) && $context_actor->isGroup()) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
|
||||
if ($actor->canModerate($context_actor)) {
|
||||
if ($context_actor->getRoles() & ActorLocalRoles::PRIVATE_GROUP) {
|
||||
$visibility_options = array_merge([_m('Group') => VisibilityScope::GROUP->value], $visibility_options);
|
||||
} else {
|
||||
$visibility_options[_m('Group')] = VisibilityScope::GROUP->value;
|
||||
}
|
||||
}
|
||||
}
|
||||
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'choices' => $visibility_options]];
|
||||
|
||||
$form_params[] = ['title', TextType::class, ['label' => _m('Title:'), 'constraints' => [new Length(['max' => 129])], 'required' => true]];
|
||||
$form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
|
||||
$form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
|
||||
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
|
||||
|
||||
if (\count($available_content_types) > 1) {
|
||||
$form_params[] = ['content_type', ChoiceType::class,
|
||||
[
|
||||
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
|
||||
'data' => $available_content_types[array_key_first($available_content_types)],
|
||||
'choices' => $available_content_types,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
|
||||
|
||||
$form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
|
||||
$form = Form::create($form_params);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted()) {
|
||||
try {
|
||||
if ($form->isValid()) {
|
||||
$data = $form->getData();
|
||||
Event::handle('PostingModifyData', [$request, $actor, &$data, $form_params, $form]);
|
||||
|
||||
if (empty($data['content']) && empty($data['attachments'])) {
|
||||
// TODO Display error: At least one of `content` and `attachments` must be provided
|
||||
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
|
||||
}
|
||||
|
||||
if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
|
||||
throw new ClientException(_m('You have selected an impossible visibility.'));
|
||||
}
|
||||
|
||||
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
|
||||
$extra_args = [];
|
||||
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
|
||||
|
||||
[,$note,] = Posting::storeLocalPage(
|
||||
actor: $actor,
|
||||
content: $data['content'],
|
||||
content_type: $content_type,
|
||||
locale: $data['language'],
|
||||
scope: VisibilityScope::from($data['visibility']),
|
||||
targets: [(int) $data['in']],
|
||||
reply_to: $data['reply_to_id'],
|
||||
attachments: $data['attachments'],
|
||||
process_note_content_extra_args: $extra_args,
|
||||
title: $data['title'],
|
||||
);
|
||||
|
||||
return new RedirectResponse($note->getConversationUrl());
|
||||
}
|
||||
} catch (FormSizeFileException $e) {
|
||||
throw new ClientException(_m('Invalid file size given'), previous: $e);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'_template' => 'blog/make_post.html.twig',
|
||||
'blog_entry_form' => $form->createView(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
{% extends 'stdgrid.html.twig' %}
|
||||
{% block title %}{% trans %}Create a blog post{% endtrans %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{{ parent() }}
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h1>{% trans %}Create a blog post{% endtrans %}</h1>
|
||||
{{ form(blog_entry_form) }}
|
||||
</section>
|
||||
{% endblock body %}
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
|
@ -44,14 +42,14 @@ class ForeignLink
|
|||
private int $user_id;
|
||||
private int $foreign_id;
|
||||
private int $service;
|
||||
private ?string $credentials = null;
|
||||
private int $noticesync = 1;
|
||||
private int $friendsync = 2;
|
||||
private int $profilesync = 1;
|
||||
private ?DateTimeInterface $last_noticesync = null;
|
||||
private ?DateTimeInterface $last_friendsync = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
private ?string $credentials;
|
||||
private int $noticesync = 1;
|
||||
private int $friendsync = 2;
|
||||
private int $profilesync = 1;
|
||||
private ?\DateTimeInterface $last_noticesync;
|
||||
private ?\DateTimeInterface $last_friendsync;
|
||||
private \DateTimeInterface $created;
|
||||
private \DateTimeInterface $modified;
|
||||
|
||||
public function setUserId(int $user_id): self
|
||||
{
|
||||
|
@ -88,7 +86,7 @@ class ForeignLink
|
|||
|
||||
public function setCredentials(?string $credentials): self
|
||||
{
|
||||
$this->credentials = \is_null($credentials) ? null : mb_substr($credentials, 0, 191);
|
||||
$this->credentials = $credentials;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
|
@ -43,9 +41,9 @@ class ForeignService
|
|||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private string $name;
|
||||
private ?string $description = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
private ?string $description;
|
||||
private \DateTimeInterface $created;
|
||||
private \DateTimeInterface $modified;
|
||||
|
||||
public function setId(int $id): self
|
||||
{
|
||||
|
@ -60,7 +58,7 @@ class ForeignService
|
|||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = mb_substr($name, 0, 32);
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
@ -71,7 +69,7 @@ class ForeignService
|
|||
|
||||
public function setDescription(?string $description): self
|
||||
{
|
||||
$this->description = \is_null($description) ? null : mb_substr($description, 0, 191);
|
||||
$this->description = $description;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
|
@ -44,7 +42,7 @@ class ForeignSubscription
|
|||
private int $service;
|
||||
private int $subscriber;
|
||||
private int $subscribed;
|
||||
private DateTimeInterface $created;
|
||||
private \DateTimeInterface $created;
|
||||
|
||||
public function setService(int $service): self
|
||||
{
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
|
@ -44,9 +42,9 @@ class ForeignUser
|
|||
private int $id;
|
||||
private int $service;
|
||||
private string $uri;
|
||||
private ?string $nickname = null;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
private ?string $nickname;
|
||||
private \DateTimeInterface $created;
|
||||
private \DateTimeInterface $modified;
|
||||
|
||||
public function setId(int $id): self
|
||||
{
|
||||
|
@ -72,7 +70,7 @@ class ForeignUser
|
|||
|
||||
public function setUri(string $uri): self
|
||||
{
|
||||
$this->uri = mb_substr($uri, 0, 191);
|
||||
$this->uri = $uri;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
@ -83,7 +81,7 @@ class ForeignUser
|
|||
|
||||
public function setNickname(?string $nickname): self
|
||||
{
|
||||
$this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 191);
|
||||
$this->nickname = $nickname;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,234 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Circle;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Feed;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Common;
|
||||
use App\Util\Nickname;
|
||||
use Component\Circle\Controller as CircleController;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Circle\Entity\ActorCircleSubscription;
|
||||
use Component\Circle\Entity\ActorTag;
|
||||
use Component\Collection\Util\MetaCollectionTrait;
|
||||
use Component\Tag\Tag;
|
||||
use Functional as F;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Component responsible for handling and representing ActorCircles and ActorTags
|
||||
*
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Phablulo <phablulo@gmail.com>
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class Circle extends Component
|
||||
{
|
||||
use MetaCollectionTrait;
|
||||
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
|
||||
protected const SLUG = 'circle';
|
||||
protected const PLURAL_SLUG = 'circles';
|
||||
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']);
|
||||
// View circle members by (tagger id or nickname) and tag
|
||||
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}', [CircleController\Circle::class, 'circleByTaggerIdAndTag']);
|
||||
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}', [CircleController\Circle::class, 'circleByTaggerNicknameAndTag']);
|
||||
|
||||
// View all circles by actor id or nickname
|
||||
$r->connect(
|
||||
id: 'actor_circles_view_by_actor_id',
|
||||
uri_path: '/actor/{tag<' . Tag::TAG_SLUG_REGEX . '>}/circles',
|
||||
target: [CircleController\Circles::class, 'collectionsViewByActorId'],
|
||||
);
|
||||
$r->connect(
|
||||
id: 'actor_circles_view_by_nickname',
|
||||
uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles',
|
||||
target: [CircleController\Circles::class, 'collectionsViewByActorNickname'],
|
||||
);
|
||||
|
||||
$r->connect('actor_circle_view_feed_by_circle_id', '/circle/{circle_id<\d+>}/feed', [CircleController\Circles::class, 'feedByCircleId']);
|
||||
// View circle feed by (tagger id or nickname) and tag
|
||||
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}/feed', [CircleController\Circles::class, 'feedByTaggerIdAndTag']);
|
||||
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}/feed', [CircleController\Circles::class, 'feedByTaggerNicknameAndTag']);
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public static function cacheKeys(string $tag_single_or_multi): array
|
||||
{
|
||||
return [
|
||||
'actor_single' => "actor-tag-feed-{$tag_single_or_multi}",
|
||||
'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}",
|
||||
];
|
||||
}
|
||||
|
||||
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
|
||||
{
|
||||
if ($section === 'profile' && \in_array($request->get('_route'), ['person_actor_settings', 'group_actor_settings'])) {
|
||||
$tabs[] = [
|
||||
'title' => 'Self tags',
|
||||
'desc' => 'Add or remove tags on yourself',
|
||||
'id' => 'settings-self-tags',
|
||||
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
|
||||
];
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
|
||||
{
|
||||
$circles = $actor->getCircles();
|
||||
foreach ($circles as $circle) {
|
||||
$tag = $circle->getTag();
|
||||
$targets["#{$tag}"] = $tag;
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// Meta Collection -------------------------------------------------------------------
|
||||
|
||||
private function getActorIdFromVars(array $vars): int
|
||||
{
|
||||
$id = $vars['request']->get('id', null);
|
||||
if ($id) {
|
||||
return (int) $id;
|
||||
}
|
||||
$nick = $vars['request']->get('nickname');
|
||||
$user = LocalUser::getByNickname($nick);
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
public static function createCircle(Actor|int $tagger_id, string $tag): int
|
||||
{
|
||||
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
|
||||
$circle = ActorCircle::create([
|
||||
'tagger' => $tagger_id,
|
||||
'tag' => $tag,
|
||||
'description' => null, // TODO
|
||||
'private' => false, // TODO
|
||||
]);
|
||||
DB::persist($circle);
|
||||
|
||||
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
||||
|
||||
return $circle->getId();
|
||||
}
|
||||
|
||||
protected function createCollection(Actor $owner, array $vars, string $name)
|
||||
{
|
||||
$this->createCircle($owner, $name);
|
||||
DB::persist(ActorTag::create([
|
||||
'tagger' => $owner->getId(),
|
||||
'tagged' => self::getActorIdFromVars($vars),
|
||||
'tag' => $name,
|
||||
]));
|
||||
}
|
||||
|
||||
protected function removeItem(Actor $owner, array $vars, $items, array $collections)
|
||||
{
|
||||
$tagger_id = $owner->getId();
|
||||
$tagged_id = $this->getActorIdFromVars($vars);
|
||||
$circles_to_remove_tagged_from = DB::findBy(ActorCircle::class, ['id' => $items]);
|
||||
foreach ($circles_to_remove_tagged_from as $circle) {
|
||||
DB::removeBy(ActorCircleSubscription::class, ['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]);
|
||||
}
|
||||
$tags = F\map($circles_to_remove_tagged_from, fn ($x) => $x->getTag());
|
||||
foreach ($tags as $tag) {
|
||||
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
|
||||
}
|
||||
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
||||
}
|
||||
|
||||
protected function addItem(Actor $owner, array $vars, $items, array $collections)
|
||||
{
|
||||
$tagger_id = $owner->getId();
|
||||
$tagged_id = $this->getActorIdFromVars($vars);
|
||||
$circles_to_add_tagged_to = DB::findBy(ActorCircle::class, ['id' => $items]);
|
||||
foreach ($circles_to_add_tagged_to as $circle) {
|
||||
DB::persist(ActorCircleSubscription::create(['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]));
|
||||
}
|
||||
$tags = F\map($circles_to_add_tagged_to, fn ($x) => $x->getTag());
|
||||
foreach ($tags as $tag) {
|
||||
DB::persist(ActorTag::create(['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]));
|
||||
}
|
||||
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see MetaCollectionPlugin->shouldAddToRightPanel
|
||||
*/
|
||||
protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
|
||||
{
|
||||
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an array of Collections owned by an Actor.
|
||||
* In this case, Collections of those within Actor's own circle of Actors, aka ActorCircle.
|
||||
*
|
||||
* Differs from the overwritten method in MetaCollectionsTrait, since retrieved Collections come from the $owner
|
||||
* itself, and from every Actor that is a part of its ActorCircle.
|
||||
*
|
||||
* @param Actor $owner the Actor, and by extension its own circle of Actors
|
||||
* @param null|array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param bool $ids_only true if only the Collections ids are to be returned
|
||||
*/
|
||||
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
|
||||
{
|
||||
$tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null;
|
||||
$circles = \is_null($tagged_id) ? $owner->getCircles() : F\select($owner->getCircles(), function ($x) use ($tagged_id) {
|
||||
foreach ($x->getActorTags() as $at) {
|
||||
if ($at->getTagged() === $tagged_id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
|
||||
}
|
||||
|
||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
|
||||
{
|
||||
DB::persist(Feed::create([
|
||||
'actor_id' => $actor_id,
|
||||
'url' => Router::url($route = 'actor_circles_view_by_nickname', ['nickname' => $user->getNickname()]),
|
||||
'route' => $route,
|
||||
'title' => _m('Circles'),
|
||||
'ordering' => $ordering++,
|
||||
]));
|
||||
return Event::next;
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Circle\Controller;
|
||||
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Exception\ClientException;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Collection\Util\Controller\CircleController;
|
||||
|
||||
class Circle extends CircleController
|
||||
{
|
||||
/**
|
||||
* Render an existing ActorCircle with the given id as a Collection of Actors
|
||||
*
|
||||
* @param ActorCircle|int $circle_id the desired ActorCircle id
|
||||
*
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
* @throws ClientException
|
||||
*/
|
||||
public function circleById(int|ActorCircle $circle_id): array
|
||||
{
|
||||
$circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id;
|
||||
unset($circle_id);
|
||||
if (\is_null($circle)) {
|
||||
throw new ClientException(_m('No such circle.'), 404);
|
||||
} else {
|
||||
return [
|
||||
'_template' => 'collection/actors.html.twig',
|
||||
'title' => _m('Circle'),
|
||||
'empty_message' => _m('No members.'),
|
||||
'sort_form_fields' => [],
|
||||
'page' => $this->int('page') ?? 1,
|
||||
'actors' => $circle->getTaggedActors(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
|
||||
{
|
||||
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
|
||||
}
|
||||
|
||||
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
|
||||
{
|
||||
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));
|
||||
}
|
||||
}
|
|
@ -1,107 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Circle\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Collection\Util\Controller\MetaCollectionController;
|
||||
|
||||
class Circles extends MetaCollectionController
|
||||
{
|
||||
protected const SLUG = 'circle';
|
||||
protected const PLURAL_SLUG = 'circles';
|
||||
protected string $page_title = 'Actor circles';
|
||||
|
||||
public function createCollection(int $owner_id, string $name)
|
||||
{
|
||||
return \Component\Circle\Circle::createCircle($owner_id, $name);
|
||||
}
|
||||
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
|
||||
{
|
||||
return Router::url(
|
||||
'actor_circle_view_by_circle_id',
|
||||
['circle_id' => $collection_id],
|
||||
);
|
||||
}
|
||||
|
||||
public function getCollectionItems(int $owner_id, $collection_id): array
|
||||
{
|
||||
$notes = []; // TODO: Use Feed::query
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'notes' => $notes,
|
||||
];
|
||||
}
|
||||
|
||||
public function feedByCircleId(int $circle_id)
|
||||
{
|
||||
// Owner id isn't used
|
||||
return $this->getCollectionItems(0, $circle_id);
|
||||
}
|
||||
|
||||
public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
|
||||
{
|
||||
// Owner id isn't used
|
||||
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
|
||||
return $this->getCollectionItems($tagger_id, $circle_id);
|
||||
}
|
||||
|
||||
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
|
||||
{
|
||||
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
|
||||
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
|
||||
return $this->getCollectionItems($tagger_id, $circle_id);
|
||||
}
|
||||
|
||||
public function getCollectionsByActorId(int $owner_id): array
|
||||
{
|
||||
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
|
||||
}
|
||||
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle
|
||||
{
|
||||
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
|
||||
}
|
||||
|
||||
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name)
|
||||
{
|
||||
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
||||
$at->setTag($name);
|
||||
}
|
||||
$collection->setTag($name);
|
||||
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
|
||||
}
|
||||
|
||||
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection)
|
||||
{
|
||||
foreach ($collection->getActorTags(db_reference: true) as $at) {
|
||||
DB::remove($at);
|
||||
}
|
||||
DB::remove($collection);
|
||||
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
|
||||
}
|
||||
}
|
|
@ -1,118 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Circle\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\Controller;
|
||||
use App\Core\DB\DB;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity as E;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Component\Circle\Entity\ActorCircle;
|
||||
use Component\Circle\Entity\ActorTag;
|
||||
use Component\Circle\Form\SelfTagsForm;
|
||||
use Component\Tag\Tag as CompTag;
|
||||
use Symfony\Component\Form\SubmitButton;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class SelfTagsSettings extends Controller
|
||||
{
|
||||
/**
|
||||
* Generic settings page for an Actor's self tags
|
||||
* TODO: We should have $actor->setSelfTags(), $actor->addSelfTags(), $actor->removeSelfTags()
|
||||
*/
|
||||
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
|
||||
{
|
||||
$actor = Common::actor();
|
||||
if (!$actor->canModerate($target)) {
|
||||
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
|
||||
}
|
||||
|
||||
$actor_self_tags = $target->getSelfTags();
|
||||
[$add_form, $existing_form] = SelfTagsForm::handleTags(
|
||||
$request,
|
||||
$actor_self_tags,
|
||||
handle_new: /**
|
||||
* Handle adding tags
|
||||
*/
|
||||
function ($form) use ($request, $target, $details_id) {
|
||||
$data = $form->getData();
|
||||
$tags = $data['new-tags'];
|
||||
foreach ($tags as $tag) {
|
||||
$tag = CompTag::sanitize($tag);
|
||||
|
||||
[$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([
|
||||
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $tag,
|
||||
]);
|
||||
if (!$actor_tag_existed) {
|
||||
DB::persist($actor_tag);
|
||||
// Try to find the self-tag circle
|
||||
$actor_circle = DB::findOneBy(
|
||||
ActorCircle::class,
|
||||
[
|
||||
'tagger' => null, // Self-tag circle
|
||||
'tag' => $tag,
|
||||
],
|
||||
return_null: true,
|
||||
);
|
||||
// It is the first time someone uses this self-tag!
|
||||
if (\is_null($actor_circle)) {
|
||||
DB::persist(ActorCircle::create([
|
||||
'tagger' => null, // Self-tag circle
|
||||
'tag' => $tag,
|
||||
'private' => false, // by definition
|
||||
'description' => null, // The controller can show this in every language as appropriate
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
|
||||
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
|
||||
},
|
||||
handle_existing: /**
|
||||
* Handle changes to the existing tags
|
||||
*/
|
||||
function ($form, array $form_definition) use ($request, $target, $details_id) {
|
||||
$changed = false;
|
||||
foreach (array_chunk($form_definition, 2) as $entry) {
|
||||
$tag = CompTag::sanitize($entry[0][2]['data']);
|
||||
|
||||
/** @var SubmitButton $remove */
|
||||
$remove = $form->get($entry[1][0]);
|
||||
if ($remove->isClicked()) {
|
||||
$changed = true;
|
||||
DB::removeBy(
|
||||
'actor_tag',
|
||||
[
|
||||
'tagger' => $target->getId(),
|
||||
'tagged' => $target->getId(),
|
||||
'tag' => $tag,
|
||||
],
|
||||
);
|
||||
// We intentionally leave the self-tag actor circle, even if it is now empty
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
DB::flush();
|
||||
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
|
||||
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
|
||||
}
|
||||
},
|
||||
remove_label: _m('Remove self tag'),
|
||||
add_label: _m('Add self tag'),
|
||||
);
|
||||
|
||||
return [
|
||||
'_template' => 'self_tags_settings.fragment.html.twig',
|
||||
'add_self_tags_form' => $add_form->createView(),
|
||||
'existing_self_tags_form' => $existing_form?->createView(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,221 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace Component\Circle\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Router\Router;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Entity for List of actors
|
||||
* This entity only makes sense when considered together with the ActorTag one.
|
||||
* Because, every circle entry will be an ActorTag.
|
||||
*
|
||||
* @category DB
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Zach Copley <zach@status.net>
|
||||
* @copyright 2010 StatusNet Inc.
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class ActorCircle extends Entity
|
||||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $id;
|
||||
private ?int $tagger = null;
|
||||
private string $tag;
|
||||
private ?string $description = null;
|
||||
private ?bool $private = false;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setId(int $id): self
|
||||
{
|
||||
$this->id = $id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTagger(?int $tagger): self
|
||||
{
|
||||
$this->tagger = $tagger;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTagger(): ?int
|
||||
{
|
||||
return $this->tagger;
|
||||
}
|
||||
|
||||
public function setTag(string $tag): self
|
||||
{
|
||||
$this->tag = mb_substr($tag, 0, 64);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTag(): string
|
||||
{
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setPrivate(?bool $private): self
|
||||
{
|
||||
$this->private = $private;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrivate(): ?bool
|
||||
{
|
||||
return $this->private;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
{
|
||||
$this->created = $created;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreated(): DateTimeInterface
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModified(): DateTimeInterface
|
||||
{
|
||||
return $this->modified;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
/**
|
||||
* For use with MetaCollection trait only
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function getActorTags(bool $db_reference = false): array
|
||||
{
|
||||
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
|
||||
if ($db_reference) {
|
||||
return $handle();
|
||||
}
|
||||
return Cache::get(
|
||||
"circle-{$this->getId()}-tagged",
|
||||
$handle,
|
||||
);
|
||||
}
|
||||
|
||||
public function getTaggedActors()
|
||||
{
|
||||
return Cache::get(
|
||||
"circle-{$this->getId()}-tagged-actors",
|
||||
function () {
|
||||
if ($this->getTagger()) {
|
||||
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = :tagger', ['tag' => $this->getTag(), 'tagger' => $this->getTagger()]);
|
||||
} else { // Self-tag
|
||||
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = at.tagged', ['tag' => $this->getTag()]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
|
||||
{
|
||||
return Cache::get(
|
||||
"circle-{$this->getId()}-subscribers",
|
||||
fn () => DB::dql(
|
||||
<<< 'EOQ'
|
||||
SELECT a
|
||||
FROM actor a
|
||||
JOIN actor_circle_subscription s
|
||||
WITH a.id = s.actor_id
|
||||
ORDER BY s.created DESC, a.id DESC
|
||||
EOQ,
|
||||
options: [
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function getUrl(int $type = Router::ABSOLUTE_PATH): string
|
||||
{
|
||||
return Router::url('actor_circle_view_by_circle_id', ['circle_id' => $this->getId()], type: $type);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'actor_circle',
|
||||
'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend',
|
||||
'fields' => [
|
||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors
|
||||
'tagger' => ['type' => 'int', 'default' => null, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag, null if self-tag. If null, is the special global self-tag circle'],
|
||||
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag
|
||||
'description' => ['type' => 'text', 'description' => 'description of the people tag'],
|
||||
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||
],
|
||||
'primary key' => ['id'], // But we will mostly refer to them with `tagger` and `tag`
|
||||
'indexes' => [
|
||||
'actor_list_modified_idx' => ['modified'],
|
||||
'actor_list_tagger_tag_idx' => ['tagger', 'tag'], // The actual identifier we will use the most
|
||||
'actor_list_tag_idx' => ['tag'],
|
||||
'actor_list_tagger_idx' => ['tagger'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Circle\Form;
|
||||
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Util\Form\ArrayTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
abstract class SelfTagsForm
|
||||
{
|
||||
/**
|
||||
* @return array [Form (add), ?Form (existing)]
|
||||
*/
|
||||
public static function handleTags(
|
||||
Request $request,
|
||||
array $actor_self_tags,
|
||||
callable $handle_new,
|
||||
callable $handle_existing,
|
||||
string $remove_label,
|
||||
string $add_label,
|
||||
): array {
|
||||
$form_definition = [];
|
||||
foreach ($actor_self_tags as $tag) {
|
||||
$tag = $tag->getTag();
|
||||
$form_definition[] = ["{$tag}:old-tag", TextType::class, ['data' => $tag, 'label' => ' ', 'disabled' => true]];
|
||||
$form_definition[] = [$existing_form_name = "{$tag}:remove", SubmitType::class, ['label' => $remove_label]];
|
||||
}
|
||||
|
||||
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
|
||||
|
||||
$add_form = Form::create([
|
||||
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
|
||||
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
|
||||
]);
|
||||
|
||||
if ($request->getMethod() === 'POST' && $request->request->has($add_form_name)) {
|
||||
$add_form->handleRequest($request);
|
||||
if ($add_form->isSubmitted() && $add_form->isValid()) {
|
||||
$handle_new($add_form);
|
||||
}
|
||||
}
|
||||
|
||||
if (!\is_null($existing_form) && $request->getMethod() === 'POST' && $request->request->has($existing_form_name ?? '')) {
|
||||
$existing_form->handleRequest($request);
|
||||
if ($existing_form->isSubmitted() && $existing_form->isValid()) {
|
||||
$handle_existing($existing_form, $form_definition);
|
||||
}
|
||||
}
|
||||
|
||||
return [$add_form, $existing_form];
|
||||
}
|
||||
}
|
|
@ -1,163 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Formatting;
|
||||
use Component\Collection\Util\Parser;
|
||||
use Component\Subscription\Entity\ActorSubscription;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class Collection extends Component
|
||||
{
|
||||
/**
|
||||
* Perform a high level query on notes or actors
|
||||
*
|
||||
* Supports a variety of query terms and is used both in feeds and
|
||||
* in search. Uses query builders to allow for extension
|
||||
*/
|
||||
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
|
||||
{
|
||||
$note_criteria = null;
|
||||
$actor_criteria = null;
|
||||
if (!empty($query = trim($query))) {
|
||||
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
|
||||
}
|
||||
|
||||
$note_qb = DB::createQueryBuilder();
|
||||
$actor_qb = DB::createQueryBuilder();
|
||||
// TODO consider selecting note related stuff, to avoid separate queries (though they're cached, so maybe it's okay)
|
||||
$note_qb->select('note')->from('App\Entity\Note', 'note');
|
||||
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor');
|
||||
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
|
||||
|
||||
// Handle ordering
|
||||
$note_order_by = !empty($note_order_by) ? $note_order_by : ['note.created' => 'DESC', 'note.id' => 'DESC'];
|
||||
$actor_order_by = !empty($actor_order_by) ? $actor_order_by : ['actor.created' => 'DESC', 'actor.id' => 'DESC'];
|
||||
foreach ($note_order_by as $field => $order) {
|
||||
$note_qb->addOrderBy($field, $order);
|
||||
}
|
||||
foreach ($actor_order_by as $field => $order) {
|
||||
$actor_qb->addOrderBy($field, $order);
|
||||
}
|
||||
|
||||
$notes = [];
|
||||
$actors = [];
|
||||
if (!\is_null($note_criteria)) {
|
||||
$note_qb->addCriteria($note_criteria);
|
||||
$notes = $note_qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
if (!\is_null($actor_criteria)) {
|
||||
$actor_qb->addCriteria($actor_criteria);
|
||||
$actors = $actor_qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
// N.B.: Scope is only enforced at FeedController level
|
||||
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
|
||||
}
|
||||
|
||||
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
|
||||
{
|
||||
$note_aliases = $note_qb->getAllAliases();
|
||||
if (!\in_array('subscription', $note_aliases)) {
|
||||
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id');
|
||||
}
|
||||
if (!\in_array('note_actor', $note_aliases)) {
|
||||
$note_qb->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
|
||||
* notes, for different types of actors and for the content of text notes
|
||||
*/
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
|
||||
{
|
||||
if (str_contains($term, ':')) {
|
||||
$term = explode(':', $term);
|
||||
if (Formatting::startsWith($term[0], 'note')) {
|
||||
switch ($term[0]) {
|
||||
case 'notes-all':
|
||||
$note_expr = $eb->neq('note.created', null);
|
||||
break;
|
||||
case 'note-local':
|
||||
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
|
||||
break;
|
||||
case 'note-types':
|
||||
case 'notes-include':
|
||||
case 'note-filter':
|
||||
if (\is_null($note_expr)) {
|
||||
$note_expr = [];
|
||||
}
|
||||
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
|
||||
$note_expr[] = $eb->neq('note.content', null);
|
||||
} else {
|
||||
$note_expr[] = $eb->eq('note.content', null);
|
||||
}
|
||||
break;
|
||||
case 'note-conversation':
|
||||
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
|
||||
break;
|
||||
case 'note-from':
|
||||
case 'notes-from':
|
||||
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
|
||||
$type_consts = [];
|
||||
if ($term[1] === 'subscribed') {
|
||||
$type_consts = null;
|
||||
}
|
||||
foreach (explode(',', $term[1]) as $from) {
|
||||
if (str_starts_with($from, 'subscribed-')) {
|
||||
[, $type] = explode('-', $from);
|
||||
if (\in_array($type, ['actor', 'actors'])) {
|
||||
$type_consts = null;
|
||||
} else {
|
||||
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type === 'organisation' ? 'group' : $type));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (\is_null($type_consts)) {
|
||||
$note_expr = $subscribed_expr;
|
||||
} elseif (!empty($type_consts)) {
|
||||
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
|
||||
}
|
||||
break;
|
||||
}
|
||||
} elseif (Formatting::startsWith($term, 'actor-')) {
|
||||
switch ($term[0]) {
|
||||
case 'actor-types':
|
||||
case 'actors-include':
|
||||
case 'actor-filter':
|
||||
case 'actor-local':
|
||||
if (\is_null($actor_expr)) {
|
||||
$actor_expr = [];
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
Actor::PERSON => ['person', 'people'],
|
||||
Actor::GROUP => ['group', 'groups', 'org', 'orgs', 'organisation', 'organisations', 'organization', 'organizations'],
|
||||
Actor::BOT => ['bot', 'bots'],
|
||||
] as $type => $match) {
|
||||
if (array_intersect(explode(',', $term[1]), $match) !== []) {
|
||||
$actor_expr[] = $eb->eq('actor.type', $type);
|
||||
} else {
|
||||
$actor_expr[] = $eb->neq('actor.type', $type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$note_expr = $eb->contains('note.content', $term);
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
class CircleController extends OrderedCollection
|
||||
{
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use Component\Collection\Collection as CollectionModule;
|
||||
|
||||
class Collection extends Controller
|
||||
{
|
||||
public function query(string $query, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
|
||||
{
|
||||
$actor ??= Common::actor();
|
||||
$locale ??= Common::currentLanguage()->getLocale();
|
||||
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor, $note_order_by, $actor_order_by);
|
||||
}
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* Base class for feed controllers
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category Controller
|
||||
*
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use Functional as F;
|
||||
|
||||
abstract class FeedController extends OrderedCollection
|
||||
{
|
||||
/**
|
||||
* Post-processing of the result of a feed controller, to remove any
|
||||
* notes or actors the user specified, as well as format the raw
|
||||
* list of notes into a usable format
|
||||
*/
|
||||
protected function postProcess(array $result): array
|
||||
{
|
||||
$actor = Common::actor();
|
||||
if (\array_key_exists('notes', $result)) {
|
||||
$notes = $result['notes'];
|
||||
self::enforceScope($notes, $actor, $result['actor'] ?? null);
|
||||
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
|
||||
Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function enforceScope(array &$notes, ?Actor $actor, ?Actor $in = null): void
|
||||
{
|
||||
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor, $in));
|
||||
}
|
||||
}
|
|
@ -1,216 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
/**
|
||||
* Collections Controller for GNU social
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category Plugin
|
||||
*
|
||||
* @author Phablulo <phablulo@gmail.com>
|
||||
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
abstract class MetaCollectionController extends FeedController
|
||||
{
|
||||
protected const SLUG = 'collectionsEntry';
|
||||
protected const PLURAL_SLUG = 'collectionsList';
|
||||
protected string $page_title = 'Collections';
|
||||
|
||||
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string;
|
||||
abstract public function getCollectionItems(int $owner_id, $collection_id): array;
|
||||
abstract public function getCollectionsByActorId(int $owner_id): array;
|
||||
abstract public function getCollectionBy(int $owner_id, int $collection_id);
|
||||
abstract public function createCollection(int $owner_id, string $name);
|
||||
|
||||
public function collectionsViewByActorNickname(Request $request, string $nickname): array
|
||||
{
|
||||
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
||||
return self::collectionsView($request, $user->getId(), $nickname);
|
||||
}
|
||||
|
||||
public function collectionsViewByActorId(Request $request, int $id): array
|
||||
{
|
||||
return self::collectionsView($request, $id, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Collections page
|
||||
*
|
||||
* @param int $id actor id
|
||||
* @param ?string $nickname actor nickname
|
||||
*
|
||||
* @return array twig template options
|
||||
*/
|
||||
public function collectionsView(Request $request, int $id, ?string $nickname): array
|
||||
{
|
||||
$collections = $this->getCollectionsByActorId($id);
|
||||
|
||||
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', static::SLUG)));
|
||||
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', static::PLURAL_SLUG)));
|
||||
// create collection form
|
||||
$create = null;
|
||||
if (Common::user()?->getId() === $id) {
|
||||
$create = Form::create([
|
||||
['name', TextType::class, [
|
||||
'label' => $create_title,
|
||||
'attr' => [
|
||||
'placeholder' => _m('Name'),
|
||||
'required' => 'required',
|
||||
],
|
||||
'data' => '',
|
||||
]],
|
||||
['add_collection', SubmitType::class, [
|
||||
'label' => $create_title,
|
||||
'attr' => [
|
||||
'title' => $create_title,
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$create->handleRequest($request);
|
||||
if ($create->isSubmitted() && $create->isValid()) {
|
||||
$this->createCollection($id, $create->getData()['name']);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
}
|
||||
|
||||
// We need to inject some functions in twig,
|
||||
// but I don't want to create an environment for this
|
||||
// as twig docs suggest in https://twig.symfony.com/doc/2.x/advanced.html#functions.
|
||||
//
|
||||
// Instead, I'm using an anonymous class to encapsulate
|
||||
// the functions and passing that class to the template.
|
||||
// This is suggested at https://web.archive.org/web/20220226132328/https://stackoverflow.com/questions/3595727/twig-pass-function-into-template/50364502
|
||||
$fn = new class($id, $nickname, $request, $this, static::SLUG) {
|
||||
private $id;
|
||||
private $nick;
|
||||
private $request;
|
||||
private $parent;
|
||||
private $slug;
|
||||
|
||||
public function __construct($id, $nickname, $request, $parent, $slug)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->nick = $nickname;
|
||||
$this->request = $request;
|
||||
$this->parent = $parent;
|
||||
$this->slug = $slug;
|
||||
}
|
||||
// there's already an injected function called path,
|
||||
// that maps to Router::url(name, args), but since
|
||||
// I want to preserve nicknames, I think it's better
|
||||
// to use that getUrl function
|
||||
public function getUrl($cid)
|
||||
{
|
||||
return $this->parent->getCollectionUrl($this->id, $this->nick, $cid);
|
||||
}
|
||||
// There are many collections in this page and we need two
|
||||
// forms for each one of them: one form to edit the collection's
|
||||
// name and another to remove the collection.
|
||||
|
||||
// creating the edit form
|
||||
public function editForm($collection)
|
||||
{
|
||||
$edit = Form::create([
|
||||
['name', TextType::class, [
|
||||
'attr' => [
|
||||
'placeholder' => 'New name',
|
||||
'required' => 'required',
|
||||
],
|
||||
'data' => '',
|
||||
]],
|
||||
['update_' . $collection->getId(), SubmitType::class, [
|
||||
'label' => _m('Save'),
|
||||
'attr' => [
|
||||
'title' => _m('Save'),
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$edit->handleRequest($this->request);
|
||||
if ($edit->isSubmitted() && $edit->isValid()) {
|
||||
$this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
return $edit->createView();
|
||||
}
|
||||
|
||||
// creating the remove form
|
||||
public function rmForm($collection)
|
||||
{
|
||||
$rm = Form::create([
|
||||
['remove_' . $collection->getId(), SubmitType::class, [
|
||||
'label' => _m('Delete ' . $this->slug),
|
||||
'attr' => [
|
||||
'title' => _m('Delete ' . $this->slug),
|
||||
'class' => 'danger',
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$rm->handleRequest($this->request);
|
||||
if ($rm->isSubmitted()) {
|
||||
$this->parent->removeCollection($this->id, $this->nick, $collection);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
return $rm->createView();
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
'_template' => 'collection/meta_collections.html.twig',
|
||||
'page_title' => $this->page_title,
|
||||
'list_title' => $collections_title,
|
||||
'add_collection' => $create?->createView(),
|
||||
'fn' => $fn,
|
||||
'collections' => $collections,
|
||||
];
|
||||
}
|
||||
|
||||
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
|
||||
{
|
||||
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
|
||||
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
|
||||
}
|
||||
|
||||
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
|
||||
{
|
||||
$collection = $this->getCollectionBy($id, $cid);
|
||||
$vars = $this->getCollectionItems($id, $cid);
|
||||
return array_merge([
|
||||
'_template' => 'collections/collection_entry_view.html.twig',
|
||||
'page_title' => $collection->getName(),
|
||||
], $vars);
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\Collection\Util\Controller;
|
||||
|
||||
class OrderedCollection extends Collection
|
||||
{
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
/**
|
||||
* Collections for GNU social
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category Plugin
|
||||
*
|
||||
* @author Phablulo <phablulo@gmail.com>
|
||||
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Collection\Util;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Formatting;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
trait MetaCollectionTrait
|
||||
{
|
||||
//protected const SLUG = 'collection';
|
||||
//protected const PLURAL_SLUG = 'collections';
|
||||
|
||||
/**
|
||||
* create a collection owned by Actor $owner.
|
||||
*
|
||||
* @param Actor $owner The collection's owner
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param string $name Collection's name
|
||||
*/
|
||||
abstract protected function createCollection(Actor $owner, array $vars, string $name);
|
||||
/**
|
||||
* remove item from collections.
|
||||
*
|
||||
* @param Actor $owner Current user
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param array $items Array of collections's ids to remove the current item from
|
||||
* @param array $collections List of ids of collections owned by $owner
|
||||
*/
|
||||
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
|
||||
/**
|
||||
* add item to collections.
|
||||
*
|
||||
* @param Actor $owner Current user
|
||||
* @param array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param array $items Array of collections's ids to add the current item to
|
||||
* @param array $collections List of ids of collections owned by $owner
|
||||
*/
|
||||
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
|
||||
|
||||
/**
|
||||
* Check the route to determine whether the widget should be added
|
||||
*/
|
||||
abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
|
||||
/**
|
||||
* Get array of collections's owned by $actor
|
||||
*
|
||||
* @param Actor $owner Collection's owner
|
||||
* @param ?array $vars Page vars sent by AppendRightPanelBlock event
|
||||
* @param bool $ids_only if true, the function must return only the primary key or each collections
|
||||
*/
|
||||
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
|
||||
|
||||
/**
|
||||
* Append Collections widget to the right panel.
|
||||
* It's compose of two forms: one to select collections to add
|
||||
* the current item to, and another to create a new collection.
|
||||
*/
|
||||
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
|
||||
{
|
||||
$user = Common::actor();
|
||||
if (\is_null($user)) {
|
||||
return Event::next;
|
||||
}
|
||||
if (!$this->shouldAddToRightPanel($user, $vars, $request)) {
|
||||
return Event::next;
|
||||
}
|
||||
$collections = $this->getCollectionsBy($user);
|
||||
|
||||
// form: add to collection
|
||||
$choices = [];
|
||||
foreach ($collections as $col) {
|
||||
$choices[$col->getName()] = $col->getId();
|
||||
}
|
||||
|
||||
$collections = array_map(fn ($x) => $x->getId(), $collections);
|
||||
|
||||
$already_selected = $this->getCollectionsBy($user, $vars, true);
|
||||
$add_form = Form::create([
|
||||
['collections', ChoiceType::class, [
|
||||
'choices' => $choices,
|
||||
'multiple' => true,
|
||||
'required' => false,
|
||||
'choice_attr' => function ($id) use ($already_selected) {
|
||||
if (\in_array($id, $already_selected)) {
|
||||
return ['selected' => 'selected'];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
]],
|
||||
['add', SubmitType::class, [
|
||||
'label' => _m('Add to ' . static::PLURAL_SLUG),
|
||||
'attr' => [
|
||||
'title' => _m('Add to ' . static::PLURAL_SLUG),
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$add_form->handleRequest($request);
|
||||
if ($add_form->isSubmitted() && $add_form->isValid()) {
|
||||
$selected = $add_form->getData()['collections'];
|
||||
$removed = array_filter($already_selected, fn ($x) => !\in_array($x, $selected));
|
||||
$added = array_filter($selected, fn ($x) => !\in_array($x, $already_selected));
|
||||
if (\count($removed) > 0) {
|
||||
$this->removeItem($user, $vars, $removed, $collections);
|
||||
}
|
||||
if (\count($added) > 0) {
|
||||
$this->addItem($user, $vars, $added, $collections);
|
||||
}
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
||||
// form: add to new collection
|
||||
$create_form = Form::create([
|
||||
['name', TextType::class, [
|
||||
'label' => _m('Add to a new ' . static::SLUG),
|
||||
'attr' => [
|
||||
'placeholder' => _m('New ' . static::SLUG . ' name'),
|
||||
'required' => 'required',
|
||||
],
|
||||
'data' => '',
|
||||
]],
|
||||
['create', SubmitType::class, [
|
||||
'label' => _m('Create a new ' . static::SLUG),
|
||||
'attr' => [
|
||||
'title' => _m('Create a new ' . static::SLUG),
|
||||
],
|
||||
]],
|
||||
]);
|
||||
$create_form->handleRequest($request);
|
||||
if ($create_form->isSubmitted() && $create_form->isValid()) {
|
||||
$name = $create_form->getData()['name'];
|
||||
$this->createCollection($user, $vars, $name);
|
||||
DB::flush();
|
||||
throw new RedirectException();
|
||||
}
|
||||
|
||||
$res[] = Formatting::twigRenderFile(
|
||||
'collection/widget_add_to.html.twig',
|
||||
[
|
||||
'ctitle' => _m('Add to ' . static::PLURAL_SLUG),
|
||||
'user' => $user,
|
||||
'has_collections' => \count($collections) > 0,
|
||||
'add_form' => $add_form->createView(),
|
||||
'create_form' => $create_form->createView(),
|
||||
],
|
||||
);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onEndShowStyles(array &$styles, string $route): bool
|
||||
{
|
||||
$styles[] = 'components/Collection/assets/css/widget.css';
|
||||
$styles[] = 'components/Collection/assets/css/pages.css';
|
||||
return Event::next;
|
||||
}
|
||||
}
|
|
@ -1,130 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Collection\Util;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Exception\ServerException;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
|
||||
abstract class Parser
|
||||
{
|
||||
/**
|
||||
* Merge $parts into $criteria_arr
|
||||
*/
|
||||
private static function connectParts(array &$parts, array &$criteria_arr, string $last_op, mixed $eb, bool $force = false): void
|
||||
{
|
||||
foreach ([' ' => 'orX', '|' => 'orX', '&' => 'andX'] as $op => $func) {
|
||||
if ($last_op === $op || $force) {
|
||||
$criteria_arr[] = $eb->{$func}(...$parts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse $input string into a Doctrine query Criteria
|
||||
*
|
||||
* Currently doesn't support nesting with parenthesis and
|
||||
* recognises either spaces (currently `or`, should be fuzzy match), `OR` or `|` (`or`) and `AND` or `&` (`and`)
|
||||
*
|
||||
* TODO: Better fuzzy match, implement exact match with quotes and nesting with parens
|
||||
* TODO: Proper parser, tokenize better. Mostly a rewrite
|
||||
*
|
||||
* @return array{?Criteria, ?Criteria} [?$note_criteria, ?$actor_criteria]
|
||||
*/
|
||||
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
|
||||
{
|
||||
if ($level === 0) {
|
||||
$input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&');
|
||||
}
|
||||
|
||||
$left = 0;
|
||||
$right = 0;
|
||||
$lenght = mb_strlen($input);
|
||||
$eb = Criteria::expr();
|
||||
$note_criteria_arr = [];
|
||||
$actor_criteria_arr = [];
|
||||
$note_parts = [];
|
||||
$actor_parts = [];
|
||||
$last_op = null;
|
||||
|
||||
for ($index = 0; $index < $lenght; ++$index) {
|
||||
$end = false;
|
||||
$match = false;
|
||||
|
||||
foreach (['&', '|', ' '] as $delimiter) {
|
||||
if ($input[$index] === $delimiter || $end = ($index === $lenght - 1)) {
|
||||
$term = mb_substr($input, $left, $end ? null : $right - $left);
|
||||
$note_res = null;
|
||||
$actor_res = null;
|
||||
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]);
|
||||
if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
|
||||
//throw new ServerException("No one claimed responsibility for a match term: {$term}");
|
||||
// It's okay if the term doesn't exist, just perform a regular search
|
||||
}
|
||||
if (!empty($note_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
|
||||
if (\is_array($note_res)) {
|
||||
$note_res = $eb->orX(...$note_res);
|
||||
}
|
||||
$note_parts[] = $note_res;
|
||||
}
|
||||
if (!empty($actor_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
|
||||
if (\is_array($actor_res)) {
|
||||
$actor_res = $eb->orX(...$actor_res);
|
||||
}
|
||||
$actor_parts[] = $actor_res;
|
||||
}
|
||||
|
||||
$right = $left = $index + 1;
|
||||
|
||||
if (!\is_null($last_op) && $last_op !== $delimiter) {
|
||||
self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: false);
|
||||
} else {
|
||||
$last_op = $delimiter;
|
||||
}
|
||||
$match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
if (!$match) {
|
||||
++$right;
|
||||
}
|
||||
}
|
||||
|
||||
$note_criteria = null;
|
||||
$actor_criteria = null;
|
||||
if (!empty($note_parts)) {
|
||||
self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: true);
|
||||
$note_criteria = new Criteria($eb->orX(...$note_criteria_arr));
|
||||
}
|
||||
if (!empty($actor_parts)) { // @phpstan-ignore-line weird, but this whole thing needs a rewrite
|
||||
self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true);
|
||||
$actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr));
|
||||
}
|
||||
|
||||
return [$note_criteria, $actor_criteria];
|
||||
}
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
{% extends 'stdgrid.html.twig' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<section class="frame-section frame-section-padding">
|
||||
<header class="feed-header">
|
||||
{% if actors_feed_title is defined %}
|
||||
{{ actors_feed_title.getHtml() }}
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% set prepend_actors_collection = handle_event('PrependActorsCollection', request) %}
|
||||
{% for widget in prepend_actors_collection %}
|
||||
{{ widget | raw }}
|
||||
{% endfor %}
|
||||
|
||||
<details class="frame-section section-details-title">
|
||||
<summary class="details-summary-title">
|
||||
<strong>
|
||||
{% trans %}Ordering rules{% endtrans %}
|
||||
</strong>
|
||||
</summary>
|
||||
<form method="GET" class="section-form">
|
||||
<div class="container-grid">
|
||||
<section class="frame-section frame-section-padding">
|
||||
<strong>{% trans %}Sort by{% endtrans %}</strong>
|
||||
<hr>
|
||||
<div class="container-block">
|
||||
{% for field in sort_form_fields %}
|
||||
<span class="container-block">
|
||||
<label for="order_by_{{ field.value }}">{{ field.label }}</label>
|
||||
<input id="order_by_{{ field.value }}" type="radio" name="order_by" value="{{ field.value }}" {% if field.checked %}checked="checked"{% endif %}>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="frame-section frame-section-padding">
|
||||
<strong class="section-title">{% trans %}Order{% endtrans %}</strong>
|
||||
<hr>
|
||||
<section class="container-block">
|
||||
<span class="container-block">
|
||||
<label for="order_op_asc">{% trans %}Ascending{% endtrans %}</label>
|
||||
<input id="order_op_asc" type="radio" name="order_op" value="ASC">
|
||||
</span>
|
||||
<span class="container-block">
|
||||
<label for="order_op_desc">{% trans %}Descending{% endtrans %}</label>
|
||||
<input id="order_op_desc" type="radio" name="order_op" value="DESC" checked="checked">
|
||||
</span>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<button type="submit">{% trans %}Order{% endtrans %}</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<section class="frame-section frame-section-padding">
|
||||
<h2>{% trans %}Results{% endtrans %}</h2>
|
||||
{% if actors is defined and actors is not empty %}
|
||||
{% for actor in actors %}
|
||||
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' %}{% endblock profile_view %}
|
||||
<hr>
|
||||
{% endfor %}
|
||||
<span class="frame-section-button-like">{% trans %}Page: %page%{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span>{{ empty_message }}</span>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
{% endblock body %}
|
|
@ -1,11 +0,0 @@
|
|||
{% extends '/collection/notes.html.twig' %}
|
||||
|
||||
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2>
|
||||
{% block collection_items %}
|
||||
{% endblock collection_items %}
|
||||
</div>
|
||||
{% endblock body %}
|
|
@ -1,34 +0,0 @@
|
|||
{% extends 'stdgrid.html.twig' %}
|
||||
|
||||
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2>
|
||||
{% if add_collection %}
|
||||
<div class="frame-section section-form">
|
||||
{{ form(add_collection) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="frame-section collections-list">
|
||||
<h3>{% trans %}%list_title%{% endtrans %}</h3>
|
||||
{% for col in collections %}
|
||||
<div class="collection-item">
|
||||
<a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a>
|
||||
<details title="Expand if you want to edit the collection's name">
|
||||
<summary>
|
||||
<span class="collection-action">{{ icon('edit') | raw }}</span>
|
||||
</summary>
|
||||
{{ form(fn.editForm(col)) }}
|
||||
</details>
|
||||
<details title="Expand if you want to delete the collection">
|
||||
<summary>
|
||||
<span class="collection-action">{{ icon('delete') | raw }}</span>
|
||||
</summary>
|
||||
{{ form(fn.rmForm(col)) }}
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock body %}
|
|
@ -1,58 +0,0 @@
|
|||
{% extends 'stdgrid.html.twig' %}
|
||||
{% import '/cards/macros/note/factory.html.twig' as NoteFactory %}
|
||||
|
||||
{% block title %}{% if page_title is defined %}{% trans %}%page_title%{% endtrans %}{% endif %}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link rel="stylesheet" href="{{ asset('assets/default_theme/feeds.css') }}" type="text/css">
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% block body %}
|
||||
{% for block in handle_event('BeforeFeed', app.request) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
|
||||
{% if notes is defined %}
|
||||
<header class="feed-header">
|
||||
{% set current_path = app.request.get('_route') %}
|
||||
{% if notes_feed_title is defined %}
|
||||
{{ notes_feed_title.getHtml() }}
|
||||
{% endif %}
|
||||
<nav class="feed-actions" title="{% trans %}Actions that change how the feed behaves{% endtrans %}">
|
||||
<details class="feed-actions-details" role="group">
|
||||
<summary>
|
||||
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
|
||||
</summary>
|
||||
<menu class="feed-actions-details-dropdown" role="toolbar">
|
||||
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
|
||||
{{ block | raw }}
|
||||
{% endfor %}
|
||||
</menu>
|
||||
</details>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% if notes is not empty %}
|
||||
{# Backwards compatibility with hAtom 0.1 #}
|
||||
<section class="feed h-feed hfeed notes" role="feed" aria-busy="false" title="{% trans %}Feed content{% endtrans %}">
|
||||
{% for conversation in notes %}
|
||||
{% block current_note %}
|
||||
{% if conversation is instanceof('array') %}
|
||||
{% set args = conversation | merge({'type': 'vanilla_full'}) %}
|
||||
{{ NoteFactory.constructor(args) }}
|
||||
{# {% else %}
|
||||
{% set args = { 'type': 'vanilla_full', 'note': conversation, 'extra': { 'depth': 0 } } %}
|
||||
{{ NoteFactory.constructor(args) }}#}
|
||||
{% endif %}
|
||||
<hr class="hr-replies-end" role="separator" aria-label="{% trans %}Marks the end of previous conversation's initial note{% endtrans %}">
|
||||
{% endblock current_note %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
|
||||
<span>{% trans %}No notes here...{% endtrans %}</span>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock body %}
|
|
@ -1,27 +0,0 @@
|
|||
<section class="frame-section collections">
|
||||
<details class="section-details-title" title="Expand if you want to access more options.">
|
||||
<summary class="details-summary-title">
|
||||
<span>{{ctitle}}</span>
|
||||
</summary>
|
||||
{% if has_collections %}
|
||||
<section class="section-form">
|
||||
{{ form(add_form) }}
|
||||
</section>
|
||||
|
||||
<details class="frame-section-padding section-details-subtitle"
|
||||
title="Expand if you want to access more options.">
|
||||
<summary class="details-summary-subtitle">
|
||||
<strong>{% trans %}Other options{% endtrans %}</strong>
|
||||
</summary>
|
||||
<section class="section-form">
|
||||
{{ form(create_form) }}
|
||||
</section>
|
||||
</details>
|
||||
{% else %}
|
||||
<section class="section-form">
|
||||
{{ form(create_form) }}
|
||||
</section>
|
||||
{% endif %}
|
||||
</details>
|
||||
</section>
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Eliseu Amaro <mail@eliseuama.ro>
|
||||
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Conversation\Controller;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\NoSuchNoteException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\HTML\Heading;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Component\Conversation\Entity\ConversationMute;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Conversation extends FeedController
|
||||
{
|
||||
/**
|
||||
* Render conversation page.
|
||||
*
|
||||
* @param int $conversation_id To identify what Conversation is to be rendered
|
||||
*
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*
|
||||
* @return array Array containing keys: 'notes' (all known notes in the given Conversation), 'should_format' (boolean, stating if onFormatNoteList events may or not format given notes), 'page_title' (used as the title header)
|
||||
*/
|
||||
public function showConversation(Request $request, int $conversation_id): array
|
||||
{
|
||||
$page_title = _m('Conversation');
|
||||
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'notes' => $this->query(
|
||||
query: "note-conversation:{$conversation_id}",
|
||||
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
|
||||
)['notes'] ?? [],
|
||||
'should_format' => false,
|
||||
'page_title' => $page_title,
|
||||
'notes_feed_title' => (new Heading(1, [], $page_title)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller for the note reply non-JS page
|
||||
*
|
||||
* Leverages the `PostingModifyData` event to add the `reply_to_id` field from the GET variable 'reply_to_id'
|
||||
*
|
||||
* @throws ClientException
|
||||
* @throws NoLoggedInUser
|
||||
* @throws NoSuchNoteException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function addReply(Request $request)
|
||||
{
|
||||
$user = Common::ensureLoggedIn();
|
||||
$note_id = $this->int('reply_to_id', new ClientException(_m('Malformed query.')));
|
||||
$note = Note::ensureCanInteract(Note::getByPK($note_id), $user);
|
||||
$conversation_id = $note->getConversationId();
|
||||
return $this->showConversation($request, $conversation_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates form view for Muting Conversation extra action.
|
||||
*
|
||||
* @param int $conversation_id The Conversation id that this action targets
|
||||
*
|
||||
* @throws \App\Util\Exception\NoLoggedInUser
|
||||
* @throws \App\Util\Exception\RedirectException
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*
|
||||
* @return array Array containing templating where the form is to be rendered, and the form itself
|
||||
*/
|
||||
public function muteConversation(Request $request, int $conversation_id)
|
||||
{
|
||||
$user = Common::ensureLoggedIn();
|
||||
$is_muted = ConversationMute::isMuted($conversation_id, $user);
|
||||
$form = Form::create([
|
||||
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Unmute') : _m('Mute'), 'attr' => ['class' => '']]],
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (!$is_muted) {
|
||||
DB::persist(ConversationMute::create(['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]));
|
||||
} else {
|
||||
DB::removeBy('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]);
|
||||
}
|
||||
DB::flush();
|
||||
Cache::delete(ConversationMute::cacheKeys($conversation_id, $user->getId())['mute']);
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$user->getId()} attempted to mute conversation {$conversation_id} and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
} else {
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException(url: $from);
|
||||
}
|
||||
} else {
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'conversation/mute.html.twig',
|
||||
'notes' => $this->query(
|
||||
query: "note-conversation:{$conversation_id}",
|
||||
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
|
||||
)['notes'] ?? [],
|
||||
'is_muted' => $is_muted,
|
||||
'form' => $form->createView(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,313 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Eliseu Amaro <mail@eliseuama.ro>
|
||||
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Conversation;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Formatting;
|
||||
use Component\Conversation\Entity\Conversation as ConversationEntity;
|
||||
use Component\Conversation\Entity\ConversationMute;
|
||||
use Functional as F;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Conversation extends Component
|
||||
{
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
|
||||
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
|
||||
$r->connect('conversation_reply_to', '/conversation/reply', [Controller\Conversation::class, 'addReply']);
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Assigns** the given local Note it's corresponding **Conversation**.
|
||||
*
|
||||
* **If a _$parent_id_ is not given**, then the Actor is not attempting a reply,
|
||||
* therefore, we can assume (for now) that we need to create a new Conversation and assign it
|
||||
* to the newly created Note (please look at Component\Posting::storeLocalNote, where this function is used)
|
||||
*
|
||||
* **On the other hand**, given a _$parent_id_, the Actor is attempting to post a reply. Meaning that,
|
||||
* this Note conversation_id should be same as the parent Note
|
||||
*
|
||||
* @param \App\Entity\Note $current_note Local Note currently being assigned a Conversation
|
||||
* @param null|int $parent_id If present, it's a reply
|
||||
*/
|
||||
public static function assignLocalConversation(Note $current_note, ?int $parent_id): void
|
||||
{
|
||||
if (!$parent_id) {
|
||||
// If none found, we don't know yet if it is a reply or root
|
||||
// Let's assume for now that it's a new conversation and deal with stitching later
|
||||
$conversation = ConversationEntity::create(['initial_note_id' => $current_note->getId()]);
|
||||
|
||||
// We need the Conversation id itself, so a persist is in order
|
||||
DB::persist($conversation);
|
||||
|
||||
// Set current_note's own conversation_id
|
||||
$current_note->setConversationId($conversation->getId());
|
||||
} else {
|
||||
// It's a reply for sure
|
||||
// Set reply_to property in newly created Note to parent's id
|
||||
// Parent will have a conversation of its own, the reply should have the same one
|
||||
$parent_note = Note::getById($parent_id);
|
||||
$current_note->setConversationId($parent_note->getConversationId());
|
||||
}
|
||||
|
||||
DB::persist($current_note);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML rendering event that adds a reply link as a note
|
||||
* action, if a user is logged in.
|
||||
*
|
||||
* @param \App\Entity\Note $note The Note being rendered
|
||||
* @param array $actions Contains keys 'url' (linking 'conversation_reply_to'
|
||||
* route), 'title' (used as title for aforementioned url),
|
||||
* 'classes' (CSS styling classes used to visually inform the user of action context),
|
||||
* 'id' (HTML markup id used to redirect user to this anchor upon performing the action)
|
||||
*
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
|
||||
{
|
||||
if (\is_null(Common::user())) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
$from = $request->query->has('from')
|
||||
? $request->query->get('from')
|
||||
: $request->getPathInfo();
|
||||
|
||||
$reply_action_url = Router::url(
|
||||
'conversation_reply_to',
|
||||
[
|
||||
'reply_to_id' => $note->getId(),
|
||||
'from' => $from,
|
||||
'_fragment' => 'note-anchor-' . $note->getId(),
|
||||
],
|
||||
Router::ABSOLUTE_PATH,
|
||||
);
|
||||
|
||||
$reply_action = [
|
||||
'url' => $reply_action_url,
|
||||
'title' => _m('Reply to this note!'),
|
||||
'classes' => 'button-container reply-button-container note-actions-unset',
|
||||
'id' => 'reply-button-container-' . $note->getId(),
|
||||
];
|
||||
|
||||
$actions[] = $reply_action;
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append on note information about user actions.
|
||||
*
|
||||
* @param array $vars Contains information related to Note currently being rendered
|
||||
* @param array $result Contains keys 'actors', and 'action'. Needed to construct a string, stating who ($result['actors']), has already performed a reply ($result['action']), in the given Note (vars['note'])
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onAppendCardNote(array $vars, array &$result): bool
|
||||
{
|
||||
if (str_contains($vars['request']->getPathInfo(), 'conversation')) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// The current Note being rendered
|
||||
$note = $vars['note'];
|
||||
|
||||
// Will have actors array, and action string
|
||||
// Actors are the subjects, action is the verb (in the final phrase)
|
||||
$reply_actors = F\map(
|
||||
$note->getReplies(),
|
||||
fn (Note $reply) => Actor::getByPK($reply->getActorId()),
|
||||
);
|
||||
|
||||
if (empty($reply_actors)) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
// Filter out multiple replies from the same actor
|
||||
$reply_actors = array_unique($reply_actors, \SORT_REGULAR);
|
||||
$result[] = ['actors' => $reply_actors, 'action' => 'replied to'];
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
private function getReplyToIdFromRequest(Request $request): ?int
|
||||
{
|
||||
if (!\is_array($request->get('post_note')) || !\array_key_exists('_next', $request->get('post_note'))) {
|
||||
return null;
|
||||
}
|
||||
$next = parse_url($request->get('post_note')['_next']);
|
||||
if (!\array_key_exists('query', $next)) {
|
||||
return null;
|
||||
}
|
||||
parse_str($next['query'], $query);
|
||||
if (!\array_key_exists('reply_to_id', $query)) {
|
||||
return null;
|
||||
}
|
||||
return (int) $query['reply_to_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given
|
||||
* Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a
|
||||
* Note **targeting** that specific Group.
|
||||
*
|
||||
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
|
||||
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): bool
|
||||
{
|
||||
$to_note_id = $this->getReplyToIdFromRequest($request);
|
||||
if (!\is_null($to_note_id)) {
|
||||
// Getting the actor itself
|
||||
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
|
||||
return Event::stop;
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posting event to add extra information to Component\Posting form data
|
||||
*
|
||||
* @param array $data Transport data to be filled with reply_to_id
|
||||
*
|
||||
* @throws \App\Util\Exception\ClientException
|
||||
* @throws \App\Util\Exception\NoSuchNoteException
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onPostingModifyData(Request $request, Actor $actor, array &$data): bool
|
||||
{
|
||||
$to_note_id = $this->getReplyToIdFromRequest($request);
|
||||
if (!\is_null($to_note_id)) {
|
||||
Note::ensureCanInteract(Note::getById($to_note_id), $actor);
|
||||
$data['reply_to_id'] = $to_note_id;
|
||||
}
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add minimal Note card to RightPanel template
|
||||
*/
|
||||
public function onPrependPostingForm(Request $request, array &$elements): bool
|
||||
{
|
||||
$elements[] = Formatting::twigRenderFile('cards/blocks/note_compact_wrapper.html.twig', ['note' => Note::getById((int) $request->query->get('reply_to_id'))]);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event launched when deleting given Note, it's deletion implies further changes to object related to this Note.
|
||||
* Please note, **replies are NOT deleted**, their reply_to is only set to null since this Note no longer exists.
|
||||
*
|
||||
* @param \App\Entity\Note $note Note being deleted
|
||||
* @param \App\Entity\Actor $actor Actor that performed the delete action
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
|
||||
{
|
||||
// Ensure we have the most up to date replies
|
||||
Cache::delete(Note::cacheKeys($note->getId())['replies']);
|
||||
DB::wrapInTransaction(fn () => F\each($note->getReplies(), fn (Note $note) => $note->setReplyTo(null)));
|
||||
Cache::delete(Note::cacheKeys($note->getId())['replies']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds extra actions related to Conversation Component, that act upon/from the given Note.
|
||||
*
|
||||
* @param \App\Entity\Note $note Current Note being rendered
|
||||
* @param array $actions Containing 'url' (Controller connected route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if needed)
|
||||
*
|
||||
* @throws \App\Util\Exception\ServerException
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions): bool
|
||||
{
|
||||
if (\is_null($user = Common::user())) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
$from = $request->query->has('from')
|
||||
? $request->query->get('from')
|
||||
: $request->getPathInfo();
|
||||
|
||||
$mute_extra_action_url = Router::url(
|
||||
'conversation_mute',
|
||||
[
|
||||
'conversation_id' => $note->getConversationId(),
|
||||
'from' => $from,
|
||||
'_fragment' => 'note-anchor-' . $note->getId(),
|
||||
],
|
||||
Router::ABSOLUTE_PATH,
|
||||
);
|
||||
|
||||
$actions[] = [
|
||||
'title' => ConversationMute::isMuted($note, $user) ? _m('Unmute conversation') : _m('Mute conversation'),
|
||||
'classes' => '',
|
||||
'url' => $mute_extra_action_url,
|
||||
];
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents new Notifications to appear for muted conversations
|
||||
*
|
||||
* @param Activity $activity Notification Activity
|
||||
*
|
||||
* @return bool EventHook
|
||||
*/
|
||||
public function onNewNotificationShould(Activity $activity, Actor $actor): bool
|
||||
{
|
||||
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
|
||||
return Event::stop;
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Conversation\Entity;
|
||||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Entity\Note;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Entity class for Conversations Mutes
|
||||
*
|
||||
* @category DB
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @copyright 2022 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class ConversationMute extends Entity
|
||||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $conversation_id;
|
||||
private int $actor_id;
|
||||
private DateTimeInterface $created;
|
||||
|
||||
public function setConversationId(int $conversation_id): self
|
||||
{
|
||||
$this->conversation_id = $conversation_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConversationId(): int
|
||||
{
|
||||
return $this->conversation_id;
|
||||
}
|
||||
|
||||
public function setActorId(int $actor_id): self
|
||||
{
|
||||
$this->actor_id = $actor_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActorId(): int
|
||||
{
|
||||
return $this->actor_id;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
{
|
||||
$this->created = $created;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreated(): DateTimeInterface
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
public static function cacheKeys(int $conversation_id, int $actor_id): array
|
||||
{
|
||||
return [
|
||||
'mute' => "conversation-mute-{$conversation_id}-{$actor_id}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a conversation referenced by $object is muted form $actor
|
||||
*/
|
||||
public static function isMuted(Activity|Note|int $object, Actor|LocalUser $actor): bool
|
||||
{
|
||||
$conversation_id = null;
|
||||
if (\is_int($object)) {
|
||||
$conversation_id = $object;
|
||||
} elseif ($object instanceof Note) {
|
||||
$conversation_id = $object->getConversationId();
|
||||
} elseif ($object instanceof Activity) {
|
||||
$conversation_id = Note::getById($object->getObjectId())->getConversationId();
|
||||
}
|
||||
|
||||
return Cache::get(
|
||||
self::cacheKeys($conversation_id, $actor->getId())['mute'],
|
||||
fn () => (bool) DB::count('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $actor->getId()]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'conversation_mute',
|
||||
'fields' => [
|
||||
'conversation_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'The conversation being blocked'],
|
||||
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Who blocked the conversation'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
],
|
||||
'primary key' => ['conversation_id', 'actor_id'],
|
||||
'foreign keys' => [
|
||||
'conversation_id_to_id_fkey' => ['conversation', ['conversation_id' => 'id']],
|
||||
'actor_id_to_id_fkey' => ['actor', ['actor_id' => 'id']],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
{% extends 'collection/notes.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="frame-section frame-section-padding">
|
||||
{% if is_muted %}
|
||||
<span class="frame-section-padding alert">
|
||||
<label>Do you wish to <b>unmute</b> this conversation?</label>
|
||||
{{ form(form) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="frame-section-padding alert">
|
||||
<label>Do you wish to <b>mute</b> this conversation?</label>
|
||||
{{ form(form) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
{{ parent() }}
|
||||
</div>
|
||||
{% endblock body %}
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* Handle network public feed
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category Controller
|
||||
*
|
||||
* @author Hugo Sales <hugo@hsal.es>
|
||||
* @author Eliseu Amaro <eliseu@fc.up.pt>
|
||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\Feed\Controller;
|
||||
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Util\Common;
|
||||
use App\Util\HTML\Heading;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Feeds extends FeedController
|
||||
{
|
||||
/**
|
||||
* The Planet feed represents every local post. Which is what this instance has to share with the universe.
|
||||
*/
|
||||
public function public(Request $request): array
|
||||
{
|
||||
$data = $this->query('note-local:true');
|
||||
$page_title = _m(\is_null(Common::user()) ? 'Feed' : 'Planet');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => $page_title,
|
||||
'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: $page_title)),
|
||||
'notes' => $data['notes'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Home feed represents everything that concerns a certain actor (its subscriptions)
|
||||
*/
|
||||
public function home(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Home'),
|
||||
'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: 'Home')),
|
||||
'notes' => $data['notes'],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
namespace Component\Feed\tests\Controller;
|
||||
|
||||
use App\Core\Router\Router;
|
||||
use App\Util\GNUsocialTestCase;
|
||||
use Component\Feed\Controller\Feeds;
|
||||
use Jchook\AssertThrows\AssertThrows;
|
||||
|
||||
class FeedsTest extends GNUsocialTestCase
|
||||
{
|
||||
use AssertThrows;
|
||||
|
||||
public function testPublic()
|
||||
{
|
||||
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
|
||||
$client = static::createClient();
|
||||
$crawler = $client->request('GET', Router::url('feed_public'));
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testHome()
|
||||
{
|
||||
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
|
||||
$client = static::createClient();
|
||||
$crawler = $client->request('GET', Router::url('feed_home'));
|
||||
$this->assertResponseStatusCodeSame(302);
|
||||
}
|
||||
|
||||
// TODO: It would be nice to actually test whether the feeds are respecting scopes and spitting
|
||||
// out the expected notes... The ActivityPub plugin have a somewhat obvious way of testing it so,
|
||||
// for now, having that, might fill that need, let's see
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* Handle network public feed
|
||||
*
|
||||
* @package GNUsocial
|
||||
* @category Controller
|
||||
*
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site>
|
||||
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\FreeNetwork\Controller;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Util\Common;
|
||||
use Component\Collection\Util\Controller\FeedController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Feeds extends FeedController
|
||||
{
|
||||
/**
|
||||
* The Meteorites feed represents every post coming from the
|
||||
* known fediverse to this instance's inbox. I.e., it's our
|
||||
* known network and excludes everything that is local only
|
||||
* or federated out.
|
||||
*/
|
||||
public function network(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = $this->query('note-local:false');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Meteorites'),
|
||||
'should_format' => true,
|
||||
'notes' => $data['notes'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Planetary System feed represents every planet-centric post, i.e.,
|
||||
* everything that is local or comes from outside with relation to local actors
|
||||
* or posts.
|
||||
*/
|
||||
public function clique(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
// TODO: maybe make this a Collection::query
|
||||
$notes = DB::dql(
|
||||
<<<'EOF'
|
||||
SELECT n FROM \App\Entity\Note AS n
|
||||
WHERE n.is_local = true OR n.id IN (
|
||||
SELECT act.object_id FROM \App\Entity\Activity AS act
|
||||
WHERE act.object_type = 'note' AND act.id IN
|
||||
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id IN
|
||||
(SELECT a.id FROM \App\Entity\Actor a WHERE a.is_local = true))
|
||||
)
|
||||
ORDER BY n.created DESC, n.id DESC
|
||||
EOF,
|
||||
);
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Planetary System'),
|
||||
'should_format' => true,
|
||||
'notes' => $notes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Galaxy feed represents everything that is federated out or federated in.
|
||||
* Given that any local post can be federated out and it's hard to specifically exclude these,
|
||||
* we simply return everything here, local and remote posts. So, a galaxy.
|
||||
*/
|
||||
public function federated(Request $request): array
|
||||
{
|
||||
Common::ensureLoggedIn();
|
||||
$data = $this->query('notes-all:yeah');
|
||||
return [
|
||||
'_template' => 'collection/notes.html.twig',
|
||||
'page_title' => _m('Galaxy'),
|
||||
'should_format' => true,
|
||||
'notes' => $data['notes'],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/**
|
||||
* @author James Walker <james@status.net>
|
||||
* @author Craig Andrews <candrews@integralblue.com>
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
*/
|
||||
|
||||
namespace Component\FreeNetwork\Controller;
|
||||
|
||||
use App\Core\Event;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\XrdController;
|
||||
|
||||
class HostMeta extends XrdController
|
||||
{
|
||||
protected string $default_mimetype = Discovery::JRD_MIMETYPE;
|
||||
|
||||
public function setXRD()
|
||||
{
|
||||
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links])) {
|
||||
Event::handle('EndHostMetaLinks', [&$this->xrd->links]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace Component\FreeNetwork\Controller;
|
||||
|
||||
/**
|
||||
* @package WebFingerPlugin
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
use App\Entity\LocalUser;
|
||||
use App\Util\Common;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\XrdController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class OwnerXrd extends XrdController
|
||||
{
|
||||
protected string $default_mimetype = Discovery::XRD_MIMETYPE;
|
||||
|
||||
public function handle(Request $request): array
|
||||
{
|
||||
$user = LocalUser::siteOwner();
|
||||
|
||||
$nick = common_canonical_nickname($user->nickname);
|
||||
$this->resource = 'acct:' . $nick . '@' . Common::config('site', 'server');
|
||||
|
||||
// We have now set $args['resource'] to the configured value, since
|
||||
// only this local site configuration knows who the owner is!
|
||||
return parent::handle($request);
|
||||
}
|
||||
|
||||
protected function setXRD()
|
||||
{
|
||||
// Check to see if a $config['webfinger']['owner'] has been set
|
||||
// and then make sure 'subject' is set to that primary identity.
|
||||
if (!empty($owner = Common::config('webfinger', 'owner'))) {
|
||||
$this->xrd->aliases[] = $this->xrd->subject;
|
||||
$this->xrd->subject = Discovery::normalize($owner);
|
||||
} else {
|
||||
$this->xrd->subject = $this->resource;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
/*
|
||||
* StatusNet - the distributed open-source microblogging tool
|
||||
* Copyright (C) 2010, StatusNet, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace Component\FreeNetwork\Controller;
|
||||
|
||||
use App\Core\Event;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NoSuchActorException;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\WebfingerResource;
|
||||
use Component\FreeNetwork\Util\XrdController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* @package WebFingerPlugin
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @author Diogo Peralta Cordeiro
|
||||
*/
|
||||
class Webfinger extends XrdController
|
||||
{
|
||||
protected $resource; // string with the resource URI
|
||||
protected $target; // object of the WebFingerResource class
|
||||
|
||||
public function handle(Request $request): array
|
||||
{
|
||||
// throws exception if resource is empty
|
||||
$this->resource = Discovery::normalize($this->string('resource'));
|
||||
|
||||
try {
|
||||
if (Event::handle('StartGetWebFingerResource', [$this->resource, &$this->target, $this->params()])) {
|
||||
Event::handle('EndGetWebFingerResource', [$this->resource, &$this->target, $this->params()]);
|
||||
}
|
||||
} catch (NoSuchActorException $e) {
|
||||
throw new ClientException($e->getMessage(), 404);
|
||||
}
|
||||
|
||||
if (!$this->target instanceof WebfingerResource) {
|
||||
// TRANS: Error message when an object URI which we cannot find was requested
|
||||
throw new ClientException(_m('Resource not found in local database.'), 404);
|
||||
}
|
||||
|
||||
return parent::handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures $this->xrd which will later be printed.
|
||||
*/
|
||||
protected function setXRD()
|
||||
{
|
||||
$this->xrd->subject = $this->resource;
|
||||
|
||||
foreach ($this->target->getAliases() as $alias) {
|
||||
if ($alias != $this->xrd->subject && !\in_array($alias, $this->xrd->aliases)) {
|
||||
$this->xrd->aliases[] = $alias;
|
||||
}
|
||||
}
|
||||
|
||||
$this->target->updateXRD($this->xrd);
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
StartHostMetaLinks: Start /.well-known/host-meta links
|
||||
- &links: array containing the links elements to be written
|
||||
|
||||
EndHostMetaLinks: End /.well-known/host-meta links
|
||||
- &links: array containing the links elements to be written
|
||||
|
||||
StartGetWebFingerResource: Get a WebFingerResource extended object by resource string
|
||||
- $resource String that contains the requested URI
|
||||
- &$target WebFingerResource extended object goes here
|
||||
- $args Array which may contains arguments such as 'rel' filtering values
|
||||
|
||||
EndGetWebFingerResource: Last attempts getting a WebFingerResource object
|
||||
- $resource String that contains the requested URI
|
||||
- &$target WebFingerResource extended object goes here
|
||||
- $args Array which may contains arguments such as 'rel' filtering values
|
||||
|
||||
StartWebFingerReconstruction: Generate an acct: uri from a Profile object
|
||||
- $profile: Profile object for which we want a WebFinger ID
|
||||
- &$acct: String reference where reconstructed ID is stored
|
||||
|
||||
EndWebFingerReconstruction: Last attempts to generate an acct: uri from a Profile object
|
||||
- $profile: Profile object for which we want a WebFinger ID
|
||||
- &$acct: String reference where reconstructed ID is stored
|
||||
|
||||
StartWebFingerNoticeLinks: About to set links for the resource descriptor of a Notice
|
||||
- $xrd: XML_XRD object being shown
|
||||
- $target: Notice being shown
|
||||
|
||||
EndWebFingerNoticeLinks: Done with links for the resource descriptor of a Notice
|
||||
- $xrd: XML_XRD object being shown
|
||||
- $target: Notice being shown
|
||||
|
||||
StartWebFingerProfileLinks: About to set links for the resource descriptor of a Profile
|
||||
- $xrd: XML_XRD object being shown
|
||||
- $target: Profile being shown
|
||||
|
||||
EndWebFingerProfileLinks: Done with links for the resource descriptor of a Profile
|
||||
- $xrd: XML_XRD object being shown
|
||||
- $target: Profile being shown
|
||||
|
||||
StartDiscoveryMethodRegistration
|
||||
- $disco: Discovery object that accepts the registrations
|
||||
|
||||
EndDiscoveryMethodRegistration: Register remote URI discovery methods
|
||||
- $disco: Discovery object that accepts the registrations
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
// {{{ License
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// }}}
|
||||
|
||||
/**
|
||||
* ActivityPub implementation for GNU social
|
||||
*
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Diogo Peralta Cordeiro <@diogo.site
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
|
||||
namespace Component\FreeNetwork\Entity;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Entity\Actor;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Table Definition for free_network_actor_protocol
|
||||
*
|
||||
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class FreeNetworkActorProtocol extends Entity
|
||||
{
|
||||
// {{{ Autocode
|
||||
// @codeCoverageIgnoreStart
|
||||
private int $actor_id;
|
||||
private ?string $protocol = null;
|
||||
private string $addr;
|
||||
private DateTimeInterface $created;
|
||||
private DateTimeInterface $modified;
|
||||
|
||||
public function setActorId(int $actor_id): self
|
||||
{
|
||||
$this->actor_id = $actor_id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActorId(): int
|
||||
{
|
||||
return $this->actor_id;
|
||||
}
|
||||
|
||||
public function setProtocol(?string $protocol): self
|
||||
{
|
||||
$this->protocol = \is_null($protocol) ? null : mb_substr($protocol, 0, 32);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProtocol(): ?string
|
||||
{
|
||||
return $this->protocol;
|
||||
}
|
||||
|
||||
public function setAddr(string $addr): self
|
||||
{
|
||||
$this->addr = $addr;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAddr(): string
|
||||
{
|
||||
return $this->addr;
|
||||
}
|
||||
|
||||
public function setCreated(DateTimeInterface $created): self
|
||||
{
|
||||
$this->created = $created;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreated(): DateTimeInterface
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
public function setModified(DateTimeInterface $modified): self
|
||||
{
|
||||
$this->modified = $modified;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModified(): DateTimeInterface
|
||||
{
|
||||
return $this->modified;
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
public static function protocolSucceeded(string $protocol, int|Actor $actor_id, string $addr): void
|
||||
{
|
||||
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
|
||||
$attributed_protocol = self::getByPK(['actor_id' => $actor_id]);
|
||||
if (\is_null($attributed_protocol)) {
|
||||
$attributed_protocol = self::create([
|
||||
'actor_id' => $actor_id,
|
||||
'protocol' => $protocol,
|
||||
'addr' => Discovery::normalize($addr),
|
||||
]);
|
||||
} else {
|
||||
$attributed_protocol->setProtocol($protocol);
|
||||
}
|
||||
DB::persist($attributed_protocol);
|
||||
}
|
||||
|
||||
public static function canIActor(string $protocol, int|Actor $actor_id): bool
|
||||
{
|
||||
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
|
||||
$attributed_protocol = self::getByPK(['actor_id' => $actor_id])?->getProtocol();
|
||||
if (\is_null($attributed_protocol)) {
|
||||
// If it is not attributed, you can go ahead.
|
||||
return true;
|
||||
} else {
|
||||
// If it is attributed, you can on the condition that you're assigned to it.
|
||||
return $attributed_protocol === $protocol;
|
||||
}
|
||||
}
|
||||
|
||||
public static function canIAddr(string $protocol, string $target): bool
|
||||
{
|
||||
// Normalize $addr, i.e. add 'acct:' if missing
|
||||
$addr = Discovery::normalize($target);
|
||||
$attributed_protocol = self::getByPK(['addr' => $addr])?->getProtocol();
|
||||
if (\is_null($attributed_protocol)) {
|
||||
// If it is not attributed, you can go ahead.
|
||||
return true;
|
||||
} else {
|
||||
// If it is attributed, you can on the condition that you're assigned to it.
|
||||
return $attributed_protocol === $protocol;
|
||||
}
|
||||
}
|
||||
|
||||
public static function schemaDef(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'free_network_actor_protocol',
|
||||
'fields' => [
|
||||
'actor_id' => ['type' => 'int', 'not null' => true],
|
||||
'protocol' => ['type' => 'varchar', 'length' => 32, 'description' => 'the protocol plugin that should handle federation of this actor'],
|
||||
'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'],
|
||||
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
|
||||
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
|
||||
],
|
||||
'primary key' => ['actor_id'],
|
||||
'foreign keys' => [
|
||||
'activitypub_actor_actor_id_fkey' => ['actor', ['actor_id' => 'id']],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
/**
|
||||
* StatusNet, the distributed open-source microblogging tool
|
||||
*
|
||||
* Class for an exception when a WebFinger acct: URI can not be constructed
|
||||
* using the data we have in a Profile.
|
||||
*
|
||||
* PHP version 5
|
||||
*
|
||||
* LICENCE: This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* @category Exception
|
||||
* @package StatusNet
|
||||
*
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @copyright 2013 Free Software Foundation, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
|
||||
*
|
||||
* @see http://status.net/
|
||||
*/
|
||||
|
||||
namespace Component\FreeNetwork\Exception;
|
||||
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Util\Exception\ServerException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Class for an exception when a WebFinger acct: URI can not be constructed
|
||||
* using the data we have in a Profile.
|
||||
*
|
||||
* @category Exception
|
||||
* @package StatusNet
|
||||
*
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
|
||||
*
|
||||
* @see http://status.net/
|
||||
*/
|
||||
class WebfingerReconstructionException extends ServerException
|
||||
{
|
||||
public function __construct(string $message = '', int $code = 500, ?Throwable $previous = null)
|
||||
{
|
||||
// We could log an entry here with the search parameters
|
||||
parent::__construct(_m('WebFinger URI generation failed.'));
|
||||
}
|
||||
}
|
|
@ -1,547 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
// {{{ License
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
// }}}
|
||||
|
||||
namespace Component\FreeNetwork;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Event;
|
||||
use App\Core\GSFile;
|
||||
use App\Core\HTTPClient;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Modules\Component;
|
||||
use App\Core\Router\RouteLoader;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\LocalUser;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\NicknameEmptyException;
|
||||
use App\Util\Exception\NicknameException;
|
||||
use App\Util\Exception\NicknameInvalidException;
|
||||
use App\Util\Exception\NicknameNotAllowedException;
|
||||
use App\Util\Exception\NicknameTakenException;
|
||||
use App\Util\Exception\NicknameTooLongException;
|
||||
use App\Util\Exception\NoSuchActorException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Formatting;
|
||||
use App\Util\Nickname;
|
||||
use Component\FreeNetwork\Controller\Feeds;
|
||||
use Component\FreeNetwork\Controller\HostMeta;
|
||||
use Component\FreeNetwork\Controller\OwnerXrd;
|
||||
use Component\FreeNetwork\Controller\Webfinger;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\WebfingerResource;
|
||||
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
|
||||
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
|
||||
use Doctrine\Common\Collections\ExpressionBuilder;
|
||||
use Exception;
|
||||
use const PREG_SET_ORDER;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use XML_XRD;
|
||||
use XML_XRD_Element_Link;
|
||||
|
||||
/**
|
||||
* Implements WebFinger (RFC7033) for GNU social, as well as Link-based Resource Descriptor Discovery based on RFC6415,
|
||||
* Web Host Metadata ('.well-known/host-meta') resource.
|
||||
*
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @author Diogo Peralta Cordeiro <mail@diogo.site>
|
||||
*/
|
||||
class FreeNetwork extends Component
|
||||
{
|
||||
public const PLUGIN_VERSION = '0.1.0';
|
||||
|
||||
public const OAUTH_ACCESS_TOKEN_REL = 'http://apinamespace.org/oauth/access_token';
|
||||
public const OAUTH_REQUEST_TOKEN_REL = 'http://apinamespace.org/oauth/request_token';
|
||||
public const OAUTH_AUTHORIZE_REL = 'http://apinamespace.org/oauth/authorize';
|
||||
private static array $protocols = [];
|
||||
|
||||
public function onInitializeComponent(): bool
|
||||
{
|
||||
Event::handle('AddFreeNetworkProtocol', [&self::$protocols]);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onAddRoute(RouteLoader $m): bool
|
||||
{
|
||||
// Feeds
|
||||
$m->connect('feed_network', '/feed/network', [Feeds::class, 'network']);
|
||||
$m->connect('feed_clique', '/feed/clique', [Feeds::class, 'clique']);
|
||||
$m->connect('feed_federated', '/feed/federated', [Feeds::class, 'federated']);
|
||||
|
||||
$m->connect('freenetwork_hostmeta', '.well-known/host-meta', [HostMeta::class, 'handle']);
|
||||
$m->connect(
|
||||
'freenetwork_hostmeta_format',
|
||||
'.well-known/host-meta.:format',
|
||||
[HostMeta::class, 'handle'],
|
||||
['format' => '(xml|json)'],
|
||||
);
|
||||
// the resource GET parameter can be anywhere, so don't mention it here
|
||||
$m->connect('freenetwork_webfinger', '.well-known/webfinger', [Webfinger::class, 'handle']);
|
||||
$m->connect(
|
||||
'freenetwork_webfinger_format',
|
||||
'.well-known/webfinger.:format',
|
||||
[Webfinger::class, 'handle'],
|
||||
['format' => '(xml|json)'],
|
||||
);
|
||||
$m->connect('freenetwork_ownerxrd', 'main/ownerxrd', [OwnerXrd::class, 'handle']);
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
|
||||
{
|
||||
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_network'), 'route' => $route, 'title' => _m('Meteorites'), 'ordering' => $ordering++]));
|
||||
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_clique'), 'route' => $route, 'title' => _m('Planetary System'), 'ordering' => $ordering++]));
|
||||
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_federated'), 'route' => $route, 'title' => _m('Galaxy'), 'ordering' => $ordering++]));
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onStartGetProfileAcctUri(Actor $profile, &$acct): bool
|
||||
{
|
||||
$wfr = new WebFingerResourceActor($profile);
|
||||
try {
|
||||
$acct = $wfr->reconstructAcct();
|
||||
} catch (Exception) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
return Event::stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last attempts getting a WebFingerResource object
|
||||
*
|
||||
* @param string $resource String that contains the requested URI
|
||||
* @param null|WebfingerResource $target WebFingerResource extended object goes here
|
||||
* @param array $args Array which may contains arguments such as 'rel' filtering values
|
||||
*
|
||||
* @throws NicknameEmptyException
|
||||
* @throws NicknameException
|
||||
* @throws NicknameInvalidException
|
||||
* @throws NicknameNotAllowedException
|
||||
* @throws NicknameTakenException
|
||||
* @throws NicknameTooLongException
|
||||
* @throws NoSuchActorException
|
||||
* @throws ServerException
|
||||
*/
|
||||
public function onEndGetWebFingerResource(string $resource, ?WebfingerResource &$target = null, array $args = []): bool
|
||||
{
|
||||
// * Either we didn't find the profile, then we want to make
|
||||
// the $profile variable null for clarity.
|
||||
// * Or we did find it but for a possibly malicious remote
|
||||
// user who might've set their profile URL to a Note URL
|
||||
// which would've caused a sort of DoS unless we continue
|
||||
// our search here by discarding the remote profile.
|
||||
$profile = null;
|
||||
if (Discovery::isAcct($resource)) {
|
||||
$parts = explode('@', mb_substr(urldecode($resource), 5)); // 5 is strlen of 'acct:'
|
||||
if (\count($parts) === 2) {
|
||||
[$nick, $domain] = $parts;
|
||||
if ($domain !== Common::config('site', 'server')) {
|
||||
throw new ServerException(_m('Remote profiles not supported via WebFinger yet.'));
|
||||
}
|
||||
|
||||
$nick = Nickname::normalize(nickname: $nick, check_already_used: false, check_is_allowed: false);
|
||||
$freenetwork_actor = LocalUser::getByPK(['nickname' => $nick]);
|
||||
if (!($freenetwork_actor instanceof LocalUser)) {
|
||||
throw new NoSuchActorException($nick);
|
||||
}
|
||||
$profile = $freenetwork_actor->getActor();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (Common::isValidHttpUrl($resource)) {
|
||||
// This means $resource is a valid url
|
||||
$resource_parts = parse_url($resource);
|
||||
// TODO: Use URLMatcher
|
||||
if ($resource_parts['host'] === Common::config('site', 'server')) {
|
||||
$str = $resource_parts['path'];
|
||||
// actor_view_nickname
|
||||
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
|
||||
// actor_view_id
|
||||
$reuri = '/\/actor\/(\d+)\/?/m';
|
||||
if (preg_match_all($renick, $str, $matches, PREG_SET_ORDER, 0) === 1) {
|
||||
$profile = LocalUser::getByPK(['nickname' => $matches[0][1]])->getActor();
|
||||
} elseif (preg_match_all($reuri, $str, $matches, PREG_SET_ORDER, 0) === 1) {
|
||||
$profile = Actor::getById((int) $matches[0][1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (NoSuchActorException $e) {
|
||||
// not a User, maybe a Note? we'll try that further down...
|
||||
|
||||
// try {
|
||||
// Log::debug(__METHOD__ . ': Finding User_group URI for WebFinger lookup on resource==' . $resource);
|
||||
// $group = new User_group();
|
||||
// $group->whereAddIn('uri', array_keys($alt_urls), $group->columnType('uri'));
|
||||
// $group->limit(1);
|
||||
// if ($group->find(true)) {
|
||||
// $profile = $group->getProfile();
|
||||
// }
|
||||
// unset($group);
|
||||
// } catch (Exception $e) {
|
||||
// Log::error(get_class($e) . ': ' . $e->getMessage());
|
||||
// throw $e;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
if ($profile instanceof Actor) {
|
||||
Log::debug(__METHOD__ . ': Found Profile with ID==' . $profile->getID() . ' for resource==' . $resource);
|
||||
$target = new WebfingerResourceActor($profile);
|
||||
return Event::stop; // We got our target, stop handler execution
|
||||
}
|
||||
|
||||
if (!\is_null($note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true))) {
|
||||
$target = new WebfingerResourceNote($note);
|
||||
return Event::stop; // We got our target, stop handler execution
|
||||
}
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onStartHostMetaLinks(array &$links): bool
|
||||
{
|
||||
foreach (Discovery::supportedMimeTypes() as $type) {
|
||||
$links[] = new XML_XRD_Element_Link(
|
||||
Discovery::LRDD_REL,
|
||||
Router::url(id: 'freenetwork_webfinger', args: [], type: Router::ABSOLUTE_URL) . '?resource={uri}',
|
||||
$type,
|
||||
isTemplate: true,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO OAuth connections
|
||||
//$links[] = new XML_XRD_Element_link(self::OAUTH_ACCESS_TOKEN_REL, common_local_url('ApiOAuthAccessToken'));
|
||||
//$links[] = new XML_XRD_Element_link(self::OAUTH_REQUEST_TOKEN_REL, common_local_url('ApiOAuthRequestToken'));
|
||||
//$links[] = new XML_XRD_Element_link(self::OAUTH_AUTHORIZE_REL, common_local_url('ApiOAuthAuthorize'));
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a link header for LRDD Discovery
|
||||
*/
|
||||
public function onStartShowHTML($action): bool
|
||||
{
|
||||
if ($action instanceof ShowstreamAction) {
|
||||
$resource = $action->getTarget()->getUri();
|
||||
$url = common_local_url('webfinger') . '?resource=' . urlencode($resource);
|
||||
|
||||
foreach ([Discovery::JRD_MIMETYPE, Discovery::XRD_MIMETYPE] as $type) {
|
||||
header('Link: <' . $url . '>; rel="' . Discovery::LRDD_REL . '"; type="' . $type . '"', false);
|
||||
}
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onStartDiscoveryMethodRegistration(Discovery $disco): bool
|
||||
{
|
||||
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodWebfinger');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onEndDiscoveryMethodRegistration(Discovery $disco): bool
|
||||
{
|
||||
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodHostMeta');
|
||||
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodLinkHeader');
|
||||
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodLinkHtml');
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ClientException
|
||||
* @throws ServerException
|
||||
*/
|
||||
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): bool
|
||||
{
|
||||
if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
$mimeType = array_intersect(array_values(Discovery::supportedMimeTypes()), $accept_header);
|
||||
/*
|
||||
* "A WebFinger resource MUST return a JRD as the representation
|
||||
* for the resource if the client requests no other supported
|
||||
* format explicitly via the HTTP "Accept" header. [...]
|
||||
* The WebFinger resource MUST silently ignore any requested
|
||||
* representations that it does not understand and support."
|
||||
* -- RFC 7033 (WebFinger)
|
||||
* http://tools.ietf.org/html/rfc7033
|
||||
*/
|
||||
$mimeType = \count($mimeType) !== 0 ? array_pop($mimeType) : $vars['default_mimetype'];
|
||||
|
||||
$headers = [];
|
||||
|
||||
if (Common::config('discovery', 'cors')) {
|
||||
$headers['Access-Control-Allow-Origin'] = '*';
|
||||
}
|
||||
|
||||
$headers['Content-Type'] = $mimeType;
|
||||
|
||||
$response = match ($mimeType) {
|
||||
Discovery::XRD_MIMETYPE => new Response(content: $vars['xrd']->to('xml'), headers: $headers),
|
||||
Discovery::JRD_MIMETYPE, Discovery::JRD_MIMETYPE_OLD => new JsonResponse(data: $vars['xrd']->to('json'), headers: $headers, json: true),
|
||||
};
|
||||
|
||||
$response->headers->set('cache-control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
return Event::stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webfinger matches: @user@example.com or even @user--one.george_orwell@1984.biz
|
||||
*
|
||||
* @param string $text The text from which to extract webfinger IDs
|
||||
* @param string $preMention Character(s) that signals a mention ('@', '!'...)
|
||||
*
|
||||
* @return array the matching IDs (without $preMention) and each respective position in the given string
|
||||
*/
|
||||
public static function extractWebfingerIds(string $text, string $preMention = '@'): array
|
||||
{
|
||||
$wmatches = [];
|
||||
$result = preg_match_all(
|
||||
'/' . Nickname::BEFORE_MENTIONS . preg_quote($preMention, '/') . '(' . Nickname::WEBFINGER_FMT . ')/',
|
||||
$text,
|
||||
$wmatches,
|
||||
\PREG_OFFSET_CAPTURE,
|
||||
);
|
||||
if ($result === false) {
|
||||
Log::error(__METHOD__ . ': Error parsing webfinger IDs from text (preg_last_error==' . preg_last_error() . ').');
|
||||
return [];
|
||||
} elseif (($n_matches = \count($wmatches)) != 0) {
|
||||
Log::debug((sprintf('Found %d matches for WebFinger IDs: %s', $n_matches, print_r($wmatches, true))));
|
||||
}
|
||||
return $wmatches[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile URL matches: @param string $text The text from which to extract URL mentions
|
||||
*
|
||||
* @param string $preMention Character(s) that signals a mention ('@', '!'...)
|
||||
*
|
||||
* @return array the matching URLs (without @ or acct:) and each respective position in the given string
|
||||
* @example.com/mublog/user
|
||||
*/
|
||||
public static function extractUrlMentions(string $text, string $preMention = '@'): array
|
||||
{
|
||||
$wmatches = [];
|
||||
// In the regexp below we need to match / _before_ URL_REGEX_VALID_PATH_CHARS because it otherwise gets merged
|
||||
// with the TLD before (but / is in URL_REGEX_VALID_PATH_CHARS anyway, it's just its positioning that is important)
|
||||
$result = preg_match_all(
|
||||
'/' . Nickname::BEFORE_MENTIONS . preg_quote($preMention, '/') . '(' . URL_REGEX_DOMAIN_NAME . '(?:\/[' . URL_REGEX_VALID_PATH_CHARS . ']*)*)/',
|
||||
$text,
|
||||
$wmatches,
|
||||
\PREG_OFFSET_CAPTURE,
|
||||
);
|
||||
if ($result === false) {
|
||||
Log::error(__METHOD__ . ': Error parsing profile URL mentions from text (preg_last_error==' . preg_last_error() . ').');
|
||||
return [];
|
||||
} elseif (\count($wmatches)) {
|
||||
Log::debug((sprintf('Found %d matches for profile URL mentions: %s', \count($wmatches), print_r($wmatches, true))));
|
||||
}
|
||||
return $wmatches[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any explicit remote mentions. Accepted forms:
|
||||
* Webfinger: @user@example.com
|
||||
* Profile link: @param Actor $sender
|
||||
*
|
||||
* @param string $text input markup text
|
||||
* @param $mentions
|
||||
*
|
||||
* @return bool hook return value
|
||||
* @example.com/mublog/user
|
||||
*/
|
||||
public function onEndFindMentions(Actor $sender, string $text, array &$mentions): bool
|
||||
{
|
||||
$matches = [];
|
||||
|
||||
foreach (self::extractWebfingerIds($text, $preMention = '@') as $wmatch) {
|
||||
[$target, $pos] = $wmatch;
|
||||
Log::info("Checking webfinger person '{$target}'");
|
||||
|
||||
$actor = null;
|
||||
|
||||
$resource_parts = explode($preMention, $target);
|
||||
if ($resource_parts[1] === Common::config('site', 'server')) {
|
||||
$actor = LocalUser::getByPK(['nickname' => $resource_parts[0]])->getActor();
|
||||
} else {
|
||||
Event::handle('FreeNetworkFindMentions', [$target, &$actor]);
|
||||
if (\is_null($actor)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
\assert($actor instanceof Actor);
|
||||
|
||||
$displayName = !empty($actor->getFullname()) ? $actor->getFullname() : $actor->getNickname() ?? $target; // TODO: we could do getBestName() or getFullname() here
|
||||
|
||||
$matches[$pos] = [
|
||||
'mentioned' => [$actor],
|
||||
'type' => 'mention',
|
||||
'text' => $displayName,
|
||||
'position' => $pos,
|
||||
'length' => mb_strlen($target),
|
||||
'url' => $actor->getUri(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach (self::extractUrlMentions($text) as $wmatch) {
|
||||
[$target, $pos] = $wmatch;
|
||||
$url = "https://{$target}";
|
||||
if (Common::isValidHttpUrl($url)) {
|
||||
// This means $resource is a valid url
|
||||
$resource_parts = parse_url($url);
|
||||
// TODO: Use URLMatcher
|
||||
if ($resource_parts['host'] === Common::config('site', 'server')) {
|
||||
$str = $resource_parts['path'];
|
||||
// actor_view_nickname
|
||||
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
|
||||
// actor_view_id
|
||||
$reuri = '/\/actor\/(\d+)\/?/m';
|
||||
if (preg_match_all($renick, $str, $matches, PREG_SET_ORDER, 0) === 1) {
|
||||
$actor = LocalUser::getByPK(['nickname' => $matches[0][1]])->getActor();
|
||||
} elseif (preg_match_all($reuri, $str, $matches, PREG_SET_ORDER, 0) === 1) {
|
||||
$actor = Actor::getById((int) $matches[0][1]);
|
||||
} else {
|
||||
Log::error('Unexpected behaviour onEndFindMentions at FreeNetwork');
|
||||
throw new ServerException('Unexpected behaviour onEndFindMentions at FreeNetwork');
|
||||
}
|
||||
} else {
|
||||
Log::info("Checking actor address '{$url}'");
|
||||
|
||||
$link = new XML_XRD_Element_Link(
|
||||
Discovery::LRDD_REL,
|
||||
'https://' . parse_url($url, \PHP_URL_HOST) . '/.well-known/webfinger?resource={uri}',
|
||||
Discovery::JRD_MIMETYPE,
|
||||
true, // isTemplate
|
||||
);
|
||||
$xrd_uri = Discovery::applyTemplate($link->template, $url);
|
||||
$response = HTTPClient::get($xrd_uri, ['headers' => ['Accept' => $link->type]]);
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$xrd = new XML_XRD();
|
||||
|
||||
switch (GSFile::mimetypeBare($response->getHeaders()['content-type'][0])) {
|
||||
case Discovery::JRD_MIMETYPE_OLD:
|
||||
case Discovery::JRD_MIMETYPE:
|
||||
$type = 'json';
|
||||
break;
|
||||
case Discovery::XRD_MIMETYPE:
|
||||
$type = 'xml';
|
||||
break;
|
||||
default:
|
||||
// fall back to letting XML_XRD auto-detect
|
||||
Log::debug('No recognized content-type header for resource descriptor body on ' . $xrd_uri);
|
||||
$type = null;
|
||||
}
|
||||
$xrd->loadString($response->getContent(), $type);
|
||||
|
||||
$actor = null;
|
||||
Event::handle('FreeNetworkFoundXrd', [$xrd, &$actor]);
|
||||
if (\is_null($actor)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$displayName = $actor->getFullname() ?? $actor->getNickname() ?? $target; // TODO: we could do getBestName() or getFullname() here
|
||||
$matches[$pos] = [
|
||||
'mentioned' => [$actor],
|
||||
'type' => 'mention',
|
||||
'text' => $displayName,
|
||||
'position' => $pos,
|
||||
'length' => mb_strlen($target),
|
||||
'url' => $actor->getUri(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($mentions as $i => $other) {
|
||||
// If we share a common prefix with a local user, override it!
|
||||
$pos = $other['position'];
|
||||
if (isset($matches[$pos])) {
|
||||
$mentions[$i] = $matches[$pos];
|
||||
unset($matches[$pos]);
|
||||
}
|
||||
}
|
||||
foreach ($matches as $mention) {
|
||||
$mentions[] = $mention;
|
||||
}
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
|
||||
{
|
||||
foreach (self::$protocols as $protocol) {
|
||||
$protocol::freeNetworkDistribute($sender, $activity, $targets, $reason);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function mentionTagToName(string $nickname, string $uri): string
|
||||
{
|
||||
return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
|
||||
}
|
||||
|
||||
public static function groupTagToName(string $nickname, string $uri): string
|
||||
{
|
||||
return '!' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add fediverse: query expression
|
||||
* // TODO: adding WebFinger would probably be nice
|
||||
*/
|
||||
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
|
||||
{
|
||||
if (Formatting::startsWith($term, ['fediverse:'])) {
|
||||
foreach (self::$protocols as $protocol) {
|
||||
// 10 is strlen of `fediverse:`
|
||||
if ($protocol::freeNetworkGrabRemote(mb_substr($term, 10))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPluginVersion(array &$versions): bool
|
||||
{
|
||||
$versions[] = [
|
||||
'name' => 'WebFinger',
|
||||
'version' => self::PLUGIN_VERSION,
|
||||
'author' => 'Mikael Nordfeldth',
|
||||
'homepage' => GNUSOCIAL_ENGINE_URL,
|
||||
// TRANS: Plugin description.
|
||||
'rawdescription' => _m('WebFinger and LRDD support'),
|
||||
];
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
The FreeNetwork component adds WebFinger (RFC7033) lookup and implements Link-based Resource Descriptor Discovery (LRDD)
|
||||
based on RFC6415, Web Host Metadata.
|
|
@ -1,226 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
/**
|
||||
* StatusNet - the distributed open-source microblogging tool
|
||||
* Copyright (C) 2010, StatusNet, Inc.
|
||||
*
|
||||
* This class performs lookups based on methods implemented in separate
|
||||
* classes, where a resource uri is given. Examples are WebFinger (RFC7033)
|
||||
* and the LRDD (Link-based Resource Descriptor Discovery) in RFC6415.
|
||||
*
|
||||
* PHP version 5
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* @category Discovery
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @copyright 2013 Free Software Foundation, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*
|
||||
* @see http://www.gnu.org/software/social/
|
||||
*/
|
||||
|
||||
namespace Component\FreeNetwork\Util;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Core\GSFile;
|
||||
use App\Core\HTTPClient;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Util\Exception\ClientException;
|
||||
use Exception;
|
||||
use XML_XRD;
|
||||
use XML_XRD_Element_Link;
|
||||
|
||||
class Discovery
|
||||
{
|
||||
public const LRDD_REL = 'lrdd';
|
||||
public const UPDATESFROM = 'http://schemas.google.com/g/2010#updates-from';
|
||||
public const HCARD = 'http://microformats.org/profile/hcard';
|
||||
public const MF2_HCARD = 'http://microformats.org/profile/h-card'; // microformats2 h-card
|
||||
|
||||
public const JRD_MIMETYPE_OLD = 'application/json'; // RFC6415 uses this
|
||||
public const JRD_MIMETYPE = 'application/jrd+json';
|
||||
public const XRD_MIMETYPE = 'application/xrd+xml';
|
||||
|
||||
public array $methods = [];
|
||||
|
||||
/**
|
||||
* Constructor for a discovery object
|
||||
*
|
||||
* Registers different discovery methods.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
if (Event::handle('StartDiscoveryMethodRegistration', [$this])) {
|
||||
Event::handle('EndDiscoveryMethodRegistration', [$this]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function supportedMimeTypes(): array
|
||||
{
|
||||
return [
|
||||
'json' => self::JRD_MIMETYPE,
|
||||
'jsonold' => self::JRD_MIMETYPE_OLD,
|
||||
'xml' => self::XRD_MIMETYPE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a discovery class
|
||||
*
|
||||
* @param string $class Class name
|
||||
*/
|
||||
public function registerMethod($class): void
|
||||
{
|
||||
$this->methods[] = $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a user ID, return the first available resource descriptor
|
||||
*
|
||||
* @param string $id User ID URI
|
||||
*
|
||||
* @return XML_XRD object for the resource descriptor of the id
|
||||
*/
|
||||
public function lookup(string $id): XML_XRD
|
||||
{
|
||||
// Normalize the incoming $id to make sure we have an uri
|
||||
$uri = self::normalize($id);
|
||||
|
||||
Log::debug(sprintf('Performing discovery for "%s" (normalized "%s")', $id, $uri));
|
||||
|
||||
foreach ($this->methods as $class) {
|
||||
try {
|
||||
$xrd = new XML_XRD();
|
||||
|
||||
Log::debug("LRDD discovery method for '{$uri}': {$class}");
|
||||
$lrdd = new $class;
|
||||
$links = $lrdd->discover($uri);
|
||||
$link = self::getService($links, self::LRDD_REL);
|
||||
|
||||
// Load the LRDD XRD
|
||||
if (!empty($link->template)) {
|
||||
$xrd_uri = self::applyTemplate($link->template, $uri);
|
||||
} elseif (!empty($link->href)) {
|
||||
$xrd_uri = $link->href;
|
||||
} else {
|
||||
throw new Exception('No resource descriptor URI in link.');
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
if (!\is_null($link->type)) {
|
||||
$headers['Accept'] = $link->type;
|
||||
}
|
||||
|
||||
$response = HTTPClient::get($xrd_uri, ['headers' => $headers]);
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new Exception('Unexpected HTTP status code.');
|
||||
}
|
||||
|
||||
switch (GSFile::mimetypeBare($response->getHeaders()['content-type'][0])) {
|
||||
case self::JRD_MIMETYPE_OLD:
|
||||
case self::JRD_MIMETYPE:
|
||||
$type = 'json';
|
||||
break;
|
||||
case self::XRD_MIMETYPE:
|
||||
$type = 'xml';
|
||||
break;
|
||||
default:
|
||||
// fall back to letting XML_XRD auto-detect
|
||||
Log::debug('No recognized content-type header for resource descriptor body on ' . $xrd_uri);
|
||||
$type = null;
|
||||
}
|
||||
$xrd->loadString($response->getContent(), $type);
|
||||
return $xrd;
|
||||
} catch (ClientException $e) {
|
||||
if ($e->getCode() === 403) {
|
||||
Log::info(sprintf('%s: Aborting discovery on URL %s: %s', $class, $uri, $e->getMessage()));
|
||||
break;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::info(sprintf('%s: Failed for %s: %s', $class, $uri, $e->getMessage()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// TRANS: Exception. %s is an ID.
|
||||
throw new Exception(sprintf(_m('Unable to find services for %s.'), $id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of links, returns the matching service
|
||||
*
|
||||
* @param array $links Links to check (as instances of XML_XRD_Element_Link)
|
||||
* @param string $service Service to find
|
||||
*
|
||||
* @return XML_XRD_Element_Link $link
|
||||
*/
|
||||
public static function getService(array $links, $service): XML_XRD_Element_Link
|
||||
{
|
||||
foreach ($links as $link) {
|
||||
if ($link->rel === $service) {
|
||||
return $link;
|
||||
}
|
||||
Log::debug('LINK: rel ' . $link->rel . ' !== ' . $service);
|
||||
}
|
||||
|
||||
throw new Exception('No service link found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a "user id" make sure it's normalized to an acct: uri
|
||||
*
|
||||
* @param string $uri User ID to normalize
|
||||
*
|
||||
* @return string normalized acct: URI
|
||||
*/
|
||||
public static function normalize(string $uri): string
|
||||
{
|
||||
$parts = parse_url($uri);
|
||||
// If we don't have a scheme, but the path implies user@host,
|
||||
// though this is far from a perfect matching procedure...
|
||||
if (!isset($parts['scheme']) && isset($parts['path'])
|
||||
&& preg_match('/[\w@\w]/u', $parts['path'])) {
|
||||
return 'acct:' . $uri;
|
||||
}
|
||||
|
||||
return $uri;
|
||||
}
|
||||
|
||||
public static function isAcct(string $uri): bool
|
||||
{
|
||||
return mb_strtolower(mb_substr($uri, 0, 5)) == 'acct:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a template using an ID
|
||||
*
|
||||
* Replaces {uri} in template string with the ID given.
|
||||
*
|
||||
* @param string $template Template to match
|
||||
* @param string $uri URI to replace with
|
||||
*
|
||||
* @return string replaced values
|
||||
*/
|
||||
public static function applyTemplate($template, $uri): string
|
||||
{
|
||||
return str_replace('{uri}', urlencode($uri), $template);
|
||||
}
|
||||
}
|
|
@ -1,128 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
/**
|
||||
* StatusNet - the distributed open-source microblogging tool
|
||||
* Copyright (C) 2010, StatusNet, Inc.
|
||||
*
|
||||
* Parse HTTP response for interesting Link: headers
|
||||
*
|
||||
* PHP version 5
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* @category Discovery
|
||||
* @package StatusNet
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*
|
||||
* @see http://status.net/
|
||||
*/
|
||||
|
||||
namespace Component\FreeNetwork\Util;
|
||||
|
||||
/**
|
||||
* Class to represent Link: headers in an HTTP response
|
||||
*
|
||||
* Since these are a fairly important part of Hammer-stack discovery, they're
|
||||
* reified and implemented here.
|
||||
*
|
||||
* @category Discovery
|
||||
* @package StatusNet
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*
|
||||
* @see http://status.net/
|
||||
* @see Discovery
|
||||
*/
|
||||
class LinkHeader
|
||||
{
|
||||
public $href;
|
||||
public $rel;
|
||||
public $type;
|
||||
|
||||
/**
|
||||
* Initialize from a string
|
||||
*
|
||||
* @param string $str Link: header value
|
||||
*/
|
||||
public function __construct(string $str = '')
|
||||
{
|
||||
preg_match('/^<[^>]+>/', $str, $uri_reference);
|
||||
//if (empty($uri_reference)) return;
|
||||
|
||||
$this->href = trim($uri_reference[0], '<>');
|
||||
$this->rel = [];
|
||||
$this->type = null;
|
||||
|
||||
// remove uri-reference from header
|
||||
$str = mb_substr($str, mb_strlen($uri_reference[0]));
|
||||
|
||||
// parse link-params
|
||||
$params = explode(';', $str);
|
||||
|
||||
foreach ($params as $param) {
|
||||
if (empty($param)) {
|
||||
continue;
|
||||
}
|
||||
[$param_name, $param_value] = explode('=', $param, 2);
|
||||
|
||||
$param_name = trim($param_name);
|
||||
$param_value = preg_replace('(^"|"$)', '', trim($param_value));
|
||||
|
||||
// for now we only care about 'rel' and 'type' link params
|
||||
// TODO do something with the other links-params
|
||||
switch ($param_name) {
|
||||
case 'rel':
|
||||
$this->rel = trim($param_value);
|
||||
break;
|
||||
|
||||
case 'type':
|
||||
$this->type = trim($param_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an HTTP response, return the requested Link: header
|
||||
*
|
||||
* @param HTTP_Request2_Response $response response to check
|
||||
* @param string $rel relationship to look for
|
||||
* @param string $type media type to look for
|
||||
*
|
||||
* @return LinkHeader discovered header, or null on failure
|
||||
*/
|
||||
public static function getLink($response, $rel = null, $type = null)
|
||||
{
|
||||
$headers = $response->getHeader('Link');
|
||||
if ($headers) {
|
||||
// Can get an array or string, so try to simplify the path
|
||||
if (!\is_array($headers)) {
|
||||
$headers = [$headers];
|
||||
}
|
||||
|
||||
foreach ($headers as $header) {
|
||||
$lh = new self($header);
|
||||
|
||||
if ((\is_null($rel) || $lh->rel == $rel) && (\is_null($type) || $lh->type == $type)) {
|
||||
return $lh->href;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Core\HTTPClient;
|
||||
use Exception;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
use XML_XRD;
|
||||
|
||||
/**
|
||||
* Abstract class for LRDD discovery methods
|
||||
*
|
||||
* Objects that extend this class can retrieve an array of
|
||||
* resource descriptor links for the URI. The array consists
|
||||
* of XML_XRD_Element_Link elements.
|
||||
*
|
||||
* @category Discovery
|
||||
* @package StatusNet
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*
|
||||
* @see http://status.net/
|
||||
*/
|
||||
abstract class LrddMethod
|
||||
{
|
||||
protected $xrd;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->xrd = new XML_XRD();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover interesting info about the URI
|
||||
*
|
||||
* @param string $uri URI to inquire about
|
||||
*
|
||||
* @return array of XML_XRD_Element_Link elements to discovered resource descriptors
|
||||
*/
|
||||
abstract public function discover($uri);
|
||||
|
||||
protected function fetchUrl($url, $method = 'get'): ResponseInterface
|
||||
{
|
||||
// If we have a blacklist enabled, let's check against it
|
||||
Event::handle('UrlBlacklistTest', [$url]);
|
||||
|
||||
$response = HTTPClient::$method($url);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new Exception('Unexpected HTTP status code.');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\LrddMethod;
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
use App\Core\Log;
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\LrddMethod;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Implementation of discovery using host-meta file
|
||||
*
|
||||
* Discovers resource descriptor file for a user by going to the
|
||||
* organization's host-meta file and trying to find a template for LRDD.
|
||||
*
|
||||
* @category Discovery
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class LrddMethodHostMeta extends LRDDMethod
|
||||
{
|
||||
/**
|
||||
* For RFC6415 and HTTP URIs, fetch the host-meta file
|
||||
* and look for LRDD templates
|
||||
*/
|
||||
public function discover($uri)
|
||||
{
|
||||
// This is allowed for RFC6415 but not the 'WebFinger' RFC7033.
|
||||
$try_schemes = ['https', 'http'];
|
||||
|
||||
$scheme = mb_strtolower(parse_url($uri, \PHP_URL_SCHEME));
|
||||
switch ($scheme) {
|
||||
case 'acct':
|
||||
// We can't use parse_url data for this, since the 'host'
|
||||
// entry is only set if the scheme has '://' after it.
|
||||
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
|
||||
|
||||
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
|
||||
throw new Exception('Bad resource URI: ' . $uri);
|
||||
}
|
||||
[, $domain] = $parts;
|
||||
break;
|
||||
case 'http':
|
||||
case 'https':
|
||||
$domain = mb_strtolower(parse_url($uri, \PHP_URL_HOST));
|
||||
$try_schemes = [$scheme];
|
||||
break;
|
||||
default:
|
||||
throw new Exception('Unable to discover resource descriptor endpoint.');
|
||||
}
|
||||
|
||||
foreach ($try_schemes as $scheme) {
|
||||
$url = $scheme . '://' . $domain . '/.well-known/host-meta';
|
||||
|
||||
try {
|
||||
$response = self::fetchUrl($url);
|
||||
$this->xrd->loadString($response->getContent());
|
||||
} catch (Exception $e) {
|
||||
Log::debug('LRDD could not load resource descriptor: ' . $url . ' (' . $e->getMessage() . ')');
|
||||
continue;
|
||||
}
|
||||
return $this->xrd->links;
|
||||
}
|
||||
|
||||
throw new Exception('Unable to retrieve resource descriptor links.');
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\LrddMethod;
|
||||
|
||||
use App\Core\Log;
|
||||
use Component\FreeNetwork\Util\LinkHeader;
|
||||
use Component\FreeNetwork\Util\LrddMethod;
|
||||
use Exception;
|
||||
use XML_XRD_Element_Link;
|
||||
|
||||
/**
|
||||
* Implementation of discovery using HTTP Link header
|
||||
*
|
||||
* Discovers XRD file for a user by fetching the URL and reading any
|
||||
* Link: headers in the HTTP response.
|
||||
*
|
||||
* @category Discovery
|
||||
* @package StatusNet
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*
|
||||
* @see http://status.net/
|
||||
*/
|
||||
class LrddMethodLinkHeader extends LRDDMethod
|
||||
{
|
||||
/**
|
||||
* For HTTP IDs fetch the URL and look for Link headers.
|
||||
*
|
||||
* @todo fail out of WebFinger URIs faster
|
||||
*/
|
||||
public function discover($uri)
|
||||
{
|
||||
$response = self::fetchUrl($uri, 'head');
|
||||
|
||||
$link_header = $response->getHeaders()['link'][0];
|
||||
if (empty($link_header)) {
|
||||
throw new Exception('No Link header found');
|
||||
}
|
||||
Log::debug('LRDD LinkHeader found: ' . var_export($link_header, true));
|
||||
|
||||
return self::parseHeader($link_header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a string or array of headers, returns JRD-like assoc array
|
||||
*
|
||||
* @param array|string $header string or array of strings for headers
|
||||
*
|
||||
* @return array of associative arrays in JRD-like array format
|
||||
*/
|
||||
protected static function parseHeader($header)
|
||||
{
|
||||
$lh = new LinkHeader($header);
|
||||
|
||||
$link = new XML_XRD_Element_Link($lh->rel, $lh->href, $lh->type);
|
||||
|
||||
return [$link];
|
||||
}
|
||||
}
|
|
@ -1,105 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\LrddMethod;
|
||||
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
use Component\FreeNetwork\Util\LrddMethod;
|
||||
use XML_XRD_Element_Link;
|
||||
|
||||
/**
|
||||
* Implementation of discovery using HTML <link> element
|
||||
*
|
||||
* Discovers XRD file for a user by fetching the URL and reading any
|
||||
* <link> elements in the HTML response.
|
||||
*
|
||||
* @category Discovery
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author James Walker <james@status.net>
|
||||
* @copyright 2010 StatusNet, Inc.
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class LrddMethodLinkHtml extends LRDDMethod
|
||||
{
|
||||
/**
|
||||
* For HTTP IDs, fetch the URL and look for <link> elements
|
||||
* in the HTML response.
|
||||
*
|
||||
* @todo fail out of WebFinger URIs faster
|
||||
*/
|
||||
public function discover($uri)
|
||||
{
|
||||
$response = self::fetchUrl($uri);
|
||||
|
||||
return self::parse($response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML and return <link> elements
|
||||
*
|
||||
* Given an HTML string, scans the string for <link> elements
|
||||
*
|
||||
* @param string $html HTML to scan
|
||||
*
|
||||
* @return array array of associative arrays in JRD-ish array format
|
||||
*/
|
||||
public function parse($html)
|
||||
{
|
||||
$links = [];
|
||||
|
||||
preg_match('/<head(\s[^>]*)?>(.*?)<\/head>/is', $html, $head_matches);
|
||||
|
||||
if (\count($head_matches) != 3) {
|
||||
return [];
|
||||
}
|
||||
[, , $head_html] = $head_matches;
|
||||
|
||||
preg_match_all('/<link\s[^>]*>/i', $head_html, $link_matches);
|
||||
|
||||
foreach ($link_matches[0] as $link_html) {
|
||||
$link_url = null;
|
||||
$link_rel = null;
|
||||
$link_type = null;
|
||||
|
||||
preg_match('/\srel=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $rel_matches);
|
||||
if (\count($rel_matches) > 3) {
|
||||
$link_rel = $rel_matches[3];
|
||||
} elseif (\count($rel_matches) > 1) {
|
||||
$link_rel = $rel_matches[1];
|
||||
}
|
||||
|
||||
preg_match('/\shref=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $href_matches);
|
||||
if (\count($href_matches) > 3) {
|
||||
$link_uri = $href_matches[3];
|
||||
} elseif (\count($href_matches) > 1) {
|
||||
$link_uri = $href_matches[1];
|
||||
}
|
||||
|
||||
preg_match('/\stype=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $type_matches);
|
||||
if (\count($type_matches) > 3) {
|
||||
$link_type = $type_matches[3];
|
||||
} elseif (\count($type_matches) > 1) {
|
||||
$link_type = $type_matches[1];
|
||||
}
|
||||
|
||||
$links[] = new XML_XRD_Element_Link($link_rel, $link_uri, $link_type);
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
// This file is part of GNU social - https://www.gnu.org/software/social
|
||||
//
|
||||
// GNU social is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// GNU social is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace Component\FreeNetwork\Util\LrddMethod;
|
||||
|
||||
use Component\FreeNetwork\Util\Discovery;
|
||||
use Component\FreeNetwork\Util\LrddMethod;
|
||||
use Exception;
|
||||
use XML_XRD_Element_Link;
|
||||
|
||||
/**
|
||||
* Implementation of WebFinger resource discovery (RFC7033)
|
||||
*
|
||||
* @category Discovery
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Mikael Nordfeldth <mmn@hethane.se>
|
||||
* @copyright 2013 Free Software Foundation, Inc http://www.fsf.org
|
||||
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
|
||||
*/
|
||||
class LrddMethodWebfinger extends LRDDMethod
|
||||
{
|
||||
/**
|
||||
* Simply returns the WebFinger URL over HTTPS at the uri's domain:
|
||||
* https://{domain}/.well-known/webfinger?resource={uri}
|
||||
*/
|
||||
public function discover($uri)
|
||||
{
|
||||
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
|
||||
|
||||
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
|
||||
throw new Exception('Bad resource URI: ' . $uri);
|
||||
}
|
||||
[, $domain] = $parts;
|
||||
if (!filter_var($domain, \FILTER_VALIDATE_IP)
|
||||
&& !filter_var(gethostbyname($domain), \FILTER_VALIDATE_IP)) {
|
||||
throw new Exception('Bad resource host.');
|
||||
}
|
||||
|
||||
$link = new XML_XRD_Element_Link(
|
||||
Discovery::LRDD_REL,
|
||||
'https://' . $domain . '/.well-known/webfinger?resource={uri}',
|
||||
Discovery::JRD_MIMETYPE,
|
||||
true, // isTemplate
|
||||
);
|
||||
|
||||
return [$link];
|
||||
}
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util;
|
||||
|
||||
use App\Core\Entity;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ServerException;
|
||||
use XML_XRD;
|
||||
|
||||
/**
|
||||
* WebFinger resource parent class
|
||||
*
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Mikael Nordfeldth
|
||||
* @copyright 2013 Free Software Foundation, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*
|
||||
* @see http://status.net/
|
||||
*/
|
||||
abstract class WebfingerResource
|
||||
{
|
||||
protected $identities = [];
|
||||
|
||||
protected $object;
|
||||
protected $type;
|
||||
|
||||
public function __construct(Entity $object)
|
||||
{
|
||||
$this->object = $object;
|
||||
}
|
||||
|
||||
public function getObject()
|
||||
{
|
||||
if ($this->object === null) {
|
||||
throw new ServerException('Object is not set');
|
||||
}
|
||||
return $this->object;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of alternative IDs of a certain Actor
|
||||
*/
|
||||
public function getAliases(): array
|
||||
{
|
||||
$aliases = $this->object->getAliasesWithIDs();
|
||||
|
||||
// Some sites have changed from http to https and still want
|
||||
// (because remote sites look for it) verify that they are still
|
||||
// the same identity as they were on HTTP. Should NOT be used if
|
||||
// you've run HTTPS all the time!
|
||||
if (Common::config('fix', 'legacy_http')) {
|
||||
foreach ($aliases as $alias => $id) {
|
||||
if (!mb_strtolower(parse_url($alias, \PHP_URL_SCHEME)) === 'https') {
|
||||
continue;
|
||||
}
|
||||
$aliases[preg_replace('/^https:/i', 'http:', $alias, 1)] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
// return a unique set of aliases by extracting only the keys
|
||||
return array_keys($aliases);
|
||||
}
|
||||
|
||||
abstract public function updateXRD(XML_XRD $xrd);
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\WebfingerResource;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Util\Common;
|
||||
use Component\FreeNetwork\Exception\WebfingerReconstructionException;
|
||||
use Component\FreeNetwork\Util\WebfingerResource;
|
||||
use XML_XRD;
|
||||
use XML_XRD_Element_Link;
|
||||
|
||||
/**
|
||||
* WebFinger resource for Profile objects
|
||||
*
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Mikael Nordfeldth
|
||||
* @author Diogo Peralta Cordeiro
|
||||
* @copyright 2013, 2021 Free Software Foundation, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*/
|
||||
class WebfingerResourceActor extends WebFingerResource
|
||||
{
|
||||
public const PROFILEPAGE = 'http://webfinger.net/rel/profile-page';
|
||||
|
||||
public function __construct(?Actor $object = null)
|
||||
{
|
||||
// The type argument above verifies that it's our class
|
||||
parent::__construct($object);
|
||||
}
|
||||
|
||||
public function getAliases(): array
|
||||
{
|
||||
$aliases = [];
|
||||
|
||||
try {
|
||||
// Try to create an acct: URI if we're dealing with a profile
|
||||
$aliases[] = $this->reconstructAcct();
|
||||
} catch (WebFingerReconstructionException $e) {
|
||||
Log::debug("WebFinger reconstruction for Profile failed (id={$this->object->getID()})");
|
||||
}
|
||||
|
||||
return array_merge($aliases, parent::getAliases());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct WebFinger acct: from object
|
||||
*
|
||||
* @throws WebfingerReconstructionException
|
||||
*
|
||||
* @return null|array|false|mixed|string|string[]
|
||||
*/
|
||||
public function reconstructAcct()
|
||||
{
|
||||
$acct = null;
|
||||
|
||||
if (Event::handle('StartWebFingerReconstruction', [$this->object, &$acct])) {
|
||||
// TODO: getUri may not always give us the correct host on remote users?
|
||||
$host = parse_url($this->object->getUri(Router::ABSOLUTE_URL), \PHP_URL_HOST);
|
||||
if (empty($this->object->getNickname()) || empty($host)) {
|
||||
throw new WebFingerReconstructionException(print_r($this->object, true));
|
||||
}
|
||||
$acct = mb_strtolower(sprintf('acct:%s@%s', $this->object->getNickname(), $host));
|
||||
|
||||
Event::handle('EndWebFingerReconstruction', [$this->object, &$acct]);
|
||||
}
|
||||
|
||||
return $acct;
|
||||
}
|
||||
|
||||
public function updateXRD(XML_XRD $xrd)
|
||||
{
|
||||
if (Event::handle('StartWebFingerProfileLinks', [$xrd, $this->object])) {
|
||||
|
||||
// Profile page, can give more metadata from Link header or HTML parsing
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
self::PROFILEPAGE,
|
||||
$this->object->getUrl(Router::ABSOLUTE_URL),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
// // XFN
|
||||
// $xrd->links[] = new XML_XRD_Element_Link('http://gmpg.org/xfn/11',
|
||||
// $this->object->getUrl(), 'text/html');
|
||||
// if ($this->object->isPerson()) {
|
||||
// // FOAF for user
|
||||
// $xrd->links[] = new XML_XRD_Element_Link('describedby',
|
||||
// common_local_url('foaf',
|
||||
// ['nickname' => $this->object->getNickname()]),
|
||||
// 'application/rdf+xml');
|
||||
//
|
||||
// // nickname discovery for apps etc.
|
||||
// $link = new XML_XRD_Element_Link('http://apinamespace.org/atom',
|
||||
// common_local_url('ApiAtomService',
|
||||
// ['id' => $this->object->getNickname()]),
|
||||
// 'application/atomsvc+xml');
|
||||
// // XML_XRD must implement changing properties first $link['http://apinamespace.org/atom/username'] = $this->object->getNickname();
|
||||
// $xrd->links[] = clone $link;
|
||||
//
|
||||
// $link = new XML_XRD_Element_Link('http://apinamespace.org/twitter', $apiRoot);
|
||||
// // XML_XRD must implement changing properties first $link['http://apinamespace.org/twitter/username'] = $this->object->getNickname();
|
||||
// $xrd->links[] = clone $link;
|
||||
// } elseif ($this->object->isGroup()) {
|
||||
// // FOAF for group
|
||||
// $xrd->links[] = new XML_XRD_Element_Link('describedby',
|
||||
// common_local_url('foafgroup',
|
||||
// ['nickname' => $this->object->getNickname()]),
|
||||
// 'application/rdf+xml');
|
||||
// }
|
||||
|
||||
Event::handle('EndWebFingerProfileLinks', [$xrd, $this->object]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util\WebfingerResource;
|
||||
|
||||
use App\Core\Event;
|
||||
use App\Entity\Note;
|
||||
use Component\FreeNetwork\Util\WebfingerResource;
|
||||
use PharIo\Manifest\InvalidUrlException;
|
||||
use XML_XRD;
|
||||
use XML_XRD_Element_Link;
|
||||
|
||||
/**
|
||||
* WebFinger resource for Note objects
|
||||
*
|
||||
* @package GNUsocial
|
||||
*
|
||||
* @author Mikael Nordfeldth
|
||||
* @copyright 2013 Free Software Foundation, Inc.
|
||||
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
|
||||
*
|
||||
* @see http://status.net/
|
||||
*/
|
||||
class WebfingerResourceNote extends WebfingerResource
|
||||
{
|
||||
public function __construct(?Note $object = null)
|
||||
{
|
||||
// The type argument above verifies that it's our class
|
||||
parent::__construct($object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update given XRD with self's data
|
||||
*/
|
||||
public function updateXRD(XML_XRD $xrd)
|
||||
{
|
||||
if (Event::handle('StartWebFingerNoticeLinks', [$xrd, $this->object])) {
|
||||
if ($this->object->isLocal()) {
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
'alternate',
|
||||
common_local_url(
|
||||
'ApiStatusesShow',
|
||||
['id' => $this->object->id,
|
||||
'format' => 'atom', ],
|
||||
),
|
||||
'application/atom+xml',
|
||||
);
|
||||
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
'alternate',
|
||||
common_local_url(
|
||||
'ApiStatusesShow',
|
||||
['id' => $this->object->id,
|
||||
'format' => 'json', ],
|
||||
),
|
||||
'application/json',
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
$xrd->links[] = new XML_XRD_Element_Link(
|
||||
'alternate',
|
||||
$this->object->getUrl(),
|
||||
'text/html',
|
||||
);
|
||||
} catch (InvalidUrlException $e) {
|
||||
// don't do a fallback in webfinger
|
||||
}
|
||||
}
|
||||
Event::handle('EndWebFingerNoticeLinks', [$xrd, $this->object]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace Component\FreeNetwork\Util;
|
||||
|
||||
use App\Core\Controller;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use XML_XRD;
|
||||
|
||||
abstract class XrdController extends Controller
|
||||
{
|
||||
protected string $default_mimetype = Discovery::JRD_MIMETYPE;
|
||||
|
||||
protected XML_XRD $xrd;
|
||||
|
||||
/*
|
||||
* Configures $this->xrd which will later be printed. Must be
|
||||
* implemented by child classes.
|
||||
*/
|
||||
abstract protected function setXRD();
|
||||
|
||||
public function __construct(RequestStack $requestStack)
|
||||
{
|
||||
parent::__construct($requestStack);
|
||||
|
||||
if ($this->request->headers->get('format', null) === null) {
|
||||
$this->request->headers->set('format', $this->default_mimetype);
|
||||
}
|
||||
|
||||
$this->xrd = new XML_XRD();
|
||||
}
|
||||
|
||||
public function handle(Request $request): array
|
||||
{
|
||||
$this->setXRD();
|
||||
return ['xrd' => $this->xrd, 'default_mimetype' => $this->default_mimetype];
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"require": {
|
||||
"pear/xml_xrd": "^0.3.1"
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-07-18 12:38+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=CHARSET\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:64
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-07-18 12:38+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=CHARSET\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:230
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Afrikaans (http://www.transifex.com/gnu-social/gnu-social/language/af/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: af\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Afrikaans (http://www.transifex.com/gnu-social/gnu-social/language/af/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: af\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Arabic (http://www.transifex.com/gnu-social/gnu-social/language/ar/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ar\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Arabic (http://www.transifex.com/gnu-social/gnu-social/language/ar/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ar\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Arabic (Egypt) (http://www.transifex.com/gnu-social/gnu-social/language/ar_EG/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ar_EG\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Arabic (Egypt) (http://www.transifex.com/gnu-social/gnu-social/language/ar_EG/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ar_EG\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Asturian (http://www.transifex.com/gnu-social/gnu-social/language/ast/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ast\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Asturian (http://www.transifex.com/gnu-social/gnu-social/language/ast/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ast\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Belarusian (Tarask) (http://www.transifex.com/gnu-social/gnu-social/language/be@tarask/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: be@tarask\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Belarusian (Tarask) (http://www.transifex.com/gnu-social/gnu-social/language/be@tarask/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: be@tarask\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Bulgarian (http://www.transifex.com/gnu-social/gnu-social/language/bg/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: bg\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Bulgarian (http://www.transifex.com/gnu-social/gnu-social/language/bg/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: bg\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Bengali (India) (http://www.transifex.com/gnu-social/gnu-social/language/bn_IN/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: bn_IN\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Bengali (India) (http://www.transifex.com/gnu-social/gnu-social/language/bn_IN/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: bn_IN\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Breton (http://www.transifex.com/gnu-social/gnu-social/language/br/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: br\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Breton (http://www.transifex.com/gnu-social/gnu-social/language/br/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: br\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
|
@ -1,23 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 08:45+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Catalan (http://www.transifex.com/gnu-social/gnu-social/language/ca/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ca\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: LRDDPlugin.php:61
|
||||
msgid "Implements LRDD support for GNU Social."
|
||||
msgstr ""
|
|
@ -1,28 +0,0 @@
|
|||
# Translation file for GNU social - the free software social networking platform
|
||||
# Copyright (C) 2015 - 2019 Free Software Foundation, Inc http://www.fsf.org
|
||||
# This file is under https://www.gnu.org/licenses/agpl v3 or later
|
||||
#
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: GNU social\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-02-02 17:47+0100\n"
|
||||
"PO-Revision-Date: 2015-02-07 12:23+0000\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: Catalan (http://www.transifex.com/gnu-social/gnu-social/language/ca/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ca\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. TRANS: Error message when an object URI which we cannot find was requested
|
||||
#: actions/webfinger.php:49
|
||||
msgid "Resource not found in local database."
|
||||
msgstr ""
|
||||
|
||||
#. TRANS: Plugin description.
|
||||
#: WebFingerPlugin.php:147
|
||||
msgid "Adds WebFinger lookup to GNU Social"
|
||||
msgstr ""
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue