Commit Graph

54 Commits

Author SHA1 Message Date
jordi fita mas 17f7520876 Add customer and invoices sections
Copied as much as i could from Numerus, and made as few modifications as
i could to adapt to this code base; it is, quite frankly, a piece of
shit.

We need to be able to create invoices from scratch “just in case”,
apparently, but it is not yet possible to create an invoice from a
booking.
2024-04-28 20:28:45 +02:00
jordi fita mas f2b24a83a3 Add check-in form
I based the form from the documentation given by the Mossos
d’Esquadra[0], required by law.

https://registreviatgers.mossos.gencat.cat/mossos_hotels/AppJava/fitxaviatger.do?reqCode=create
2024-04-26 17:09:36 +02:00
jordi fita mas c9e8165f83 Allow updating bookings
I need to retrieve the values from the database and put them in the
form, like all other forms, but in this case the processing is done as
if it were a new form, because everything comes from the query string
and there is no need to do any extra work then.

Had to move the <footer> from the fields.gohtml to form.gohtml because
then it could not know that it was editing an existing booking.  Had to
move the <fieldset> out too, in order to give it an ID and make it
htmx’s target, or it would replace the form, causing even more problems
—the button would disappear then—.  The target **must** be in <form>
because it is needed for tis children’s hx-get and for its own hx-put.
2024-04-25 20:27:08 +02:00
jordi fita mas fdf9502c8b “Finish” the new booking form
Had to bring the same fields that i have for a payment to booking,
except that some of those should be nullable, because it is unreasonable
to ask front desk to gather all customer data when they have a booking
via phone, for instance.

Therefore, i can not take advantage of the validation for customer data
that i use in the public-facing form, but, fortunately, most of the
validations where in separated functions, thus only had to rewrite that
one for this case.

I already have to create a booking from a payment, when receiving a
payment from the public instance, thus i made that function and reused
it here.  Then i “overwrite” the newly created pre-booking with the
customer data from the form, and set is as confirmed, as we do not see
any point of allowing pre-bookings from employees.
2024-04-24 20:19:13 +02:00
jordi fita mas 0412ffca05 Compute ACSI discount
After months of keeping what does the ACSI checkbox mean, now customer
told us that we should add a discount based on a series of
arbitrary conditions that, and need to be done NOW!

There is no UI to edit the conditions due to lack of time.
2024-03-14 22:08:01 +01:00
jordi fita mas 990a614897 Change draft_payment return type to row of payment
This way i can use the function in the from clause of the query that
i already had to do to get the totals formatted with to_price.  In this
case, i believe it is better to leave out Go’s function because it would
force me to perform two queries.

Instead of binding a nullable string pointer with the payment’s slug,
i wanted to use pgtype’s zeronull.Text type, but it can not work in this
case because it encodes the value as a text, while the parameters is
uuid.  I can not use zero.UUID, because it is a [16]byte array, while i
have it in a string.

Thus, had to create my own ZeroNullUUID type that use a string as a base
but encodes it as a UUID.
2024-02-13 19:51:39 +01:00
jordi fita mas ff6750fbea Handle payment notifications from Redsys
I have to basically do the reverse of signing the request to verify that
the notification comes from them.  Lots of code just for that.

I return the changed status from the PL/pgSQL function because i will
need to email customers when a payment is completed, and i need to know
when.
2024-02-13 02:38:38 +01:00
jordi fita mas 15dde3f491 Add ready_payment function and use their slug as URL
Now that the payments have slug, i can use them in the URL to show the
actual data of a payment, and kickstart the payment process with Redsys.
2024-02-12 18:06:17 +01:00
jordi fita mas e4636592c5 Add payment relation and use it to compute the booking’s cart
I had to add the payment concept separate from the booking, unlike other
eCommerce solutions that subsume the two into a single “order”, like
WooCommerce, because bookings should be done in a separate Camper
instance that will sync to the public instance, but the payment is done
by the public instance.  There will be a queue or something between
the public and the private instance to pass along the booking
information once the payment is complete, but the public instance still
needs to keep track of payments without creating bookings.

To compute the total for that payment i had to do the same as was doing
until now for the cart.  To prevent duplications, or having functions
with complex return types, i now create a “draft” payment while the
user is filling in the form, and compute the cart there; from Go i only
have to retrieve the data from the relation, that simplifies the work,
actually.

Since the payment is computed way before customers enter their details,
i can not have that data in the same payment relation, unless i allow
NULL values.  Allowing NULL values means that i can create a payment
without customer, thus i moved all customer details to a separate
relation.  It still allows payment without customer, but at least there
are no NULL values.

Draft payments should be removed after a time, but i believe this needs
to be done in a cronjob or similar, not in the Go application.

To update the same payment while filling the same booking form, i now
have a hidden field with the payment slug.  A competent developer would
have used a cookie or something like that; i am not competent.
2024-02-12 05:21:00 +01:00
jordi fita mas ea997a4154 Allow campsite type option to be just per unit, not per unit per night
Customer told us that there are some options, such as towels, that have
a fixed price for the whole stay, not a per night price.  Thus, had to
add a boolean to know whether to use sum or max when computing the
cart’s total for each option.
2024-02-11 21:45:00 +01:00
jordi fita mas 92dba96b29 Add campsite_type_pet_cost relation to hold price of dogs in campsites
It is a separate relation, instead of having a field in campsite_type,
because not all campsite types allow dogs.  I could have added a new
field to campsite_type, but then its values it would be meaningless for
campsites that do not allow dogs, and a nullable field is not a valid
solution because NULL means “unknown”, but we **do** know the price —
none.

A separate relation encodes the same information without ambiguities nor
null values, and, in fact, removed the dogs_allowed field from
campsite_type to prevent erroneous status, such as a campsite type that
allows dogs without having a cost — even if the cost is zero, it has to
be added to the new relation.
2024-02-10 06:18:30 +01:00
jordi fita mas 2c36e45663 Compute and show the “cart” for the booking form
I have to ask number and age ranges of hosts of guests for all campsite
types, not only those that have price options for adults, children, etc.
because i must compute the tourist tax for adults.  These numbers will
be used to generate de rows for guests when actually creating the
booking, which is not done already.

To satisfy the campsite types that do have a price per guest, not only
per night, i had to add the prices for each range in the
campsite_type_cost relation.  If a campsite type does not have price
per person, then that should be zero; the website then does not display
the price.

The minimal price for any campsite type is one adult for one night,
thus to compute the price i need at least the campsite type, the dates,
and the number of adults, that has a minimum of one.  I changed the
order of the form to ask for these values first, so i can compute the
initial price as soon as possible.  To help further, i show the
<fieldset>s progressively when visitors select options.
2024-02-04 06:37:25 +01:00
jordi fita mas 4adad7fa7d Replace min_nights from campsite_type_costs to range in campsite_type
Customer told us that the minimum number of nights is per campsite type,
not per season.  And he wants this, along with the maximum number of
nights, in order to limit the range of departure dates that guests can
choose when booking.
2024-01-31 23:06:45 +01:00
jordi fita mas 51540151ff Refactor ISO date, and datestamp format in constant 2024-01-31 19:58:46 +01:00
jordi fita mas 23be6ff26c Add ask_zone_preferences and overflow_allowed to campsite_type
The “overflow” is for when people want to book plots for more guests
than is permitted, which the system would need to add a new plot to the
“shopping cart”, as it were; not implemented yet.

The ask zone preferences is to whether show the corresponding input on
the booking form, that it was done implicitly when the campsite type had
options, because up until now it was only for plots, but it is no longer
the case, thus i need to know when to show it; now it is explicit.
2024-01-29 03:38:11 +01:00
jordi fita mas eeaa3b415e Add amenities section and public page
This is more or less the same as the campsites, as public information
goes, but for buildings and other amenities that the camping provides
that are not campsites.
2024-01-27 22:51:41 +01:00
jordi fita mas 629ef1a262 Add function to delete campsite type features 2024-01-26 22:54:19 +01:00
jordi fita mas c284230436 Add public pages for each individual accommodation
A small page with a brief description, carousel, and feature list of
each individual accommodation.

Most of the relations and functions for carousel and features are like
the ones for campsite types, but i had to use the accommodation’s label
to find them, because they do not have slugs; i did not even though
these would be public, and they already have a label, although not
unique for all companies, like UUID slugs are.
2024-01-26 22:27:54 +01:00
jordi fita mas 186a5fdb38 Refactor the processing of the campsite form in a common function 2024-01-25 20:57:07 +01:00
jordi fita mas ad161f57b2 Add database functions for AddCampsite and EditCampsite 2024-01-25 20:48:39 +01:00
jordi fita mas f514f9132e Add ad management for surroundings
They only want a single ad (for now, i guess).
2024-01-23 14:53:15 +01:00
jordi fita mas e34f253620 Make the slogan user-editable and translatable
Because God forbid we have any performance; everything —**everything**—
must be user editable.
2024-01-23 11:52:39 +01:00
jordi fita mas 17aaf045bb Replace raw call to remove_campsite_type_carousel_slide with Go func 2024-01-22 20:56:48 +01:00
jordi fita mas bc790762d6 Add remove_campsite_type_option function 2024-01-22 20:54:03 +01:00
jordi fita mas 1f2ab494dd Add check_in and check_out fields to campsite_type
Apparently, each campsite type could have different check-in and
check-out times, thus i need them in the database.

I thought about using an integer or a datetime field, but customer seems
to want a text field to maybe add “before” and “after” there as well.
Translatable text it is.
2024-01-22 20:19:19 +01:00
jordi fita mas 36213c75de Add a Go function for delete_service SQL function 2024-01-16 18:12:38 +01:00
jordi fita mas a0f9c10193 Add management of surroundings’ highlights (points of interest)
Customer does not want the new “masonry-like” design of the surroundings
page, and wants the same style they already had: a regular list with
text and photo, alternating the photo’s side.

And, of course, they want to be able to add and edit them themselves. It
is like another carousel, but with an additional rich-text description.

The photos that we had in that page are no longer of use.
2024-01-16 01:26:35 +01:00
jordi fita mas dabd197f02 Add additional information field to campsite types
This is actually just to have a fourth column in order to “balance”
lists for types that have a long descriptions.
2024-01-15 00:28:34 +01:00
jordi fita mas 976b2bcbcf Add position to services to be able to manually sort by admins 2024-01-13 01:15:24 +01:00
jordi fita mas 4e126237a0 Replace L10nInput with I18nInput in service
locale.Translation and form.L10nInput are no longer used.

The translation type in Postgres is now also useless, and i believe it
was never used, but i keep it because I already have a tag and i can not
just remove it, meaning that dropping it is more trouble that worth it.
2024-01-12 21:06:12 +01:00
jordi fita mas 58c3b607a1 Replace L10nInput with I18nInput in season 2024-01-12 20:26:45 +01:00
jordi fita mas f28664acce Replace use of L10NInput with I18nInput for campsite 2024-01-10 21:52:49 +01:00
jordi fita mas 03c20fec88 Add management of legal texts 2023-12-22 02:23:18 +01:00
jordi fita mas ff6e9497b5 Replace contact page with location
Customer does not want a contact page, but a page where they can write
the direction on how to reach the campground, with a Google map embed
instead of using Leaflet, because Google Maps shows the reviews right
in the map.

That means i had to replace the GPS locations with XML fields for the
customer to write.  In all four languages.

This time i tried a translation approach inspired by PrestaShop: instead
of opening a new page for each language, i have all languages in the
same page and use AlpineJS to show just a single language.  It is far
easier to write the translations, even though you do not have the source
text visible, specially in this section that there is no place for me
to put the language links.
2023-12-21 21:17:04 +01:00
jordi fita mas c9a6df658f Add position to campsite type features 2023-12-21 17:33:01 +01:00
jordi fita mas 678b5cc523 Add user-defined order to campsite types, options, seasons and carousels
I use Sortable, exactly like HTMx’s sorting example does[0].  Had to
export the slug or ID of some entries to be able to add it in the hidden
input.

For forms that use ID instead of slug, had to use an input name other
than “id” because otherwise the swap would fail due to bug #1496[1].  It
is apparently fixed in a recent version of HTMx, but i did not want to
update for fear of behaviour changes.

[0]: https://htmx.org/examples/sortable/
[1]: https://github.com/bigskysoftware/htmx/issues/1496
2023-12-20 19:52:14 +01:00
jordi fita mas 6c090a63da Register PostgreSQL types once on start with guest user
There are types inaccessible to the authenticator role, such as
redsys_request, that can not be registered because that roles is unable
to get the OID of.

I could have moved these types to the public schema, but then it would
seem pointless to have a separate schema.
2023-12-13 16:47:31 +01:00
jordi fita mas 0cbf973cbb Add the payment form to admin
Had to change setup_redsys because admins can not read the current
encrypt key, thus it is not possible to `set encrypt_key =
coalesce(…, encrypt_key)`.

Not that it did much sense, anyway, as i was already inside the branch
of the if when encrpty_key is null.

However, it seems that this also affects in the `on conflict` update. I
assume this is because `excluded` is some kind of row of the relation
and has the same restrictions.
2023-10-27 16:08:13 +02:00
jordi fita mas 007f401d33 Add a cache of OID in database to register types
It makes no sense to retrieve the same OIDs each and every connection,
because they are not going to change unless the database is reset,
something it is very unlikely to happen in production.

Thus, it is best to query them the first time the application connects
to the database, that it is done at startup to query the available
languages, and then reuse the OIDs.

I can get away of using an “unprotected” map, instead of sync.Map or a
map in tandem with sync.RWMutex, because the application establishes a
connection at startup from a single goroutine and it registers _all_
types we will need to register within the application’s lifespan, hence
it there will be no more writes to that map once the web server is
listening for incomming connections.

This is risky, however, and i hope i do not have to regret it.
2023-10-27 12:30:05 +02:00
jordi fita mas 62b54961f4 Implement Redsys request signature in PostgreSQL
Every company need to have its own merchant code and encryption key,
thus it is not possible to use environment variables to keep that data,
and i have to store it in the database.

I do not want to give SELECT permission on the encryption key to guest,
because i am going to fuck it up sooner or later, and everyone would be
able to read that secret; i know it would.  Therefore, i need a security
definer function that takes the data to encrypt, use the key to encrypt
it, and returns the result; nobody else should have access to that key,
not even admins!

By the way, i found out that every merchant receives the same key, thus
it is not a problem to keep it in the repository.

Since i need that SQL function to encrypt the data, i thought that i may
go the whole nine yards and sign the request in PostgreSQL too, after
all the data to sign comes from there, and it has JSON functions to
create and base64-code an object.

Fortunately, pg_crypto has all the functions that i need, but i can no
longer keep that extension inside the auth schema, because it is used
from others, and the public schema, like every other extensions, seems
more appropriate.

Instead of having the list of currency and language codes that Redsys
uses as constants in the code, i moved that as field to the currency
and language relations, so i can simply pass the lang_tag to the
function and it can transform that tag to the correct code; the currency
is from the company’s relation, since it is the only currency used in
the whole application (for now).

As a consequence, i had to grant execute to currency and the parse_price
functions to guest, too.

To generate the test data used in the unit tests, i used a third-party
PHP implementation[0], but i only got from that the resulting base64-coded
JSON object and signature, using the same that as in the unit test, and
did not use any code from there.

PostgreSQL formats the JSON as text differently than most
implementations i have seen: it adds spaces between the key name and
the colons, and space between the value and the separating comma.  The
first implementation used replace() to format the JSON as exactly as
the PHP implementation, so that the result matches, and then tried to do
generate the form by hand using the output from PostgreSQL without the
replace(), to verify that Redsys would still accept my input.  Finally,
i adjusted the unit test to whatever pg_prove said it was getting from
the function.

I still have the form’s action hard-codded to the test environment, but
the idea is that administrators should be able to switch from test to
live themselves.  That means that i need that info in the redsys
relation as well.  I think this is one of the few use cases for SQL’s
types, because it is unlikely to change anytime soon, and i do not need
the actual labels.

Unfortunately, i could not use enumerations for the request’s
transaction type because i can not attach an arbitrary number to the
enum’s values.  Having a relation is overkill, because i would need
a constant in Go to refer to its primary key anyway, thus i kept the
same constant i had before for that.  Language and currency constant
went out, as this is in the corresponding relations.

In setup_redsys i must have a separate update if the encrypt_key is null
because PostgreSQL checks constraints before key conflict, and i do
not want to give a default value to the key if the row is not there yet.
The problem is that i want to use null to mean “keep the same password”,
because it is what i intend to do with the user-facing form: leave the
input empty to keep the same password.

As now Go needs to pass composite types back and forth with PostgreSQL,
i need to register these types, and i have to do it every time because
the only moment i have access to the non-pooled connection is in the
AfterConnect function, but at that point i have no idea whether the
user is going to request a payment.  I do not know how much the
performance degrades because of this.

[0]: https://github.com/ssheduardo/sermepa/blob/master/src/Sermepa/Tpv/Tpv.php
2023-10-27 01:52:04 +02:00
jordi fita mas 2e10966ad7 Add the list of features for campsite type 2023-10-13 20:30:31 +02:00
jordi fita mas d784291a04 Create Go functions for PostgreSQL functions for campsite type option
I am going to base these to create the function for campsite type
feature, and since i intend to
2023-10-13 19:05:17 +02:00
jordi fita mas 7fc87b5cb5 Add the info and facilities fields to campsite type
I wish CKEditor would have support for <dl>, but it is unlikely that
they will add any time soon[0], specially when in 2018 they already
“forgotten”[1] that this was requested back in 2007[2].

Therefore, in the demo i did it more or less like in the original
design, with “extra” <h4>, although i added them under a <h3>, to avoid
“gaps” in the outline.

[0]: https://github.com/ckeditor/ckeditor5/issues/775
[1]: https://github.com/ckeditor/ckeditor5/issues/775#issuecomment-358591747
[2]: https://dev.ckeditor.com/ticket/1333
2023-10-13 18:09:28 +02:00
jordi fita mas 59fe8dd131 Add Go functions for campsite_type PostgreSQL functions
I want these because when there are changes in the signature i then have
to find where it is used, and it is easier to do when the compiler tells
you.

For relations it is less necessary because GoLand knows how to validate
SQL strings for them, but it seems to not work with functions,
apparently due to the lack of the “FROM” keyword.

Besides, it tx.FunctionName(ctx, params...) is shorter than
tx.Exec("select functions_name($1, $2…)", params...).
2023-10-13 12:53:30 +02:00
jordi fita mas ef6a8f5aee Add the campsite type cost per season 2023-10-01 21:14:39 +02:00
jordi fita mas 7330ae83ec Copy RecordArray from pgtype to database
Debian’s pgtype version is 1.10.0, but pgtype did not add RecordArray
type until 1.11.0, thus could not compile the application with
Debian 12.

I just copied the code from 1.11.0 without any modification besides
adding the reference to the pgtype package to types and functions.
2023-09-12 21:58:30 +02:00
jordi fita mas da127124a1 Add cover media to campsite types
This is the image that is shown at the home page, and maybe other pages
in the future.  We can not use a static file because this image can be
changed by the customer, not us; just like name and description.

I decided to keep the actual media content in the database, but to copy
this file out to the file system the first time it is accessed. This is
because we are going to replicate the database to a public instance that
must show exactly the same image, but the customer will update the image
from the private instance, behind a firewall.  We could also synchronize
the folder where they upload the images, the same way we will replicate,
but i thought that i would make the whole thing a little more brittle:
this way if it can replicate the update of the media, it is impossible
to not have its contents; dumping it to a file is to improve subsequent
requests to the same media.

I use the hex representation of the media’s hash as the URL to the
resource, because PostgreSQL’s base64 is not URL save (i.e., it uses
RFC2045’s charset that includes the forward slash[0]), and i did not
feel necessary write a new function just to slightly reduce the URLs’
length.

Before checking if the file exists, i make sure that the given hash is
an hex string, like i do for UUID, otherwise any other check is going
to fail for sure.  I moved out hex.Valid function from UUID to check for
valid hex values, but the actual hash check is inside app/media because
i doubt it will be used outside that module.

[0]: https://datatracker.ietf.org/doc/html/rfc2045#section-6.8
2023-09-10 03:04:18 +02:00
jordi fita mas 50fbfce9ee Add the form to update company’s tax details
It is inside the “user menu” only because this is where Numerus has the
same option, although it makes less sense in this case, because Numerus
is geared toward individual freelancers while Camper is for companies.
But, since it is easy to change afterward, this will do for now.

However, it should be only shown to admin users, because regular
employees have no UPDATE privilege on the company relation.  Thus, the
need for a new template function to check if the user is admin.

Part of #17.
2023-08-15 22:35:21 +02:00
jordi fita mas f65110824e Add the company’s slug in the URL before company-dependent handlers
I really doubt that they are going to use more than a single company,
but the application is based on Numerus, that **does** have multiple
company, and followed the same architecture and philosophy: use the URL
to choose the company to manage, even if the user has a single company.

The reason i use the slug instead of the ID is because i do not want to
make the ID public in case the application is really used by employees
of many unrelated companies: they need not need to guess how many
companies there are based on the ID.

I validate this slug to be a valid UUID instead of relaying on the
query’s empty result because casting a string with a malformed value to
UUID results in an error other than data not found.  Not with that
select, but it would fail with a function parameter, and i want to add
that UUID check to all functions that do use slugs.

I based uuid.Valid function on Parse() from Google’s uuid package[0]
instead of using regular expression, as it was my first idea, because
that function is an order of magnitude faster in benchmarks:

  goos: linux
  goarch: amd64
  pkg: dev.tandem.ws/tandem/numerus/pkg
  cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz
  BenchmarkValidUuid-4            36946050                29.37 ns/op
  BenchmarkValidUuid_Re-4          3633169               306.70 ns/op

The regular expression used for the benchmark was:

  var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")

And the input parameter for both functions was the following valid UUID,
because most of the time the passed UUID will be valid:

  "f47ac10b-58cc-0372-8567-0e02b2c3d479"

I did not use the uuid package as is, even though it is in Debian’s
repository, because i only need to check whether the value is valid,
not convert it to a byte array.  As far as i know, that package can not
do that.

Adding the Company struct into auth was not my intention, as it makes
little sense name-wise, but i need to have the Company when rendering
templates and the company package has templates to render, thus using
the company package for the Company struct would create a dependency
loop between template and company.  I’ve chosen the auth package only
because User is also there; User and Company are very much related in
this application, but not enough to include the company inside the user,
or vice versa, as the User comes from the cookie while the company from
the URL.

Finally, had to move methodNotAllowed to the http package, as an
exported function, because it is used now from other packages, namely
campsite.

[0]: https://github.com/google/uuid
2023-07-31 18:51:50 +02:00
jordi fita mas ebe8217862 Add the logout button
Conceptually, to logout we have to “delete the session”, thus the best
HTTP verb would be `DELETE`.  However, there is no way to send a
`DELETE` request with a regular HTML form, and it seems that never will
be[0].

I could use a POST, optionally with a “method override” technique, but
i was planing to use HTMx anyway, so this was as good an opportunity to
include it as any.

In this application i am not concerned with people not having JavaScript
enabled, because it is for a customer that has a known environment, and
we do not have much time anyway.  Therefore, i opted to forgo
progressive enhancement in cases like this: if `DELETE` is needed, use
`hx-delete`.

Unfortunately, i can not use a <form> with a hidden <input> for the
CSRF token, because `DELETE` requests do not have body and the value
should be added as query parameters, like a form with GET method, but
HTMx does the incorrect thing here: sends the values in the request’s
body.  That’s why i have to use a custom header and the `hx-header`
directive to include the CSRF token.

Then, by default HTMx targets the triggered element for swap with the
response from the server, but after a logout i want to redirect the
user to the login form again.  I could set the hx-target to button to
replace the whole body, or tell the client to redirect to the new
location.  I actually do not know which one is “better”.  Maybe the
hx-target is best because then everything is handled by the client, but
in the case of logout, since it is possible that i might want to load
scripts only for logged-in users in the future, i opted for the full
page reload.

However, HTMx does not want to reload a page that return HTTP 401,
hence i had to include the GET method to /login in order to return the
login form with a response of HTTP 200, which also helps when
reloading in the browser after a failed login attempt.  I am not worried
with the HTTP 401 when attempting to load a page as guest, because
this request most probably comes from the browser, not HTMx, and it will
show the login form as intended—even though it is not compliant, since
it does not return the WWW-Authenticate header, but this is the best i
can do given that no cookie-based authentication method has been
accepted[1].

[0]: https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671#c16
[1]: https://datatracker.ietf.org/doc/id/draft-broyer-http-cookie-auth-00.html
2023-07-26 13:49:47 +02:00