This commit is contained in:
2026-07-11 18:21:33 +02:00
parent cbea23083d
commit 92d94f1c95
129 changed files with 85783 additions and 11294 deletions
-36
View File
@@ -1,36 +0,0 @@
APP_NAME="Kaffeeliste"
APP_ENV=production
APP_DEBUG=0
APP_URL=http://localhost:8080
# APP_BASE_PATH: Leer für direkten Zugriff, /proxy/8080 für code-server Proxy
APP_BASE_PATH=
APP_TIMEZONE=Europe/Berlin
APP_KEY=
ALLOW_PUBLIC_REGISTRATION=0
RFID_INGEST_ENABLED=0
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=kaffeeliste
DB_USER=root
DB_PASS=
MAIL_FROM=noreply@example.com
MAIL_REPLY_TO=
MAIL_SUBJECT_PREFIX="[Kaffeeliste] "
MAIL_LOG_FALLBACK=1
MAIL_LOG_FILE=
PASSWORD_RESET_URL=http://localhost:8080/reset-password?token={{token}}
PASSWORD_RESET_TTL_MINUTES=60
PASSWORD_RESET_MAX_ACTIVE_TOKENS=3
PASSWORD_RESET_SUBJECT="Passwort zurücksetzen"
RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5
RATE_LIMIT_LOGIN_WINDOW_SECONDS=900
RATE_LIMIT_LOGIN_BLOCK_SECONDS=900
RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3
RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600
RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600
RFID_SHARED_SECRET=change-me
-41
View File
@@ -1,41 +0,0 @@
APP_NAME="Kaffeekasse SaaS"
APP_ENV=production
APP_DEBUG=0
APP_URL=https://your-domain.tld
# APP_BASE_PATH: Leer für direkten Zugriff auf Netcup
APP_BASE_PATH=
APP_TIMEZONE=Europe/Berlin
APP_KEY=your-32-character-app-key-here
ALLOW_PUBLIC_REGISTRATION=0
RFID_INGEST_ENABLED=0
# Netcup MySQL Konfiguration
DB_HOST=localhost
DB_PORT=3306
DB_NAME=your_database_name
DB_USER=your_database_user
DB_PASS=your_database_password
# Mail-Konfiguration für Netcup
MAIL_FROM=noreply@your-domain.tld
MAIL_REPLY_TO=support@your-domain.tld
MAIL_SUBJECT_PREFIX="[Kaffeekasse] "
MAIL_LOG_FALLBACK=1
MAIL_LOG_FILE=storage/logs/mail.log
# Password Reset URLs für Netcup
PASSWORD_RESET_URL=https://your-domain.tld/reset-password?token={{token}}
PASSWORD_RESET_TTL_MINUTES=60
PASSWORD_RESET_MAX_ACTIVE_TOKENS=3
PASSWORD_RESET_SUBJECT="Passwort zurücksetzen"
# Rate Limiting
RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5
RATE_LIMIT_LOGIN_WINDOW_SECONDS=900
RATE_LIMIT_LOGIN_BLOCK_SECONDS=900
RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3
RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600
RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600
# RFID Secret (ändern Sie diesen Wert!)
RFID_SHARED_SECRET=your-secure-rfid-secret-here
-14
View File
@@ -1,14 +0,0 @@
/vendor/
/.env
/build/
/storage/cache/*
/storage/logs/*
/storage/uploads/*
/storage/cache/.gitkeep
/storage/logs/.gitkeep
/storage/uploads/.gitkeep
/storage/cron.lock
/storage/installed.lock
/.idea/
/.vscode/
/.DS_Store
-55
View File
@@ -1,55 +0,0 @@
# Deployment auf Netcup Webspace
## Zielbild
- Code unter einem Release-Pfad wie `/apps/kaffeekasse/current`
- Document Root in Plesk auf `/apps/kaffeekasse/current/public`
- `.env` ausserhalb des Webroots oder direkt im Projektverzeichnis ohne
Webzugriff
- Migrationen ueber `bin/migrate.php`
- zyklische Aufgaben ueber `bin/cron.php`
## Erstinstallation
1. Neue Domain oder Subdomain im Netcup WCP/Plesk anlegen.
2. Document Root auf das Verzeichnis `public/` des Releases zeigen lassen.
3. MySQL-Datenbank und separaten User anlegen.
4. Projekt per FTPES oder SFTP hochladen.
5. Installer unter `/install` aufrufen oder `.env` manuell setzen.
6. Alternativ oder zusaetzlich:
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/migrate.php`
7. Plattform-Admin einloggen und ersten Mandanten registrieren.
## Scheduled Tasks
- Alle 5 Minuten:
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/cron.php`
- Optional Healthcheck:
`/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/healthcheck.php`
## Release-Prozess
1. `scripts/build-release.sh` erzeugt ein auslieferbares Paket.
2. Paket hochladen und entpacken.
3. `.env` und persistente `storage/`-Pfade sichern.
4. `bin/migrate.php` ausfuehren.
5. Healthcheck und Login pruefen.
## Sicherheitshinweise
- `public/` bleibt der einzige Webroot.
- `.env`, Dumps, Backups und `.git` duerfen nicht oeffentlich erreichbar sein.
- Fuer produktive Domains HTTPS erzwingen.
- Session-Cookies sind auf `HttpOnly`, `SameSite=Lax` und `Secure` ausgelegt.
- Tenant-Daten werden serverseitig ueber Session und Memberships zugeordnet.
## Git-Remote vorbereiten
Beispiel fuer spaeteres Pushen auf `git.ctb-it.de`:
```bash
git remote add origin ssh://git@git.ctb-it.de/namespace/kaffeekasse-saas.git
git push -u origin main
```
Die genaue URL haengt von eurem Namespace und dem Zielsystem ab.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+159
View File
File diff suppressed because one or more lines are too long
+358
View File
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
GPL Cooperation Commitment
Version 1.0
Before filing or continuing to prosecute any legal proceeding or claim
(other than a Defensive Action) arising from termination of a Covered
License, we commit to extend to the person or entity ('you') accused
of violating the Covered License the following provisions regarding
cure and reinstatement, taken from GPL version 3. As used here, the
term 'this License' refers to the specific Covered License being
enforced.
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly
and finally terminates your license, and (b) permanently, if the
copyright holder fails to notify you of the violation by some
reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you
have received notice of violation of this License (for any work)
from that copyright holder, and you cure the violation prior to 30
days after your receipt of the notice.
We intend this Commitment to be irrevocable, and binding and
enforceable against us and assignees of or successors to our
copyrights.
Definitions
'Covered License' means the GNU General Public License, version 2
(GPLv2), the GNU Lesser General Public License, version 2.1
(LGPLv2.1), or the GNU Library General Public License, version 2
(LGPLv2), all as published by the Free Software Foundation.
'Defensive Action' means a legal proceeding or claim that We bring
against you in response to a prior proceeding or claim initiated by
you or your affiliate.
'We' means each contributor to this repository as of the date of
inclusion of this file, including subsidiaries of a corporate
contributor.
This work is available under a Creative Commons Attribution-ShareAlike
4.0 International license (https://creativecommons.org/licenses/by-sa/4.0/).
+502
View File
@@ -0,0 +1,502 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library 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
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!
+79
View File
@@ -0,0 +1,79 @@
{
"name": "phpmailer/phpmailer",
"type": "library",
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"require": {
"php": ">=5.5.0",
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^9.3.5",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7.2",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)"
},
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"PHPMailer\\Test\\": "test/"
}
},
"license": "LGPL-2.1-only",
"scripts": {
"check": "./vendor/bin/phpcs",
"test": "./vendor/bin/phpunit --no-coverage",
"coverage": "./vendor/bin/phpunit",
"lint": [
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . --show-deprecated -e php,phps --exclude vendor --exclude .git --exclude build"
]
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* PHPMailer - PHP email creation and transport class.
* PHP Version 5.5
* @package PHPMailer
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
/**
* Get an OAuth2 token from an OAuth2 provider.
* * Install this script on your server so that it's accessible
* as [https/http]://<yourdomain>/<folder>/get_oauth_token.php
* e.g.: http://localhost/phpmailer/get_oauth_token.php
* * Ensure dependencies are installed with 'composer install'
* * Set up an app in your Google/Yahoo/Microsoft account
* * Set the script address as the app's redirect URL
* If no refresh token is obtained when running this file,
* revoke access to your app and run the script again.
*/
namespace PHPMailer\PHPMailer;
/**
* Aliases for League Provider Classes
* Make sure you have added these to your composer.json and run `composer install`
* Plenty to choose from here:
* @see http://oauth2-client.thephpleague.com/providers/thirdparty/
*/
//@see https://github.com/thephpleague/oauth2-google
use League\OAuth2\Client\Provider\Google;
//@see https://packagist.org/packages/hayageek/oauth2-yahoo
use Hayageek\OAuth2\Client\Provider\Yahoo;
//@see https://github.com/stevenmaguire/oauth2-microsoft
use Stevenmaguire\OAuth2\Client\Provider\Microsoft;
//@see https://github.com/greew/oauth2-azure-provider
use Greew\OAuth2\Client\Provider\Azure;
if (!isset($_GET['code']) && !isset($_POST['provider'])) {
?>
<html>
<body>
<form method="post">
<h1>Select Provider</h1>
<input type="radio" name="provider" value="Google" id="providerGoogle">
<label for="providerGoogle">Google</label><br>
<input type="radio" name="provider" value="Yahoo" id="providerYahoo">
<label for="providerYahoo">Yahoo</label><br>
<input type="radio" name="provider" value="Microsoft" id="providerMicrosoft">
<label for="providerMicrosoft">Microsoft</label><br>
<input type="radio" name="provider" value="Azure" id="providerAzure">
<label for="providerAzure">Azure</label><br>
<h1>Enter id and secret</h1>
<p>These details are obtained by setting up an app in your provider's developer console.
</p>
<p>ClientId: <input type="text" name="clientId"><p>
<p>ClientSecret: <input type="text" name="clientSecret"></p>
<p>TenantID (only relevant for Azure): <input type="text" name="tenantId"></p>
<input type="submit" value="Continue">
</form>
</body>
</html>
<?php
exit;
}
require 'vendor/autoload.php';
session_start();
$providerName = '';
$clientId = '';
$clientSecret = '';
$tenantId = '';
if (array_key_exists('provider', $_POST)) {
$providerName = $_POST['provider'];
$clientId = $_POST['clientId'];
$clientSecret = $_POST['clientSecret'];
$tenantId = $_POST['tenantId'];
$_SESSION['provider'] = $providerName;
$_SESSION['clientId'] = $clientId;
$_SESSION['clientSecret'] = $clientSecret;
$_SESSION['tenantId'] = $tenantId;
} elseif (array_key_exists('provider', $_SESSION)) {
$providerName = $_SESSION['provider'];
$clientId = $_SESSION['clientId'];
$clientSecret = $_SESSION['clientSecret'];
$tenantId = $_SESSION['tenantId'];
}
//If you don't want to use the built-in form, set your client id and secret here
//$clientId = 'RANDOMCHARS-----duv1n2.apps.googleusercontent.com';
//$clientSecret = 'RANDOMCHARS-----lGyjPcRtvP';
//If this automatic URL doesn't work, set it yourself manually to the URL of this script
$redirectUri = (isset($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
//$redirectUri = 'http://localhost/PHPMailer/redirect';
$params = [
'clientId' => $clientId,
'clientSecret' => $clientSecret,
'redirectUri' => $redirectUri,
'accessType' => 'offline'
];
$options = [];
$provider = null;
switch ($providerName) {
case 'Google':
$provider = new Google($params);
$options = [
'scope' => [
'https://mail.google.com/'
]
];
break;
case 'Yahoo':
$provider = new Yahoo($params);
break;
case 'Microsoft':
$provider = new Microsoft($params);
$options = [
'scope' => [
'wl.imap',
'wl.offline_access'
]
];
break;
case 'Azure':
$params['tenantId'] = $tenantId;
$provider = new Azure($params);
$options = [
'scope' => [
'https://outlook.office.com/SMTP.Send',
'offline_access'
]
];
break;
}
if (null === $provider) {
exit('Provider missing');
}
if (!isset($_GET['code'])) {
//If we don't have an authorization code then get one
$authUrl = $provider->getAuthorizationUrl($options);
$_SESSION['oauth2state'] = $provider->getState();
header('Location: ' . $authUrl);
exit;
//Check given state against previously stored one to mitigate CSRF attack
} elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
unset($_SESSION['oauth2state']);
unset($_SESSION['provider']);
exit('Invalid state');
} else {
unset($_SESSION['provider']);
//Try to get an access token (using the authorization code grant)
$token = $provider->getAccessToken(
'authorization_code',
[
'code' => $_GET['code']
]
);
//Use this to interact with an API on the users behalf
//Use this to get a new access token if the old one expires
echo 'Refresh Token: ', $token->getRefreshToken();
}
-186
View File
@@ -1,186 +0,0 @@
# Quick Start: Proxy & Webspace Setup
## 🚀 Schnellstart für Code-Server Proxy
```bash
# 1. .env erstellen
cp .env.example .env
# 2. .env anpassen
nano .env
# Setzen Sie: APP_BASE_PATH=/proxy/8080
# 3. Server starten
php -S 0.0.0.0:8080 -t public public/router.php
# 4. Zugriff über Browser
# https://ihr-code-server.de/proxy/8080/
```
## 🌐 Schnellstart für Netcup Webspace
```bash
# 1. Dateien hochladen (FTP/SFTP)
# Document Root → /public/
# 2. .env erstellen
cp .env.example .env
# 3. .env anpassen
nano .env
# Setzen Sie:
# APP_URL=https://ihre-domain.de
# APP_BASE_PATH=
# DB_HOST=localhost
# DB_NAME=ihre_datenbank
# DB_USER=ihr_benutzer
# DB_PASS=ihr_passwort
# 4. Rechte setzen
chmod 755 storage/ storage/cache/ storage/logs/ storage/uploads/
# 5. Installation
# Besuchen Sie: https://ihre-domain.de/install
```
## ✅ Funktioniert automatisch
Die Anwendung erkennt automatisch:
- ✅ Code-Server Proxy (via `X-Forwarded-Prefix` Header)
- ✅ Subdirectory-Installationen (via `APP_BASE_PATH`)
- ✅ HTTPS hinter Reverse Proxy (via `X-Forwarded-Proto`)
- ✅ Alle URLs werden dynamisch generiert
## 📖 Detaillierte Dokumentation
Siehe: `docs/deployment-proxy-guide.md`
## 🔧 Konfigurationsoptionen
### .env Variablen für Proxy-Setup
```env
# Für Code-Server Proxy
APP_BASE_PATH=/proxy/8080
# Für Subdirectory (z.B. https://example.com/kaffeekasse/)
APP_BASE_PATH=/kaffeekasse
# Für Root-Installation (Standard)
APP_BASE_PATH=
```
### Manuelle Proxy-Prefix Überschreibung
Falls automatische Erkennung nicht funktioniert:
```bash
# Apache .htaccess
SetEnv KAFFEEKASSE_PROXY_PREFIX /proxy/8080
# Nginx
fastcgi_param KAFFEEKASSE_PROXY_PREFIX /proxy/8080;
# PHP Development Server
KAFFEEKASSE_PROXY_PREFIX=/proxy/8080 php -S 0.0.0.0:8080 -t public public/router.php
```
## 🧪 Testen
```bash
# Base Path prüfen
php -r "require 'app/Support/helpers.php'; var_dump(base_path());"
# URL-Generierung testen
php -r "
require 'app/Support/helpers.php';
\$_ENV['APP_BASE_PATH'] = '/proxy/8080';
echo url('/admin/login') . PHP_EOL;
echo tenant_url('test', 'bookings') . PHP_EOL;
echo asset_url('app.css') . PHP_EOL;
"
```
## 🐛 Troubleshooting
### URLs sind falsch
```bash
# Debug-Ausgabe in public/index.php einfügen (temporär):
var_dump([
'base_path' => base_path(),
'current_path' => current_path(),
'REQUEST_URI' => $_SERVER['REQUEST_URI'] ?? null,
'X-Forwarded-Prefix' => $_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null,
]);
exit;
```
### CSS lädt nicht
```env
# In .env explizit setzen:
APP_BASE_PATH=/proxy/8080
```
### Sessions funktionieren nicht
```bash
# Prüfen Sie Verzeichnisrechte:
chmod 755 storage/
ls -la storage/
```
## 📋 Checkliste
### Code-Server Proxy
- [ ] `.env` erstellt und `APP_BASE_PATH=/proxy/8080` gesetzt
- [ ] PHP Server gestartet: `php -S 0.0.0.0:8080 -t public public/router.php`
- [ ] Zugriff über `/proxy/8080/` funktioniert
- [ ] CSS und Assets werden geladen
- [ ] Login funktioniert
### Netcup Webspace
- [ ] Dateien hochgeladen (Document Root → `/public/`)
- [ ] `.env` erstellt und konfiguriert
- [ ] `APP_BASE_PATH=` (leer gelassen)
- [ ] Datenbank-Zugangsdaten eingetragen
- [ ] Verzeichnisrechte gesetzt (755 für storage/)
- [ ] Installation unter `/install` durchgeführt
- [ ] HTTPS funktioniert
- [ ] Login funktioniert
## 💡 Tipps
### Entwicklung mit Code-Server
```bash
# Screen-Session für dauerhaften Betrieb
screen -dmS kaffeekasse bash -c 'cd /config/workspace/kaffeeliste-neustart && php -S 0.0.0.0:8080 -t public public/router.php'
# Session wieder verbinden
screen -r kaffeekasse
# Session beenden
screen -X -S kaffeekasse quit
```
### Production auf Netcup
```apache
# HTTPS erzwingen in public/.htaccess
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
```
### SQLite für Entwicklung
```env
# Einfacher für lokale Entwicklung
DB_HOST=sqlite
DB_NAME=/config/workspace/kaffeeliste-neustart/storage/database.sqlite
```
## 🔗 Weitere Ressourcen
- **Vollständige Dokumentation**: `docs/deployment-proxy-guide.md`
- **Deployment Guide**: `DEPLOY.md`
- **Runbook**: `RUNBOOK.md`
- **Netcup Checkliste**: `docs/go-live-checklist-netcup.md`
+18 -73
View File
@@ -1,80 +1,25 @@
# Kaffeekasse SaaS Neustart # Legacy App Archiv
Frischer Neustart fuer eine mandantenfaehige Kaffeeliste mit: Dieses Verzeichnis enthaelt den bisherigen PHP-Bestand der Kaffeeliste.
- klassischer Papierliste inklusive Nacherfassung Es bleibt aus drei Gruenden im Repository:
- digitaler Selbstbuchung fuer Browser, Tablet oder Kiosk
- vorbereitetem RFID-Pfad ueber Device-, Tag- und Event-Modelle
- Netcup-tauglichem PHP/MySQL-Betrieb ohne Root-Zwang
- produktionsnaher Vermarktungs- und Betriebsdokumentation
## Projektziel - als Referenz fuer die bestehende Fachlogik
- als Quelle fuer Datenmigrationen in die SaaS-Version
- als Rueckfalloption waehrend der Uebergangsphase
Dieses Repository bildet eine pragmatische SaaS-Basis fuer mehrere Kunden oder ## Wichtige Legacy-Bereiche
Mandanten. Jeder Tenant arbeitet unter `t/{slug}` in einer getrennten
Organisation, waehrend Produktlogik, Betrieb und Vermarktung zentral bleiben.
## Kernentscheidungen - `index.php`: persoenliches Dashboard
- `stricheintragen.php`: Sammelerfassung fuer Kaffee-Striche
- `einzahlung.php`: Sammelerfassung fuer Einzahlungen
- `kaffeeliste.php`: operative Gesamtuebersicht
- `mitarbeiterverwalten.php`: Mitglieder- und Rollenpflege
- `letzteneintraege.php`: Korrektur letzter Buchungen
- `hinweise.php`: Banner/Hinweise
- `PHP 8.x + MySQL` ohne schwere Runtime-Voraussetzungen ## Umgang Mit Dem Archiv
- `public/` als einziger Webroot
- `tenant_id` auf allen tenantbezogenen Fachtabellen
- ein gemeinsames Ledger fuer `digital`, `paper` und spaeter `rfid`
- Webspace-freundliche Cron- und CLI-Hooks statt Daemons
## Hauptfunktionen - Keine neuen Produktfunktionen mehr hier entwickeln.
- Nur noch fuer Referenz, Datenabgleich oder Notfallbetrieb verwenden.
- Plattform-Installation per Browser - Neue Arbeit findet ausschliesslich in `../saas-app/` und `../docs/` statt.
- Self-Service-Registrierung fuer neue Mandanten
- Plattform-Admin mit Tenant-Uebersicht
- Tenant-Login mit Rollen `owner`, `admin`, `member`
- Mitglieder, Produkte und Preisgrundlagen
- digitale Buchungen und Einzahlungen
- Papierlisten als Entwurf, Zeilen und Ledger-Posting
- RFID-Geraete, Karten und Event-Inbox
## Schnellstart lokal
1. `.env.example` nach `.env` kopieren oder den Installer unter `/install` nutzen.
2. `database/schema.sql` per `bin/migrate.php` gegen MySQL ausfuehren.
3. Webroot auf `public/` zeigen lassen.
4. Plattform-Admin anlegen und anschliessend Mandanten registrieren.
## Testumgebung im code-server
Fuer die SQLite-basierte Testumgebung kann der eingebaute PHP-Server genutzt werden:
```bash
php -S 0.0.0.0:8080 public/router_test.php
```
Der Test-Router unterstuetzt code-server-Proxy-Pfade wie `/proxy/8080` und
`/absproxy/8080`. Links, Form-Actions und Assets werden ueber zentrale Helper
erzeugt, damit CSS und JavaScript auch hinter einem Pfad-Prefix geladen werden.
Wenn ein Proxy den Prefix nicht als Header durchreicht, bleiben die erzeugten
URLs relativ und funktionieren weiterhin innerhalb des aktuellen Proxy-Pfads.
Optional kann ein fester Pfad-Prefix gesetzt werden, z.B. in `.env.test`:
```dotenv
APP_BASE_PATH=/proxy/8080
```
Ein einheitlicher Hosteintrag ist dafuer nicht noetig. Zeitgemaesser ist eine
Basis-Pfad-/Forwarded-Prefix-Unterstuetzung, weil Entwicklungsumgebungen wie
code-server Anwendungen haeufig unter einem Unterpfad statt unter einem eigenen
Host ausliefern.
## Wichtige Dateien
- [public/index.php](/config/workspace/kaffeeliste-neustart/public/index.php)
- [database/schema.sql](/config/workspace/kaffeeliste-neustart/database/schema.sql)
- [DEPLOY.md](/config/workspace/kaffeeliste-neustart/DEPLOY.md)
- [RUNBOOK.md](/config/workspace/kaffeeliste-neustart/RUNBOOK.md)
- [docs/go-live-checklist-netcup.md](/config/workspace/kaffeeliste-neustart/docs/go-live-checklist-netcup.md)
- [docs/go-to-market-30-60-90.md](/config/workspace/kaffeeliste-neustart/docs/go-to-market-30-60-90.md)
- [docs/next-steps-roadmap.md](/config/workspace/kaffeeliste-neustart/docs/next-steps-roadmap.md)
- [docs/product-strategy.md](/config/workspace/kaffeeliste-neustart/docs/product-strategy.md)
- [docs/marketing-ops.md](/config/workspace/kaffeeliste-neustart/docs/marketing-ops.md)
- [docs/production-blueprint.md](/config/workspace/kaffeeliste-neustart/docs/production-blueprint.md)
- [docs/rfid-roadmap.md](/config/workspace/kaffeeliste-neustart/docs/rfid-roadmap.md)
-37
View File
@@ -1,37 +0,0 @@
# Runbook
## Regelbetrieb
- Installer nach dem ersten Setup nicht erneut oeffnen.
- Healthcheck vor und nach Releases pruefen.
- Cron-Lauf auf erfolgreiche Ausgabe und Lock-Freigabe kontrollieren.
- Backup-Plan fuer DB, `.env` und Uploads fest einplanen.
## Monitoring-Minimum
- Login-Fehler und Access-Denied-Ereignisse im Audit beobachten
- offene RFID-Events aus `bin/cron.php` beobachten
- Wachstum von `consumption_events`, `ledger_entries` und `audit_events` im Blick
behalten
## Backup-Strategie
- taegliches Datenbank-Backup
- Offsite-Kopie von `.env` und `storage/uploads`
- Vor jedem Release ein frisches DB-Backup
## Wiederherstellung
1. Letztes stabiles Release erneut deployen.
2. Datenbank-Backup einspielen.
3. `.env` und Uploads wiederherstellen.
4. Healthcheck ausfuehren.
5. Admin-Login, Tenant-Login und eine Testbuchung pruefen.
## Offene Ausbaupfade
- Mail-Verifikation und Einladungs-Workflow
- MFA fuer Owner/Admin
- Rate-Limits fuer Login und Registrierung
- automatisierte Export- und Reminder-Jobs
- regelbasierte Uebernahme von RFID-Events in echte Buchungen
+24754
View File
File diff suppressed because one or more lines are too long
+241
View File
@@ -0,0 +1,241 @@
<?php
//============================================================+
// File name : tcpdf_autoconfig.php
// Version : 1.1.1
// Begin : 2013-05-16
// Last Update : 2014-12-18
// Authors : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
// License : GNU-LGPL v3 (http://www.gnu.org/copyleft/lesser.html)
// -------------------------------------------------------------------
// Copyright (C) 2011-2014 Nicola Asuni - Tecnick.com LTD
//
// This file is part of TCPDF software library.
//
// TCPDF is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// TCPDF 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 Lesser General Public License for more details.
//
// You should have received a copy of the License
// along with TCPDF. If not, see
// <http://www.tecnick.com/pagefiles/tcpdf/LICENSE.TXT>.
//
// See LICENSE.TXT file for more information.
// -------------------------------------------------------------------
//
// Description : Try to automatically configure some TCPDF
// constants if not defined.
//
//============================================================+
/**
* @file
* Try to automatically configure some TCPDF constants if not defined.
* @package com.tecnick.tcpdf
* @version 1.1.1
*/
// DOCUMENT_ROOT fix for IIS Webserver
if ((!isset($_SERVER['DOCUMENT_ROOT'])) OR (empty($_SERVER['DOCUMENT_ROOT']))) {
if(isset($_SERVER['SCRIPT_FILENAME'])) {
$_SERVER['DOCUMENT_ROOT'] = str_replace( '\\', '/', substr($_SERVER['SCRIPT_FILENAME'], 0, 0-strlen($_SERVER['PHP_SELF'])));
} elseif(isset($_SERVER['PATH_TRANSLATED'])) {
$_SERVER['DOCUMENT_ROOT'] = str_replace( '\\', '/', substr(str_replace('\\\\', '\\', $_SERVER['PATH_TRANSLATED']), 0, 0-strlen($_SERVER['PHP_SELF'])));
} else {
// define here your DOCUMENT_ROOT path if the previous fails (e.g. '/var/www')
$_SERVER['DOCUMENT_ROOT'] = '/';
}
}
$_SERVER['DOCUMENT_ROOT'] = str_replace('//', '/', $_SERVER['DOCUMENT_ROOT']);
if (substr($_SERVER['DOCUMENT_ROOT'], -1) != '/') {
$_SERVER['DOCUMENT_ROOT'] .= '/';
}
// Load main configuration file only if the K_TCPDF_EXTERNAL_CONFIG constant is set to false.
if (!defined('K_TCPDF_EXTERNAL_CONFIG') OR !K_TCPDF_EXTERNAL_CONFIG) {
// define a list of default config files in order of priority
$tcpdf_config_files = array(dirname(__FILE__).'/config/tcpdf_config.php', '/etc/php-tcpdf/tcpdf_config.php', '/etc/tcpdf/tcpdf_config.php', '/etc/tcpdf_config.php');
foreach ($tcpdf_config_files as $tcpdf_config) {
if (@file_exists($tcpdf_config) AND is_readable($tcpdf_config)) {
require_once($tcpdf_config);
break;
}
}
}
if (!defined('K_PATH_MAIN')) {
define ('K_PATH_MAIN', dirname(__FILE__).'/');
}
if (!defined('K_PATH_FONTS')) {
define ('K_PATH_FONTS', K_PATH_MAIN.'fonts/');
}
if (!defined('K_PATH_URL')) {
$k_path_url = K_PATH_MAIN; // default value for console mode
if (isset($_SERVER['HTTP_HOST']) AND (!empty($_SERVER['HTTP_HOST']))) {
if(isset($_SERVER['HTTPS']) AND (!empty($_SERVER['HTTPS'])) AND (strtolower($_SERVER['HTTPS']) != 'off')) {
$k_path_url = 'https://';
} else {
$k_path_url = 'http://';
}
$k_path_url .= $_SERVER['HTTP_HOST'];
$k_path_url .= str_replace( '\\', '/', substr(K_PATH_MAIN, (strlen($_SERVER['DOCUMENT_ROOT']) - 1)));
}
define ('K_PATH_URL', $k_path_url);
}
if (!defined('K_PATH_IMAGES')) {
$tcpdf_images_dirs = array(K_PATH_MAIN.'examples/images/', K_PATH_MAIN.'images/', '/usr/share/doc/php-tcpdf/examples/images/', '/usr/share/doc/tcpdf/examples/images/', '/usr/share/doc/php/tcpdf/examples/images/', '/var/www/tcpdf/images/', '/var/www/html/tcpdf/images/', '/usr/local/apache2/htdocs/tcpdf/images/', K_PATH_MAIN);
foreach ($tcpdf_images_dirs as $tcpdf_images_path) {
if (@file_exists($tcpdf_images_path)) {
define ('K_PATH_IMAGES', $tcpdf_images_path);
break;
}
}
}
if (!defined('PDF_HEADER_LOGO')) {
$tcpdf_header_logo = '';
if (@file_exists(K_PATH_IMAGES.'tcpdf_logo.jpg')) {
$tcpdf_header_logo = 'tcpdf_logo.jpg';
}
define ('PDF_HEADER_LOGO', $tcpdf_header_logo);
}
if (!defined('PDF_HEADER_LOGO_WIDTH')) {
if (!empty($tcpdf_header_logo)) {
define ('PDF_HEADER_LOGO_WIDTH', 30);
} else {
define ('PDF_HEADER_LOGO_WIDTH', 0);
}
}
if (!defined('K_PATH_CACHE')) {
$K_PATH_CACHE = ini_get('upload_tmp_dir') ? ini_get('upload_tmp_dir') : sys_get_temp_dir();
if (substr($K_PATH_CACHE, -1) != '/') {
$K_PATH_CACHE .= '/';
}
define ('K_PATH_CACHE', $K_PATH_CACHE);
}
if (!defined('K_BLANK_IMAGE')) {
define ('K_BLANK_IMAGE', '_blank.png');
}
if (!defined('PDF_PAGE_FORMAT')) {
define ('PDF_PAGE_FORMAT', 'A4');
}
if (!defined('PDF_PAGE_ORIENTATION')) {
define ('PDF_PAGE_ORIENTATION', 'P');
}
if (!defined('PDF_CREATOR')) {
define ('PDF_CREATOR', 'TCPDF');
}
if (!defined('PDF_AUTHOR')) {
define ('PDF_AUTHOR', 'TCPDF');
}
if (!defined('PDF_HEADER_TITLE')) {
define ('PDF_HEADER_TITLE', 'TCPDF Example');
}
if (!defined('PDF_HEADER_STRING')) {
define ('PDF_HEADER_STRING', "by Nicola Asuni - Tecnick.com\nwww.tcpdf.org");
}
if (!defined('PDF_UNIT')) {
define ('PDF_UNIT', 'mm');
}
if (!defined('PDF_MARGIN_HEADER')) {
define ('PDF_MARGIN_HEADER', 5);
}
if (!defined('PDF_MARGIN_FOOTER')) {
define ('PDF_MARGIN_FOOTER', 10);
}
if (!defined('PDF_MARGIN_TOP')) {
define ('PDF_MARGIN_TOP', 27);
}
if (!defined('PDF_MARGIN_BOTTOM')) {
define ('PDF_MARGIN_BOTTOM', 25);
}
if (!defined('PDF_MARGIN_LEFT')) {
define ('PDF_MARGIN_LEFT', 15);
}
if (!defined('PDF_MARGIN_RIGHT')) {
define ('PDF_MARGIN_RIGHT', 15);
}
if (!defined('PDF_FONT_NAME_MAIN')) {
define ('PDF_FONT_NAME_MAIN', 'helvetica');
}
if (!defined('PDF_FONT_SIZE_MAIN')) {
define ('PDF_FONT_SIZE_MAIN', 10);
}
if (!defined('PDF_FONT_NAME_DATA')) {
define ('PDF_FONT_NAME_DATA', 'helvetica');
}
if (!defined('PDF_FONT_SIZE_DATA')) {
define ('PDF_FONT_SIZE_DATA', 8);
}
if (!defined('PDF_FONT_MONOSPACED')) {
define ('PDF_FONT_MONOSPACED', 'courier');
}
if (!defined('PDF_IMAGE_SCALE_RATIO')) {
define ('PDF_IMAGE_SCALE_RATIO', 1.25);
}
if (!defined('HEAD_MAGNIFICATION')) {
define('HEAD_MAGNIFICATION', 1.1);
}
if (!defined('K_CELL_HEIGHT_RATIO')) {
define('K_CELL_HEIGHT_RATIO', 1.25);
}
if (!defined('K_TITLE_MAGNIFICATION')) {
define('K_TITLE_MAGNIFICATION', 1.3);
}
if (!defined('K_SMALL_RATIO')) {
define('K_SMALL_RATIO', 2/3);
}
if (!defined('K_THAI_TOPCHARS')) {
define('K_THAI_TOPCHARS', true);
}
if (!defined('K_TCPDF_CALLS_IN_HTML')) {
define('K_TCPDF_CALLS_IN_HTML', false);
}
if (!defined('K_TCPDF_THROW_EXCEPTION_ERROR')) {
define('K_TCPDF_THROW_EXCEPTION_ERROR', false);
}
if (!defined('K_TIMEZONE')) {
define('K_TIMEZONE', @date_default_timezone_get());
}
//============================================================+
// END OF FILE
//============================================================+
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
<?php
//============================================================+
// File name : tcpdf_barcodes_2d.php
// Version : 1.0.015
// Begin : 2009-04-07
// Last Update : 2014-05-20
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
// License : GNU-LGPL v3 (http://www.gnu.org/copyleft/lesser.html)
// -------------------------------------------------------------------
// Copyright (C) 2009-2014 Nicola Asuni - Tecnick.com LTD
//
// This file is part of TCPDF software library.
//
// TCPDF is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// TCPDF 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with TCPDF. If not, see <http://www.gnu.org/licenses/>.
//
// See LICENSE.TXT file for more information.
// -------------------------------------------------------------------
//
// Description : PHP class to creates array representations for
// 2D barcodes to be used with TCPDF.
//
//============================================================+
/**
* @file
* PHP class to creates array representations for 2D barcodes to be used with TCPDF.
* @package com.tecnick.tcpdf
* @author Nicola Asuni
* @version 1.0.015
*/
/**
* @class TCPDF2DBarcode
* PHP class to creates array representations for 2D barcodes to be used with TCPDF (http://www.tcpdf.org).
* @package com.tecnick.tcpdf
* @version 1.0.015
* @author Nicola Asuni
*/
class TCPDF2DBarcode {
/**
* Array representation of barcode.
* @protected
*/
protected $barcode_array = array();
/**
* This is the class constructor.
* Return an array representations for 2D barcodes:<ul>
* <li>$arrcode['code'] code to be printed on text label</li>
* <li>$arrcode['num_rows'] required number of rows</li>
* <li>$arrcode['num_cols'] required number of columns</li>
* <li>$arrcode['bcode'][$r][$c] value of the cell is $r row and $c column (0 = transparent, 1 = black)</li></ul>
* @param string $code code to print
* @param string $type type of barcode: <ul><li>DATAMATRIX : Datamatrix (ISO/IEC 16022)</li><li>PDF417 : PDF417 (ISO/IEC 15438:2006)</li><li>PDF417,a,e,t,s,f,o0,o1,o2,o3,o4,o5,o6 : PDF417 with parameters: a = aspect ratio (width/height); e = error correction level (0-8); t = total number of macro segments; s = macro segment index (0-99998); f = file ID; o0 = File Name (text); o1 = Segment Count (numeric); o2 = Time Stamp (numeric); o3 = Sender (text); o4 = Addressee (text); o5 = File Size (numeric); o6 = Checksum (numeric). NOTES: Parameters t, s and f are required for a Macro Control Block, all other parameters are optional. To use a comma character ',' on text options, replace it with the character 255: "\xff".</li><li>QRCODE : QRcode Low error correction</li><li>QRCODE,L : QRcode Low error correction</li><li>QRCODE,M : QRcode Medium error correction</li><li>QRCODE,Q : QRcode Better error correction</li><li>QRCODE,H : QR-CODE Best error correction</li><li>RAW: raw mode - comma-separad list of array rows</li><li>RAW2: raw mode - array rows are surrounded by square parenthesis.</li><li>TEST : Test matrix</li></ul>
*/
public function __construct($code, $type) {
$this->setBarcode($code, $type);
}
/**
* Return an array representations of barcode.
* @return array
*/
public function getBarcodeArray() {
return $this->barcode_array;
}
/**
* Send barcode as SVG image object to the standard output.
* @param int $w Width of a single rectangle element in user units.
* @param int $h Height of a single rectangle element in user units.
* @param string $color Foreground color (in SVG format) for bar elements (background is transparent).
* @public
*/
public function getBarcodeSVG($w=3, $h=3, $color='black') {
// send headers
$code = $this->getBarcodeSVGcode($w, $h, $color);
header('Content-Type: application/svg+xml');
header('Cache-Control: public, must-revalidate, max-age=0'); // HTTP/1.1
header('Pragma: public');
header('Expires: Sat, 26 Jul 1997 05:00:00 GMT'); // Date in the past
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
header('Content-Disposition: inline; filename="'.md5($code).'.svg";');
//header('Content-Length: '.strlen($code));
echo $code;
}
/**
* Return a SVG string representation of barcode.
* @param int $w Width of a single rectangle element in user units.
* @param int $h Height of a single rectangle element in user units.
* @param string $color Foreground color (in SVG format) for bar elements (background is transparent).
* @return string SVG code.
* @public
*/
public function getBarcodeSVGcode($w=3, $h=3, $color='black') {
// replace table for special characters
$repstr = array("\0" => '', '&' => '&amp;', '<' => '&lt;', '>' => '&gt;');
$svg = '<'.'?'.'xml version="1.0" standalone="no"'.'?'.'>'."\n";
$svg .= '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'."\n";
$svg .= '<svg width="'.round(($this->barcode_array['num_cols'] * $w), 3).'" height="'.round(($this->barcode_array['num_rows'] * $h), 3).'" version="1.1" xmlns="http://www.w3.org/2000/svg">'."\n";
$svg .= "\t".'<desc>'.strtr($this->barcode_array['code'], $repstr).'</desc>'."\n";
$svg .= "\t".'<g id="elements" fill="'.$color.'" stroke="none">'."\n";
// print barcode elements
$y = 0;
// for each row
for ($r = 0; $r < $this->barcode_array['num_rows']; ++$r) {
$x = 0;
// for each column
for ($c = 0; $c < $this->barcode_array['num_cols']; ++$c) {
if ($this->barcode_array['bcode'][$r][$c] == 1) {
// draw a single barcode cell
$svg .= "\t\t".'<rect x="'.$x.'" y="'.$y.'" width="'.$w.'" height="'.$h.'" />'."\n";
}
$x += $w;
}
$y += $h;
}
$svg .= "\t".'</g>'."\n";
$svg .= '</svg>'."\n";
return $svg;
}
/**
* Return an HTML representation of barcode.
* @param int $w Width of a single rectangle element in pixels.
* @param int $h Height of a single rectangle element in pixels.
* @param string $color Foreground color for bar elements (background is transparent).
* @return string HTML code.
* @public
*/
public function getBarcodeHTML($w=10, $h=10, $color='black') {
$html = '<div style="font-size:0;position:relative;width:'.($w * $this->barcode_array['num_cols']).'px;height:'.($h * $this->barcode_array['num_rows']).'px;">'."\n";
// print barcode elements
$y = 0;
// for each row
for ($r = 0; $r < $this->barcode_array['num_rows']; ++$r) {
$x = 0;
// for each column
for ($c = 0; $c < $this->barcode_array['num_cols']; ++$c) {
if ($this->barcode_array['bcode'][$r][$c] == 1) {
// draw a single barcode cell
$html .= '<div style="background-color:'.$color.';width:'.$w.'px;height:'.$h.'px;position:absolute;left:'.$x.'px;top:'.$y.'px;">&nbsp;</div>'."\n";
}
$x += $w;
}
$y += $h;
}
$html .= '</div>'."\n";
return $html;
}
/**
* Send a PNG image representation of barcode (requires GD or Imagick library).
* @param int $w Width of a single rectangle element in pixels.
* @param int $h Height of a single rectangle element in pixels.
* @param array $color RGB (0-255) foreground color for bar elements (background is transparent).
* @public
*/
public function getBarcodePNG($w=3, $h=3, $color=array(0,0,0)) {
$data = $this->getBarcodePngData($w, $h, $color);
// send headers
header('Content-Type: image/png');
header('Cache-Control: public, must-revalidate, max-age=0'); // HTTP/1.1
header('Pragma: public');
header('Expires: Sat, 26 Jul 1997 05:00:00 GMT'); // Date in the past
header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
//header('Content-Length: '.strlen($data));
echo $data;
}
/**
* Return a PNG image representation of barcode (requires GD or Imagick library).
* @param int $w Width of a single rectangle element in pixels.
* @param int $h Height of a single rectangle element in pixels.
* @param array $color RGB (0-255) foreground color for bar elements (background is transparent).
* @return string|Imagick|false image or false in case of error.
* @public
*/
public function getBarcodePngData($w=3, $h=3, $color=array(0,0,0)) {
// calculate image size
$width = ($this->barcode_array['num_cols'] * $w);
$height = ($this->barcode_array['num_rows'] * $h);
if (function_exists('imagecreate')) {
// GD library
$imagick = false;
$png = imagecreate($width, $height);
$bgcol = imagecolorallocate($png, 255, 255, 255);
imagecolortransparent($png, $bgcol);
$fgcol = imagecolorallocate($png, $color[0], $color[1], $color[2]);
} elseif (extension_loaded('imagick')) {
$imagick = true;
$bgcol = new imagickpixel('rgb(255,255,255');
$fgcol = new imagickpixel('rgb('.$color[0].','.$color[1].','.$color[2].')');
$png = new Imagick();
$png->newImage($width, $height, 'none', 'png');
$bar = new imagickdraw();
$bar->setfillcolor($fgcol);
} else {
return false;
}
// print barcode elements
$y = 0;
// for each row
for ($r = 0; $r < $this->barcode_array['num_rows']; ++$r) {
$x = 0;
// for each column
for ($c = 0; $c < $this->barcode_array['num_cols']; ++$c) {
if ($this->barcode_array['bcode'][$r][$c] == 1) {
// draw a single barcode cell
if ($imagick) {
$bar->rectangle($x, $y, ($x + $w - 1), ($y + $h - 1));
} else {
imagefilledrectangle($png, $x, $y, ($x + $w - 1), ($y + $h - 1), $fgcol);
}
}
$x += $w;
}
$y += $h;
}
if ($imagick) {
$png->drawimage($bar);
return $png;
} else {
ob_start();
imagepng($png);
$imagedata = ob_get_clean();
imagedestroy($png);
return $imagedata;
}
}
/**
* Set the barcode.
* @param string $code code to print
* @param string $type type of barcode: <ul><li>DATAMATRIX : Datamatrix (ISO/IEC 16022)</li><li>PDF417 : PDF417 (ISO/IEC 15438:2006)</li><li>PDF417,a,e,t,s,f,o0,o1,o2,o3,o4,o5,o6 : PDF417 with parameters: a = aspect ratio (width/height); e = error correction level (0-8); t = total number of macro segments; s = macro segment index (0-99998); f = file ID; o0 = File Name (text); o1 = Segment Count (numeric); o2 = Time Stamp (numeric); o3 = Sender (text); o4 = Addressee (text); o5 = File Size (numeric); o6 = Checksum (numeric). NOTES: Parameters t, s and f are required for a Macro Control Block, all other parameters are optional. To use a comma character ',' on text options, replace it with the character 255: "\xff".</li><li>QRCODE : QRcode Low error correction</li><li>QRCODE,L : QRcode Low error correction</li><li>QRCODE,M : QRcode Medium error correction</li><li>QRCODE,Q : QRcode Better error correction</li><li>QRCODE,H : QR-CODE Best error correction</li><li>RAW: raw mode - comma-separad list of array rows</li><li>RAW2: raw mode - array rows are surrounded by square parenthesis.</li><li>TEST : Test matrix</li></ul>
* @return void
*/
public function setBarcode($code, $type) {
$mode = explode(',', $type);
$qrtype = strtoupper($mode[0]);
switch ($qrtype) {
case 'DATAMATRIX': { // DATAMATRIX (ISO/IEC 16022)
require_once(dirname(__FILE__).'/include/barcodes/datamatrix.php');
$qrcode = new Datamatrix($code);
$this->barcode_array = $qrcode->getBarcodeArray();
$this->barcode_array['code'] = $code;
break;
}
case 'PDF417': { // PDF417 (ISO/IEC 15438:2006)
require_once(dirname(__FILE__).'/include/barcodes/pdf417.php');
if (!isset($mode[1]) OR ($mode[1] === '')) {
$aspectratio = 2; // default aspect ratio (width / height)
} else {
$aspectratio = floatval($mode[1]);
}
if (!isset($mode[2]) OR ($mode[2] === '')) {
$ecl = -1; // default error correction level (auto)
} else {
$ecl = intval($mode[2]);
}
// set macro block
$macro = array();
if (isset($mode[3]) AND ($mode[3] !== '') AND isset($mode[4]) AND ($mode[4] !== '') AND isset($mode[5]) AND ($mode[5] !== '')) {
$macro['segment_total'] = intval($mode[3]);
$macro['segment_index'] = intval($mode[4]);
$macro['file_id'] = strtr($mode[5], "\xff", ',');
for ($i = 0; $i < 7; ++$i) {
$o = $i + 6;
if (isset($mode[$o]) AND ($mode[$o] !== '')) {
// add option
$macro['option_'.$i] = strtr($mode[$o], "\xff", ',');
}
}
}
$qrcode = new PDF417($code, $ecl, $aspectratio, $macro);
$this->barcode_array = $qrcode->getBarcodeArray();
$this->barcode_array['code'] = $code;
break;
}
case 'QRCODE': { // QR-CODE
require_once(dirname(__FILE__).'/include/barcodes/qrcode.php');
if (!isset($mode[1]) OR (!in_array($mode[1],array('L','M','Q','H')))) {
$mode[1] = 'L'; // Ddefault: Low error correction
}
$qrcode = new QRcode($code, strtoupper($mode[1]));
$this->barcode_array = $qrcode->getBarcodeArray();
$this->barcode_array['code'] = $code;
break;
}
case 'RAW':
case 'RAW2': { // RAW MODE
// remove spaces
$code = preg_replace('/[\s]*/si', '', $code);
if (strlen($code) < 3) {
break;
}
if ($qrtype == 'RAW') {
// comma-separated rows
$rows = explode(',', $code);
} else { // RAW2
// rows enclosed in square parentheses
$code = substr($code, 1, -1);
$rows = explode('][', $code);
}
$this->barcode_array['num_rows'] = count($rows);
$this->barcode_array['num_cols'] = strlen($rows[0]);
$this->barcode_array['bcode'] = array();
foreach ($rows as $r) {
$this->barcode_array['bcode'][] = str_split($r, 1);
}
$this->barcode_array['code'] = $code;
break;
}
case 'TEST': { // TEST MODE
$this->barcode_array['num_rows'] = 5;
$this->barcode_array['num_cols'] = 15;
$this->barcode_array['bcode'] = array(
array(1,1,1,0,1,1,1,0,1,1,1,0,1,1,1),
array(0,1,0,0,1,0,0,0,1,0,0,0,0,1,0),
array(0,1,0,0,1,1,0,0,1,1,1,0,0,1,0),
array(0,1,0,0,1,0,0,0,0,0,1,0,0,1,0),
array(0,1,0,0,1,1,1,0,1,1,1,0,0,1,0));
$this->barcode_array['code'] = $code;
break;
}
default: {
$this->barcode_array = array();
}
}
}
} // end of class
//============================================================+
// END OF FILE
//============================================================+
+104
View File
@@ -0,0 +1,104 @@
<?php
//============================================================+
// File name : tcpdf_import.php
// Version : 1.0.001
// Begin : 2011-05-23
// Last Update : 2013-09-17
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
// License : GNU-LGPL v3 (http://www.gnu.org/copyleft/lesser.html)
// -------------------------------------------------------------------
// Copyright (C) 2011-2013 Nicola Asuni - Tecnick.com LTD
//
// This file is part of TCPDF software library.
//
// TCPDF is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// TCPDF 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 Lesser General Public License for more details.
//
// You should have received a copy of the License
// along with TCPDF. If not, see
// <http://www.tecnick.com/pagefiles/tcpdf/LICENSE.TXT>.
//
// See LICENSE.TXT file for more information.
// -------------------------------------------------------------------
//
// Description : This is a PHP class extension of the TCPDF library to
// import existing PDF documents.
//
//============================================================+
/**
* @file
* !!! THIS CLASS IS UNDER DEVELOPMENT !!!
* This is a PHP class extension of the TCPDF (http://www.tcpdf.org) library to import existing PDF documents.<br>
* @package com.tecnick.tcpdf
* @author Nicola Asuni
* @version 1.0.001
*/
// include the TCPDF class
require_once(dirname(__FILE__).'/tcpdf.php');
// include PDF parser class
require_once(dirname(__FILE__).'/tcpdf_parser.php');
/**
* @class TCPDF_IMPORT
* !!! THIS CLASS IS UNDER DEVELOPMENT !!!
* PHP class extension of the TCPDF (http://www.tcpdf.org) library to import existing PDF documents.<br>
* @package com.tecnick.tcpdf
* @brief PHP class extension of the TCPDF library to import existing PDF documents.
* @version 1.0.001
* @author Nicola Asuni - info@tecnick.com
*/
class TCPDF_IMPORT extends TCPDF {
/**
* Import an existing PDF document
* @param string $filename Filename of the PDF document to import.
* @return void
* @public
* @since 1.0.000 (2011-05-24)
*/
public function importPDF($filename) {
// load document
$rawdata = file_get_contents($filename);
if ($rawdata === false) {
$this->Error('Unable to get the content of the file: '.$filename);
}
// configuration parameters for parser
$cfg = array(
'die_for_errors' => false,
'ignore_filter_decoding_errors' => true,
'ignore_missing_filter_decoders' => true,
);
try {
// parse PDF data
$pdf = new TCPDF_PARSER($rawdata, $cfg);
} catch (Exception $e) {
die($e->getMessage());
}
// get the parsed data
$data = $pdf->getParsedData();
// release some memory
unset($rawdata);
// ...
print_r($data); // DEBUG
unset($pdf);
}
} // END OF CLASS
//============================================================+
// END OF FILE
//============================================================+
-459
View File
@@ -1,459 +0,0 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Csrf;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Response;
use App\Core\Session;
use App\Core\TenantContext;
use App\Core\View;
use App\Services\AuditService;
use App\Services\MailerService;
use App\Services\PasswordResetService;
use App\Services\RateLimiterService;
use App\Services\SetupService;
use App\Services\TenantRegistrationService;
use RuntimeException;
final class PlatformController
{
public function __construct(
private readonly string $rootPath,
private readonly array $config,
private readonly DatabaseInterface $database,
private readonly View $view,
private readonly Session $session,
private readonly Csrf $csrf
) {
}
public function home(): void
{
$setup = new SetupService($this->rootPath);
$stats = null;
if ($setup->isInstalled() && $this->database->isConfigured()) {
try {
$pdo = $this->database->pdo();
$stats = [
'tenants' => (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn(),
'members' => (int) $pdo->query('SELECT COUNT(*) FROM members')->fetchColumn(),
'events' => (int) $pdo->query('SELECT COUNT(*) FROM consumption_events')->fetchColumn(),
];
} catch (\Throwable) {
$stats = null;
}
}
$this->view->render('home/index', [
'title' => 'Kaffeeliste für Teams, Vereine und Büroküchen',
'setupComplete' => $setup->isInstalled(),
'stats' => $stats,
'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false,
]);
}
public function installForm(): void
{
$setup = new SetupService($this->rootPath);
if ($setup->isInstalled()) {
Response::redirect('/admin/login');
}
$this->view->render('install/index', [
'title' => 'Installation',
]);
}
public function installSubmit(): void
{
$this->assertCsrf();
$setup = new SetupService($this->rootPath);
try {
$setup->install($_POST);
$this->session->flash('success', 'Die Installation wurde abgeschlossen. Sie können sich jetzt als Plattform-Administrator anmelden.');
Response::redirect('/admin/login');
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
Response::redirect('/install');
}
}
public function registerForm(): void
{
if (!(new SetupService($this->rootPath))->isInstalled()) {
Response::redirect('/install');
}
if (!$this->allowsPublicRegistration()) {
$this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Bitte kontaktieren Sie den Betreiber.');
Response::redirect('/');
}
$this->view->render('home/register', [
'title' => 'Kaffeeliste registrieren',
]);
}
public function registerSubmit(): void
{
$this->assertCsrf();
try {
if (!$this->allowsPublicRegistration()) {
throw new RuntimeException('Die offene Registrierung ist derzeit deaktiviert.');
}
$pdo = $this->database->pdo();
$rateLimiter = $this->rateLimiter($pdo);
$email = mb_strtolower(trim((string) ($_POST['owner_email'] ?? '')));
$scopes = $this->buildScopes('registration', $email);
$this->ensureAllowed($rateLimiter, 'registration', $scopes);
$audit = new AuditService($pdo);
$service = new TenantRegistrationService($pdo, $audit);
$slug = $service->register($_POST);
$this->clearScopes($rateLimiter, 'registration', $scopes);
$this->session->flash('success', 'Die Kaffeeliste wurde angelegt. Sie können sich jetzt anmelden.');
Response::redirect('/t/' . rawurlencode($slug) . '/login');
} catch (\Throwable $throwable) {
if ($this->database->isConfigured()) {
try {
$this->registerFailure('registration', mb_strtolower(trim((string) ($_POST['owner_email'] ?? ''))));
} catch (\Throwable) {
}
}
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
Response::redirect('/register');
}
}
public function adminLoginForm(): void
{
$this->view->render('admin/login', [
'title' => 'Kaffeelisten verwalten',
]);
}
public function adminLoginSubmit(): void
{
$this->assertCsrf();
try {
$pdo = $this->database->pdo();
$email = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
$rateLimiter = $this->rateLimiter($pdo);
$scopes = $this->buildScopes('login', 'platform|' . $email);
$this->ensureAllowed($rateLimiter, 'login', $scopes);
$auth = new Auth($pdo, $this->session);
$audit = new AuditService($pdo);
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
'email' => $email,
]);
if (!$success) {
$this->hitScopes($rateLimiter, 'login', $scopes);
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
}
$this->clearScopes($rateLimiter, 'login', $scopes);
$this->session->flash('success', 'Willkommen im Plattformbereich.');
Response::redirect('/admin');
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
Response::redirect('/admin/login');
}
}
public function passwordResetRequestForm(): void
{
$scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
$tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
$this->view->render('auth/reset-request', [
'title' => 'Passwort vergessen',
'scope' => $scope,
'tenantSlug' => $tenantSlug,
'tenant' => $tenant,
]);
}
public function passwordResetRequestSubmit(): void
{
$this->assertCsrf();
try {
$pdo = $this->database->pdo();
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
$service = new PasswordResetService(
$pdo,
new MailerService($this->config['mail'] ?? []),
$this->config['password_reset'] ?? []
);
$service->requestReset((string) ($_POST['email'] ?? ''), $this->passwordResetContext($scope, $tenant, $tenantSlug));
$this->session->flash('success', 'Falls ein Konto existiert, wurde ein Link zum Zuruecksetzen versendet.');
Response::redirect($this->passwordResetRequestUrl($scope, $tenant, $tenantSlug));
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
Response::redirect($this->passwordResetRequestUrl($scope, null, $tenantSlug));
}
}
public function passwordResetConfirmForm(): void
{
$scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
$tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
$token = (string) ($_GET['token'] ?? '');
$resetToken = null;
if ($this->database->isConfigured() && trim($token) !== '') {
try {
$resetToken = $this->passwordResetService($this->database->pdo())->findValidToken($token);
} catch (\Throwable) {
$resetToken = null;
}
}
$this->view->render('auth/reset-confirm', [
'title' => 'Neues Passwort setzen',
'scope' => $scope,
'tenantSlug' => $tenantSlug,
'tenant' => $tenant,
'token' => $token,
'resetToken' => $resetToken,
]);
}
public function passwordResetConfirmSubmit(): void
{
$this->assertCsrf();
try {
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
$password = (string) ($_POST['password'] ?? '');
$passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
if ($password !== $passwordConfirm) {
throw new RuntimeException('Die Passwort-Bestätigung stimmt nicht überein.');
}
$pdo = $this->database->pdo();
$service = $this->passwordResetService($pdo);
$userId = $service->resetPassword(
(string) ($_POST['token'] ?? ''),
$password
);
(new AuditService($pdo))->log(null, $userId, 'password.reset', 'user', $userId, 'success');
$this->session->flash('success', 'Das Passwort wurde aktualisiert. Du kannst dich jetzt anmelden.');
Response::redirect($this->passwordResetLoginUrl($scope, $tenant, $tenantSlug));
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
$token = trim((string) ($_POST['token'] ?? ''));
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
Response::redirect($this->passwordResetConfirmUrl($token, $scope, null, $tenantSlug));
}
}
public function adminDashboard(): void
{
$pdo = $this->database->pdo();
$auth = new Auth($pdo, $this->session);
if (!$auth->checkPlatformAdmin()) {
$this->session->flash('error', 'Bitte zuerst als Plattform-Admin anmelden.');
Response::redirect('/admin/login');
}
$tenants = $pdo->query(
'SELECT t.*,
COUNT(DISTINCT m.id) AS member_count,
COUNT(DISTINCT p.id) AS product_count
FROM tenants t
LEFT JOIN members m ON m.tenant_id = t.id
LEFT JOIN products p ON p.tenant_id = t.id
GROUP BY t.id
ORDER BY t.created_at DESC'
)->fetchAll();
$this->view->render('admin/index', [
'title' => 'Plattform-Dashboard',
'tenants' => $tenants,
]);
}
public function adminLogout(): void
{
$this->assertCsrf();
$pdo = $this->database->pdo();
$auth = new Auth($pdo, $this->session);
$auth->logout();
$this->session->flash('success', 'Du wurdest aus dem Plattformbereich abgemeldet.');
Response::redirect('/admin/login');
}
private function assertCsrf(): void
{
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
}
}
private function allowsPublicRegistration(): bool
{
return (bool) ($this->config['allow_public_registration'] ?? false);
}
private function rateLimiter(\PDO $pdo): RateLimiterService
{
return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []);
}
private function passwordResetService(\PDO $pdo): PasswordResetService
{
return new PasswordResetService(
$pdo,
new MailerService($this->config['mail'] ?? []),
$this->config['password_reset'] ?? []
);
}
private function buildScopes(string $action, string $identifier = ''): array
{
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
$scopes = [$action . '|ip|' . $ip];
if (trim($identifier) !== '') {
$scopes[] = $action . '|subject|' . mb_strtolower(trim($identifier));
}
return $scopes;
}
private function ensureAllowed(RateLimiterService $rateLimiter, string $action, array $scopes): void
{
foreach ($scopes as $scope) {
$state = $rateLimiter->inspect($action, $scope);
if (!$state['allowed']) {
throw new RuntimeException(
'Zu viele Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.'
);
}
}
}
private function hitScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
{
foreach ($scopes as $scope) {
$rateLimiter->hit($action, $scope);
}
}
private function clearScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
{
foreach ($scopes as $scope) {
$rateLimiter->clear($action, $scope);
}
}
private function registerFailure(string $action, string $identifier = ''): void
{
$pdo = $this->database->pdo();
$rateLimiter = $this->rateLimiter($pdo);
foreach ($this->buildScopes($action, $identifier) as $scope) {
$rateLimiter->hit($action, $scope);
}
}
private function normalizePasswordResetScope(string $scope): string
{
return trim($scope) === 'tenant' ? 'tenant' : 'platform';
}
private function passwordResetTenant(string $scope, string $tenantSlug): ?array
{
$tenantSlug = trim($tenantSlug);
if ($scope !== 'tenant' || $tenantSlug === '' || !$this->database->isConfigured()) {
return null;
}
$tenant = (new TenantContext($this->database->pdo()))->bySlug($tenantSlug);
return is_array($tenant) ? $tenant : null;
}
private function passwordResetContext(string $scope, ?array $tenant, string $tenantSlug): array
{
$context = ['scope' => $scope];
$resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
if ($scope === 'tenant' && is_string($resolvedSlug) && $resolvedSlug !== '') {
$context['tenant'] = $resolvedSlug;
}
return $context;
}
private function passwordResetRequestUrl(string $scope, ?array $tenant, string $tenantSlug): string
{
$query = http_build_query($this->passwordResetContext($scope, $tenant, $tenantSlug), '', '&', PHP_QUERY_RFC3986);
return '/password/forgot' . ($query !== '' ? '?' . $query : '');
}
private function passwordResetConfirmUrl(string $token, string $scope, ?array $tenant, string $tenantSlug): string
{
$query = $this->passwordResetContext($scope, $tenant, $tenantSlug);
if (trim($token) !== '') {
$query['token'] = $token;
}
$encoded = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
return '/reset-password' . ($encoded !== '' ? '?' . $encoded : '');
}
private function passwordResetLoginUrl(string $scope, ?array $tenant, string $tenantSlug): string
{
if ($scope === 'tenant') {
$resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
if (is_string($resolvedSlug) && $resolvedSlug !== '') {
return tenant_url($resolvedSlug, 'login');
}
}
return '/admin/login';
}
}
File diff suppressed because it is too large Load Diff
-147
View File
@@ -1,147 +0,0 @@
<?php
namespace App\Core;
use PDO;
final class Auth
{
public function __construct(
private readonly PDO $pdo,
private readonly Session $session
) {
}
public function attemptPlatformLogin(string $email, string $password): bool
{
$statement = $this->pdo->prepare(
'SELECT id, password_hash, platform_role FROM users WHERE email = :email LIMIT 1'
);
$statement->execute(['email' => mb_strtolower($email)]);
$user = $statement->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
if (($user['platform_role'] ?? 'user') !== 'platform_admin') {
return false;
}
$this->rehashPasswordIfNeeded((int) $user['id'], (string) $user['password_hash'], $password);
$this->session->regenerate();
$this->session->put('auth', [
'user_id' => (int) $user['id'],
'platform_role' => $user['platform_role'],
]);
return true;
}
public function attemptTenantLogin(string $tenantSlug, string $email, string $password): bool
{
$statement = $this->pdo->prepare(
'SELECT u.id,
u.password_hash,
m.tenant_id,
m.role,
t.slug AS tenant_slug,
member.id AS member_id
FROM users u
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
LEFT JOIN members member
ON member.user_id = u.id
AND member.tenant_id = m.tenant_id
AND member.status = "active"
WHERE u.email = :email
LIMIT 1'
);
$statement->execute([
'slug' => $tenantSlug,
'email' => mb_strtolower($email),
]);
$membership = $statement->fetch();
if (!$membership || !password_verify($password, $membership['password_hash'])) {
return false;
}
$this->rehashPasswordIfNeeded((int) $membership['user_id'], (string) $membership['password_hash'], $password);
$this->session->regenerate();
$this->session->put('auth', [
'user_id' => (int) $membership['user_id'],
'tenant_id' => (int) $membership['tenant_id'],
'tenant_slug' => $membership['tenant_slug'],
'tenant_role' => $membership['role'],
'member_id' => isset($membership['member_id']) ? (int) $membership['member_id'] : null,
'platform_role' => 'user',
]);
return true;
}
public function user(): ?array
{
$auth = $this->session->get('auth');
return is_array($auth) ? $auth : null;
}
public function checkPlatformAdmin(): bool
{
return ($this->user()['platform_role'] ?? null) === 'platform_admin';
}
public function checkTenantAccess(string $tenantSlug): bool
{
$auth = $this->user();
return is_array($auth)
&& isset($auth['tenant_slug'])
&& hash_equals($auth['tenant_slug'], $tenantSlug);
}
public function requireRole(array $allowedRoles): bool
{
$role = $this->user()['tenant_role'] ?? null;
return is_string($role) && in_array($role, $allowedRoles, true);
}
public function tenantMemberId(): ?int
{
$memberId = $this->user()['member_id'] ?? null;
return is_int($memberId) ? $memberId : null;
}
public function canManageTenant(): bool
{
$role = $this->user()['tenant_role'] ?? null;
return is_string($role) && in_array($role, ['owner', 'admin'], true);
}
public function logout(): void
{
$this->session->invalidate();
}
private function rehashPasswordIfNeeded(int $userId, string $currentHash, string $password): void
{
if (!password_needs_rehash($currentHash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT)) {
return;
}
$statement = $this->pdo->prepare(
'UPDATE users SET password_hash = :password_hash WHERE id = :id'
);
$statement->execute([
'password_hash' => secure_password_hash($password),
'id' => $userId,
]);
}
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace App\Core;
final class Autoloader
{
public static function register(string $rootPath): void
{
spl_autoload_register(static function (string $class) use ($rootPath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = $rootPath . '/app/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace App\Core;
final class Csrf
{
public function __construct(private readonly Session $session)
{
}
public function token(): string
{
$token = $this->session->get('_csrf_token');
if (!is_string($token) || $token === '') {
$token = bin2hex(random_bytes(32));
$this->session->put('_csrf_token', $token);
}
return $token;
}
public function validate(?string $token): bool
{
$expected = $this->session->get('_csrf_token');
return is_string($expected) && is_string($token) && hash_equals($expected, $token);
}
}
-51
View File
@@ -1,51 +0,0 @@
<?php
namespace App\Core;
use PDO;
use PDOException;
final class Database implements DatabaseInterface
{
private ?PDO $pdo = null;
public function __construct(private readonly array $config)
{
}
public function pdo(): PDO
{
if ($this->pdo instanceof PDO) {
return $this->pdo;
}
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config['host'],
$this->config['port'],
$this->config['name'],
$this->config['charset']
);
try {
$this->pdo = new PDO($dsn, $this->config['user'], $this->config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $exception) {
throw new PDOException(
'Database connection failed: ' . $exception->getMessage(),
(int) $exception->getCode(),
$exception
);
}
return $this->pdo;
}
public function isConfigured(): bool
{
return $this->config['name'] !== '' && $this->config['user'] !== '';
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
/**
* Gemeinsames Interface für Database (MySQL) und DatabaseSqlite.
* Ermöglicht den Betrieb beider Backends ohne Type-Errors.
*/
interface DatabaseInterface
{
public function pdo(): PDO;
public function isConfigured(): bool;
}
-123
View File
@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use PDOException;
use RuntimeException;
/**
* SQLite-basierte Datenbank für Test-/Entwicklungsumgebungen.
* Ersetzt die MySQL-Database-Klasse und emuliert MySQL-spezifische Features.
*/
final class DatabaseSqlite implements DatabaseInterface
{
private ?PDO $pdo = null;
private readonly string $dbPath;
public function __construct(private readonly array $config = [])
{
$this->dbPath = $this->config['path']
?? dirname(__DIR__, 2) . '/storage/database.sqlite';
}
public function pdo(): PDO
{
if ($this->pdo instanceof PDO) {
return $this->pdo;
}
$dbDir = dirname($this->dbPath);
if (!is_dir($dbDir) && !mkdir($dbDir, 0775, true) && !is_dir($dbDir)) {
throw new RuntimeException('Das Datenbank-Verzeichnis konnte nicht erstellt werden: ' . $dbDir);
}
try {
$this->pdo = new PDO(
'sqlite:' . $this->dbPath,
null,
null,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$this->pdo->exec('PRAGMA journal_mode=WAL');
$this->pdo->exec('PRAGMA foreign_keys=ON');
$this->pdo->exec('PRAGMA busy_timeout=5000');
} catch (PDOException $exception) {
throw new PDOException(
'SQLite connection failed: ' . $exception->getMessage(),
(int) $exception->getCode(),
$exception
);
}
return $this->pdo;
}
public function isConfigured(): bool
{
return $this->dbPath !== '';
}
/**
* Führt schema.sqlite.sql aus, um die Datenbank zu initialisieren.
*/
public function initializeSchema(): void
{
$schemaFile = dirname(__DIR__, 2) . '/database/schema.sqlite.sql';
if (!is_file($schemaFile)) {
throw new RuntimeException('SQLite-Schema nicht gefunden: ' . $schemaFile);
}
$schema = file_get_contents($schemaFile);
if (!is_string($schema) || $schema === '') {
throw new RuntimeException('SQLite-Schema konnte nicht geladen werden.');
}
$this->pdo()->exec($schema);
}
/**
* SQLite-kompatibler UPSERT: INSERT OR REPLACE + SELECT als Alternative zu ON DUPLICATE KEY.
*/
public function upsertRateLimit(string $actionKey, string $bucketKey, string $now): void
{
$pdo = $this->pdo();
$existing = $pdo->prepare(
'SELECT id FROM rate_limits WHERE action_key = :action_key AND bucket_key = :bucket_key LIMIT 1'
);
$existing->execute([
'action_key' => $actionKey,
'bucket_key' => $bucketKey,
]);
if (!$existing->fetchColumn()) {
$insert = $pdo->prepare(
'INSERT INTO rate_limits (action_key, bucket_key, attempts, window_start, last_attempt_at, created_at)
VALUES (:action_key, :bucket_key, 0, :window_start, :last_attempt_at, :created_at)'
);
$insert->execute([
'action_key' => $actionKey,
'bucket_key' => $bucketKey,
'window_start' => $now,
'last_attempt_at' => $now,
'created_at' => $now,
]);
}
}
public function getDbPath(): string
{
return $this->dbPath;
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
namespace App\Core;
final class Env
{
public static function load(string $file): void
{
if (!is_file($file)) {
return;
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
continue;
}
[$name, $value] = array_pad(explode('=', $trimmed, 2), 2, '');
$name = trim($name);
$value = trim($value);
if ($value !== '' && ($value[0] === '"' || $value[0] === '\'')) {
$value = trim($value, "\"'");
}
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Core;
final class Response
{
public static function redirect(string $url): never
{
if (str_starts_with($url, '/') && function_exists('url')) {
$url = \url($url);
} else {
$prefix = $_SERVER['KAFFEEKASSE_PROXY_PREFIX'] ?? '';
if ($prefix !== '' && str_starts_with($url, '/') && !str_starts_with($url, $prefix)) {
$url = $prefix . $url;
}
}
header('Location: ' . $url);
exit;
}
public static function notFound(string $message = 'Seite nicht gefunden.'): void
{
http_response_code(404);
echo $message;
}
}
-62
View File
@@ -1,62 +0,0 @@
<?php
namespace App\Core;
final class Session
{
public function start(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'httponly' => true,
'samesite' => 'Lax',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
'path' => '/',
]);
session_start();
}
}
public function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}
public function put(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}
public function forget(string $key): void
{
unset($_SESSION[$key]);
}
public function flash(string $key, mixed $value): void
{
$_SESSION['_flash'][$key] = $value;
}
public function pullFlash(string $key, mixed $default = null): mixed
{
$value = $_SESSION['_flash'][$key] ?? $default;
unset($_SESSION['_flash'][$key]);
return $value;
}
public function regenerate(): void
{
session_regenerate_id(true);
}
public function invalidate(): void
{
$_SESSION = [];
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
}
}
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace App\Core;
use PDO;
final class TenantContext
{
public function __construct(private readonly PDO $pdo)
{
}
public function bySlug(string $slug): ?array
{
$statement = $this->pdo->prepare(
'SELECT * FROM tenants WHERE slug = :slug LIMIT 1'
);
$statement->execute(['slug' => $slug]);
$tenant = $statement->fetch();
return $tenant ?: null;
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
namespace App\Core;
final class View
{
public function __construct(
private readonly string $rootPath,
private readonly array $shared = []
) {
}
public function render(string $template, array $data = []): void
{
$templateFile = $this->rootPath . '/resources/views/' . $template . '.php';
if (!is_file($templateFile)) {
http_response_code(500);
echo 'View not found: ' . htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
return;
}
$shared = $this->shared;
extract($shared, EXTR_SKIP);
extract($data, EXTR_SKIP);
require $templateFile;
}
}
-65
View File
@@ -1,65 +0,0 @@
<?php
namespace App\Services;
use PDO;
final class AuditService
{
public function __construct(private readonly PDO $pdo)
{
}
public function log(
?int $tenantId,
?int $actorUserId,
string $action,
string $targetType,
?int $targetId,
string $result,
array $meta = []
): void {
$statement = $this->pdo->prepare(
'INSERT INTO audit_events (
tenant_id,
actor_user_id,
action,
target_type,
target_id,
result,
request_id,
ip_hash,
user_agent_hash,
meta_json
) VALUES (
:tenant_id,
:actor_user_id,
:action,
:target_type,
:target_id,
:result,
:request_id,
:ip_hash,
:user_agent_hash,
:meta_json
)'
);
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(18));
$statement->execute([
'tenant_id' => $tenantId,
'actor_user_id' => $actorUserId,
'action' => $action,
'target_type' => $targetType,
'target_id' => $targetId,
'result' => $result,
'request_id' => substr($requestId, 0, 36),
'ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
'user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
'meta_json' => $meta !== [] ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
]);
}
}
-408
View File
@@ -1,408 +0,0 @@
<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class ExportService
{
private const CSV_DELIMITER = ';';
private const FLUSH_EVERY_ROWS = 250;
public function __construct(private readonly PDO $pdo)
{
}
public function streamMemberBalancesCsv(int $tenantId, ?string $filename = null): void
{
$this->assertTenantId($tenantId);
$statement = $this->pdo->prepare(
'SELECT
m.id AS member_id,
m.display_name,
m.email,
m.status,
m.created_at,
COALESCE(balance_summary.balance_cents, 0) AS balance_cents,
balance_summary.last_occurred_at
FROM members m
LEFT JOIN (
SELECT
ll.member_id,
SUM(ll.amount_cents) AS balance_cents,
MAX(le.occurred_at) AS last_occurred_at
FROM ledger_lines ll
INNER JOIN ledger_entries le
ON le.id = ll.ledger_entry_id
AND le.tenant_id = ll.tenant_id
WHERE ll.tenant_id = :balance_tenant_id
AND ll.account_code = "member_balance"
AND ll.member_id IS NOT NULL
GROUP BY ll.member_id
) balance_summary
ON balance_summary.member_id = m.id
WHERE m.tenant_id = :tenant_id
ORDER BY m.display_name ASC, m.id ASC'
);
$statement->execute([
'balance_tenant_id' => $tenantId,
'tenant_id' => $tenantId,
]);
$handle = $this->openCsvOutput($filename ?? $this->defaultFilename('member-balances', $tenantId));
try {
$this->writeCsvRow($handle, [
'mitglied_id',
'anzeigename',
'email',
'status',
'saldo_cents',
'saldo_eur',
'letzte_buchung_am',
'angelegt_am',
]);
$rowCount = 0;
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
$balanceCents = (int) $row['balance_cents'];
$this->writeCsvRow($handle, [
(int) $row['member_id'],
(string) $row['display_name'],
$row['email'],
(string) $row['status'],
$balanceCents,
$this->formatMoneyFromCents($balanceCents),
$row['last_occurred_at'],
(string) $row['created_at'],
]);
$rowCount++;
$this->flushIfNeeded($rowCount);
}
} finally {
fclose($handle);
}
}
public function streamBookingsCsv(
int $tenantId,
?int $memberId = null,
?string $from = null,
?string $until = null,
?string $filename = null
): void {
$this->assertTenantId($tenantId);
if ($memberId !== null && $memberId <= 0) {
throw new RuntimeException('Die Mitglieds-ID muss groesser als 0 sein.');
}
$from = $this->normalizeDateTimeFilter($from, false);
$until = $this->normalizeDateTimeFilter($until, true);
$sql = 'SELECT
le.id AS booking_id,
le.type,
le.reference_type,
le.reference_id,
le.description,
le.occurred_at,
le.created_at AS entry_created_at,
le.member_id,
m.display_name AS member_name,
s.code AS source_code,
s.name AS source_name,
COALESCE(line_totals.member_balance_cents, 0) AS member_balance_cents,
COALESCE(line_totals.cashbox_cents, 0) AS cashbox_cents,
COALESCE(line_totals.coffee_revenue_cents, 0) AS coffee_revenue_cents,
c.id AS consumption_event_id,
c.quantity,
c.unit_price_cents,
c.total_cents,
c.effective_at,
c.recorded_at,
c.source_reference,
c.notes,
c.paper_sheet_line_id,
p.id AS product_id,
p.name AS product_name
FROM ledger_entries le
LEFT JOIN members m
ON m.id = le.member_id
AND m.tenant_id = le.tenant_id
LEFT JOIN capture_sources s
ON s.id = le.source_id
AND s.tenant_id = le.tenant_id
LEFT JOIN (
SELECT
ledger_entry_id,
SUM(CASE WHEN account_code = "member_balance" THEN amount_cents ELSE 0 END) AS member_balance_cents,
SUM(CASE WHEN account_code = "cashbox" THEN amount_cents ELSE 0 END) AS cashbox_cents,
SUM(CASE WHEN account_code = "coffee_revenue" THEN amount_cents ELSE 0 END) AS coffee_revenue_cents
FROM ledger_lines
WHERE tenant_id = :line_tenant_id
GROUP BY ledger_entry_id
) line_totals
ON line_totals.ledger_entry_id = le.id
LEFT JOIN consumption_events c
ON c.id = le.reference_id
AND c.tenant_id = le.tenant_id
AND le.reference_type = "consumption_event"
LEFT JOIN products p
ON p.id = c.product_id
AND p.tenant_id = c.tenant_id
WHERE le.tenant_id = :tenant_id';
$params = [
'line_tenant_id' => $tenantId,
'tenant_id' => $tenantId,
];
if ($memberId !== null) {
$sql .= ' AND le.member_id = :member_id';
$params['member_id'] = $memberId;
}
if ($from !== null) {
$sql .= ' AND le.occurred_at >= :from_occurred_at';
$params['from_occurred_at'] = $from;
}
if ($until !== null) {
$sql .= ' AND le.occurred_at <= :until_occurred_at';
$params['until_occurred_at'] = $until;
}
$sql .= ' ORDER BY le.occurred_at DESC, le.id DESC';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
$handle = $this->openCsvOutput($filename ?? $this->defaultFilename('bookings', $tenantId));
try {
$this->writeCsvRow($handle, [
'buchung_id',
'typ',
'buchungsdatum',
'mitglied_id',
'mitglied_name',
'beschreibung',
'quellcode',
'quelle',
'referenz_typ',
'referenz_id',
'saldo_delta_cents',
'saldo_delta_eur',
'kasse_delta_cents',
'kasse_delta_eur',
'umsatz_delta_cents',
'umsatz_delta_eur',
'produkt_id',
'produkt_name',
'menge',
'einzelpreis_cents',
'einzelpreis_eur',
'gesamt_cents',
'gesamt_eur',
'notizen',
'wirksam_am',
'erfasst_am',
'quellreferenz',
'papierlisten_zeile_id',
'angelegt_am',
]);
$rowCount = 0;
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
$memberBalanceCents = (int) $row['member_balance_cents'];
$cashboxCents = (int) $row['cashbox_cents'];
$coffeeRevenueCents = (int) $row['coffee_revenue_cents'];
$unitPriceCents = $row['unit_price_cents'] !== null ? (int) $row['unit_price_cents'] : null;
$totalCents = $row['total_cents'] !== null ? (int) $row['total_cents'] : null;
$recordedAt = $row['recorded_at'] ?? $row['entry_created_at'];
$this->writeCsvRow($handle, [
(int) $row['booking_id'],
(string) $row['type'],
(string) $row['occurred_at'],
$row['member_id'] !== null ? (int) $row['member_id'] : '',
$row['member_name'],
(string) $row['description'],
$row['source_code'],
$row['source_name'],
$row['reference_type'],
$row['reference_id'],
$memberBalanceCents,
$this->formatMoneyFromCents($memberBalanceCents),
$cashboxCents,
$this->formatMoneyFromCents($cashboxCents),
$coffeeRevenueCents,
$this->formatMoneyFromCents($coffeeRevenueCents),
$row['product_id'] !== null ? (int) $row['product_id'] : '',
$row['product_name'],
$this->formatQuantity($row['quantity']),
$unitPriceCents ?? '',
$unitPriceCents !== null ? $this->formatMoneyFromCents($unitPriceCents) : '',
$totalCents ?? '',
$totalCents !== null ? $this->formatMoneyFromCents($totalCents) : '',
$row['notes'],
$row['effective_at'] ?? $row['occurred_at'],
$recordedAt,
$row['source_reference'],
$row['paper_sheet_line_id'],
(string) $row['entry_created_at'],
]);
$rowCount++;
$this->flushIfNeeded($rowCount);
}
} finally {
fclose($handle);
}
}
private function assertTenantId(int $tenantId): void
{
if ($tenantId <= 0) {
throw new RuntimeException('Die Standort-ID muss größer als 0 sein.');
}
}
private function defaultFilename(string $prefix, int $tenantId): string
{
return sprintf('%s-tenant-%d-%s.csv', $prefix, $tenantId, gmdate('Ymd-His'));
}
private function openCsvOutput(string $filename)
{
if (headers_sent($file, $line)) {
throw new RuntimeException(sprintf('Export-Header können nicht mehr gesendet werden (%s:%d).', $file, $line));
}
$safeFilename = $this->sanitizeFilename($filename);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . rawurlencode($safeFilename));
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Accel-Buffering: no');
header('X-Content-Type-Options: nosniff');
$handle = fopen('php://output', 'wb');
if ($handle === false) {
throw new RuntimeException('Die Exportausgabe konnte nicht geöffnet werden.');
}
fwrite($handle, "\xEF\xBB\xBF");
return $handle;
}
private function sanitizeFilename(string $filename): string
{
$sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($filename)) ?? '';
$sanitized = trim($sanitized, '.-');
if ($sanitized === '') {
$sanitized = 'export';
}
if (!str_ends_with(strtolower($sanitized), '.csv')) {
$sanitized .= '.csv';
}
return $sanitized;
}
private function writeCsvRow($handle, array $row): void
{
$values = array_map(function (mixed $value): string {
return $this->sanitizeCsvValue($value);
}, $row);
if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) {
throw new RuntimeException('Die Exportzeile konnte nicht geschrieben werden.');
}
}
private function sanitizeCsvValue(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
$string = str_replace(["\r\n", "\r"], "\n", (string) $value);
// Prevent spreadsheet formula execution for user-controlled text cells.
if (
$string !== ''
&& in_array($string[0], ['=', '+', '-', '@', "\t"], true)
&& !is_numeric($string)
) {
return "'" . $string;
}
return $string;
}
private function formatMoneyFromCents(int $amountCents): string
{
return number_format($amountCents / 100, 2, ',', '.');
}
private function formatQuantity(mixed $quantity): string
{
if ($quantity === null || $quantity === '') {
return '';
}
return rtrim(rtrim(number_format((float) $quantity, 2, '.', ''), '0'), '.');
}
private function normalizeDateTimeFilter(?string $value, bool $endOfDay): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$value = str_replace('T', ' ', $value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00');
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) {
return $value . ':00';
}
return $value;
}
private function flushIfNeeded(int $rowCount): void
{
if ($rowCount % self::FLUSH_EVERY_ROWS !== 0) {
return;
}
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}
-748
View File
@@ -1,748 +0,0 @@
<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class LedgerService
{
public function __construct(
private readonly PDO $pdo,
private readonly AuditService $audit
) {
}
public function createConsumption(
int $tenantId,
int $memberId,
int $productId,
string $sourceCode,
string $effectiveAt,
int $recordedByUserId,
float $quantity,
?string $notes = null,
?int $paperSheetLineId = null,
?string $sourceReference = null
): void {
if ($quantity <= 0) {
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
}
$this->assertTenantMembership($tenantId, $recordedByUserId);
$this->assertTenantMember($tenantId, $memberId);
$this->assertTenantProduct($tenantId, $productId);
$source = $this->findSource($tenantId, $sourceCode);
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
$totalCents = (int) round($priceCents * $quantity);
$description = sprintf('Verbrauch %s x #%d', rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'), $productId);
$recordedAt = gmdate('Y-m-d H:i:s');
$this->pdo->beginTransaction();
try {
$eventInsert = $this->pdo->prepare(
'INSERT INTO consumption_events (
tenant_id,
member_id,
product_id,
source_id,
paper_sheet_line_id,
quantity,
unit_price_cents,
total_cents,
effective_at,
recorded_at,
recorded_by_user_id,
source_reference,
notes
) VALUES (
:tenant_id,
:member_id,
:product_id,
:source_id,
:paper_sheet_line_id,
:quantity,
:unit_price_cents,
:total_cents,
:effective_at,
:recorded_at,
:recorded_by_user_id,
:source_reference,
:notes
)'
);
$eventInsert->execute([
'tenant_id' => $tenantId,
'member_id' => $memberId,
'product_id' => $productId,
'source_id' => $source['id'],
'paper_sheet_line_id' => $paperSheetLineId,
'quantity' => $quantity,
'unit_price_cents' => $priceCents,
'total_cents' => $totalCents,
'effective_at' => $effectiveAt,
'recorded_at' => $recordedAt,
'recorded_by_user_id' => $recordedByUserId,
'source_reference' => $sourceReference,
'notes' => $notes,
]);
$eventId = (int) $this->pdo->lastInsertId();
$entryInsert = $this->pdo->prepare(
'INSERT INTO ledger_entries (
tenant_id,
member_id,
source_id,
type,
reference_type,
reference_id,
description,
occurred_at,
created_by_user_id
) VALUES (
:tenant_id,
:member_id,
:source_id,
"consumption",
"consumption_event",
:reference_id,
:description,
:occurred_at,
:created_by_user_id
)'
);
$entryInsert->execute([
'tenant_id' => $tenantId,
'member_id' => $memberId,
'source_id' => $source['id'],
'reference_id' => $eventId,
'description' => $description,
'occurred_at' => $effectiveAt,
'created_by_user_id' => $recordedByUserId,
]);
$entryId = (int) $this->pdo->lastInsertId();
$lineInsert = $this->pdo->prepare(
'INSERT INTO ledger_lines (
ledger_entry_id,
tenant_id,
member_id,
account_code,
amount_cents
) VALUES (
:ledger_entry_id,
:tenant_id,
:member_id,
:account_code,
:amount_cents
)'
);
$lineInsert->execute([
'ledger_entry_id' => $entryId,
'tenant_id' => $tenantId,
'member_id' => $memberId,
'account_code' => 'member_balance',
'amount_cents' => $totalCents,
]);
$lineInsert->execute([
'ledger_entry_id' => $entryId,
'tenant_id' => $tenantId,
'member_id' => null,
'account_code' => 'coffee_revenue',
'amount_cents' => -$totalCents,
]);
$this->pdo->commit();
} catch (\Throwable $throwable) {
$this->pdo->rollBack();
throw $throwable;
}
$this->audit->log($tenantId, $recordedByUserId, 'consumption.created', 'consumption_event', $eventId, 'success', [
'member_id' => $memberId,
'product_id' => $productId,
'quantity' => $quantity,
'source' => $sourceCode,
'total_cents' => $totalCents,
]);
}
public function createPayment(
int $tenantId,
int $memberId,
int $amountCents,
string $occurredAt,
int $recordedByUserId,
?string $notes = null
): void {
if ($amountCents <= 0) {
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
}
$this->assertTenantMembership($tenantId, $recordedByUserId);
$this->assertTenantMember($tenantId, $memberId);
$source = $this->findSource($tenantId, 'admin_backoffice');
$entryInsert = $this->pdo->prepare(
'INSERT INTO ledger_entries (
tenant_id,
member_id,
source_id,
type,
description,
occurred_at,
created_by_user_id,
reference_type
) VALUES (
:tenant_id,
:member_id,
:source_id,
"payment",
:description,
:occurred_at,
:created_by_user_id,
"payment"
)'
);
$this->pdo->beginTransaction();
try {
$entryInsert->execute([
'tenant_id' => $tenantId,
'member_id' => $memberId,
'source_id' => $source['id'],
'description' => $notes !== null && trim($notes) !== '' ? $notes : 'Einzahlung',
'occurred_at' => $occurredAt,
'created_by_user_id' => $recordedByUserId,
]);
$entryId = (int) $this->pdo->lastInsertId();
$lineInsert = $this->pdo->prepare(
'INSERT INTO ledger_lines (
ledger_entry_id,
tenant_id,
member_id,
account_code,
amount_cents
) VALUES (
:ledger_entry_id,
:tenant_id,
:member_id,
:account_code,
:amount_cents
)'
);
$lineInsert->execute([
'ledger_entry_id' => $entryId,
'tenant_id' => $tenantId,
'member_id' => null,
'account_code' => 'cashbox',
'amount_cents' => $amountCents,
]);
$lineInsert->execute([
'ledger_entry_id' => $entryId,
'tenant_id' => $tenantId,
'member_id' => $memberId,
'account_code' => 'member_balance',
'amount_cents' => -$amountCents,
]);
$this->pdo->commit();
} catch (\Throwable $throwable) {
$this->pdo->rollBack();
throw $throwable;
}
$this->audit->log($tenantId, $recordedByUserId, 'payment.created', 'ledger_entry', $entryId, 'success', [
'member_id' => $memberId,
'amount_cents' => $amountCents,
]);
}
public function createPaperSheet(
int $tenantId,
string $title,
?string $periodLabel,
?string $periodStart,
?string $periodEnd,
?string $notes,
int $createdByUserId
): int {
$this->assertTenantMembership($tenantId, $createdByUserId);
if (trim($title) === '') {
throw new RuntimeException('Bitte einen Titel für die Liste angeben.');
}
$statement = $this->pdo->prepare(
'INSERT INTO paper_sheets (
tenant_id,
title,
period_label,
period_start,
period_end,
notes,
created_by_user_id
) VALUES (
:tenant_id,
:title,
:period_label,
:period_start,
:period_end,
:notes,
:created_by_user_id
)'
);
$statement->execute([
'tenant_id' => $tenantId,
'title' => $title,
'period_label' => $periodLabel,
'period_start' => $periodStart ?: null,
'period_end' => $periodEnd ?: null,
'notes' => $notes,
'created_by_user_id' => $createdByUserId,
]);
$sheetId = (int) $this->pdo->lastInsertId();
$this->audit->log($tenantId, $createdByUserId, 'paper_sheet.created', 'paper_sheet', $sheetId, 'success');
return $sheetId;
}
public function addPaperSheetLine(
int $tenantId,
int $sheetId,
int $memberId,
int $productId,
float $quantity,
?string $effectiveAt,
?string $note,
int $actorUserId
): void {
if ($quantity <= 0) {
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
}
$this->assertTenantMembership($tenantId, $actorUserId);
$this->assertTenantMember($tenantId, $memberId);
$this->assertTenantProduct($tenantId, $productId);
$sheet = $this->pdo->prepare(
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
);
$sheet->execute([
'id' => $sheetId,
'tenant_id' => $tenantId,
]);
$paperSheet = $sheet->fetch();
if (!$paperSheet) {
throw new RuntimeException('Die Liste wurde nicht gefunden.');
}
if ($paperSheet['status'] !== 'draft') {
throw new RuntimeException('Nur Entwürfe können erweitert werden.');
}
$statement = $this->pdo->prepare(
'INSERT INTO paper_sheet_lines (
paper_sheet_id,
tenant_id,
member_id,
product_id,
quantity,
effective_at,
note
) VALUES (
:paper_sheet_id,
:tenant_id,
:member_id,
:product_id,
:quantity,
:effective_at,
:note
)'
);
$statement->execute([
'paper_sheet_id' => $sheetId,
'tenant_id' => $tenantId,
'member_id' => $memberId,
'product_id' => $productId,
'quantity' => $quantity,
'effective_at' => $effectiveAt ?: null,
'note' => $note,
]);
$lineId = (int) $this->pdo->lastInsertId();
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.line_added', 'paper_sheet_line', $lineId, 'success', [
'paper_sheet_id' => $sheetId,
]);
}
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
{
$this->assertTenantMembership($tenantId, $actorUserId);
$sheetStatement = $this->pdo->prepare(
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
);
$sheetStatement->execute([
'id' => $sheetId,
'tenant_id' => $tenantId,
]);
$sheet = $sheetStatement->fetch();
if (!$sheet) {
throw new RuntimeException('Die Liste wurde nicht gefunden.');
}
if ($sheet['status'] !== 'draft') {
throw new RuntimeException('Die Liste wurde bereits verbucht.');
}
$linesStatement = $this->pdo->prepare(
'SELECT * FROM paper_sheet_lines WHERE paper_sheet_id = :paper_sheet_id AND tenant_id = :tenant_id ORDER BY id ASC'
);
$linesStatement->execute([
'paper_sheet_id' => $sheetId,
'tenant_id' => $tenantId,
]);
$lines = $linesStatement->fetchAll();
if ($lines === []) {
throw new RuntimeException('Die Liste enthält noch keine Positionen.');
}
foreach ($lines as $line) {
$this->createConsumption(
$tenantId,
(int) $line['member_id'],
(int) $line['product_id'],
'paper_sheet',
$line['effective_at'] ?: gmdate('Y-m-d H:i:s'),
$actorUserId,
(float) $line['quantity'],
$line['note'] ?: 'Liste',
(int) $line['id'],
'sheet:' . $sheetId
);
}
$update = $this->pdo->prepare(
'UPDATE paper_sheets SET status = "posted", posted_at = :posted_at WHERE id = :id AND tenant_id = :tenant_id'
);
$update->execute([
'posted_at' => gmdate('Y-m-d H:i:s'),
'id' => $sheetId,
'tenant_id' => $tenantId,
]);
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
}
public function reverseLedgerEntry(int $tenantId, int $ledgerEntryId, int $actorUserId, ?string $reason = null): int
{
$this->assertTenantMembership($tenantId, $actorUserId);
$entry = $this->ledgerEntry($tenantId, $ledgerEntryId);
if ($this->hasReversal($tenantId, $ledgerEntryId)) {
throw new RuntimeException('Dieser Vorgang wurde bereits storniert.');
}
$linesStatement = $this->pdo->prepare(
'SELECT member_id, account_code, amount_cents
FROM ledger_lines
WHERE tenant_id = :tenant_id AND ledger_entry_id = :ledger_entry_id
ORDER BY id ASC'
);
$linesStatement->execute([
'tenant_id' => $tenantId,
'ledger_entry_id' => $ledgerEntryId,
]);
$lines = $linesStatement->fetchAll();
if ($lines === []) {
throw new RuntimeException('Es wurden keine Buchungszeilen für den Vorgang gefunden.');
}
$description = 'Storno: ' . $entry['description'];
if ($reason !== null && trim($reason) !== '') {
$description .= ' (' . trim($reason) . ')';
}
$this->pdo->beginTransaction();
try {
$entryInsert = $this->pdo->prepare(
'INSERT INTO ledger_entries (
tenant_id,
member_id,
source_id,
type,
reference_type,
reference_id,
description,
occurred_at,
created_by_user_id,
reversal_of_entry_id
) VALUES (
:tenant_id,
:member_id,
:source_id,
"correction",
"ledger_reversal",
:reference_id,
:description,
:occurred_at,
:created_by_user_id,
:reversal_of_entry_id
)'
);
$entryInsert->execute([
'tenant_id' => $tenantId,
'member_id' => $entry['member_id'],
'source_id' => $entry['source_id'],
'reference_id' => $ledgerEntryId,
'description' => $description,
'occurred_at' => gmdate('Y-m-d H:i:s'),
'created_by_user_id' => $actorUserId,
'reversal_of_entry_id' => $ledgerEntryId,
]);
$reversalId = (int) $this->pdo->lastInsertId();
$lineInsert = $this->pdo->prepare(
'INSERT INTO ledger_lines (
ledger_entry_id,
tenant_id,
member_id,
account_code,
amount_cents
) VALUES (
:ledger_entry_id,
:tenant_id,
:member_id,
:account_code,
:amount_cents
)'
);
foreach ($lines as $line) {
$lineInsert->execute([
'ledger_entry_id' => $reversalId,
'tenant_id' => $tenantId,
'member_id' => $line['member_id'] !== null ? (int) $line['member_id'] : null,
'account_code' => $line['account_code'],
'amount_cents' => 0 - (int) $line['amount_cents'],
]);
}
$this->pdo->commit();
} catch (\Throwable $throwable) {
$this->pdo->rollBack();
throw $throwable;
}
$this->audit->log($tenantId, $actorUserId, 'ledger_entry.reversed', 'ledger_entry', $reversalId, 'success', [
'reversal_of_entry_id' => $ledgerEntryId,
]);
return $reversalId;
}
public function reversePaperSheet(int $tenantId, int $sheetId, int $actorUserId, ?string $reason = null): int
{
$this->assertTenantMembership($tenantId, $actorUserId);
$statement = $this->pdo->prepare(
'SELECT DISTINCT le.id
FROM ledger_entries le
INNER JOIN consumption_events ce
ON ce.id = le.reference_id
AND le.reference_type = "consumption_event"
INNER JOIN paper_sheet_lines psl
ON psl.id = ce.paper_sheet_line_id
WHERE le.tenant_id = :tenant_id
AND psl.paper_sheet_id = :paper_sheet_id
ORDER BY le.id ASC'
);
$statement->execute([
'tenant_id' => $tenantId,
'paper_sheet_id' => $sheetId,
]);
$entryIds = $statement->fetchAll(PDO::FETCH_COLUMN);
if ($entryIds === []) {
throw new RuntimeException('Zu dieser Liste wurden keine verbuchten Einträge gefunden.');
}
$reversed = 0;
foreach ($entryIds as $entryId) {
if ($this->hasReversal($tenantId, (int) $entryId)) {
continue;
}
$this->reverseLedgerEntry(
$tenantId,
(int) $entryId,
$actorUserId,
$reason !== null && trim($reason) !== '' ? $reason : 'Liste #' . $sheetId
);
$reversed++;
}
if ($reversed === 0) {
throw new RuntimeException('Diese Liste wurde bereits vollständig storniert.');
}
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [
'reversed_entries' => $reversed,
]);
return $reversed;
}
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
{
$statement = $this->pdo->prepare(
'SELECT price_cents
FROM product_prices
WHERE tenant_id = :tenant_id
AND product_id = :product_id
AND valid_from <= :effective_at
AND (valid_until IS NULL OR valid_until > :effective_at)
ORDER BY valid_from DESC
LIMIT 1'
);
$statement->execute([
'tenant_id' => $tenantId,
'product_id' => $productId,
'effective_at' => $effectiveAt,
]);
$price = $statement->fetchColumn();
if ($price === false) {
throw new RuntimeException('Für das Produkt ist kein aktiver Preis hinterlegt.');
}
return (int) $price;
}
private function findSource(int $tenantId, string $code): array
{
$statement = $this->pdo->prepare(
'SELECT id, code FROM capture_sources WHERE tenant_id = :tenant_id AND code = :code LIMIT 1'
);
$statement->execute([
'tenant_id' => $tenantId,
'code' => $code,
]);
$source = $statement->fetch();
if (!$source) {
throw new RuntimeException('Die Erfassungsquelle wurde nicht gefunden.');
}
return $source;
}
private function assertTenantMember(int $tenantId, int $memberId): void
{
$statement = $this->pdo->prepare(
'SELECT id FROM members WHERE id = :id AND tenant_id = :tenant_id AND status = "active" LIMIT 1'
);
$statement->execute([
'id' => $memberId,
'tenant_id' => $tenantId,
]);
if (!$statement->fetchColumn()) {
throw new RuntimeException('Das gewählte Mitglied gehört nicht zu diesem Standort.');
}
}
private function assertTenantProduct(int $tenantId, int $productId): void
{
$statement = $this->pdo->prepare(
'SELECT id FROM products WHERE id = :id AND tenant_id = :tenant_id AND is_active = 1 LIMIT 1'
);
$statement->execute([
'id' => $productId,
'tenant_id' => $tenantId,
]);
if (!$statement->fetchColumn()) {
throw new RuntimeException('Das gewählte Produkt gehört nicht zu dieser Kaffeeliste.');
}
}
private function assertTenantMembership(int $tenantId, int $userId): void
{
$statement = $this->pdo->prepare(
'SELECT id
FROM tenant_memberships
WHERE tenant_id = :tenant_id
AND user_id = :user_id
AND status = "active"
LIMIT 1'
);
$statement->execute([
'tenant_id' => $tenantId,
'user_id' => $userId,
]);
if (!$statement->fetchColumn()) {
throw new RuntimeException('Der Benutzer ist für diesen Standort nicht aktiv.');
}
}
private function ledgerEntry(int $tenantId, int $ledgerEntryId): array
{
$statement = $this->pdo->prepare(
'SELECT *
FROM ledger_entries
WHERE id = :id
AND tenant_id = :tenant_id
LIMIT 1'
);
$statement->execute([
'id' => $ledgerEntryId,
'tenant_id' => $tenantId,
]);
$entry = $statement->fetch();
if (!$entry) {
throw new RuntimeException('Der angeforderte Vorgang wurde nicht gefunden.');
}
return $entry;
}
private function hasReversal(int $tenantId, int $ledgerEntryId): bool
{
$statement = $this->pdo->prepare(
'SELECT id
FROM ledger_entries
WHERE tenant_id = :tenant_id
AND reversal_of_entry_id = :reversal_of_entry_id
LIMIT 1'
);
$statement->execute([
'tenant_id' => $tenantId,
'reversal_of_entry_id' => $ledgerEntryId,
]);
return (bool) $statement->fetchColumn();
}
}
-177
View File
@@ -1,177 +0,0 @@
<?php
namespace App\Services;
use RuntimeException;
final class MailerService
{
private readonly string $rootPath;
public function __construct(private readonly array $config = [])
{
$this->rootPath = dirname(__DIR__, 2);
}
public function send(string $to, string $subject, string $textBody, array $headers = []): bool
{
$to = trim($to);
$subject = trim($subject);
$textBody = trim($textBody);
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Zieladresse für den Mailversand ist ungültig.');
}
if ($subject === '' || $textBody === '') {
throw new RuntimeException('Betreff und Inhalt für den Mailversand sind erforderlich.');
}
$subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject));
$headerLines = $this->buildHeaders($headers);
$body = $this->normalizeBody($textBody);
$sent = false;
if (function_exists('mail')) {
$sent = @mail($to, $subjectLine, $body, implode("\r\n", $headerLines));
}
if ($sent) {
return true;
}
if (!$this->usesLogFallback()) {
throw new RuntimeException('Die E-Mail konnte nicht versendet werden.');
}
$this->writeFallbackLog([
'to' => $to,
'subject' => $this->subjectPrefix() . $subject,
'headers' => $headerLines,
'body' => $textBody,
]);
return false;
}
private function buildHeaders(array $headers): array
{
$headerLines = [
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
'X-Mailer: Kaffeeliste',
];
$from = trim((string) ($this->config['from'] ?? ''));
if ($from !== '') {
$headerLines[] = 'From: ' . $this->sanitizeHeaderValue($from);
}
$replyTo = trim((string) ($this->config['reply_to'] ?? ''));
if ($replyTo !== '') {
$headerLines[] = 'Reply-To: ' . $this->sanitizeHeaderValue($replyTo);
}
foreach ($headers as $name => $value) {
if (is_int($name)) {
$line = trim((string) $value);
if ($line !== '' && str_contains($line, ':')) {
[$rawName, $rawValue] = array_pad(explode(':', $line, 2), 2, '');
$this->appendHeader($headerLines, $rawName, $rawValue);
}
continue;
}
$this->appendHeader($headerLines, (string) $name, (string) $value);
}
return $headerLines;
}
private function appendHeader(array &$headerLines, string $name, string $value): void
{
$name = trim($name);
if ($name === '' || preg_match('/[^A-Za-z0-9-]/', $name)) {
return;
}
$headerLines[] = $name . ': ' . $this->sanitizeHeaderValue($value);
}
private function writeFallbackLog(array $payload): void
{
$path = $this->resolveLogPath();
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('Das Mail-Log-Verzeichnis konnte nicht erstellt werden.');
}
$encodedPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($encodedPayload)) {
$encodedPayload = '{"error":"mail payload could not be encoded"}';
}
$entry = '[' . gmdate(DATE_ATOM) . "] mail_fallback\n" . $encodedPayload . "\n\n";
if (file_put_contents($path, $entry, FILE_APPEND | LOCK_EX) === false) {
throw new RuntimeException('Das Mail-Fallback-Log konnte nicht geschrieben werden.');
}
}
private function resolveLogPath(): string
{
$configuredPath = trim((string) ($this->config['log_file'] ?? ''));
if ($configuredPath === '') {
return $this->rootPath . '/storage/logs/mail.log';
}
if (str_starts_with($configuredPath, '/')) {
return $configuredPath;
}
return $this->rootPath . '/' . ltrim($configuredPath, '/');
}
private function usesLogFallback(): bool
{
return (bool) ($this->config['log_fallback'] ?? true);
}
private function subjectPrefix(): string
{
return (string) ($this->config['subject_prefix'] ?? '');
}
private function sanitizeHeaderValue(string $value): string
{
return trim(str_replace(["\r", "\n"], ' ', $value));
}
private function encodeSubject(string $subject): string
{
if ($subject === '') {
return $subject;
}
if (function_exists('mb_encode_mimeheader')) {
return mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n");
}
return $subject;
}
private function normalizeBody(string $textBody): string
{
return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $textBody));
}
}
-445
View File
@@ -1,445 +0,0 @@
<?php
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use RuntimeException;
final class PasswordResetService
{
private readonly DateTimeZone $utc;
public function __construct(
private readonly PDO $pdo,
private readonly MailerService $mailer,
private readonly array $config = []
) {
$this->utc = new DateTimeZone('UTC');
}
public function requestReset(string $email, array $context = []): void
{
$email = $this->normalizeEmail($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return;
}
$user = $this->findUserByEmail($email);
if (!$user) {
return;
}
$token = $this->createTokenForUser((int) $user['id'], $context);
$this->sendResetMail($token);
}
public function createTokenForUser(int $userId, array $context = []): array
{
if ($userId <= 0) {
throw new RuntimeException('Der Benutzer für den Passwortwechsel ist ungültig.');
}
$user = $this->findUserById($userId);
if (!$user) {
throw new RuntimeException('Der Benutzer für den Passwortwechsel wurde nicht gefunden.');
}
$nowTs = time();
$now = $this->formatTimestamp($nowTs);
$expiresAt = $this->formatTimestamp($nowTs + ($this->ttlMinutes() * 60));
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
$userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$this->pdo->beginTransaction();
try {
$cleanupStatement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id
AND (consumed_at IS NOT NULL OR expires_at < :now)'
);
$cleanupStatement->execute([
'user_id' => $userId,
'now' => $now,
]);
$insertStatement = $this->pdo->prepare(
'INSERT INTO password_reset_tokens (
user_id,
email,
token_hash,
requested_ip_hash,
requested_user_agent_hash,
expires_at,
consumed_at,
created_at
) VALUES (
:user_id,
:email,
:token_hash,
:requested_ip_hash,
:requested_user_agent_hash,
:expires_at,
NULL,
:created_at
)'
);
$insertStatement->execute([
'user_id' => $userId,
'email' => $user['email'],
'token_hash' => $tokenHash,
'requested_ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
'requested_user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
'expires_at' => $expiresAt,
'created_at' => $now,
]);
$trimStatement = $this->pdo->prepare(
'SELECT id
FROM password_reset_tokens
WHERE user_id = :user_id
AND consumed_at IS NULL
AND expires_at >= :now
ORDER BY created_at DESC, id DESC'
);
$trimStatement->execute([
'user_id' => $userId,
'now' => $now,
]);
$tokenIds = array_map(
static fn (array $row): int => (int) $row['id'],
$trimStatement->fetchAll()
);
$idsToDelete = array_slice($tokenIds, $this->maxActiveTokens());
if ($idsToDelete !== []) {
$deleteStatement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id
AND id = :id'
);
foreach ($idsToDelete as $tokenId) {
$deleteStatement->execute([
'user_id' => $userId,
'id' => $tokenId,
]);
}
}
$this->pdo->commit();
} catch (\Throwable $throwable) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $throwable;
}
return [
'user_id' => $userId,
'email' => $user['email'],
'full_name' => $user['full_name'],
'token' => $token,
'expires_at' => $expiresAt,
'reset_url' => $this->buildResetUrl($token, $context),
];
}
public function findValidToken(string $token): ?array
{
$token = trim($token);
if ($token === '') {
return null;
}
$statement = $this->pdo->prepare(
'SELECT prt.id,
prt.user_id,
prt.email,
prt.expires_at,
prt.consumed_at,
prt.created_at,
u.full_name
FROM password_reset_tokens prt
INNER JOIN users u ON u.id = prt.user_id
WHERE prt.token_hash = :token_hash
LIMIT 1'
);
$statement->execute([
'token_hash' => hash('sha256', $token),
]);
$resetToken = $statement->fetch();
if (!$resetToken) {
return null;
}
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
if (
$resetToken['consumed_at'] !== null
|| $expiresAtTs === null
|| $expiresAtTs < time()
) {
return null;
}
return $resetToken;
}
public function resetPassword(string $token, string $newPassword): int
{
$token = trim($token);
$newPassword = (string) $newPassword;
if ($token === '') {
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
}
if (mb_strlen($newPassword) < 12) {
throw new RuntimeException('Das neue Passwort muss mindestens 12 Zeichen lang sein.');
}
$tokenHash = hash('sha256', $token);
$nowTs = time();
$now = $this->formatTimestamp($nowTs);
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'SELECT id, user_id, expires_at, consumed_at
FROM password_reset_tokens
WHERE token_hash = :token_hash
LIMIT 1
FOR UPDATE'
);
$statement->execute(['token_hash' => $tokenHash]);
$resetToken = $statement->fetch();
if (!$resetToken) {
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
}
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
if (
$resetToken['consumed_at'] !== null
|| $expiresAtTs === null
|| $expiresAtTs < $nowTs
) {
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
}
$updateUserStatement = $this->pdo->prepare(
'UPDATE users
SET password_hash = :password_hash
WHERE id = :user_id'
);
$updateUserStatement->execute([
'password_hash' => secure_password_hash($newPassword),
'user_id' => $resetToken['user_id'],
]);
$consumeTokenStatement = $this->pdo->prepare(
'UPDATE password_reset_tokens
SET consumed_at = :consumed_at
WHERE id = :id'
);
$consumeTokenStatement->execute([
'consumed_at' => $now,
'id' => $resetToken['id'],
]);
$revokeOtherTokensStatement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id
AND id <> :id'
);
$revokeOtherTokensStatement->execute([
'user_id' => $resetToken['user_id'],
'id' => $resetToken['id'],
]);
$this->pdo->commit();
} catch (\Throwable $throwable) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $throwable;
}
return (int) $resetToken['user_id'];
}
public function revokeTokensForUser(int $userId): int
{
if ($userId <= 0) {
return 0;
}
$statement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE user_id = :user_id'
);
$statement->execute(['user_id' => $userId]);
return $statement->rowCount();
}
public function purgeExpiredTokens(): int
{
$statement = $this->pdo->prepare(
'DELETE FROM password_reset_tokens
WHERE consumed_at IS NOT NULL
OR expires_at < :now'
);
$statement->execute(['now' => $this->formatTimestamp(time())]);
return $statement->rowCount();
}
private function sendResetMail(array $token): void
{
$name = trim((string) ($token['full_name'] ?? ''));
$greeting = $name !== '' ? 'Guten Tag ' . $name . ',' : 'Guten Tag,';
$body = implode("\n\n", [
$greeting,
'für Ihr Konto wurde ein Passwortwechsel angefragt.',
'Bitte nutzen Sie diesen Link, um ein neues Passwort zu setzen:',
(string) $token['reset_url'],
'Der Link ist gültig bis ' . (string) $token['expires_at'] . '.',
'Falls Sie den Passwortwechsel nicht angefragt haben, können Sie diese Nachricht ignorieren.',
]);
$this->mailer->send(
(string) $token['email'],
$this->mailSubject(),
$body
);
}
private function findUserByEmail(string $email): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, full_name, email
FROM users
WHERE email = :email
LIMIT 1'
);
$statement->execute(['email' => $email]);
$user = $statement->fetch();
return $user ?: null;
}
private function findUserById(int $userId): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, full_name, email
FROM users
WHERE id = :id
LIMIT 1'
);
$statement->execute(['id' => $userId]);
$user = $statement->fetch();
return $user ?: null;
}
private function normalizeEmail(string $email): string
{
return mb_strtolower(trim($email));
}
private function buildResetUrl(string $token, array $context = []): string
{
$template = trim((string) ($this->config['reset_url'] ?? ''));
if ($template === '') {
$template = 'http://localhost:8080/reset-password?token={{token}}';
}
$query = [];
foreach ($context as $key => $value) {
if (!is_string($key) || trim($key) === '' || !is_scalar($value)) {
continue;
}
$normalizedValue = trim((string) $value);
if ($normalizedValue === '') {
continue;
}
$query[$key] = $normalizedValue;
}
if (str_contains($template, '{{token}}')) {
$url = str_replace('{{token}}', rawurlencode($token), $template);
if ($query === []) {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
$query['token'] = $token;
$separator = str_contains($template, '?') ? '&' : '?';
return $template . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
private function mailSubject(): string
{
$subject = trim((string) ($this->config['subject'] ?? ''));
return $subject !== '' ? $subject : 'Passwort zurücksetzen';
}
private function ttlMinutes(): int
{
return max(5, (int) ($this->config['ttl_minutes'] ?? 60));
}
private function maxActiveTokens(): int
{
return max(1, (int) ($this->config['max_active_tokens'] ?? 3));
}
private function formatTimestamp(int $timestamp): string
{
return gmdate('Y-m-d H:i:s', $timestamp);
}
private function timestampFromDatabase(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
if (!$date instanceof DateTimeImmutable) {
return null;
}
return $date->getTimestamp();
}
}
-306
View File
@@ -1,306 +0,0 @@
<?php
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use RuntimeException;
use Throwable;
final class RateLimiterService
{
private readonly DateTimeZone $utc;
public function __construct(
private readonly PDO $pdo,
private readonly array $config = []
) {
$this->utc = new DateTimeZone('UTC');
}
public function inspect(string $action, string $scope): array
{
$policy = $this->resolvePolicy($action);
$statement = $this->pdo->prepare(
'SELECT attempts, window_start, last_attempt_at, blocked_until
FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key
LIMIT 1'
);
$statement->execute([
'action_key' => $policy['action'],
'bucket_key' => $this->bucketKey($scope),
]);
return $this->buildState($policy, $statement->fetch() ?: null, time());
}
public function hit(string $action, string $scope): array
{
$policy = $this->resolvePolicy($action);
$bucketKey = $this->bucketKey($scope);
$nowTs = time();
$now = $this->formatTimestamp($nowTs);
$isSqlite = $this->isSqlite();
$this->pdo->beginTransaction();
try {
if ($isSqlite) {
$existingStmt = $this->pdo->prepare(
'SELECT id FROM rate_limits WHERE action_key = :action_key AND bucket_key = :bucket_key LIMIT 1'
);
$existingStmt->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
if (!$existingStmt->fetchColumn()) {
$this->pdo->prepare(
'INSERT INTO rate_limits (action_key, bucket_key, attempts, window_start, last_attempt_at, created_at)
VALUES (:action_key, :bucket_key, 0, :window_start, :last_attempt_at, :created_at)'
)->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
'window_start' => $now,
'last_attempt_at' => $now,
'created_at' => $now,
]);
}
} else {
$bootstrapStatement = $this->pdo->prepare(
'INSERT INTO rate_limits (
action_key,
bucket_key,
attempts,
window_start,
last_attempt_at,
blocked_until,
created_at
) VALUES (
:action_key,
:bucket_key,
0,
:window_start,
:last_attempt_at,
NULL,
:created_at
)
ON DUPLICATE KEY UPDATE
action_key = action_key'
);
$bootstrapStatement->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
'window_start' => $now,
'last_attempt_at' => $now,
'created_at' => $now,
]);
}
$forUpdate = $isSqlite ? '' : ' FOR UPDATE';
$selectStatement = $this->pdo->prepare(
'SELECT attempts, window_start, last_attempt_at, blocked_until
FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key
LIMIT 1' . $forUpdate
);
$selectStatement->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
$row = $selectStatement->fetch();
if (!$row) {
throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.');
}
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
$isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs;
$resetWindow = $windowStartTs === null
|| ($windowStartTs + $policy['window_seconds']) <= $nowTs
|| ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs);
if ($isBlocked) {
$updatedRow = $row;
} else {
$attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1);
$windowStart = $resetWindow ? $now : (string) $row['window_start'];
$blockedUntil = $attempts >= $policy['max_attempts']
? $this->formatTimestamp($nowTs + $policy['block_seconds'])
: null;
$updateStatement = $this->pdo->prepare(
'UPDATE rate_limits
SET attempts = :attempts,
window_start = :window_start,
last_attempt_at = :last_attempt_at,
blocked_until = :blocked_until
WHERE action_key = :action_key
AND bucket_key = :bucket_key'
);
$updateStatement->execute([
'attempts' => $attempts,
'window_start' => $windowStart,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil,
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
$updatedRow = [
'attempts' => $attempts,
'window_start' => $windowStart,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil,
];
}
$this->pdo->commit();
} catch (Throwable $throwable) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $throwable;
}
return $this->buildState($policy, $updatedRow, $nowTs);
}
public function clear(string $action, string $scope): void
{
$policy = $this->resolvePolicy($action);
$statement = $this->pdo->prepare(
'DELETE FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key'
);
$statement->execute([
'action_key' => $policy['action'],
'bucket_key' => $this->bucketKey($scope),
]);
}
public function tooManyAttempts(string $action, string $scope): bool
{
return !$this->inspect($action, $scope)['allowed'];
}
public function prune(int $staleAfterSeconds = 604800): int
{
$cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds));
$statement = $this->pdo->prepare(
'DELETE FROM rate_limits
WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff)
OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)'
);
$statement->execute(['cutoff' => $cutoff]);
return $statement->rowCount();
}
private function buildState(array $policy, ?array $row, int $nowTs): array
{
$attempts = 0;
$blockedUntil = null;
$retryAfter = 0;
$allowed = true;
$resetAtTs = $nowTs + $policy['window_seconds'];
if ($row) {
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
$attempts = (int) $row['attempts'];
$blockedUntil = (string) $row['blocked_until'];
$retryAfter = max(1, $blockedUntilTs - $nowTs);
$allowed = false;
$resetAtTs = $blockedUntilTs;
} elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) {
$attempts = (int) $row['attempts'];
$resetAtTs = $windowStartTs + $policy['window_seconds'];
}
}
return [
'action' => $policy['action'],
'allowed' => $allowed,
'attempts' => $attempts,
'max_attempts' => $policy['max_attempts'],
'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts),
'window_seconds' => $policy['window_seconds'],
'block_seconds' => $policy['block_seconds'],
'retry_after_seconds' => $retryAfter,
'reset_at' => $this->formatTimestamp($resetAtTs),
'blocked_until' => $blockedUntil,
];
}
private function resolvePolicy(string $action): array
{
$normalizedAction = mb_strtolower(trim($action));
if ($normalizedAction === '') {
throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.');
}
$policy = $this->config[$normalizedAction] ?? null;
if (!is_array($policy)) {
throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction));
}
return [
'action' => $normalizedAction,
'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)),
'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)),
'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)),
];
}
private function isSqlite(): bool
{
try {
return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
} catch (\Throwable) {
return false;
}
}
private function bucketKey(string $scope): string
{
$scope = trim($scope);
if ($scope === '') {
throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.');
}
return hash('sha256', $scope);
}
private function formatTimestamp(int $timestamp): string
{
return gmdate('Y-m-d H:i:s', $timestamp);
}
private function timestampFromDatabase(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
if (!$date instanceof DateTimeImmutable) {
return null;
}
return $date->getTimestamp();
}
}
-271
View File
@@ -1,271 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use RuntimeException;
/**
* SQLite-kompatible Version des RateLimiterService.
* Ersetzt MySQL-spezifisches ON DUPLICATE KEY UPDATE durch SELECT+INSERT.
*/
final class RateLimiterSqliteService
{
private readonly DateTimeZone $utc;
public function __construct(
private readonly PDO $pdo,
private readonly array $config = []
) {
$this->utc = new DateTimeZone('UTC');
}
public function inspect(string $action, string $scope): array
{
$policy = $this->resolvePolicy($action);
$statement = $this->pdo->prepare(
'SELECT attempts, window_start, last_attempt_at, blocked_until
FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key
LIMIT 1'
);
$statement->execute([
'action_key' => $policy['action'],
'bucket_key' => $this->bucketKey($scope),
]);
return $this->buildState($policy, $statement->fetch() ?: null, time());
}
public function hit(string $action, string $scope): array
{
$policy = $this->resolvePolicy($action);
$bucketKey = $this->bucketKey($scope);
$nowTs = time();
$now = $this->formatTimestamp($nowTs);
$this->pdo->beginTransaction();
try {
// SQLite-kompatibler Upsert: SELECT + INSERT falls nicht vorhanden
$existingStmt = $this->pdo->prepare(
'SELECT id FROM rate_limits WHERE action_key = :action_key AND bucket_key = :bucket_key LIMIT 1'
);
$existingStmt->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
if (!$existingStmt->fetchColumn()) {
$insertStmt = $this->pdo->prepare(
'INSERT INTO rate_limits (action_key, bucket_key, attempts, window_start, last_attempt_at, created_at)
VALUES (:action_key, :bucket_key, 0, :window_start, :last_attempt_at, :created_at)'
);
$insertStmt->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
'window_start' => $now,
'last_attempt_at' => $now,
'created_at' => $now,
]);
}
$selectStatement = $this->pdo->prepare(
'SELECT attempts, window_start, last_attempt_at, blocked_until
FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key
LIMIT 1'
);
$selectStatement->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
$row = $selectStatement->fetch();
if (!$row) {
throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.');
}
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
$isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs;
$resetWindow = $windowStartTs === null
|| ($windowStartTs + $policy['window_seconds']) <= $nowTs
|| ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs);
if ($isBlocked) {
$updatedRow = $row;
} else {
$attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1);
$windowStart = $resetWindow ? $now : (string) $row['window_start'];
$blockedUntil = $attempts >= $policy['max_attempts']
? $this->formatTimestamp($nowTs + $policy['block_seconds'])
: null;
$updateStatement = $this->pdo->prepare(
'UPDATE rate_limits
SET attempts = :attempts,
window_start = :window_start,
last_attempt_at = :last_attempt_at,
blocked_until = :blocked_until
WHERE action_key = :action_key
AND bucket_key = :bucket_key'
);
$updateStatement->execute([
'attempts' => $attempts,
'window_start' => $windowStart,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil,
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
$updatedRow = [
'attempts' => $attempts,
'window_start' => $windowStart,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil,
];
}
$this->pdo->commit();
} catch (\Throwable $throwable) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $throwable;
}
return $this->buildState($policy, $updatedRow, $nowTs);
}
public function clear(string $action, string $scope): void
{
$policy = $this->resolvePolicy($action);
$statement = $this->pdo->prepare(
'DELETE FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key'
);
$statement->execute([
'action_key' => $policy['action'],
'bucket_key' => $this->bucketKey($scope),
]);
}
public function tooManyAttempts(string $action, string $scope): bool
{
return !$this->inspect($action, $scope)['allowed'];
}
public function prune(int $staleAfterSeconds = 604800): int
{
$cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds));
$statement = $this->pdo->prepare(
'DELETE FROM rate_limits
WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff)
OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)'
);
$statement->execute(['cutoff' => $cutoff]);
return $statement->rowCount();
}
private function buildState(array $policy, ?array $row, int $nowTs): array
{
$attempts = 0;
$blockedUntil = null;
$retryAfter = 0;
$allowed = true;
$resetAtTs = $nowTs + $policy['window_seconds'];
if ($row) {
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
$attempts = (int) $row['attempts'];
$blockedUntil = (string) $row['blocked_until'];
$retryAfter = max(1, $blockedUntilTs - $nowTs);
$allowed = false;
$resetAtTs = $blockedUntilTs;
} elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) {
$attempts = (int) $row['attempts'];
$resetAtTs = $windowStartTs + $policy['window_seconds'];
}
}
return [
'action' => $policy['action'],
'allowed' => $allowed,
'attempts' => $attempts,
'max_attempts' => $policy['max_attempts'],
'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts),
'window_seconds' => $policy['window_seconds'],
'block_seconds' => $policy['block_seconds'],
'retry_after_seconds' => $retryAfter,
'reset_at' => $this->formatTimestamp($resetAtTs),
'blocked_until' => $blockedUntil,
];
}
private function resolvePolicy(string $action): array
{
$normalizedAction = mb_strtolower(trim($action));
if ($normalizedAction === '') {
throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.');
}
$policy = $this->config[$normalizedAction] ?? null;
if (!is_array($policy)) {
throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction));
}
return [
'action' => $normalizedAction,
'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)),
'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)),
'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)),
];
}
private function bucketKey(string $scope): string
{
$scope = trim($scope);
if ($scope === '') {
throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.');
}
return hash('sha256', $scope);
}
private function formatTimestamp(int $timestamp): string
{
return gmdate('Y-m-d H:i:s', $timestamp);
}
private function timestampFromDatabase(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
if (!$date instanceof DateTimeImmutable) {
return null;
}
return $date->getTimestamp();
}
}
-146
View File
@@ -1,146 +0,0 @@
<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class RfidService
{
public function __construct(
private readonly PDO $pdo,
private readonly AuditService $audit
) {
}
public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string
{
if (trim($name) === '') {
throw new RuntimeException('Bitte einen Namen für den Kartenleser angeben.');
}
$token = bin2hex(random_bytes(20));
$statement = $this->pdo->prepare(
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
VALUES (:tenant_id, :name, :location_label, :device_token, "active")'
);
$statement->execute([
'tenant_id' => $tenantId,
'name' => trim($name),
'location_label' => $location,
'device_token' => $token,
]);
$deviceId = (int) $this->pdo->lastInsertId();
$this->audit->log($tenantId, $actorUserId, 'rfid.device_created', 'rfid_device', $deviceId, 'success');
return $token;
}
public function assignTag(int $tenantId, int $memberId, string $uid, ?string $label, int $actorUserId): void
{
if (trim($uid) === '') {
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
}
$memberStatement = $this->pdo->prepare(
'SELECT id FROM members WHERE tenant_id = :tenant_id AND id = :id AND status = "active" LIMIT 1'
);
$memberStatement->execute([
'tenant_id' => $tenantId,
'id' => $memberId,
]);
if (!$memberStatement->fetchColumn()) {
throw new RuntimeException('Das ausgewählte Mitglied ist für Karten nicht verfügbar.');
}
$statement = $this->pdo->prepare(
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
);
$statement->execute([
'tenant_id' => $tenantId,
'member_id' => $memberId,
'uid_hash' => hash('sha256', trim($uid)),
'label' => $label,
]);
$this->audit->log($tenantId, $actorUserId, 'rfid.tag_assigned', 'rfid_tag', (int) $this->pdo->lastInsertId(), 'success');
}
public function ingest(array $payload): void
{
$deviceToken = trim((string) ($payload['device_token'] ?? ''));
$uid = trim((string) ($payload['uid'] ?? ''));
$externalEventId = trim((string) ($payload['event_id'] ?? ''));
if ($deviceToken === '' || $uid === '') {
throw new RuntimeException('device_token und uid sind Pflichtfelder.');
}
$deviceStatement = $this->pdo->prepare(
'SELECT d.id, d.tenant_id, d.status
FROM rfid_devices d
WHERE d.device_token = :device_token
LIMIT 1'
);
$deviceStatement->execute(['device_token' => $deviceToken]);
$device = $deviceStatement->fetch();
if (!$device) {
throw new RuntimeException('Der Kartenleser ist unbekannt.');
}
if (($device['status'] ?? '') !== 'active') {
throw new RuntimeException('Der Kartenleser ist nicht aktiv.');
}
$status = 'received';
$tagStatement = $this->pdo->prepare(
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
);
$tagStatement->execute([
'tenant_id' => $device['tenant_id'],
'uid_hash' => hash('sha256', $uid),
]);
if ($tagStatement->fetch()) {
$status = 'linked';
}
$statement = $this->pdo->prepare(
'INSERT INTO rfid_events (
tenant_id,
device_id,
external_event_id,
uid_hash,
payload_json,
status,
received_at
) VALUES (
:tenant_id,
:device_id,
:external_event_id,
:uid_hash,
:payload_json,
:status,
:received_at
)'
);
$statement->execute([
'tenant_id' => $device['tenant_id'],
'device_id' => $device['id'],
'external_event_id' => $externalEventId !== '' ? $externalEventId : null,
'uid_hash' => hash('sha256', $uid),
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'status' => $status,
'received_at' => gmdate('Y-m-d H:i:s'),
]);
$this->audit->log((int) $device['tenant_id'], null, 'rfid.event_received', 'rfid_event', (int) $this->pdo->lastInsertId(), 'success', [
'status' => $status,
'device_id' => (int) $device['id'],
]);
}
}
-97
View File
@@ -1,97 +0,0 @@
<?php
namespace App\Services;
final class SecurityHeadersService
{
public function buildHeaders(?bool $isHttps = null): array
{
$isHttps = $isHttps ?? $this->isHttpsRequest();
$headers = [
'Content-Security-Policy' => $this->buildContentSecurityPolicy($isHttps),
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
'Cross-Origin-Opener-Policy' => 'same-origin',
'Cross-Origin-Resource-Policy' => 'same-origin',
'Origin-Agent-Cluster' => '?1',
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'SAMEORIGIN',
'X-Permitted-Cross-Domain-Policies' => 'none',
];
if ($isHttps) {
$headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
}
return $headers;
}
public function send(array $overrides = [], ?bool $isHttps = null): void
{
if (headers_sent()) {
return;
}
$headers = $this->buildHeaders($isHttps);
foreach ($overrides as $name => $value) {
if (!is_string($name) || trim($name) === '') {
continue;
}
if ($value === null) {
unset($headers[$name]);
continue;
}
$headers[$name] = (string) $value;
}
header_remove('X-Powered-By');
foreach ($headers as $name => $value) {
header($name . ': ' . $value, true);
}
}
private function buildContentSecurityPolicy(bool $isHttps): string
{
$directives = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
"object-src 'none'",
"connect-src 'self'",
"font-src 'self' data:",
"img-src 'self' data:",
"manifest-src 'self'",
"script-src 'self'",
"style-src 'self'",
];
if ($isHttps) {
$directives[] = 'upgrade-insecure-requests';
}
return implode('; ', $directives);
}
private function isHttpsRequest(): bool
{
$https = $_SERVER['HTTPS'] ?? null;
if (is_string($https) && $https !== '' && strtolower($https) !== 'off') {
return true;
}
$forwardedProto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '';
if (is_string($forwardedProto) && strtolower($forwardedProto) === 'https') {
return true;
}
return (string) ($_SERVER['SERVER_PORT'] ?? '') === '443';
}
}
-267
View File
@@ -1,267 +0,0 @@
<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class SetupService
{
public function __construct(private readonly string $rootPath)
{
}
public function isInstalled(): bool
{
return is_file($this->rootPath . '/storage/installed.lock');
}
public function install(array $input): void
{
$required = [
'app_url',
'db_host',
'db_port',
'db_name',
'db_user',
'admin_name',
'admin_email',
'admin_password',
];
foreach ($required as $field) {
if (trim((string) ($input[$field] ?? '')) === '') {
throw new RuntimeException('Bitte alle Pflichtfelder ausfüllen.');
}
}
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Admin-E-Mail ist ungültig.');
}
if (mb_strlen($input['admin_password']) < 12) {
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
}
$this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url']));
// Prüfe verfügbare PDO-Treiber und verwende entsprechende Datenbank
$availableDrivers = PDO::getAvailableDrivers();
$useMySQL = in_array('mysql', $availableDrivers) && trim((string) $input['db_host']) !== 'sqlite';
if ($useMySQL) {
$pdo = $this->setupMySQLDatabase($input);
} else {
$pdo = $this->setupSQLiteDatabase();
}
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
$existing = $statement->fetchColumn();
if (!$existing) {
$insert = $pdo->prepare(
'INSERT INTO users (full_name, email, password_hash, platform_role)
VALUES (:full_name, :email, :password_hash, "platform_admin")'
);
$insert->execute([
'full_name' => trim((string) $input['admin_name']),
'email' => mb_strtolower((string) $input['admin_email']),
'password_hash' => secure_password_hash((string) $input['admin_password']),
]);
}
$envContent = $this->buildEnvFile($input, $useMySQL);
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
}
$lockContent = json_encode([
'installed_at' => gmdate(DATE_ATOM),
'app_url' => trim((string) $input['app_url']),
'database_type' => $useMySQL ? 'mysql' : 'sqlite',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent);
}
private function setupMySQLDatabase(array $input): PDO
{
$config = [
'host' => trim((string) $input['db_host']),
'port' => (int) $input['db_port'],
'name' => trim((string) $input['db_name']),
'user' => trim((string) $input['db_user']),
'pass' => (string) ($input['db_pass'] ?? ''),
'charset' => 'utf8mb4',
];
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['name'],
$config['charset']
);
try {
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
if (!is_string($schema) || $schema === '') {
throw new RuntimeException('Das MySQL-Datenbankschema konnte nicht geladen werden.');
}
$pdo->exec($schema);
return $pdo;
} catch (\PDOException $e) {
throw new RuntimeException('MySQL-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage() . '. Bitte prüfen Sie Ihre Datenbankeinstellungen oder verwenden Sie SQLite als Alternative.');
}
}
private function setupSQLiteDatabase(): PDO
{
$dbPath = $this->rootPath . '/storage/database.sqlite';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir) && !mkdir($dbDir, 0775, true) && !is_dir($dbDir)) {
throw new RuntimeException('Das Datenbank-Verzeichnis konnte nicht erstellt werden: ' . $dbDir);
}
try {
$pdo = new PDO(
'sqlite:' . $dbPath,
null,
null,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA foreign_keys=ON');
$pdo->exec('PRAGMA busy_timeout=5000');
$schema = file_get_contents($this->rootPath . '/database/schema.sqlite.sql');
if (!is_string($schema) || $schema === '') {
throw new RuntimeException('Das SQLite-Datenbankschema konnte nicht geladen werden.');
}
$pdo->exec($schema);
return $pdo;
} catch (\PDOException $e) {
throw new RuntimeException('SQLite-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage());
}
}
private function buildEnvFile(array $input, bool $useMySQL = true): string
{
$appUrl = $this->normalizeAppUrl((string) $input['app_url']);
$mailFrom = $this->resolveMailFrom($input, $appUrl);
$appKey = bin2hex(random_bytes(32));
$rfidSecret = bin2hex(random_bytes(24));
$lines = [
'APP_NAME="Kaffeeliste"',
'APP_ENV=production',
'APP_DEBUG=0',
'APP_URL=' . $appUrl,
'APP_TIMEZONE=Europe/Berlin',
'APP_KEY=' . $appKey,
'ALLOW_PUBLIC_REGISTRATION=1',
'RFID_INGEST_ENABLED=0',
'',
];
// Datenbankeinstellungen je nach verwendetem System
if ($useMySQL) {
$lines = array_merge($lines, [
'DB_HOST=' . trim((string) $input['db_host']),
'DB_PORT=' . (int) $input['db_port'],
'DB_NAME=' . trim((string) $input['db_name']),
'DB_USER=' . trim((string) $input['db_user']),
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
]);
} else {
// SQLite-Konfiguration
$lines = array_merge($lines, [
'DB_HOST=sqlite',
'DB_PORT=0',
'DB_NAME=' . $this->rootPath . '/storage/database.sqlite',
'DB_USER=',
'DB_PASS=',
]);
}
$lines = array_merge($lines, [
'',
'MAIL_FROM=' . $mailFrom,
'MAIL_REPLY_TO=' . $mailFrom,
'MAIL_SUBJECT_PREFIX="[Kaffeeliste] "',
'MAIL_LOG_FALLBACK=1',
'MAIL_LOG_FILE=storage/logs/mail.log',
'',
'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}',
'PASSWORD_RESET_TTL_MINUTES=60',
'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3',
'PASSWORD_RESET_SUBJECT="Passwort zurücksetzen"',
'',
'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5',
'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900',
'RATE_LIMIT_LOGIN_BLOCK_SECONDS=900',
'RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3',
'RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600',
'RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600',
'',
'RFID_SHARED_SECRET=' . $rfidSecret,
]);
return implode(PHP_EOL, $lines) . PHP_EOL;
}
private function normalizeAppUrl(string $appUrl): string
{
return rtrim(trim($appUrl), '/');
}
private function resolveMailFrom(array $input, string $appUrl): string
{
$mailFrom = mb_strtolower(trim((string) ($input['mail_from'] ?? '')));
if ($mailFrom === '' || $mailFrom === 'noreply@example.com') {
return $this->defaultMailFrom($appUrl);
}
if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die Mail-Absenderadresse ist ungültig.');
}
return $mailFrom;
}
private function defaultMailFrom(string $appUrl): string
{
$host = parse_url($appUrl, PHP_URL_HOST);
if (!is_string($host)) {
return 'noreply@example.com';
}
$host = preg_replace('/^www\./i', '', mb_strtolower(trim($host))) ?? '';
if ($host === '' || !str_contains($host, '.')) {
return 'noreply@example.com';
}
return 'noreply@' . $host;
}
}
-197
View File
@@ -1,197 +0,0 @@
<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class TenantRegistrationService
{
public function __construct(
private readonly PDO $pdo,
private readonly AuditService $audit
) {
}
public function register(array $input): string
{
$name = trim((string) ($input['tenant_name'] ?? ''));
$slug = $this->slugify((string) ($input['tenant_slug'] ?? $name));
$ownerName = trim((string) ($input['owner_name'] ?? ''));
$ownerEmail = mb_strtolower(trim((string) ($input['owner_email'] ?? '')));
$ownerPassword = (string) ($input['owner_password'] ?? '');
$plan = (string) ($input['plan'] ?? 'starter');
$defaultPriceCents = max(50, (int) round(((float) ($input['default_price_eur'] ?? 1.20)) * 100));
if ($name === '' || $slug === '' || $ownerName === '' || $ownerEmail === '' || $ownerPassword === '') {
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
}
if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Die E-Mail-Adresse ist ungültig.');
}
if (mb_strlen($ownerPassword) < 12) {
throw new RuntimeException('Das Passwort muss mindestens 12 Zeichen lang sein.');
}
$allowedPlans = ['starter', 'team', 'business'];
if (!in_array($plan, $allowedPlans, true)) {
$plan = 'starter';
}
$existingTenant = $this->pdo->prepare('SELECT id FROM tenants WHERE slug = :slug LIMIT 1');
$existingTenant->execute(['slug' => $slug]);
if ($existingTenant->fetchColumn()) {
throw new RuntimeException('Der gewuenschte Kurzname ist bereits vergeben.');
}
$existingUser = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$existingUser->execute(['email' => $ownerEmail]);
if ($existingUser->fetchColumn()) {
throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Bitte verwenden Sie eine andere Adresse.');
}
$now = gmdate('Y-m-d H:i:s');
$this->pdo->beginTransaction();
try {
$userInsert = $this->pdo->prepare(
'INSERT INTO users (full_name, email, password_hash, platform_role)
VALUES (:full_name, :email, :password_hash, "user")'
);
$userInsert->execute([
'full_name' => $ownerName,
'email' => $ownerEmail,
'password_hash' => secure_password_hash($ownerPassword),
]);
$userId = (int) $this->pdo->lastInsertId();
$tenantInsert = $this->pdo->prepare(
'INSERT INTO tenants (name, slug, plan, status, created_at)
VALUES (:name, :slug, :plan, "active", :created_at)'
);
$tenantInsert->execute([
'name' => $name,
'slug' => $slug,
'plan' => $plan,
'created_at' => $now,
]);
$tenantId = (int) $this->pdo->lastInsertId();
$membershipInsert = $this->pdo->prepare(
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
VALUES (:tenant_id, :user_id, "owner", "active")'
);
$membershipInsert->execute([
'tenant_id' => $tenantId,
'user_id' => $userId,
]);
$settingsInsert = $this->pdo->prepare(
'INSERT INTO tenant_settings (
tenant_id,
currency_code,
default_product_price_cents,
allow_self_service,
allow_paper_lists,
allow_rfid
) VALUES (
:tenant_id,
"EUR",
:default_product_price_cents,
1,
1,
0
)'
);
$settingsInsert->execute([
'tenant_id' => $tenantId,
'default_product_price_cents' => $defaultPriceCents,
]);
$memberInsert = $this->pdo->prepare(
'INSERT INTO members (tenant_id, user_id, display_name, email, status)
VALUES (:tenant_id, :user_id, :display_name, :email, "active")'
);
$memberInsert->execute([
'tenant_id' => $tenantId,
'user_id' => $userId,
'display_name' => $ownerName,
'email' => $ownerEmail,
]);
$sourceInsert = $this->pdo->prepare(
'INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
VALUES (:tenant_id, :code, :name, :channel, 1)'
);
foreach ([
['digital_self', 'Selbst gebucht', 'digital'],
['paper_sheet', 'Papierliste / Nachtrag', 'paper'],
['admin_backoffice', 'Nachgetragen durch Verwaltung', 'admin'],
['rfid_reader', 'Kartenleser', 'rfid'],
] as [$code, $sourceName, $channel]) {
$sourceInsert->execute([
'tenant_id' => $tenantId,
'code' => $code,
'name' => $sourceName,
'channel' => $channel,
]);
}
foreach ([
['Kaffee', $defaultPriceCents],
['Espresso', $defaultPriceCents],
['Tee', max(80, $defaultPriceCents - 20)],
] as [$productName, $priceCents]) {
$productInsert = $this->pdo->prepare(
'INSERT INTO products (tenant_id, name, sku, is_active)
VALUES (:tenant_id, :name, :sku, 1)'
);
$productInsert->execute([
'tenant_id' => $tenantId,
'name' => $productName,
'sku' => strtolower(str_replace(' ', '-', $productName)),
]);
$productId = (int) $this->pdo->lastInsertId();
$priceInsert = $this->pdo->prepare(
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
);
$priceInsert->execute([
'tenant_id' => $tenantId,
'product_id' => $productId,
'price_cents' => $priceCents,
'valid_from' => $now,
]);
}
$this->pdo->commit();
} catch (\Throwable $throwable) {
$this->pdo->rollBack();
throw $throwable;
}
$this->audit->log($tenantId, $userId, 'tenant.registered', 'tenant', $tenantId, 'success', [
'plan' => $plan,
'slug' => $slug,
]);
return $slug;
}
private function slugify(string $value): string
{
$value = mb_strtolower($value);
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
$value = trim($value, '-');
return $value;
}
}
-248
View File
@@ -1,248 +0,0 @@
<?php
use App\Core\Csrf;
use App\Core\Session;
function base_path(): string
{
foreach ([
$_SERVER['KAFFEEKASSE_PROXY_PREFIX'] ?? null,
$_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null,
$_ENV['APP_BASE_PATH'] ?? null,
] as $prefix) {
$prefix = trim((string) $prefix);
if ($prefix !== '') {
return normalize_base_path($prefix);
}
}
return '';
}
function normalize_base_path(string $prefix): string
{
$path = parse_url($prefix, PHP_URL_PATH);
$path = is_string($path) ? $path : $prefix;
$path = '/' . trim($path, '/');
return $path === '/' ? '' : $path;
}
function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function current_path(): string
{
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$path = is_string($uri) && $uri !== '' ? $uri : '/';
$basePath = base_path();
if ($basePath !== '' && ($path === $basePath || str_starts_with($path, $basePath . '/'))) {
$path = substr($path, strlen($basePath)) ?: '/';
}
$path = '/' . ltrim($path, '/');
return $path === '/' ? '/' : rtrim($path, '/');
}
function url(string $path = '/', array $query = []): string
{
$path = trim($path);
if ($path === '') {
$path = '/';
}
if (preg_match('#^(?:[a-z][a-z0-9+.-]*:)?//#i', $path) || str_starts_with($path, '#')) {
return append_query($path, $query);
}
$fragment = '';
$hashPosition = strpos($path, '#');
if ($hashPosition !== false) {
$fragment = substr($path, $hashPosition);
$path = substr($path, 0, $hashPosition);
}
$existingQuery = '';
$queryPosition = strpos($path, '?');
if ($queryPosition !== false) {
$existingQuery = substr($path, $queryPosition + 1);
$path = substr($path, 0, $queryPosition);
}
$targetPath = '/' . ltrim($path, '/');
$targetPath = $targetPath === '//' ? '/' : $targetPath;
$targetPath = $targetPath === '/' ? '/' : rtrim($targetPath, '/');
$queryString = build_query_string($existingQuery, $query);
$basePath = base_path();
if ($basePath !== '') {
return $basePath . ($targetPath === '/' ? '/' : $targetPath) . $queryString . $fragment;
}
return relative_url($targetPath) . $queryString . $fragment;
}
function append_query(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
function build_query_string(string $existingQuery, array $query): string
{
$parts = [];
if ($existingQuery !== '') {
$parts[] = $existingQuery;
}
if ($query !== []) {
$parts[] = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
return $parts === [] ? '' : '?' . implode('&', $parts);
}
function relative_url(string $targetPath): string
{
$currentPath = current_path();
$fromSegments = path_segments($currentPath);
if (!str_ends_with($currentPath, '/')) {
array_pop($fromSegments);
}
$toSegments = path_segments($targetPath);
$common = 0;
$maxCommon = min(count($fromSegments), count($toSegments));
while ($common < $maxCommon && $fromSegments[$common] === $toSegments[$common]) {
$common++;
}
if ($toSegments !== [] && $common === count($toSegments) && $common === count($fromSegments)) {
return '../' . end($toSegments);
}
$relativeSegments = array_merge(
array_fill(0, count($fromSegments) - $common, '..'),
array_slice($toSegments, $common)
);
return $relativeSegments === [] ? '.' : implode('/', $relativeSegments);
}
function path_segments(string $path): array
{
$trimmed = trim($path, '/');
return $trimmed === '' ? [] : explode('/', $trimmed);
}
function asset_url(string $path): string
{
return url('/assets/' . ltrim($path, '/'));
}
function old(string $key, mixed $default = ''): mixed
{
return $_SESSION['_old'][$key] ?? $default;
}
function remember_old_input(array $input): void
{
foreach (['password', 'admin_password', 'login_password', 'owner_password', 'db_pass'] as $sensitiveKey) {
if (array_key_exists($sensitiveKey, $input)) {
unset($input[$sensitiveKey]);
}
}
$_SESSION['_old'] = $input;
}
function clear_old_input(): void
{
unset($_SESSION['_old']);
}
function csrf_field(Csrf $csrf): string
{
return '<input type="hidden" name="_token" value="' . e($csrf->token()) . '">';
}
function flash(Session $session, string $key, mixed $default = null): mixed
{
return $session->pullFlash($key, $default);
}
function tenant_url(string $tenantSlug, string $path = ''): string
{
return url(tenant_path($tenantSlug, $path));
}
function tenant_path(string $tenantSlug, string $path = ''): string
{
$suffix = $path === '' ? '' : '/' . ltrim($path, '/');
return '/t/' . rawurlencode($tenantSlug) . $suffix;
}
function money_from_cents(int $amountCents): string
{
return number_format($amountCents / 100, 2, ',', '.') . ' EUR';
}
function secure_password_hash(string $password): string
{
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
return password_hash($password, $algo);
}
function normalize_datetime_input(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return gmdate('Y-m-d H:i:s');
}
$normalized = str_replace('T', ' ', $value);
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
return $normalized . ':00';
}
return $normalized;
}
function request_origin_matches_host(): bool
{
$host = $_SERVER['HTTP_HOST'] ?? '';
if ($host === '') {
return true;
}
foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) {
if (!empty($_SERVER[$header])) {
$originHost = parse_url((string) $_SERVER[$header], PHP_URL_HOST);
if (is_string($originHost) && $originHost !== '' && !hash_equals($host, $originHost)) {
return false;
}
}
}
return true;
}
-71
View File
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
use App\Core\Autoloader;
use App\Core\Csrf;
use App\Core\Database;
use App\Core\DatabaseSqlite;
use App\Core\Env;
use App\Core\Session;
use App\Core\View;
$rootPath = dirname(__DIR__);
require_once $rootPath . '/app/Support/helpers.php';
require_once $rootPath . '/app/Core/Autoloader.php';
Autoloader::register($rootPath);
Env::load($rootPath . '/.env');
$config = require $rootPath . '/config/app.php';
date_default_timezone_set($config['timezone']);
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
session_name('kaffeekasse_session');
$session = new Session();
$session->start();
$csrf = new Csrf($session);
// SQLite oder MySQL basierend auf DB_HOST Konfiguration und verfügbaren Treibern
if (($config['db']['host'] ?? '') === 'sqlite') {
$database = new DatabaseSqlite(['path' => $config['db']['name']]);
} else {
// Prüfe ob MySQL PDO Treiber verfügbar ist
$availableDrivers = PDO::getAvailableDrivers();
if (in_array('mysql', $availableDrivers)) {
try {
$database = new Database($config['db']);
// Teste die Verbindung
$database->pdo();
} catch (\Throwable $e) {
// Fallback zu SQLite wenn MySQL-Verbindung fehlschlägt
error_log('MySQL connection failed, falling back to SQLite: ' . $e->getMessage());
$database = new DatabaseSqlite(['path' => $rootPath . '/storage/database.sqlite']);
}
} else {
// Fallback zu SQLite wenn MySQL-Treiber nicht verfügbar
error_log('MySQL PDO driver not available, using SQLite');
$database = new DatabaseSqlite(['path' => $rootPath . '/storage/database.sqlite']);
}
}
$view = new View($rootPath, [
'config' => $config,
'session' => $session,
'csrf' => $csrf,
]);
return [
'rootPath' => $rootPath,
'config' => $config,
'session' => $session,
'csrf' => $csrf,
'database' => $database,
'view' => $view,
];
-202
View File
@@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
use App\Core\Autoloader;
use App\Core\Csrf;
use App\Core\DatabaseSqlite;
use App\Core\Env;
use App\Core\Session;
use App\Core\View;
/**
* Test-Bootstrap für SQLite-basierte Entwicklungsumgebung.
* Lädt .env.test oder .env, erstellt eine SQLite-Datenbank in storage/.
*/
$rootPath = dirname(__DIR__);
require_once $rootPath . '/app/Support/helpers.php';
require_once $rootPath . '/app/Core/Autoloader.php';
Autoloader::register($rootPath);
// Versuche zuerst .env.test, dann .env
$envFile = $rootPath . '/.env.test';
if (!is_file($envFile)) {
$envFile = $rootPath . '/.env';
}
Env::load($envFile);
// Test-Konfiguration überschreiben falls .env.test Werte setzt
$config = require $rootPath . '/config/app.php';
$config['env'] = 'development';
$config['debug'] = true;
$config['name'] = 'Kaffeekasse DEV';
$config['allow_public_registration'] = true;
$config['rate_limits']['login']['max_attempts'] = 50;
$config['rate_limits']['login']['window_seconds'] = 3600;
$config['rate_limits']['registration']['max_attempts'] = 50;
$config['rate_limits']['registration']['window_seconds'] = 3600;
date_default_timezone_set($config['timezone']);
// Session-Konfiguration NUR setzen, wenn noch keine Session aktiv
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
session_name('kaffeekasse_session_test');
}
$session = new Session();
$session->start();
$csrf = new Csrf($session);
// SQLite-Datenbank
$dbConfig = [
'path' => $rootPath . '/storage/test.sqlite',
];
$database = new DatabaseSqlite($dbConfig);
$database->initializeSchema();
// Prüfe, ob ein Plattform-Admin existiert, sonst lege Demo-Admin an
try {
$pdo = $database->pdo();
$adminStmt = $pdo->query("SELECT id FROM users WHERE platform_role = 'platform_admin' LIMIT 1");
if (!$adminStmt->fetchColumn()) {
$pdo->prepare(
"INSERT INTO users (full_name, email, password_hash, platform_role)
VALUES (:full_name, :email, :password_hash, 'platform_admin')"
)->execute([
'full_name' => 'Admin',
'email' => 'admin@demo.kaffeekasse',
'password_hash' => password_hash('admin12345678', PASSWORD_BCRYPT),
]);
echo "[Test-Bootstrap] Demo-Admin angelegt: admin@demo.kaffeekasse / admin12345678\n";
}
// Prüfe, ob ein Demo-Tenant existiert, sonst lege an
$tenantStmt = $pdo->query("SELECT id FROM tenants WHERE slug = 'demo' LIMIT 1");
if (!$tenantStmt->fetchColumn()) {
$now = gmdate('Y-m-d H:i:s');
// Tenant
$pdo->prepare(
"INSERT INTO tenants (name, slug, plan, status, created_at)
VALUES (:name, :slug, :plan, :status, :created_at)"
)->execute([
'name' => 'Demo-Tenant',
'slug' => 'demo',
'plan' => 'team',
'status' => 'active',
'created_at' => $now,
]);
$tenantId = (int) $pdo->lastInsertId();
// Owner-Benutzer
$pdo->prepare(
"INSERT INTO users (full_name, email, password_hash, platform_role)
VALUES (:full_name, :email, :password_hash, 'user')"
)->execute([
'full_name' => 'Demo-Owner',
'email' => 'owner@demo.kaffeekasse',
'password_hash' => password_hash('owner12345678', PASSWORD_BCRYPT),
]);
$ownerUserId = (int) $pdo->lastInsertId();
// Member-Benutzer
$pdo->prepare(
"INSERT INTO users (full_name, email, password_hash, platform_role)
VALUES (:full_name, :email, :password_hash, 'user')"
)->execute([
'full_name' => 'Demo-Member',
'email' => 'member@demo.kaffeekasse',
'password_hash' => password_hash('member12345678', PASSWORD_BCRYPT),
]);
$memberUserId = (int) $pdo->lastInsertId();
// Memberships
$pdo->prepare(
"INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
VALUES (:tenant_id, :user_id, :role, 'active')"
)->execute(['tenant_id' => $tenantId, 'user_id' => $ownerUserId, 'role' => 'owner']);
$pdo->prepare(
"INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
VALUES (:tenant_id, :user_id, :role, 'active')"
)->execute(['tenant_id' => $tenantId, 'user_id' => $memberUserId, 'role' => 'member']);
// Settings
$pdo->prepare(
"INSERT INTO tenant_settings (tenant_id, currency_code, default_product_price_cents, allow_self_service, allow_paper_lists, allow_rfid)
VALUES (:tenant_id, 'EUR', 120, 1, 1, 0)"
)->execute(['tenant_id' => $tenantId]);
// Members
$pdo->prepare(
"INSERT INTO members (tenant_id, user_id, display_name, email, status)
VALUES (:tenant_id, :user_id, :display_name, :email, 'active')"
)->execute(['tenant_id' => $tenantId, 'user_id' => $ownerUserId, 'display_name' => 'Demo-Owner', 'email' => 'owner@demo.kaffeekasse']);
$memberMemberIdStmt = $pdo->prepare(
"INSERT INTO members (tenant_id, user_id, display_name, email, status)
VALUES (:tenant_id, :user_id, :display_name, :email, 'active')"
);
$memberMemberIdStmt->execute(['tenant_id' => $tenantId, 'user_id' => $memberUserId, 'display_name' => 'Demo-Member', 'email' => 'member@demo.kaffeekasse']);
$memberMemberId = (int) $pdo->lastInsertId();
// Capture Sources
foreach ([
['digital_self', 'Digitale Selbstbuchung', 'digital'],
['paper_sheet', 'Papierliste / Nacherfassung', 'paper'],
['admin_backoffice', 'Backoffice-Buchung', 'admin'],
['rfid_reader', 'RFID-Geraet', 'rfid'],
] as [$code, $sourceName, $channel]) {
$pdo->prepare(
"INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
VALUES (:tenant_id, :code, :name, :channel, 1)"
)->execute(['tenant_id' => $tenantId, 'code' => $code, 'name' => $sourceName, 'channel' => $channel]);
}
// Products
foreach ([
['Kaffee', 120],
['Espresso', 120],
['Tee', 100],
] as [$productName, $priceCents]) {
$pdo->prepare(
"INSERT INTO products (tenant_id, name, sku, is_active)
VALUES (:tenant_id, :name, :sku, 1)"
)->execute(['tenant_id' => $tenantId, 'name' => $productName, 'sku' => strtolower(str_replace(' ', '-', $productName))]);
$productId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)"
)->execute(['tenant_id' => $tenantId, 'product_id' => $productId, 'price_cents' => $priceCents, 'valid_from' => $now]);
}
echo "[Test-Bootstrap] Demo-Tenant 'demo' angelegt\n";
echo " Owner-Login: owner@demo.kaffeekasse / owner12345678\n";
echo " Member-Login: member@demo.kaffeekasse / member12345678\n";
echo " Tenant-Pfad: /t/demo\n";
}
} catch (\Throwable $e) {
echo "[Test-Bootstrap] Fehler: " . $e->getMessage() . "\n";
}
$view = new View($rootPath, [
'config' => $config,
'session' => $session,
'csrf' => $csrf,
]);
return [
'rootPath' => $rootPath,
'config' => $config,
'session' => $session,
'csrf' => $csrf,
'database' => $database,
'view' => $view,
];
File diff suppressed because one or more lines are too long
+2312
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
/* breakpoints.js v1.0 | @ajlkn | MIT licensed */
var breakpoints=function(){"use strict";function e(e){t.init(e)}var t={list:null,media:{},events:[],init:function(e){t.list=e,window.addEventListener("resize",t.poll),window.addEventListener("orientationchange",t.poll),window.addEventListener("load",t.poll),window.addEventListener("fullscreenchange",t.poll)},active:function(e){var n,a,s,i,r,d,c;if(!(e in t.media)){if(">="==e.substr(0,2)?(a="gte",n=e.substr(2)):"<="==e.substr(0,2)?(a="lte",n=e.substr(2)):">"==e.substr(0,1)?(a="gt",n=e.substr(1)):"<"==e.substr(0,1)?(a="lt",n=e.substr(1)):"!"==e.substr(0,1)?(a="not",n=e.substr(1)):(a="eq",n=e),n&&n in t.list)if(i=t.list[n],Array.isArray(i)){if(r=parseInt(i[0]),d=parseInt(i[1]),isNaN(r)){if(isNaN(d))return;c=i[1].substr(String(d).length)}else c=i[0].substr(String(r).length);if(isNaN(r))switch(a){case"gte":s="screen";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: -1px)";break;case"not":s="screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (max-width: "+d+c+")"}else if(isNaN(d))switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen";break;case"gt":s="screen and (max-width: -1px)";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+")";break;default:s="screen and (min-width: "+r+c+")"}else switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+"), screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (min-width: "+r+c+") and (max-width: "+d+c+")"}}else s="("==i.charAt(0)?"screen and "+i:i;t.media[e]=!!s&&s}return t.media[e]!==!1&&window.matchMedia(t.media[e]).matches},on:function(e,n){t.events.push({query:e,handler:n,state:!1}),t.active(e)&&n()},poll:function(){var e,n;for(e=0;e<t.events.length;e++)n=t.events[e],t.active(n.query)?n.state||(n.state=!0,n.handler()):n.state&&(n.state=!1)}};return e._=t,e.on=function(e,n){t.on(e,n)},e.active=function(e){return t.active(e)},e}();!function(e,t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?module.exports=t():e.breakpoints=t()}(this,function(){return breakpoints});
+2
View File
@@ -0,0 +1,2 @@
/* browser.js v1.0.1 | @ajlkn | MIT licensed */
var browser=function(){"use strict";var t={name:null,version:null,os:null,osVersion:null,touch:null,mobile:null,_canUse:null,canUse:function(e){t._canUse||(t._canUse=document.createElement("div"));var n=t._canUse.style,r=e.charAt(0).toUpperCase()+e.slice(1);return e in n||"Moz"+r in n||"Webkit"+r in n||"O"+r in n||"ms"+r in n},init:function(){for(var e=navigator.userAgent,n="other",r=0,i=[["firefox",/Firefox\/([0-9\.]+)/],["bb",/BlackBerry.+Version\/([0-9\.]+)/],["bb",/BB[0-9]+.+Version\/([0-9\.]+)/],["opera",/OPR\/([0-9\.]+)/],["opera",/Opera\/([0-9\.]+)/],["edge",/Edge\/([0-9\.]+)/],["safari",/Version\/([0-9\.]+).+Safari/],["chrome",/Chrome\/([0-9\.]+)/],["ie",/MSIE ([0-9]+)/],["ie",/Trident\/.+rv:([0-9]+)/]],o=0;o<i.length;o++)if(e.match(i[o][1])){n=i[o][0],r=parseFloat(RegExp.$1);break}for(t.name=n,t.version=r,n="other",i=[["ios",/([0-9_]+) like Mac OS X/,function(e){return e.replace("_",".").replace("_","")}],["ios",/CPU like Mac OS X/,function(e){return 0}],["wp",/Windows Phone ([0-9\.]+)/,null],["android",/Android ([0-9\.]+)/,null],["mac",/Macintosh.+Mac OS X ([0-9_]+)/,function(e){return e.replace("_",".").replace("_","")}],["windows",/Windows NT ([0-9\.]+)/,null],["bb",/BlackBerry.+Version\/([0-9\.]+)/,null],["bb",/BB[0-9]+.+Version\/([0-9\.]+)/,null],["linux",/Linux/,null],["bsd",/BSD/,null],["unix",/X11/,null]],o=r=0;o<i.length;o++)if(e.match(i[o][1])){n=i[o][0],r=parseFloat(i[o][2]?i[o][2](RegExp.$1):RegExp.$1);break}"mac"==n&&"ontouchstart"in window&&(1024==screen.width&&1366==screen.height||834==screen.width&&1112==screen.height||810==screen.width&&1080==screen.height||768==screen.width&&1024==screen.height)&&(n="ios"),t.os=n,t.osVersion=r,t.touch="wp"==t.os?0<navigator.msMaxTouchPoints:!!("ontouchstart"in window),t.mobile="wp"==t.os||"android"==t.os||"ios"==t.os||"bb"==t.os}};return t.init(),t}();!function(e,n){"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?module.exports=n():e.browser=n()}(this,function(){return browser});
+2
View File
File diff suppressed because one or more lines are too long
+262
View File
@@ -0,0 +1,262 @@
/*
Editorial by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
*/
(function($) {
var $window = $(window),
$head = $('head'),
$body = $('body');
// Breakpoints.
breakpoints({
xlarge: [ '1281px', '1680px' ],
large: [ '981px', '1280px' ],
medium: [ '737px', '980px' ],
small: [ '481px', '736px' ],
xsmall: [ '361px', '480px' ],
xxsmall: [ null, '360px' ],
'xlarge-to-max': '(min-width: 1681px)',
'small-to-xlarge': '(min-width: 481px) and (max-width: 1680px)'
});
// Stops animations/transitions until the page has ...
// ... loaded.
$window.on('load', function() {
window.setTimeout(function() {
$body.removeClass('is-preload');
}, 100);
});
// ... stopped resizing.
var resizeTimeout;
$window.on('resize', function() {
// Mark as resizing.
$body.addClass('is-resizing');
// Unmark after delay.
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(function() {
$body.removeClass('is-resizing');
}, 100);
});
// Fixes.
// Object fit images.
if (!browser.canUse('object-fit')
|| browser.name == 'safari')
$('.image.object').each(function() {
var $this = $(this),
$img = $this.children('img');
// Hide original image.
$img.css('opacity', '0');
// Set background.
$this
.css('background-image', 'url("' + $img.attr('src') + '")')
.css('background-size', $img.css('object-fit') ? $img.css('object-fit') : 'cover')
.css('background-position', $img.css('object-position') ? $img.css('object-position') : 'center');
});
// Sidebar.
var $sidebar = $('#sidebar'),
$sidebar_inner = $sidebar.children('.inner');
// Inactive by default on <= large.
breakpoints.on('<=large', function() {
$sidebar.addClass('inactive');
});
breakpoints.on('>large', function() {
$sidebar.removeClass('inactive');
});
// Hack: Workaround for Chrome/Android scrollbar position bug.
if (browser.os == 'android'
&& browser.name == 'chrome')
$('<style>#sidebar .inner::-webkit-scrollbar { display: none; }</style>')
.appendTo($head);
// Toggle.
$('<a href="#sidebar" class="toggle">Toggle</a>')
.appendTo($sidebar)
.on('click', function(event) {
// Prevent default.
event.preventDefault();
event.stopPropagation();
// Toggle.
$sidebar.toggleClass('inactive');
});
// Events.
// Link clicks.
$sidebar.on('click', 'a', function(event) {
// >large? Bail.
if (breakpoints.active('>large'))
return;
// Vars.
var $a = $(this),
href = $a.attr('href'),
target = $a.attr('target');
// Prevent default.
event.preventDefault();
event.stopPropagation();
// Check URL.
if (!href || href == '#' || href == '')
return;
// Hide sidebar.
$sidebar.addClass('inactive');
// Redirect to href.
setTimeout(function() {
if (target == '_blank')
window.open(href);
else
window.location.href = href;
}, 500);
});
// Prevent certain events inside the panel from bubbling.
$sidebar.on('click touchend touchstart touchmove', function(event) {
// >large? Bail.
if (breakpoints.active('>large'))
return;
// Prevent propagation.
event.stopPropagation();
});
// Hide panel on body click/tap.
$body.on('click touchend', function(event) {
// >large? Bail.
if (breakpoints.active('>large'))
return;
// Deactivate.
$sidebar.addClass('inactive');
});
// Scroll lock.
// Note: If you do anything to change the height of the sidebar's content, be sure to
// trigger 'resize.sidebar-lock' on $window so stuff doesn't get out of sync.
$window.on('load.sidebar-lock', function() {
var sh, wh, st;
// Reset scroll position to 0 if it's 1.
if ($window.scrollTop() == 1)
$window.scrollTop(0);
$window
.on('scroll.sidebar-lock', function() {
var x, y;
// <=large? Bail.
if (breakpoints.active('<=large')) {
$sidebar_inner
.data('locked', 0)
.css('position', '')
.css('top', '');
return;
}
// Calculate positions.
x = Math.max(sh - wh, 0);
y = Math.max(0, $window.scrollTop() - x);
// Lock/unlock.
if ($sidebar_inner.data('locked') == 1) {
if (y <= 0)
$sidebar_inner
.data('locked', 0)
.css('position', '')
.css('top', '');
else
$sidebar_inner
.css('top', -1 * x);
}
else {
if (y > 0)
$sidebar_inner
.data('locked', 1)
.css('position', 'fixed')
.css('top', -1 * x);
}
})
.on('resize.sidebar-lock', function() {
// Calculate heights.
wh = $window.height();
sh = $sidebar_inner.outerHeight() + 30;
// Trigger scroll.
$window.trigger('scroll.sidebar-lock');
})
.trigger('resize.sidebar-lock');
});
// Menu.
var $menu = $('#menu'),
$menu_openers = $menu.children('ul').find('.opener');
// Openers.
$menu_openers.each(function() {
var $this = $(this);
$this.on('click', function(event) {
// Prevent default.
event.preventDefault();
// Toggle.
$menu_openers.not($this).removeClass('active');
$this.toggleClass('active');
// Trigger resize (sidebar lock).
$window.triggerHandler('resize.sidebar-lock');
});
});
})(jQuery);
+587
View File
@@ -0,0 +1,587 @@
(function($) {
/**
* Generate an indented list of links from a nav. Meant for use with panel().
* @return {jQuery} jQuery object.
*/
$.fn.navList = function() {
var $this = $(this);
$a = $this.find('a'),
b = [];
$a.each(function() {
var $this = $(this),
indent = Math.max(0, $this.parents('li').length - 1),
href = $this.attr('href'),
target = $this.attr('target');
b.push(
'<a ' +
'class="link depth-' + indent + '"' +
( (typeof target !== 'undefined' && target != '') ? ' target="' + target + '"' : '') +
( (typeof href !== 'undefined' && href != '') ? ' href="' + href + '"' : '') +
'>' +
'<span class="indent-' + indent + '"></span>' +
$this.text() +
'</a>'
);
});
return b.join('');
};
/**
* Panel-ify an element.
* @param {object} userConfig User config.
* @return {jQuery} jQuery object.
*/
$.fn.panel = function(userConfig) {
// No elements?
if (this.length == 0)
return $this;
// Multiple elements?
if (this.length > 1) {
for (var i=0; i < this.length; i++)
$(this[i]).panel(userConfig);
return $this;
}
// Vars.
var $this = $(this),
$body = $('body'),
$window = $(window),
id = $this.attr('id'),
config;
// Config.
config = $.extend({
// Delay.
delay: 0,
// Hide panel on link click.
hideOnClick: false,
// Hide panel on escape keypress.
hideOnEscape: false,
// Hide panel on swipe.
hideOnSwipe: false,
// Reset scroll position on hide.
resetScroll: false,
// Reset forms on hide.
resetForms: false,
// Side of viewport the panel will appear.
side: null,
// Target element for "class".
target: $this,
// Class to toggle.
visibleClass: 'visible'
}, userConfig);
// Expand "target" if it's not a jQuery object already.
if (typeof config.target != 'jQuery')
config.target = $(config.target);
// Panel.
// Methods.
$this._hide = function(event) {
// Already hidden? Bail.
if (!config.target.hasClass(config.visibleClass))
return;
// If an event was provided, cancel it.
if (event) {
event.preventDefault();
event.stopPropagation();
}
// Hide.
config.target.removeClass(config.visibleClass);
// Post-hide stuff.
window.setTimeout(function() {
// Reset scroll position.
if (config.resetScroll)
$this.scrollTop(0);
// Reset forms.
if (config.resetForms)
$this.find('form').each(function() {
this.reset();
});
}, config.delay);
};
// Vendor fixes.
$this
.css('-ms-overflow-style', '-ms-autohiding-scrollbar')
.css('-webkit-overflow-scrolling', 'touch');
// Hide on click.
if (config.hideOnClick) {
$this.find('a')
.css('-webkit-tap-highlight-color', 'rgba(0,0,0,0)');
$this
.on('click', 'a', function(event) {
var $a = $(this),
href = $a.attr('href'),
target = $a.attr('target');
if (!href || href == '#' || href == '' || href == '#' + id)
return;
// Cancel original event.
event.preventDefault();
event.stopPropagation();
// Hide panel.
$this._hide();
// Redirect to href.
window.setTimeout(function() {
if (target == '_blank')
window.open(href);
else
window.location.href = href;
}, config.delay + 10);
});
}
// Event: Touch stuff.
$this.on('touchstart', function(event) {
$this.touchPosX = event.originalEvent.touches[0].pageX;
$this.touchPosY = event.originalEvent.touches[0].pageY;
})
$this.on('touchmove', function(event) {
if ($this.touchPosX === null
|| $this.touchPosY === null)
return;
var diffX = $this.touchPosX - event.originalEvent.touches[0].pageX,
diffY = $this.touchPosY - event.originalEvent.touches[0].pageY,
th = $this.outerHeight(),
ts = ($this.get(0).scrollHeight - $this.scrollTop());
// Hide on swipe?
if (config.hideOnSwipe) {
var result = false,
boundary = 20,
delta = 50;
switch (config.side) {
case 'left':
result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX > delta);
break;
case 'right':
result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX < (-1 * delta));
break;
case 'top':
result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY > delta);
break;
case 'bottom':
result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY < (-1 * delta));
break;
default:
break;
}
if (result) {
$this.touchPosX = null;
$this.touchPosY = null;
$this._hide();
return false;
}
}
// Prevent vertical scrolling past the top or bottom.
if (($this.scrollTop() < 0 && diffY < 0)
|| (ts > (th - 2) && ts < (th + 2) && diffY > 0)) {
event.preventDefault();
event.stopPropagation();
}
});
// Event: Prevent certain events inside the panel from bubbling.
$this.on('click touchend touchstart touchmove', function(event) {
event.stopPropagation();
});
// Event: Hide panel if a child anchor tag pointing to its ID is clicked.
$this.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.removeClass(config.visibleClass);
});
// Body.
// Event: Hide panel on body click/tap.
$body.on('click touchend', function(event) {
$this._hide(event);
});
// Event: Toggle.
$body.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.toggleClass(config.visibleClass);
});
// Window.
// Event: Hide on ESC.
if (config.hideOnEscape)
$window.on('keydown', function(event) {
if (event.keyCode == 27)
$this._hide(event);
});
return $this;
};
/**
* Apply "placeholder" attribute polyfill to one or more forms.
* @return {jQuery} jQuery object.
*/
$.fn.placeholder = function() {
// Browser natively supports placeholders? Bail.
if (typeof (document.createElement('input')).placeholder != 'undefined')
return $(this);
// No elements?
if (this.length == 0)
return $this;
// Multiple elements?
if (this.length > 1) {
for (var i=0; i < this.length; i++)
$(this[i]).placeholder();
return $this;
}
// Vars.
var $this = $(this);
// Text, TextArea.
$this.find('input[type=text],textarea')
.each(function() {
var i = $(this);
if (i.val() == ''
|| i.val() == i.attr('placeholder'))
i
.addClass('polyfill-placeholder')
.val(i.attr('placeholder'));
})
.on('blur', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/))
return;
if (i.val() == '')
i
.addClass('polyfill-placeholder')
.val(i.attr('placeholder'));
})
.on('focus', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/))
return;
if (i.val() == i.attr('placeholder'))
i
.removeClass('polyfill-placeholder')
.val('');
});
// Password.
$this.find('input[type=password]')
.each(function() {
var i = $(this);
var x = $(
$('<div>')
.append(i.clone())
.remove()
.html()
.replace(/type="password"/i, 'type="text"')
.replace(/type=password/i, 'type=text')
);
if (i.attr('id') != '')
x.attr('id', i.attr('id') + '-polyfill-field');
if (i.attr('name') != '')
x.attr('name', i.attr('name') + '-polyfill-field');
x.addClass('polyfill-placeholder')
.val(x.attr('placeholder')).insertAfter(i);
if (i.val() == '')
i.hide();
else
x.hide();
i
.on('blur', function(event) {
event.preventDefault();
var x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') {
i.hide();
x.show();
}
});
x
.on('focus', function(event) {
event.preventDefault();
var i = x.parent().find('input[name=' + x.attr('name').replace('-polyfill-field', '') + ']');
x.hide();
i
.show()
.focus();
})
.on('keypress', function(event) {
event.preventDefault();
x.val('');
});
});
// Events.
$this
.on('submit', function() {
$this.find('input[type=text],input[type=password],textarea')
.each(function(event) {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/))
i.attr('name', '');
if (i.val() == i.attr('placeholder')) {
i.removeClass('polyfill-placeholder');
i.val('');
}
});
})
.on('reset', function(event) {
event.preventDefault();
$this.find('select')
.val($('option:first').val());
$this.find('input,textarea')
.each(function() {
var i = $(this),
x;
i.removeClass('polyfill-placeholder');
switch (this.type) {
case 'submit':
case 'reset':
break;
case 'password':
i.val(i.attr('defaultValue'));
x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') {
i.hide();
x.show();
}
else {
i.show();
x.hide();
}
break;
case 'checkbox':
case 'radio':
i.attr('checked', i.attr('defaultValue'));
break;
case 'text':
case 'textarea':
i.val(i.attr('defaultValue'));
if (i.val() == '') {
i.addClass('polyfill-placeholder');
i.val(i.attr('placeholder'));
}
break;
default:
i.val(i.attr('defaultValue'));
break;
}
});
});
return $this;
};
/**
* Moves elements to/from the first positions of their respective parents.
* @param {jQuery} $elements Elements (or selector) to move.
* @param {bool} condition If true, moves elements to the top. Otherwise, moves elements back to their original locations.
*/
$.prioritize = function($elements, condition) {
var key = '__prioritize';
// Expand $elements if it's not already a jQuery object.
if (typeof $elements != 'jQuery')
$elements = $($elements);
// Step through elements.
$elements.each(function() {
var $e = $(this), $p,
$parent = $e.parent();
// No parent? Bail.
if ($parent.length == 0)
return;
// Not moved? Move it.
if (!$e.data(key)) {
// Condition is false? Bail.
if (!condition)
return;
// Get placeholder (which will serve as our point of reference for when this element needs to move back).
$p = $e.prev();
// Couldn't find anything? Means this element's already at the top, so bail.
if ($p.length == 0)
return;
// Move element to top of parent.
$e.prependTo($parent);
// Mark element as moved.
$e.data(key, $p);
}
// Moved already?
else {
// Condition is true? Bail.
if (condition)
return;
$p = $e.data(key);
// Move element back to its original location (using our placeholder).
$e.insertAfter($p);
// Unmark element as moved.
$e.removeData(key);
}
});
};
})(jQuery);
-64
View File
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\MailerService;
use App\Services\PasswordResetService;
use App\Services\RateLimiterService;
$rootPath = dirname(__DIR__);
$lockFile = $rootPath . '/storage/cron.lock';
if (is_file($lockFile) && (time() - filemtime($lockFile)) < 600) {
fwrite(STDOUT, "Cron already running.\n");
exit(0);
}
file_put_contents($lockFile, (string) time(), LOCK_EX);
try {
$app = require $rootPath . '/app/bootstrap.php';
if (!$app['database']->isConfigured()) {
throw new RuntimeException('Database config missing.');
}
$pdo = $app['database']->pdo();
$passwordResetService = new PasswordResetService(
$pdo,
new MailerService($app['config']['mail'] ?? []),
$app['config']['password_reset'] ?? []
);
$rateLimiterService = new RateLimiterService(
$pdo,
$app['config']['rate_limits'] ?? []
);
$purgedPasswordResetTokens = $passwordResetService->purgeExpiredTokens();
$prunedRateLimits = $rateLimiterService->prune();
$pendingRfid = (int) $pdo->query(
'SELECT COUNT(*) FROM rfid_events WHERE status = "received"'
)->fetchColumn();
$summary = [
'timestamp' => gmdate(DATE_ATOM),
'cleanup' => [
'password_reset_tokens_removed' => $purgedPasswordResetTokens,
'rate_limit_entries_removed' => $prunedRateLimits,
],
'pending_rfid_events' => $pendingRfid,
'notes' => [
'Cron bereinigt abgelaufene Passwort-Reset-Tokens und veraltete Rate-Limit-Eintraege.',
'In diesem MVP dient Cron ausserdem als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.',
],
];
fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable->getMessage() . PHP_EOL);
exit(1);
} finally {
if (is_file($lockFile)) {
unlink($lockFile);
}
}
-27
View File
@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
$app = require dirname(__DIR__) . '/app/bootstrap.php';
$status = [
'app_env' => $app['config']['env'],
'database_configured' => $app['database']->isConfigured(),
'installed' => is_file(dirname(__DIR__) . '/storage/installed.lock'),
];
try {
if ($status['database_configured']) {
$count = $app['database']->pdo()->query('SELECT COUNT(*) FROM tenants')->fetchColumn();
$status['database_reachable'] = true;
$status['tenant_count'] = (int) $count;
} else {
$status['database_reachable'] = false;
}
} catch (\Throwable $throwable) {
$status['database_reachable'] = false;
$status['error'] = $throwable->getMessage();
}
header('Content-Type: application/json');
echo json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
-20
View File
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
$app = require dirname(__DIR__) . '/app/bootstrap.php';
if (!$app['database']->isConfigured()) {
fwrite(STDERR, "Database config missing.\n");
exit(1);
}
$schema = file_get_contents(dirname(__DIR__) . '/database/schema.sql');
if (!is_string($schema) || $schema === '') {
fwrite(STDERR, "Schema missing.\n");
exit(1);
}
$app['database']->pdo()->exec($schema);
fwrite(STDOUT, "Schema applied successfully.\n");
+39
View File
@@ -0,0 +1,39 @@
<?php
### ZUGANGSDATEN AD
$aduser = '';
$adpassword = '';
$domain = '';
## OU für die Suche der Benutzerkonten
#$basedn = '';
$basedn = '';
## OU für die Suche der Benutzerkonten
#$basednandereKasse = '';
## OU für die Suche der Gruppen
$basedngroup = '';
## Zugriffsgruppe für Automatisierungsclient
$zugriffsACgroup = '';
## Zugriffsgruppe für Automatische Abwesenheitsnachricht
$zugriffsAbwesenheitgroup = '';
## Zugriffsgruppe für alle Anfragen des Automatisierungsclient
$zugriffsalleACgroup = '';
## Zugriffsgruppe für Anfragen der Abwesenheitsnachrichten
$zugriffsalleAbwesenheitgroup = '';
## Zugriff auf alles
$zugriffsAdminsgroup = '';
// Verbindung zur Datenbank herstellen (ersetze die Platzhalter durch deine Daten)
$serverName = "";
$connectionOptions = array(
"Database" => "",
"Uid" => "",
"PWD" => "",
"TrustServerCertificate"=>true
);
$conn = sqlsrv_connect($serverName, $connectionOptions);
// Überprüfen der Verbindung
if (!$conn) {
die(print_r(sqlsrv_errors(), true));
}
?>
-47
View File
@@ -1,47 +0,0 @@
<?php
return [
'name' => $_ENV['APP_NAME'] ?? 'Kaffeeliste',
'env' => $_ENV['APP_ENV'] ?? 'production',
'debug' => (($_ENV['APP_DEBUG'] ?? '0') === '1'),
'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin',
'app_key' => $_ENV['APP_KEY'] ?? '',
'allow_public_registration' => (($_ENV['ALLOW_PUBLIC_REGISTRATION'] ?? '0') === '1'),
'rfid_ingest_enabled' => (($_ENV['RFID_INGEST_ENABLED'] ?? '0') === '1'),
'db' => [
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => (int) ($_ENV['DB_PORT'] ?? 3306),
'name' => $_ENV['DB_NAME'] ?? '',
'user' => $_ENV['DB_USER'] ?? '',
'pass' => $_ENV['DB_PASS'] ?? '',
'charset' => 'utf8mb4',
],
'mail' => [
'from' => trim($_ENV['MAIL_FROM'] ?? 'noreply@example.com'),
'reply_to' => trim($_ENV['MAIL_REPLY_TO'] ?? ''),
'subject_prefix' => $_ENV['MAIL_SUBJECT_PREFIX'] ?? '[Kaffeeliste] ',
'log_fallback' => (($_ENV['MAIL_LOG_FALLBACK'] ?? '1') === '1'),
'log_file' => trim($_ENV['MAIL_LOG_FILE'] ?? ''),
],
'password_reset' => [
'reset_url' => $_ENV['PASSWORD_RESET_URL']
?? rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/') . '/reset-password?token={{token}}',
'ttl_minutes' => max(5, (int) ($_ENV['PASSWORD_RESET_TTL_MINUTES'] ?? 60)),
'max_active_tokens' => max(1, (int) ($_ENV['PASSWORD_RESET_MAX_ACTIVE_TOKENS'] ?? 3)),
'subject' => $_ENV['PASSWORD_RESET_SUBJECT'] ?? 'Passwort zurücksetzen',
],
'rate_limits' => [
'login' => [
'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_LOGIN_MAX_ATTEMPTS'] ?? 5)),
'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_WINDOW_SECONDS'] ?? 900)),
'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_BLOCK_SECONDS'] ?? 900)),
],
'registration' => [
'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS'] ?? 3)),
'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_WINDOW_SECONDS'] ?? 3600)),
'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_BLOCK_SECONDS'] ?? 3600)),
],
],
'rfid_shared_secret' => $_ENV['RFID_SHARED_SECRET'] ?? '',
];
+218
View File
@@ -0,0 +1,218 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
// Funktion zum Überprüfen, ob der Mitarbeiter in der Tabelle vorhanden ist
function isMitarbeiterExist($conn, $name)
{
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ?";
$params = array($name);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
return ($row !== false);
}
// Funktion zum Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
function isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)
{
// Um nur den gleichen Tag zu vergleichen, konvertieren wir den Datum-String zu einem DateTime-Objekt und verwenden die Funktion CONVERT
$convertedDatum = date_format(date_create($datum), 'Y-m-d');
#$sql = "SELECT count FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
$sql = "SELECT * FROM dbo.kl_Einzahlungen WHERE MitarbeiterID = ? AND Betrag = ? AND CONVERT(VARCHAR, Datum, 23) = ?";
$params = array($mitarbeiterID, $betrag, $convertedDatum);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
return ($row['MitarbeiterID'] > 0);
}
// Funktion zum Verarbeiten der CSV-Datei und Rückgabe der Ergebnisse
function processCSV($conn, $file)
{
$handle = fopen($file, "r");
$success_entries = [];
$failed_entries = [];
// Überspringe die erste Zeile (Header) der CSV-Datei
fgetcsv($handle);
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
$name = $data[3]; // Index 3 entspricht dem "Name"-Feld in der CSV
$betrag = $data[7]; // Index 7 entspricht dem "Brutto"-Feld in der CSV
$betrag = str_replace(",", ".", $betrag);
#echo "<br>";
#$Ausgabe = array($data[0], $data[3], $data[7]);
// Überprüfen, ob der Mitarbeiter existiert und der Betrag positiv ist
if (isMitarbeiterExist($conn, $name) && $betrag > 0) {
$mitarbeiterID = getMitarbeiterID($conn, $name);
$datum = $data[0]; // Index 0 entspricht dem "Datum"-Feld in der CSV
// Überprüfen, ob ein identischer Eintrag bereits vorhanden ist
if (!isDuplicateEntry($conn, $mitarbeiterID, $betrag, $datum)) {
// Eintrag in die Tabelle dbo.kl_Einzahlungen einfügen
$sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)";
$params = array($mitarbeiterID, $betrag, $datum);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt) {
$success_entries[] = array($data[0], $data[3], $data[7], 0);
} else {
$failed_entries[] = array($data[0], $data[3], $data[7], 1);
#die(print_r(sqlsrv_errors(), true));
}
}else {
#echo "Eintrag bereits vorhanden: " . print_r($data, true) . "\n";
$failed_entries[] = array($data[0], $data[3], $data[7], 2);
}
} else {
$failed_entries[] = array($data[0], $data[3], $data[7], 3);
}
}
fclose($handle);
return ['success' => $success_entries, 'failed' => $failed_entries];
}
// Funktion zum Abrufen der MitarbeiterID
function getMitarbeiterID($conn, $name)
{
$sql = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Name = ? or paypalname = ?";
$params = array($name);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
while( $row = sqlsrv_fetch_array( $stmt, SQLSRV_FETCH_ASSOC))
{
return $row['MitarbeiterID'];
}
#echo $mitarbeiterid;
}
// Überprüfen, ob das Formular abgeschickt wurde
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// Überprüfen, ob eine Datei hochgeladen wurde
if (isset($_FILES["csv_file"]) && $_FILES["csv_file"]["error"] == 0) {
$file_name = $_FILES["csv_file"]["name"];
$file_tmp = $_FILES["csv_file"]["tmp_name"];
// Datei in den Upload-Ordner verschieben
move_uploaded_file($file_tmp, "uploads/" . $file_name);
// CSV-Datei verarbeiten
$result = processCSV($conn, "uploads/" . $file_name);
echo "CSV-Datei erfolgreich verarbeitet.\n";
echo "<h3>Auswertung</h3>";
echo "<table>
<tr>
<th>Name</th>
<th>Datum</th>
<th>Betrag</th>
<th>Ergebnis</th>
</tr>";
foreach ($result['success'] as $eintrag){
echo "<tr>
<td>$eintrag[0]</td>
<td>$eintrag[1]</td>
<td>$eintrag[2]</td>
<td>Erfolgreich gespeichert</td>
</tr>";
}
foreach ($result['failed'] as $eintrag){
echo "<tr>
<td>$eintrag[0]</td>
<td>$eintrag[1]</td>
<td>$eintrag[2]</td>
<td>";
if($eintrag[3] == 1){
echo "SQL Fehler";
}elseif($eintrag[3] == 2){
echo "Eintrag schon vorhanden";
}elseif($eintrag[3] == 3){
echo "Benutzer nicht gefunden";
}
echo "</td>
</tr>";
}
echo "</table>";
} else {
echo "Fehler beim Hochladen der Datei.";
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSV Verarbeitung</title>
</head>
<body>
<form action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post" enctype="multipart/form-data">
<label for="csv_file">CSV-Datei auswählen:</label>
<input type="file" name="csv_file" accept=".csv" required>
<button type="submit">Datei hochladen</button>
</form>
</body>
</html>
<?php
}else{
echo "<h2>Kein Zugriff</h2>";
}
?>
</div>
</section>
<?php include "footer.php"; ?>
-278
View File
@@ -1,278 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
full_name VARCHAR(160) NOT NULL,
email VARCHAR(190) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
platform_role ENUM('platform_admin', 'user') NOT NULL DEFAULT 'user',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
email VARCHAR(190) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
requested_ip_hash CHAR(64) NULL,
requested_user_agent_hash CHAR(64) NULL,
expires_at DATETIME NOT NULL,
consumed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_password_reset_user_created (user_id, created_at),
INDEX idx_password_reset_expires (expires_at),
CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS rate_limits (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action_key VARCHAR(80) NOT NULL,
bucket_key CHAR(64) NOT NULL,
attempts INT UNSIGNED NOT NULL DEFAULT 0,
window_start DATETIME NOT NULL,
last_attempt_at DATETIME NOT NULL,
blocked_until DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_rate_limit_bucket (action_key, bucket_key),
INDEX idx_rate_limit_blocked_until (blocked_until),
INDEX idx_rate_limit_last_attempt (last_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tenants (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
plan ENUM('starter', 'team', 'business') NOT NULL DEFAULT 'starter',
status ENUM('trial', 'active', 'suspended') NOT NULL DEFAULT 'trial',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tenant_domains (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
host VARCHAR(190) NOT NULL UNIQUE,
is_primary TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tenant_domains_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tenant_memberships (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
role ENUM('owner', 'admin', 'member', 'device') NOT NULL DEFAULT 'member',
status ENUM('active', 'invited', 'disabled') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_tenant_user (tenant_id, user_id),
CONSTRAINT fk_memberships_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_memberships_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tenant_settings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'EUR',
default_product_price_cents INT NOT NULL DEFAULT 120,
allow_self_service TINYINT(1) NOT NULL DEFAULT 1,
allow_paper_lists TINYINT(1) NOT NULL DEFAULT 1,
allow_rfid TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_tenant_settings (tenant_id),
CONSTRAINT fk_tenant_settings_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS members (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NULL,
display_name VARCHAR(160) NOT NULL,
email VARCHAR(190) NULL,
access_pin VARCHAR(255) NULL,
status ENUM('active', 'archived') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_member_email (tenant_id, email),
CONSTRAINT fk_members_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_members_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS products (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
sku VARCHAR(60) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_products_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS product_prices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
price_cents INT NOT NULL,
valid_from DATETIME NOT NULL,
valid_until DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_prices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_product_prices_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS capture_sources (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
code VARCHAR(60) NOT NULL,
name VARCHAR(120) NOT NULL,
channel ENUM('paper', 'digital', 'admin', 'rfid') NOT NULL,
is_system TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_capture_source_code (tenant_id, code),
CONSTRAINT fk_capture_sources_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS paper_sheets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(190) NOT NULL,
period_label VARCHAR(120) NULL,
period_start DATE NULL,
period_end DATE NULL,
status ENUM('draft', 'posted') NOT NULL DEFAULT 'draft',
notes TEXT NULL,
created_by_user_id BIGINT UNSIGNED NULL,
posted_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_paper_sheets_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_paper_sheets_user FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS paper_sheet_lines (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
paper_sheet_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity DECIMAL(8,2) NOT NULL DEFAULT 1.00,
effective_at DATETIME NULL,
note VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_paper_sheet_lines_sheet FOREIGN KEY (paper_sheet_id) REFERENCES paper_sheets (id) ON DELETE CASCADE,
CONSTRAINT fk_paper_sheet_lines_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_paper_sheet_lines_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE,
CONSTRAINT fk_paper_sheet_lines_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS consumption_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
source_id BIGINT UNSIGNED NOT NULL,
paper_sheet_line_id BIGINT UNSIGNED NULL,
quantity DECIMAL(8,2) NOT NULL DEFAULT 1.00,
unit_price_cents INT NOT NULL,
total_cents INT NOT NULL,
effective_at DATETIME NOT NULL,
recorded_at DATETIME NOT NULL,
recorded_by_user_id BIGINT UNSIGNED NULL,
source_reference VARCHAR(120) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_consumption_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_consumption_events_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE,
CONSTRAINT fk_consumption_events_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
CONSTRAINT fk_consumption_events_source FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE CASCADE,
CONSTRAINT fk_consumption_events_sheet_line FOREIGN KEY (paper_sheet_line_id) REFERENCES paper_sheet_lines (id) ON DELETE SET NULL,
CONSTRAINT fk_consumption_events_recorded_by FOREIGN KEY (recorded_by_user_id) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS ledger_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NULL,
source_id BIGINT UNSIGNED NULL,
type ENUM('consumption', 'payment', 'correction', 'credit', 'opening_balance') NOT NULL,
reference_type VARCHAR(60) NULL,
reference_id BIGINT UNSIGNED NULL,
description VARCHAR(255) NOT NULL,
occurred_at DATETIME NOT NULL,
created_by_user_id BIGINT UNSIGNED NULL,
reversal_of_entry_id BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ledger_entries_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_ledger_entries_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL,
CONSTRAINT fk_ledger_entries_source FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE SET NULL,
CONSTRAINT fk_ledger_entries_user FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_ledger_entries_reversal FOREIGN KEY (reversal_of_entry_id) REFERENCES ledger_entries (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS ledger_lines (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ledger_entry_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NULL,
account_code ENUM('member_balance', 'cashbox', 'coffee_revenue') NOT NULL,
amount_cents INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ledger_lines_entry FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries (id) ON DELETE CASCADE,
CONSTRAINT fk_ledger_lines_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_ledger_lines_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS rfid_devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
location_label VARCHAR(160) NULL,
device_token VARCHAR(80) NOT NULL UNIQUE,
status ENUM('planned', 'active', 'disabled') NOT NULL DEFAULT 'planned',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rfid_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS rfid_tags (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NULL,
uid_hash CHAR(64) NOT NULL,
label VARCHAR(120) NULL,
status ENUM('active', 'lost', 'retired') NOT NULL DEFAULT 'active',
last_seen_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_rfid_tag (tenant_id, uid_hash),
CONSTRAINT fk_rfid_tags_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_rfid_tags_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS rfid_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
device_id BIGINT UNSIGNED NULL,
external_event_id VARCHAR(120) NULL,
uid_hash CHAR(64) NOT NULL,
payload_json JSON NULL,
status ENUM('received', 'linked', 'ignored', 'error') NOT NULL DEFAULT 'received',
received_at DATETIME NOT NULL,
processed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_rfid_external_event (tenant_id, external_event_id),
CONSTRAINT fk_rfid_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
CONSTRAINT fk_rfid_events_device FOREIGN KEY (device_id) REFERENCES rfid_devices (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS audit_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NULL,
actor_user_id BIGINT UNSIGNED NULL,
action VARCHAR(120) NOT NULL,
target_type VARCHAR(80) NOT NULL,
target_id BIGINT UNSIGNED NULL,
result ENUM('success', 'failure') NOT NULL,
request_id CHAR(36) NOT NULL,
ip_hash CHAR(64) NULL,
user_agent_hash CHAR(64) NULL,
meta_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_tenant_created (tenant_id, created_at),
CONSTRAINT fk_audit_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE SET NULL,
CONSTRAINT fk_audit_events_user FOREIGN KEY (actor_user_id) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-278
View File
@@ -1,278 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
full_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
platform_role TEXT NOT NULL DEFAULT 'user' CHECK(platform_role IN ('platform_admin', 'user')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
requested_ip_hash TEXT,
requested_user_agent_hash TEXT,
expires_at TEXT NOT NULL,
consumed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_password_reset_user_created ON password_reset_tokens (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens (expires_at);
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_key TEXT NOT NULL,
bucket_key TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
window_start TEXT NOT NULL,
last_attempt_at TEXT NOT NULL,
blocked_until TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (action_key, bucket_key)
);
CREATE INDEX IF NOT EXISTS idx_rate_limit_blocked_until ON rate_limits (blocked_until);
CREATE INDEX IF NOT EXISTS idx_rate_limit_last_attempt ON rate_limits (last_attempt_at);
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'starter' CHECK(plan IN ('starter', 'team', 'business')),
status TEXT NOT NULL DEFAULT 'trial' CHECK(status IN ('trial', 'active', 'suspended')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tenant_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
host TEXT NOT NULL UNIQUE,
is_primary INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tenant_memberships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('owner', 'admin', 'member', 'device')),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'invited', 'disabled')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tenant_id, user_id),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tenant_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL UNIQUE,
currency_code TEXT NOT NULL DEFAULT 'EUR',
default_product_price_cents INTEGER NOT NULL DEFAULT 120,
allow_self_service INTEGER NOT NULL DEFAULT 1,
allow_paper_lists INTEGER NOT NULL DEFAULT 1,
allow_rfid INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
user_id INTEGER,
display_name TEXT NOT NULL,
email TEXT,
access_pin TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tenant_id, email),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
name TEXT NOT NULL,
sku TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS product_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
price_cents INTEGER NOT NULL,
valid_from TEXT NOT NULL,
valid_until TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS capture_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
channel TEXT NOT NULL CHECK(channel IN ('paper', 'digital', 'admin', 'rfid')),
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tenant_id, code),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS paper_sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
title TEXT NOT NULL,
period_label TEXT,
period_start TEXT,
period_end TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'posted')),
notes TEXT,
created_by_user_id INTEGER,
posted_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS paper_sheet_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
paper_sheet_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity REAL NOT NULL DEFAULT 1.00,
effective_at TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (paper_sheet_id) REFERENCES paper_sheets (id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS consumption_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
source_id INTEGER NOT NULL,
paper_sheet_line_id INTEGER,
quantity REAL NOT NULL DEFAULT 1.00,
unit_price_cents INTEGER NOT NULL,
total_cents INTEGER NOT NULL,
effective_at TEXT NOT NULL,
recorded_at TEXT NOT NULL,
recorded_by_user_id INTEGER,
source_reference TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE CASCADE,
FOREIGN KEY (paper_sheet_line_id) REFERENCES paper_sheet_lines (id) ON DELETE SET NULL,
FOREIGN KEY (recorded_by_user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS ledger_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
member_id INTEGER,
source_id INTEGER,
type TEXT NOT NULL CHECK(type IN ('consumption', 'payment', 'correction', 'credit', 'opening_balance')),
reference_type TEXT,
reference_id INTEGER,
description TEXT NOT NULL,
occurred_at TEXT NOT NULL,
created_by_user_id INTEGER,
reversal_of_entry_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL,
FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE SET NULL,
FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (reversal_of_entry_id) REFERENCES ledger_entries (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS ledger_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_entry_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
member_id INTEGER,
account_code TEXT NOT NULL CHECK(account_code IN ('member_balance', 'cashbox', 'coffee_revenue')),
amount_cents INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries (id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS rfid_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
name TEXT NOT NULL,
location_label TEXT,
device_token TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'planned' CHECK(status IN ('planned', 'active', 'disabled')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS rfid_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
member_id INTEGER,
uid_hash TEXT NOT NULL,
label TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'lost', 'retired')),
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tenant_id, uid_hash),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS rfid_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
device_id INTEGER,
external_event_id TEXT,
uid_hash TEXT NOT NULL,
payload_json TEXT,
status TEXT NOT NULL DEFAULT 'received' CHECK(status IN ('received', 'linked', 'ignored', 'error')),
received_at TEXT NOT NULL,
processed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tenant_id, external_event_id),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE,
FOREIGN KEY (device_id) REFERENCES rfid_devices (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER,
actor_user_id INTEGER,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER,
result TEXT NOT NULL CHECK(result IN ('success', 'failure')),
request_id TEXT NOT NULL,
ip_hash TEXT,
user_agent_hash TEXT,
meta_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE SET NULL,
FOREIGN KEY (actor_user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_tenant_created ON audit_events (tenant_id, created_at);
-50
View File
@@ -1,50 +0,0 @@
<?php
// Debug script to check PDO drivers in web context
echo "<h1>PDO Debug Information</h1>";
echo "<h2>Available PDO Drivers:</h2>";
echo "<pre>" . implode(', ', PDO::getAvailableDrivers()) . "</pre>";
echo "<h2>PHP Extensions:</h2>";
echo "<pre>";
$extensions = get_loaded_extensions();
sort($extensions);
foreach ($extensions as $ext) {
if (strpos(strtolower($ext), 'pdo') !== false || strpos(strtolower($ext), 'mysql') !== false) {
echo "" . $ext . "\n";
}
}
echo "</pre>";
echo "<h2>MySQL Connection Test:</h2>";
try {
$pdo = new PDO('mysql:host=mysql2f5b.netcup.net;port=3306;dbname=k25330_kaffesaas;charset=utf8mb4', 'k25330_kaffesaas', '~GOf7a3M@qqlif5i', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
echo "<pre style='color: green;'>✓ MySQL connection successful</pre>";
} catch (Exception $e) {
echo "<pre style='color: red;'>✗ MySQL connection failed: " . htmlspecialchars($e->getMessage()) . "</pre>";
}
echo "<h2>Environment Variables:</h2>";
echo "<pre>";
echo "DB_HOST: " . ($_ENV['DB_HOST'] ?? 'not set') . "\n";
echo "DB_PORT: " . ($_ENV['DB_PORT'] ?? 'not set') . "\n";
echo "DB_NAME: " . ($_ENV['DB_NAME'] ?? 'not set') . "\n";
echo "DB_USER: " . ($_ENV['DB_USER'] ?? 'not set') . "\n";
echo "</pre>";
echo "<h2>PHP Version:</h2>";
echo "<pre>" . phpversion() . "</pre>";
echo "<h2>PHP Info (PDO section):</h2>";
ob_start();
phpinfo(INFO_MODULES);
$phpinfo = ob_get_clean();
if (preg_match('/PDO.*?(?=<h2>|$)/s', $phpinfo, $matches)) {
echo $matches[0];
} else {
echo "<pre>PDO section not found in phpinfo</pre>";
}
-322
View File
@@ -1,322 +0,0 @@
# Deployment Guide: Netcup Webspace & Code-Server Proxy
Diese Anleitung erklärt, wie die Kaffeekasse-SaaS-Anwendung sowohl auf einem Netcup Webspace als auch über code-server Proxy-Weiterleitung betrieben werden kann.
## Übersicht
Die Anwendung ist so entwickelt, dass sie automatisch erkennt, ob sie:
1. **Direkt** auf einem Webspace läuft (z.B. `https://example.com/`)
2. **Hinter einem Reverse Proxy** läuft (z.B. `https://code-server.example.com/proxy/8080/`)
## 1. Deployment auf Netcup Webspace
### Voraussetzungen
- PHP 8.0 oder höher
- MySQL/MariaDB Datenbank
- Apache mit mod_rewrite aktiviert
- HTTPS-Zertifikat (Let's Encrypt empfohlen)
### Schritte
1. **Dateien hochladen**
```bash
# Via FTP/SFTP alle Dateien hochladen
# Document Root sollte auf /public/ zeigen
```
2. **.env Datei konfigurieren**
```bash
cp .env.example .env
nano .env
```
Wichtige Einstellungen:
```env
APP_URL=https://ihre-domain.de
APP_BASE_PATH=
DB_HOST=localhost
DB_NAME=ihre_datenbank
DB_USER=ihr_benutzer
DB_PASS=ihr_passwort
MAIL_FROM=noreply@ihre-domain.de
PASSWORD_RESET_URL=https://ihre-domain.de/reset-password?token={{token}}
```
3. **Verzeichnisrechte setzen**
```bash
chmod 755 storage/
chmod 755 storage/cache/
chmod 755 storage/logs/
chmod 755 storage/uploads/
```
4. **Installation durchführen**
- Besuchen Sie `https://ihre-domain.de/install`
- Folgen Sie dem Setup-Assistenten
### Apache Virtual Host Konfiguration (falls nötig)
```apache
<VirtualHost *:443>
ServerName ihre-domain.de
DocumentRoot /var/www/kaffeekasse/public
<Directory /var/www/kaffeekasse/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# SSL Konfiguration
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/ihre-domain.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/ihre-domain.de/privkey.pem
</VirtualHost>
```
## 2. Deployment mit Code-Server Proxy
### Voraussetzungen
- Code-Server läuft und ist erreichbar
- PHP 8.0+ CLI verfügbar
- SQLite oder MySQL/MariaDB
### Schritte
1. **.env Datei konfigurieren**
```env
APP_URL=http://localhost:8080
APP_BASE_PATH=/proxy/8080
# Für SQLite (einfacher für Entwicklung)
DB_HOST=sqlite
DB_NAME=/config/workspace/kaffeeliste-neustart/storage/database.sqlite
# Oder MySQL
DB_HOST=127.0.0.1
DB_NAME=kaffeekasse
DB_USER=root
DB_PASS=
```
2. **PHP Development Server starten**
```bash
cd /config/workspace/kaffeeliste-neustart
php -S 0.0.0.0:8080 -t public public/router.php
```
3. **Zugriff über Code-Server Proxy**
- Die Anwendung ist nun erreichbar über:
`https://ihr-code-server.de/proxy/8080/`
- Code-Server setzt automatisch den Header `X-Forwarded-Prefix`
- Die Anwendung erkennt dies und passt alle URLs an
### Automatischer Start (Optional)
Erstellen Sie ein Systemd-Service oder Screen-Session:
```bash
# Screen-Session
screen -dmS kaffeekasse bash -c 'cd /config/workspace/kaffeeliste-neustart && php -S 0.0.0.0:8080 -t public public/router.php'
# Später wieder verbinden
screen -r kaffeekasse
```
## 3. Wie funktioniert die automatische Erkennung?
### Base Path Detection
Die Funktion `base_path()` in `app/Support/helpers.php` prüft in dieser Reihenfolge:
1. **`$_SERVER['KAFFEEKASSE_PROXY_PREFIX']`** - Manuell gesetzter Prefix
2. **`$_SERVER['HTTP_X_FORWARDED_PREFIX']`** - Von Code-Server gesetzt
3. **`$_ENV['APP_BASE_PATH']`** - Aus .env Datei
```php
function base_path(): string
{
foreach ([
$_SERVER['KAFFEEKASSE_PROXY_PREFIX'] ?? null,
$_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null,
$_ENV['APP_BASE_PATH'] ?? null,
] as $prefix) {
$prefix = trim((string) $prefix);
if ($prefix !== '') {
return normalize_base_path($prefix);
}
}
return '';
}
```
### URL-Generierung
Alle URLs werden dynamisch generiert:
```php
// Beispiele
url('/') // → / oder /proxy/8080/
url('/admin/login') // → /admin/login oder /proxy/8080/admin/login
tenant_url('standort1', 'bookings') // → /t/standort1/bookings oder /proxy/8080/t/standort1/bookings
asset_url('app.css') // → /assets/app.css oder /proxy/8080/assets/app.css
```
### .htaccess Proxy-Unterstützung
Die `.htaccess` leitet HTTPS-Informationen vom Proxy weiter:
```apache
# Proxy-Unterstützung: X-Forwarded-* Header durchreichen
RewriteCond %{HTTP:X-Forwarded-Proto} ^https$
RewriteRule ^ - [E=HTTPS:on]
```
## 4. Troubleshooting
### Problem: URLs zeigen auf falschen Pfad
**Lösung:** Prüfen Sie die Base Path Detection:
```php
// Temporär in public/index.php hinzufügen zum Debuggen
var_dump([
'base_path' => base_path(),
'current_path' => current_path(),
'KAFFEEKASSE_PROXY_PREFIX' => $_SERVER['KAFFEEKASSE_PROXY_PREFIX'] ?? null,
'HTTP_X_FORWARDED_PREFIX' => $_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null,
'APP_BASE_PATH' => $_ENV['APP_BASE_PATH'] ?? null,
]);
```
### Problem: CSS/Assets werden nicht geladen
**Ursache:** Base Path wird nicht korrekt erkannt
**Lösung:** Setzen Sie `APP_BASE_PATH` explizit in der `.env`:
```env
# Für Code-Server Proxy auf Port 8080
APP_BASE_PATH=/proxy/8080
# Für Subdirectory-Installation
APP_BASE_PATH=/kaffeekasse
```
### Problem: Formular-Submissions funktionieren nicht
**Ursache:** CSRF-Token oder falsche Action-URLs
**Lösung:**
1. Prüfen Sie, ob Sessions funktionieren
2. Stellen Sie sicher, dass alle Forms `<?= csrf_field($csrf) ?>` enthalten
3. Verwenden Sie immer `url()` oder `tenant_url()` für Action-Attribute
### Problem: Redirect-Loops
**Ursache:** Proxy-Header werden nicht korrekt weitergeleitet
**Lösung für Nginx Reverse Proxy:**
```nginx
location /proxy/8080/ {
proxy_pass http://localhost:8080/;
proxy_set_header X-Forwarded-Prefix /proxy/8080;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
```
## 5. Sicherheitshinweise
### Für Netcup Webspace
1. **HTTPS erzwingen** - Fügen Sie in `.htaccess` hinzu:
```apache
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
```
2. **Verzeichnisse schützen**
- Stellen Sie sicher, dass nur `/public` öffentlich erreichbar ist
- Dateien außerhalb von `/public` sollten nicht direkt aufrufbar sein
3. **APP_KEY setzen**
```bash
# Generieren Sie einen sicheren Key
php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
```
### Für Code-Server
1. **Nicht für Production verwenden**
- Code-Server Proxy ist für Entwicklung gedacht
- Für Production: Netcup Webspace oder dedizierter Server
2. **Zugriffsbeschränkung**
- Schützen Sie Code-Server mit starkem Passwort
- Verwenden Sie HTTPS
- Beschränken Sie IP-Zugriff wenn möglich
## 6. Migrations-Checkliste
### Von Code-Server zu Netcup
- [ ] Datenbank exportieren (falls SQLite → MySQL)
- [ ] `.env` Datei anpassen (APP_URL, APP_BASE_PATH, DB_*)
- [ ] Dateien via FTP/SFTP hochladen
- [ ] Verzeichnisrechte setzen
- [ ] Datenbank importieren
- [ ] Installation testen
- [ ] HTTPS-Zertifikat einrichten
- [ ] Cron-Jobs einrichten (falls benötigt)
### Von Netcup zu Code-Server
- [ ] Datenbank exportieren
- [ ] `.env` Datei anpassen (APP_BASE_PATH=/proxy/8080)
- [ ] PHP Development Server starten
- [ ] Über Proxy-URL testen
## 7. Performance-Optimierung
### Für Netcup Webspace
1. **OPcache aktivieren** (php.ini):
```ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
```
2. **Session-Speicher optimieren**:
```ini
session.save_handler=files
session.save_path=/tmp
```
### Für Code-Server
1. **SQLite für Entwicklung**:
- Schneller Setup
- Keine separate Datenbank nötig
- Gut für Tests
2. **Development Server Optionen**:
```bash
# Mit mehr Workers
php -S 0.0.0.0:8080 -t public public/router.php
```
## Support
Bei Problemen:
1. Prüfen Sie die Logs in `storage/logs/`
2. Aktivieren Sie Debug-Modus: `APP_DEBUG=1` in `.env`
3. Prüfen Sie PHP Error Logs
4. Konsultieren Sie die Hauptdokumentation in `DEPLOY.md`
-222
View File
@@ -1,222 +0,0 @@
# Go-Live-Checkliste fuer Netcup Webspace
Diese Checkliste ist fuer den ersten echten Betrieb auf Netcup Webspace gedacht.
Sie baut auf [DEPLOY.md](/config/workspace/kaffeeliste-neustart/DEPLOY.md),
[RUNBOOK.md](/config/workspace/kaffeeliste-neustart/RUNBOOK.md) und
[docs/production-blueprint.md](/config/workspace/kaffeeliste-neustart/docs/production-blueprint.md)
auf, ist aber absichtlich strenger und operativer.
## 1. Go-Live-Scope festziehen
- [ ] Entscheiden, ob der erste Livegang ein geschlossener Pilot oder ein oeffentliches SaaS-Launch ist.
- [ ] Fuer einen oeffentlichen Launch Registrierung absichern oder vorerst deaktivieren.
Das Repo nennt Mail-Verifikation, Passwort-Reset und Rate-Limits selbst als offene Punkte vor echtem Go-Live.
- [ ] Falls diese Punkte noch fehlen: nur mit eingeladenen Testkunden oder manuell freigeschalteten Mandanten live gehen.
- [ ] Einen Release-Owner und einen Restore-Owner benennen.
- [ ] Ein Wartungsfenster fuer den Erststart und fuer kuenftige Releases definieren.
## 2. Environment-Modell festlegen
Empfehlung fuer Netcup Webspace:
- [ ] `local`: Entwicklung lokal.
- [ ] `staging`: echte Netcup-Subdomain wie `staging.example.tld`, eigene DB, eigene `.env`.
- [ ] `production`: produktive Domain, eigene DB, eigene `.env`.
Pragmatisches Verzeichnislayout ohne Root-Zwang:
- [ ] `staging` unter `/apps/kaffeekasse-stage/current`
- [ ] `production` unter `/apps/kaffeekasse/current`
- [ ] Offsite- oder Backup-Artefakte nicht im Webroot ablegen.
Wichtig fuer dieses Repo:
- [ ] `public/` bleibt der einzige Webroot.
- [ ] `.env` wird aus dem Projektwurzelverzeichnis geladen.
- [ ] Der Browser-Installer schreibt `.env` in das Projektwurzelverzeichnis und `storage/installed.lock` in `storage/`.
- [ ] `bin/cron.php` schreibt `storage/cron.lock`.
- [ ] Deshalb muessen Projektwurzel und `storage/` mindestens fuer den Deployment-User, besser auch fuer den Webspace-User, sauber beschreibbar sein.
## 3. Netcup vorbereiten
- [ ] Domain oder Subdomain im CCP/WCP anlegen.
- [ ] DNS frueh setzen und Aufloesung pruefen.
- [ ] In Plesk/WCP pro Environment eine eigene Site mit eigenem Document Root anlegen.
- [ ] Document Root fuer jedes Environment auf `.../current/public` setzen.
- [ ] Pro Environment eine eigene MySQL-Datenbank und einen eigenen DB-User anlegen.
- [ ] PHP-Version im WCP fest auf 8.3 oder 8.4 pinnen und nicht auf "System Default" lassen.
- [ ] Dieselbe PHP-Hauptversion fuer Web und CLI verwenden.
## 4. Secrets und Konfiguration vorbereiten
Pflichtwerte laut App:
- [ ] `APP_URL`
- [ ] `APP_ENV`
- [ ] `APP_DEBUG=0`
- [ ] `APP_TIMEZONE`
- [ ] `APP_KEY`
- [ ] `DB_HOST`
- [ ] `DB_PORT`
- [ ] `DB_NAME`
- [ ] `DB_USER`
- [ ] `DB_PASS`
- [ ] `RFID_SHARED_SECRET`
Empfehlung:
- [ ] `staging` und `production` nie dieselben Secrets oder dieselbe DB teilen.
- [ ] Admin-Zugang fuer die Plattform separat dokumentieren und im Passwortsafe ablegen.
- [ ] Wenn der Browser-Installer verwendet wird: danach `.env` sichern und offsite ablegen.
- [ ] Wenn `.env` manuell gepflegt wird: `.env` vor dem ersten Deploy lokal erzeugen und erst dann hochladen.
## 5. Erstes Staging-Deployment
- [ ] Lokal oder in CI `scripts/build-release.sh` ausfuehren.
- [ ] Release-Artefakt sicher ablegen. Es ist Teil des Rollback-Pfads.
- [ ] Artefakt per FTPES oder SFTP nach Netcup hochladen.
- [ ] Paket in den festen Environment-Pfad entpacken.
- [ ] Pruefen, dass `.env`, `storage/uploads`, `storage/logs` und `storage/cache` nicht versehentlich ueberschrieben oder geloescht wurden.
Das Build-Skript schliesst diese Pfade aus dem Release aus.
- [ ] Falls der Browser-Installer genutzt wird: `/install` nur auf `staging` einmal durchlaufen.
- [ ] Falls manuell installiert wird: `.env` hochladen und danach
`/usr/local/php83/bin/php /apps/kaffeekasse-stage/current/bin/migrate.php`
oder die zu eurer PHP-Version passende Binary ausfuehren.
- [ ] Pruefen, dass `storage/installed.lock` angelegt wurde.
## 6. Staging-Smoketest vor Production
- [ ] Startseite laden.
- [ ] `/admin/login` erfolgreich testen.
- [ ] Einen Test-Mandanten anlegen.
- [ ] Tenant-Login pruefen.
- [ ] Eine Testbuchung und eine Test-Einzahlung anlegen.
- [ ] `/usr/local/php83/bin/php /apps/kaffeekasse-stage/current/bin/healthcheck.php` ausfuehren und JSON pruefen.
- [ ] `/usr/local/php83/bin/php /apps/kaffeekasse-stage/current/bin/cron.php` manuell ausfuehren.
- [ ] Pruefen, dass `cron.lock` nach dem Lauf wieder verschwindet.
## 7. SSL und HTTPS
- [ ] Erst SSL ausrollen, wenn DNS sauber auf Netcup zeigt.
- [ ] Im WCP fuer jede produktive Domain ein Let's-Encrypt-Zertifikat ausstellen.
- [ ] `www` nur dann mit absichern, wenn `www` auch wirklich genutzt werden soll.
- [ ] Fuer den ersten Go-Live kein Wildcard-Zertifikat verwenden, wenn es nicht zwingend noetig ist.
Bei Netcup werden Wildcard-Zertifikate nicht automatisch erneuert.
- [ ] HTTPS fuer die produktive Domain erzwingen.
- [ ] Nach Aktivierung pruefen, dass alle Logins und Formulare nur noch ueber HTTPS laufen.
- [ ] Session-Cookies unter HTTPS pruefen, weil `secure` in der App vom HTTPS-Kontext abhaengt.
## 8. Backup-Strategie produktionsreif machen
Minimum:
- [ ] Taegliches DB-Backup einrichten.
- [ ] Vor jedem Release ein frisches manuelles DB-Backup ziehen.
- [ ] `.env` offsite sichern.
- [ ] `storage/uploads` offsite sichern.
- [ ] Letztes stabiles Release-Artefakt offsite sichern.
Netcup-spezifisch:
- [ ] Falls euer Tarif den Backup Manager anbietet: wiederkehrendes Backup in WCP aktivieren.
- [ ] Backups regelmaessig herunterladen oder an einen zweiten Ort kopieren.
- [ ] Backup Manager nicht als einzigen Disaster-Recovery-Pfad betrachten.
Netcup dokumentiert, dass diese Backups nur auf demselben Hosting wiederhergestellt werden sollen.
## 9. Cron sauber einrichten
- [ ] In WCP eine geplante Aufgabe fuer `bin/cron.php` alle 5 Minuten anlegen.
- [ ] Dieselbe PHP-Version verwenden wie fuer die Website.
- [ ] Den Task vor dem Speichern mit "Run Now" testen.
- [ ] Wenn WCP auf eurem Tarif mit chroot oder relativen Pfaden zickt: den final funktionierenden Befehl direkt dokumentieren.
- [ ] Eine zweite geplante Aufgabe fuer `bin/healthcheck.php` einrichten, mindestens stuendlich oder taeglich.
- [ ] Task-Ausgaben in eine Mail oder Logdatei laufen lassen, statt sie still zu verwerfen.
Wichtige Einschraenkung dieses Repos:
- [ ] `bin/healthcheck.php` liefert JSON, setzt aber bei DB-Problemen derzeit keinen expliziten Exit-Code ungleich 0.
- [ ] Fuer Alarmierung deshalb nicht nur auf "Errors only" bei Scheduled Tasks vertrauen.
- [ ] Zusaetzlich einen externen HTTPS-Uptime-Check auf Startseite oder Login-Seite einrichten.
## 10. Monitoring-Minimum fuer den Start
- [ ] Externes Uptime-Monitoring auf die produktive HTTPS-URL aktivieren.
- [ ] Plesk-/PHP-Error-Logs in die Regelbetriebsroutine aufnehmen.
- [ ] Nach dem Go-Live taeglich Audit-Ereignisse fuer fehlgeschlagene Logins und Access-Denied pruefen.
- [ ] Wachstum der Tabellen `consumption_events`, `ledger_entries` und `audit_events` beobachten.
- [ ] Offene RFID-Ereignisse aus dem Cron-Output beobachten, falls RFID pilotiert wird.
- [ ] Einen klaren Empfaenger fuer Alarmmails hinterlegen.
## 11. Release-Prozess fuer echte Produktion festlegen
Empfohlener erster Produktionsprozess auf Netcup Webspace:
- [ ] Aenderungen auf `staging` komplett durchtesten.
- [ ] Release-Artefakt mit Zeitstempel bauen.
- [ ] Direkt vor dem Produktionsdeploy DB-Backup erstellen.
- [ ] `.env` und persistente `storage/`-Inhalte zusatzlich sichern.
- [ ] Release nach `production` hochladen.
- [ ] `bin/migrate.php` ausfuehren.
- [ ] `bin/healthcheck.php` ausfuehren.
- [ ] Admin-Login, Tenant-Login und Testbuchung sofort pruefen.
- [ ] Den naechsten automatischen Cron-Lauf beobachten.
- [ ] Erst danach das Release als "stabil" markieren.
Wichtige Einschraenkung dieses Repos:
- [ ] `bin/migrate.php` spielt nur `database/schema.sql` erneut ein.
- [ ] Das aktuelle `schema.sql` arbeitet mit `CREATE TABLE IF NOT EXISTS` und ist damit fuer Erstinstallation geeignet, aber kein vollwertiges inkrementelles Migrationssystem.
- [ ] Vor dem ersten Release mit DB-Schema-Aenderungen eine klare Migrationskonvention festlegen, zum Beispiel versionierte SQL-Dateien plus Runbook-Schritt.
## 12. Fallback und Restore vorher ueben
Rollback fuer einen fehlgeschlagenen Release:
- [ ] Letztes stabiles Release-Artefakt erneut deployen.
- [ ] Wenn noetig DB-Backup vom Start des Releases einspielen.
- [ ] `.env` und `storage/uploads` wiederherstellen.
- [ ] `bin/healthcheck.php` ausfuehren.
- [ ] Admin-Login, Tenant-Login und Testbuchung pruefen.
Restore fuer echten Ausfall:
- [ ] Zielreihenfolge dokumentieren: Code, `.env`, Uploads, DB, Healthcheck, Smoke-Test.
- [ ] Restore nicht nur theoretisch planen, sondern einmal auf `staging` ueben.
- [ ] Ziel-RTO und Ziel-RPO festlegen, auch wenn sie anfangs noch pragmatisch sind.
- [ ] Ansprechpartner und Zugangsdaten fuer Restore ausserhalb des Repos dokumentieren.
## 13. Finale Go-Live-Freigabe
Nur live schalten, wenn alles abgehakt ist:
- [ ] Production-Domain zeigt auf Netcup und loest korrekt auf.
- [ ] SSL ist gueltig und HTTPS wird erzwungen.
- [ ] `public/` ist einziger Webroot.
- [ ] `.env`, Backups, Dumps und `.git` sind nicht oeffentlich erreichbar.
- [ ] DB-Backup von heute existiert.
- [ ] Cron laeuft erfolgreich.
- [ ] Externes Monitoring prueft die Seite.
- [ ] Admin-Login und ein Test-Tenant funktionieren.
- [ ] Restore wurde mindestens einmal auf `staging` geprobt.
- [ ] Es gibt eine bewusste Entscheidung, ob offene Registrierung bereits verantwortbar ist.
## Quellen
- Repo-intern:
[DEPLOY.md](/config/workspace/kaffeeliste-neustart/DEPLOY.md),
[RUNBOOK.md](/config/workspace/kaffeeliste-neustart/RUNBOOK.md),
[docs/production-blueprint.md](/config/workspace/kaffeeliste-neustart/docs/production-blueprint.md),
[app/bootstrap.php](/config/workspace/kaffeeliste-neustart/app/bootstrap.php),
[config/app.php](/config/workspace/kaffeeliste-neustart/config/app.php),
[app/Services/SetupService.php](/config/workspace/kaffeeliste-neustart/app/Services/SetupService.php),
[bin/migrate.php](/config/workspace/kaffeeliste-neustart/bin/migrate.php),
[bin/cron.php](/config/workspace/kaffeeliste-neustart/bin/cron.php),
[bin/healthcheck.php](/config/workspace/kaffeeliste-neustart/bin/healthcheck.php),
[scripts/build-release.sh](/config/workspace/kaffeeliste-neustart/scripts/build-release.sh)
- Offizielle Doku:
https://www.netcup.com/en/helpcenter/documentation/web-hosting/php-shell
https://www.netcup.com/en/helpcenter/documentation/web-hosting/scheduled-tasks
https://www.netcup.com/en/helpcenter/documentation/web-hosting/enabling-ssl-tls
https://www.netcup.com/en/helpcenter/documentation/web-hosting/backup-manager
https://docs.plesk.com/en-US/obsidian/customer-guide/scheduling-tasks.65207/
https://www.plesk.com/kb/docs/managing-web-hosting-changing-the-document-root-directory/
-394
View File
@@ -1,394 +0,0 @@
# Go-to-Market 30-60-90 Tage
Stand: 2026-06-15
## Kurzfazit
Die naechsten 90 Tage sollten nicht mit breitem Marketing, sondern mit einem
scharfen Beachhead beginnen:
- Primaere Zielgruppe: Teams, Vereine und kleine Community-Standorte mit 10-50
aktiven Nutzern, die heute Papier, Excel oder lose Strichlisten verwenden.
- Sekundaere Zielgruppe: Coworking-Standorte nur als sales-assistierter Pilot,
nicht als Hauptbotschaft auf der allgemeinen Startseite.
- Kernversprechen: `Papier bleibt moeglich. Abrechnung wird sauber. RFID kommt,
wenn es sich wirklich lohnt.`
Das Ziel bis Tag 90 ist nicht Reichweite, sondern belastbares Marktfeedback:
- 10+ Discovery-Gespraeche
- 3-5 aktive Pilotkunden
- 2 zahlungsbereite Referenzen oder Verlangerungen
- ein belastbarer Preiskorridor
- ein sauber messbarer Lead-zu-Aktivierungs-Funnel
## Repo-basierte Diagnose
Das Produkt ist fuer fruehe Pilotkunden weiter als die Vermarktung.
- Die Startseite erklaert das System technisch stark, aber den operativen Nutzen
noch zu wenig. In [resources/views/home/index.php](../resources/views/home/index.php)
stehen aktuell `PHP/MySQL`, `Netcup-Webspace` und `RFID-Roadmap` sehr weit
vorne.
- Die CTA-Struktur ist zu hart fuer Erstkontakt. Auf der Startseite gibt es
aktuell primaer `Mandant starten`, aber keinen sichtbaren `Demo ansehen`,
`Pilot anfragen` oder `Kontakt`.
- Die Paketsektion nennt Pakete, aber keine oeffentlich validierte Preislogik,
keine Mitgliedergrenzen und keine Testzusage.
- Tracking ist in [docs/marketing-ops.md](./marketing-ops.md) sauber gedacht,
aber im Produkt noch nicht sichtbar umgesetzt.
- Gleichzeitig ist das Produkt onboardingfaehig:
[app/Services/TenantRegistrationService.php](../app/Services/TenantRegistrationService.php)
legt beim Signup bereits Owner, Standardprodukte, Quellen und Grundeinstellungen
an. Das ist eine gute Basis fuer Demo, Trial und Pilot.
## Positionierung
### Empfehlung
Nicht `Kaffeekasse fuer alle` vermarkten. Stattdessen ein klares Einstiegsproblem
verkaufen:
- `Wir ersetzen nicht eure Gewohnheit auf einen Schlag.`
- `Wir machen aus Papier, Nachtrag und Selbstbedienung eine saubere Abrechnung.`
- `Wenn ihr spaeter RFID wollt, muesst ihr nicht neu anfangen.`
### Empfohlene Marktansprache
- Primaer: Vereinsvorstand, Getraenkewart, Office Manager, Team-Admin,
Community Manager, die heute Fehler, Rueckfragen und fehlende Einzahlungen
manuell klaeren.
- Sekundaer: Coworking-Betreiber mit Community-Kueche oder Honesty Bar. Hier
ist das Produkt eher eine schlanke Beverage-Operations-Loesung als eine
komplette Coworking-Plattform.
### Messaging-Hierarchie
- Hauptclaim: `Die Kaffeekasse fuer Teams und Vereine, die noch nicht ganz auf
Papier verzichten wollen.`
- Unterclaim: `Digitale Buchungen, Papier-Nacherfassung und spaeter RFID - alles
in einer nachvollziehbaren Abrechnung.`
- Beweis 1: `Mitglieder, Produkte, Einzahlungen und Salden in einem Ablauf`
- Beweis 2: `Sofort startklar im Browser, Tablet oder Kiosk`
- Beweis 3: `RFID nicht heute noetig, aber morgen anschliessbar`
### Was nicht in die Hero-Message gehoert
- `PHP/MySQL`
- `Netcup`
- `mandantenfaehig`
- `Ledger`
Diese Begriffe sind sinnvoll fuer Betrieb, Doku und Technikvertrauen, aber nicht
fuer den ersten Kaufimpuls.
## Demo-Setup
### Warum das jetzt Prioritaet hat
Aktuelle Wettbewerber kombinieren sehr oft Demo oder Trial mit sehr einfacher
Preislogik. Stand 2026-06-15:
- Getraenkewart kommuniziert eine sofortige Demo und eine kostenlose kleine
Einstiegsstufe.
- Clubfridge stellt ein Testsystem und 30 Tage Gratis-Test in den Vordergrund.
- Cobot kombiniert `Try it for free` und `Schedule a demo`.
Die Lehre daraus: Frueher Markt will erst sehen, dann glauben, dann kaufen.
### Empfohlenes Demo-Setup
- Ein `Team-Demo` mit 12 Mitgliedern, 4 Produkten, 30 Buchungen und 3
Einzahlungen
- Ein `Verein-Demo` mit Papierliste als Entwurf plus verbuchter Papierliste
- Ein `Coworking-Demo` mit vorbereiteten RFID-Geraeten, Tags und Events, aber
ohne die RFID-Roadmap als Hauptverkaufspunkt zu missbrauchen
### Empfohlene Demo-Artefakte
- Oeffentliche Browser-Demo mit taeglichem oder stuendlichem Reset
- 7-Minuten-Live-Demo-Skript
- 90-Sekunden-Klickpfad fuer die Startseite:
`Mitglied anlegen -> Buchung erfassen -> Einzahlung buchen -> Papierliste
verbuchen`
- Einseitiges Pilot-PDF:
`Was wird in 14 Tagen gemeinsam getestet?`
### Demo-Story
- Problem: `Striche fehlen, Spaetzahler fehlen, niemand will am Monatsende
rechnen`
- Heute: `Papier bleibt erlaubt`
- Morgen: `Digitale Selbstbuchung spart Rueckfragen`
- Spaeter: `RFID ist ein Upgrade, kein Neuanfang`
## Landingpage-Verbesserungen
### Sofort priorisieren
- Hero von technischer auf operative Sprache drehen
- Primaere CTA auf `Demo ansehen` oder `Pilot anfragen`
- Sekundaere CTA auf `Kostenlos testen`
- Einen Abschnitt `Fuer wen ist das?` mit drei Kacheln:
`Team`, `Verein`, `Coworking-Kueche`
- Einen Abschnitt `So startet ihr in 1 Nachmittag`
- Einen Abschnitt `Papier heute, digital morgen, RFID spaeter`
### Danach
- Preissektion mit realen Stufen statt nur Paketnamen
- FAQ zu `Brauchen wir App-Downloads?`, `Kann Papier bleiben?`, `Was kostet
RFID spaeter?`, `Wie schnell sind wir live?`
- Vertrauenselemente:
`Hosting in DE/EU`, `Datenschutz`, `keine Kreditkarte fuer Pilot`, `persoenliche
Begleitung`
- Spater: Logos, Zitate und Mini-Case-Studies von echten Piloten
### Konkrete Copy-Richtung fuer die Hero
- Headline: `Die Kaffeekasse ohne Zettelchaos`
- Subline: `Erfasst Kaffee, Getraenke und Einzahlungen digital, fuehrt
Papierlisten weiter und haltet Salden sauber nachvollziehbar - fuer Teams,
Vereine und Community-Standorte.`
- CTA 1: `Demo ansehen`
- CTA 2: `Pilot starten`
## Lead-Funnel
### Zielbild
Nicht direkt von `Landing Visit` zu `voller Self-Service-Registrierung`
optimieren. Dazwischen braucht es eine niedrigere Huerde.
Empfohlener Funnel:
1. Landing Visit
2. CTA Klick auf `Demo ansehen` oder `Pilot anfragen`
3. Lead erfasst
4. Demo angesehen oder Termin gebucht
5. Pilot zugesagt
6. Tenant angelegt
7. Erste Buchung innerhalb von 24 Stunden
8. Erste Einzahlung oder erste Papierliste innerhalb von 7 Tagen
9. Verlaengerung oder Upgrade
### Minimaler Funnel fuer die ersten 90 Tage
- Kanal 1: Warmes Netzwerk
- Kanal 2: Direktansprache lokaler Vereine, Coworking-Spaces, Bueros,
Feuerwehren, Gemeinschaftsraeume
- Kanal 3: 2-3 kurze Content-Stuecke mit konkretem Problemfokus:
`Excel vs. Kaffeeliste`, `Papierliste sauber nacherfassen`, `Wann lohnt sich
RFID wirklich?`
### Tracking, das sofort noetig ist
Die Eventnamen existieren schon in [docs/marketing-ops.md](./marketing-ops.md).
Fuer die ersten 90 Tage reicht:
- `landing_view`
- `demo_clicked`
- `pilot_requested`
- `register_started`
- `tenant_registered`
- `first_consumption_recorded`
- `first_payment_recorded`
- `paper_sheet_created`
### CRM-Minimum
Noch kein grosses CRM-Projekt starten. Eine einfache Tabelle oder ein sehr
schlankes CRM reicht mit diesen Feldern:
- Organisation
- Segment
- Ansprechpartner
- Problem heute
- Anzahl Nutzer
- Heute Papier, digital oder gemischt
- Interesse an RFID spaeter: ja/nein
- Status: `neu`, `demo`, `pilot`, `aktiv`, `verloren`
- Grund fuer Verlust
## Pricing-Validierung
### Marktbeobachtung am 2026-06-15
- Getraenkewart kommuniziert eine einfache Preislogik von `20 Cent pro Mitglied
und Monat`, mit `0 EUR` fuer sehr kleine Teams und `5 EUR / Monat` fuer 25
Mitglieder.
- selbstbedienBar zeigt eine kostenlose Einstiegsstufe mit bis zu 5 Usern.
- Clubfridge bewirbt 30 Tage Gratis-Test und argumentiert ueber geringe
laufende Kosten und klare Einsparung von Verwaltungsaufwand.
- Cobot liegt als Coworking-Management-Plattform deutlich hoeher und eignet sich
eher als Referenz fuer den sales-assistierten Coworking-Pfad als fuer die
breite Preiserwartung im Vereins- und Teamsegment.
### Schlussfolgerung
Der Team-/Vereinsmarkt ist preissensibel. Eine `normale B2B-SaaS-Optik` mit
hohem Einstiegspreis wird dort frueh bremsen. Gleichzeitig ist Coworking als
Segment eher bereit, fuer operative Zuverlaessigkeit und spaetere Integrationen
mehr zu zahlen.
### Validierungsansatz
Nicht sofort eine endgueltige Preisarchitektur festschreiben. In den ersten
Piloten zwei Preislogiken testen:
- Variante A: flache Standortpreise
- Beispiel: `9 EUR`, `19 EUR`, `39 EUR`
- Variante B: faire Mitgliederlogik
- Beispiel: `0,20 EUR pro aktivem Mitglied/Monat` mit Mindestpreis
### Was oeffentlich sichtbar sein sollte
- 30 Tage Pilot oder Testphase
- Kein Kreditkarten-Zwang am Anfang
- RFID nicht oeffentlich festpreisen, sondern `auf Anfrage / Pilot`
- Klarer Satz: `Fuer groessere Standorte oder RFID-Szenarien sprechen wir kurz
direkt miteinander`
### Was in jedem Gespraech abgefragt werden sollte
- Wie viele aktive Nutzer gibt es wirklich?
- Wie viel Verwaltungszeit spart das pro Monat?
- Ist eher `pro Mitglied` oder `pro Standort` gefuehlt fair?
- Wuerde man ohne RFID schon zahlen?
- Ist Papier-Nacherfassung kaufentscheidend oder nur hilfreich?
## Erster Pilotkundenprozess
### Idealer Pilotkunde
- 10-50 aktive Nutzer
- klares heutiges Problem mit Papier, Excel oder Nachbuchungen
- ein verantwortlicher Owner
- Bereitschaft, 14 Tage aktiv zu testen
- Bereitschaft fuer 2 Feedback-Termine
### Pilotablauf
1. 20-Minuten-Qualifizierung
2. 30-Minuten-Demo entlang des echten Prozesses
3. Pilotzusage mit klarem Testziel
4. Tenant anlegen und Owner live einrichten
5. Binnen 24 Stunden erste echte Buchung
6. Tag 7: Check-in zu Aktivierung, Rueckfragen, Widerstaenden
7. Tag 14 oder 21: Review, Preisgespraech, Verlaengerung oder Abschluss
### Pilot-Erfolgskriterien
- Owner ist aktiv
- mindestens 5-10 Mitglieder angelegt
- mindestens 20 echte Buchungen
- mindestens 1 Einzahlung oder 1 Papierlisten-Posting
- kein schwerer Vertrauensbruch in Salden oder Nachvollziehbarkeit
- klare Aussage: `Wuerden wir dafuer zahlen?`
### Pilotangebot
- `Begleiteter Pilot fuer 14 oder 30 Tage`
- `Einrichtung gemeinsam in 30 Minuten`
- `Keine Datenmigration noetig`
- `Feedback gegen guenstige Early-Adopter-Konditionen`
## Priorisierte 30-60-90-Tage-Empfehlung
## Tage 1-30
Ziel: Message klaeren, Demo faehig machen, erste Gespraeche starten.
- Einen Beachhead final auswaehlen:
`Teams/Vereine mit Hybrid-Prozess` sollte aktuell gewinnen.
- Startseite sprachlich neu ausrichten und technische Begriffe aus der Hero nach
unten verschieben.
- Demo-Umgebung mit 2-3 vorbereiteten Tenants aufsetzen.
- CTA-Struktur erweitern:
`Demo ansehen`, `Pilot anfragen`, `Kostenlos testen`
- Minimales Tracking fuer Landing, Demo, Signup und Aktivierung einbauen.
- Outreach-Liste mit 50 passenden Erstkontakten aufbauen, zuerst warm, dann
lokal-kalt.
- 10 Discovery-Gespraeche fuehren.
- 3 Pilotkunden anbahnen.
Messbar bis Tag 30:
- 10 Gespraeche
- 3 qualifizierte Pilotkandidaten
- 1 funktionierende Browser-Demo
- 1 Landingpage-Version mit klarer Botschaft
## Tage 31-60
Ziel: Piloten aktivieren, Preissignale einsammeln, erste Beweise erzeugen.
- 2-3 Piloten live nehmen.
- Zwei Pricing-Frames in Gespraechen testen:
`pro Standort` vs. `pro Mitglied`
- Erste Landingpage-Variante nach Segment pruefen:
`Verein/Team` gegen `Coworking`
- Pilot-Onboarding standardisieren:
Checkliste, Demo-Skript, Follow-up-Mail, Tag-7-Review
- Ein erstes Mini-Case-Study-Format bauen:
Problem, Einfuehrung, erste Ergebnisse, Zitat
- Die haeufigsten Einwaende und Kaufgruende dokumentieren.
Messbar bis Tag 60:
- 2-3 aktive Piloten
- 70% der Piloten mit erster Buchung in 24 Stunden
- 2 belastbare Preisreaktionen pro Segment
- 1-2 verwertbare Kundenstimmen
## Tage 61-90
Ziel: Gewinner schaerfen, erste bezahlte Wiederholbarkeit bauen.
- Gewinner-Positionierung oeffentlich festziehen.
- Eine oeffentliche Preislogik veroeffentlichen, aber RFID weiter als
gesonderten Sales-Pfad lassen.
- Referenzen und Vertrauenselemente auf die Startseite bringen.
- Den Pilotprozess in einen wiederholbaren `Demo -> Pilot -> Paid` Ablauf
ueberfuehren.
- 2 Piloten in bezahlte Kunden oder verlaengerte Nutzung umwandeln.
- Entscheiden, ob Coworking eine eigene Unterseite und eigene Sales-Story
verdient oder vorerst nur Upside bleibt.
Messbar bis Tag 90:
- 3-5 aktive Pilotkunden insgesamt
- 2 zahlungsbereite oder zahlende Referenzkunden
- 1 veroeffentlichte Preislogik
- klarer Entscheid zur Rolle von Coworking im GTM
## Klare Priorisierung
Wenn nur wenig Kapazitaet da ist, dann in genau dieser Reihenfolge:
1. Positionierung fuer einen Beachhead
2. Demo und Pilotprozess
3. Landingpage-CTA und Tracking
4. Pricing-Validierung in echten Gespraechen
5. Erst danach breitere Content- oder Paid-Massnahmen
## Was ich explizit nicht priorisieren wuerde
- Paid Ads vor dem ersten wiederholbaren Pilotprozess
- eine zu breite Positionierung fuer Teams, Vereine und Coworking gleichzeitig
- oeffentliche RFID-Versprechen, die noch keine echte Kaufhuerde geloest haben
- grosse CRM- oder Marketing-Automation-Projekte vor den ersten 3-5 Piloten
## Externe Referenzen
Stand 2026-06-15:
- Getraenkewart Startseite: https://getraenkewart.com/
- Getraenkewart Preise: https://getraenkewart.com/preise/
- Getraenkewart FAQ: https://getraenkewart.com/faq
- Clubfridge Startseite: https://clubfridge.com/
- Clubfridge Testsystem / Testen: https://clubfridge.com/clubfridge-testen/
- Clubfridge RFID-Artikel: https://clubfridge.com/rfid-kuehlschrank-im-vereinsheim-einrichten-so-funktioniert-die-digitale-getraenkekasse/
- selbstbedienBar Preise: https://www.selbstbedienbar.de/preise/
- Cobot Pricing: https://www.cobot.me/en/pricing
- Cobot Startseite: https://www.cobot.me/en/
-51
View File
@@ -1,51 +0,0 @@
# Marketing-Operations
## Ziel
Der Produktauftritt soll nicht nur schoen aussehen, sondern einen messbaren
Pfad von `Interesse` zu `aktivem Tenant` schaffen.
## Funnel-Stufen
1. `Landing Visit`
2. `CTA Klick auf Registrierung`
3. `Tenant registriert`
4. `Erster Login`
5. `Erste Buchung`
6. `Erste Papierliste oder erstes RFID-Setup`
## Tracking-Ereignisse
- `landing_view`
- `pricing_view`
- `register_started`
- `tenant_registered`
- `tenant_login_success`
- `first_consumption_recorded`
- `paper_sheet_created`
- `rfid_device_created`
## Tooling-Empfehlung
- datenschutzfreundliches Web-Analytics statt schwerem Adtech-Setup
- Formular-Events serverseitig oder ueber schlankes Frontend-Tracking mitschreiben
- CRM-Anbindung zunaechst ueber CSV-Export oder Webhook-Fassade
- spaeter: automatisch segmentierte Onboarding-Mails pro Paket
## Operative Fragen fuer den Go-Live
- Welche CTA-Version konvertiert fuer Vereine besser: `Digitale Strichliste` oder
`Kaffeekasse digitalisieren`?
- Wie viele registrierte Tenants legen binnen 24 Stunden Mitglieder und Produkte
an?
- Wo brechen Interessenten ab: auf der Landingpage, im Signup oder erst beim
Login?
## Direkt im Produkt nutzbar
Die Landingpage im Repo ist bereits auf diese Funnel-Logik abgestimmt:
- klares Problem-Narrativ
- Paketdarstellung
- direkter CTA zur Registrierung
- technischer Vertrauensaufbau ueber Netcup-, Papier- und RFID-Kompatibilitaet
-207
View File
@@ -1,207 +0,0 @@
# Netcup Deployment Guide - Kaffeekasse SaaS
Dieser Guide führt Sie Schritt für Schritt durch das Deployment der Kaffeekasse SaaS auf Netcup Webspace.
## Übersicht
Die Kaffeekasse SaaS ist bereits vollständig für Netcup Webspace vorbereitet. Dieses Deployment-Guide ergänzt die bestehende Dokumentation ([DEPLOY.md](../DEPLOY.md), [docs/go-live-checklist-netcup.md](go-live-checklist-netcup.md)) um praktische Schritte.
## Voraussetzungen
- Netcup Webspace mit PHP 8.3+ und MySQL
- FTP/SFTP Zugang zu Ihrem Webspace
- Domain oder Subdomain für die Anwendung
## Schritt 1: Lokale Vorbereitung
### 1.1 Deployment-Skript ausführen
```bash
# Im Projektverzeichnis
./scripts/deploy-netcup.sh
```
Das Skript erstellt:
- Ein Release-Paket (`build/kaffeekasse-saas-YYYYMMDD-HHMMSS.tar.gz`)
- Deployment-Anweisungen
- .htaccess Informationen
### 1.2 Umgebungskonfiguration anpassen
Bearbeiten Sie `.env.netcup` und passen Sie folgende Werte an:
```env
APP_URL=https://ihre-domain.tld
APP_KEY=ihr-32-stelliger-app-schluessel
DB_NAME=ihre_datenbank
DB_USER=ihr_db_benutzer
DB_PASS=ihr_db_passwort
MAIL_FROM=noreply@ihre-domain.tld
MAIL_REPLY_TO=support@ihre-domain.tld
RFID_SHARED_SECRET=ihr-sicherer-rfid-schluessel
```
**Wichtig:** Generieren Sie sichere, zufällige Werte für `APP_KEY` und `RFID_SHARED_SECRET`!
## Schritt 2: Netcup Webspace vorbereiten
### 2.1 Domain/Subdomain einrichten
1. Loggen Sie sich in das Netcup WCP (Webhosting Control Panel) ein
2. Erstellen Sie eine neue Domain oder Subdomain
3. Setzen Sie den Document Root auf: `/apps/kaffeekasse/current/public`
### 2.2 MySQL Datenbank erstellen
1. Erstellen Sie eine neue MySQL Datenbank
2. Erstellen Sie einen separaten Datenbankbenutzer
3. Gewähren Sie dem Benutzer alle Rechte auf die Datenbank
4. Notieren Sie sich die Zugangsdaten für die `.env` Datei
### 2.3 PHP Version einstellen
1. Stellen Sie die PHP Version auf 8.3 oder 8.4
2. Aktivieren Sie alle benötigten PHP Extensions (PDO, MySQL, etc.)
## Schritt 3: Upload und Installation
### 3.1 Dateien hochladen
```bash
# Per SFTP/FTP
# 1. Release-Paket nach /apps/kaffeekasse/ hochladen
# 2. .env.netcup als .env nach /apps/kaffeekasse/ hochladen
```
### 3.2 Entpacken und einrichten
```bash
# Auf dem Server (SSH) oder per File Manager
cd /apps/kaffeekasse
tar -xzf kaffeekasse-saas-*.tar.gz
mv kaffeekasse-saas-* current
mv .env current/
```
### 3.3 Verzeichnisberechtigungen
Stellen Sie sicher, dass folgende Verzeichnisse beschreibbar sind:
- `storage/`
- `storage/cache/`
- `storage/logs/`
- `storage/uploads/`
## Schritt 4: Installation
### Option A: Browser-Installer (empfohlen)
1. Öffnen Sie `https://ihre-domain.tld/install`
2. Folgen Sie den Anweisungen des Installers
3. Der Installer erstellt automatisch die Datenbanktabellen
### Option B: Manuelle Installation
```bash
# Per SSH auf dem Server
/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/migrate.php
```
## Schritt 5: Cron-Jobs einrichten
### 5.1 Hauptcron (alle 5 Minuten)
```bash
*/5 * * * * /usr/local/php83/bin/php /apps/kaffeekasse/current/bin/cron.php
```
### 5.2 Healthcheck (täglich)
```bash
0 6 * * * /usr/local/php83/bin/php /apps/kaffeekasse/current/bin/healthcheck.php
```
## Schritt 6: SSL/HTTPS einrichten
1. Aktivieren Sie Let's Encrypt in Ihrem WCP
2. Erzwingen Sie HTTPS für die Domain
3. Testen Sie die SSL-Konfiguration
## Schritt 7: Tests durchführen
### 7.1 Grundfunktionen testen
1. **Startseite**: `https://ihre-domain.tld`
2. **Admin-Login**: `https://ihre-domain.tld/admin/login`
3. **Healthcheck**:
```bash
/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/healthcheck.php
```
### 7.2 Vollständiger Test
1. Loggen Sie sich als Admin ein
2. Erstellen Sie einen Test-Mandanten
3. Loggen Sie sich als Mandant ein
4. Erstellen Sie Testprodukte und -buchungen
5. Prüfen Sie die Cron-Ausführung
## Schritt 8: Produktionsbereitschaft
### 8.1 Sicherheitscheck
- [ ] Nur `public/` ist über Web erreichbar
- [ ] `.env`, `.git`, Backups sind nicht öffentlich zugänglich
- [ ] HTTPS ist erzwungen
- [ ] Starke Passwörter für Admin und DB
### 8.2 Backup einrichten
- [ ] Tägliches DB-Backup
- [ ] `.env` Datei sichern
- [ ] `storage/uploads` sichern
- [ ] Release-Artefakte aufbewahren
### 8.3 Monitoring
- [ ] Externes Uptime-Monitoring
- [ ] Log-Überwachung
- [ ] Cron-Job Überwachung
## Troubleshooting
### Häufige Probleme
**Problem**: 500 Internal Server Error
- **Lösung**: Prüfen Sie Apache Error Logs und PHP Error Logs
- **Häufige Ursache**: Falsche Dateiberechtigungen oder .htaccess Probleme
**Problem**: Datenbank-Verbindungsfehler
- **Lösung**: Prüfen Sie DB-Zugangsdaten in `.env`
- **Tipp**: Testen Sie die Verbindung mit einem separaten PHP-Skript
**Problem**: Cron-Jobs laufen nicht
- **Lösung**: Prüfen Sie PHP-Pfad und Dateiberechtigungen
- **Tipp**: Testen Sie Cron-Jobs manuell per SSH
### Log-Dateien
- **Apache Error Log**: Meist in `/var/log/apache2/error.log` oder über WCP
- **PHP Error Log**: Konfigurierbar in PHP-Einstellungen
- **Application Logs**: `storage/logs/`
## Support und weitere Informationen
- **Vollständige Checkliste**: [docs/go-live-checklist-netcup.md](go-live-checklist-netcup.md)
- **Deployment-Dokumentation**: [DEPLOY.md](../DEPLOY.md)
- **Betriebshandbuch**: [RUNBOOK.md](../RUNBOOK.md)
## Nächste Schritte nach Go-Live
1. Überwachen Sie die Anwendung in den ersten Tagen intensiv
2. Richten Sie regelmäßige Backups ein
3. Planen Sie Updates und Wartungsfenster
4. Dokumentieren Sie Ihre spezifische Konfiguration
---
**Hinweis**: Dieser Guide basiert auf der aktuellen Netcup Webspace-Konfiguration. Bei Änderungen der Hosting-Umgebung können Anpassungen erforderlich sein.
-149
View File
@@ -1,149 +0,0 @@
# Naechste Schritte Roadmap
Stand: 2026-06-15
Diese Roadmap verdichtet die Empfehlungen mehrerer Fach-Agenten fuer Produkt,
Sicherheit, Betrieb, QA und Go-to-Market in einen konkreten Fahrplan fuer das
hochgeladene Repository.
## Leitplanke
Der erste echte Einsatz soll **kein breiter oeffentlicher SaaS-Launch** sein,
sondern ein **kontrollierter Pilot** mit 1-2 Mandanten.
Vor Day 1 gewinnt nicht die groesste Funktionsbreite, sondern der Hybrid-Kern:
- Mitglieder
- Produkte und Preise
- digitale Buchungen
- Einzahlungen
- Papierlisten-Nacherfassung
- saubere Salden und Auditierbarkeit
RFID bleibt fuer den ersten Produktivstart vorbereitet, aber nicht
automatisiert.
## Phase 1: Vor dem Pilot hart absichern
Diese Punkte sollten vor einem echten Testkunden produktionsnah erledigt sein.
### Muss
- serverseitige Ownership-Pruefung fuer alle `tenant_id`-bezogenen IDs
bei Buchungen, Einzahlungen, Papierlisten und RFID
- Self-Service-Rollenmodell schaerfen:
`member` soll nur fuer sich selbst buchen und nur eigene Daten sehen
- PINs nicht mehr im Klartext speichern oder anzeigen
- Rate-Limits fuer Login und Registrierung
- Passwort-Reset fuer Owner und Mitglieder
- Registrierung entweder als geschlossenen Pilot betreiben oder erst nach
Mail-Verifikation oeffnen
- Korrektur-/Storno-Flow fuer Fehlbuchungen, Einzahlungen und Papierlisten
- Mitglieder-Saldenliste und Buchungshistorie pro Mitglied
- CSV-Exporte fuer Salden und Buchungen
- RFID-Intake fuer Day 1 deaktivieren oder explizit haerten
### Soll
- inkrementellen Migrationspfad einfuehren statt reinem `schema.sql`-Replay
- Healthcheck um klaren Monitoring-Pfad erweitern
- Audit-Retention und datensparsame Audit-Metadaten definieren
- Netcup-Hardening technisch erzwingen:
HTTPS, Header, Session-Secure-Handling, Installer-Lockdown
### Abnahme
- ein neuer Tenant kann ohne DB-Eingriff eingerichtet werden
- ein verlorenes Passwort ist ohne manuelle SQL-Hilfe loesbar
- eine Fehlbuchung laesst sich im Produkt sauber korrigieren
- Salden in UI, Export und DB stimmen ueberein
- Cross-Tenant-Zugriffe sind negativ getestet
## Phase 2: Staging, Go-Live-Readiness und Pilot-UAT
### Betrieb
- Staging-Umgebung auf Netcup anlegen
- produktionsnahes Deployment durchspielen
- SSL, Cron, Healthcheck und Backup auf Staging pruefen
- Restore einmal wirklich testen
- Release- und Rollback-Ablauf dokumentiert ueben
### QA
- Smoke-Tests fuer Installation, Admin-Login, Tenant-Registrierung,
Owner-Login, Mitglieder, Produkte, Buchungen, Einzahlungen und Papierlisten
- DB-Stichproben fuer `consumption_events`, `ledger_entries`, `ledger_lines`
- Rechte- und Mandantentrennung negativ testen
- RFID nur als optionalen Inbox-Test behandeln
### Pilotkundenprozess
- 1 kleiner digitaler Pilotmandant
- 1 Hybrid-Pilotmandant mit Papierliste
- 5-7 Tage UAT mit echten oder realistisch simulierten Buchungen
- Freigabe durch Owner und Admin des Piloten
### Abnahme
- alle Smoke-Tests bestanden
- keine kritischen Fehler offen
- Backup, Cron und Restore nachweislich geprueft
- mindestens 10 Buchungen, 2 Einzahlungen und 1 Papierlisten-Posting pro Pilot
## Phase 3: 30-60-90 Tage nach dem Upload
## 30 Tage
- Beachhead-Segment festziehen:
Teams und Vereine mit Hybrid-Prozess
- Landingpage verkaufsnaher schaerfen:
Problem, Demo, Pilot statt Technik zuerst
- Demo-Setup bauen:
Team-Demo, Vereins-Demo, 7-Minuten-Skript
- 10+ Discovery-Gespraeche fuehren
- 3 Pilotkunden anbahnen
## 60 Tage
- 2-3 Piloten aktiv im System
- Preisvalidierung zwischen Standortpreis und Mitgliederpreis
- Tracking fuer `demo_clicked`, `pilot_requested`,
`tenant_registered`, `first_consumption_recorded`,
`first_payment_recorded`, `paper_sheet_created`
- wiederholbaren `Demo -> Pilot -> Aktiv` Ablauf aufbauen
## 90 Tage
- 2 zahlungsbereite Referenzen oder Verlaengerungen
- erste oeffentliche Preislogik festziehen
- Landingpage mit Vertrauenselementen und FAQ erweitern
- Pilot-Feedback in priorisierte Produktarbeit ueberfuehrt
## Bewusst spaeter
Diese Punkte sind sinnvoll, aber nicht noetig fuer den ersten kontrollierten
Pilot:
- MFA fuer Owner/Admin
- Einladungs-Workflow
- RFID-Autobuchung und Event-Regeln
- White-Labeling
- automatisches Billing
- groessere CRM- oder Marketing-Automation
## Sofort als naechste 5 Arbeitspakete
1. Ownership-Checks, Rollenmodell und PIN-Hardening umsetzen.
2. Passwort-Reset, Rate-Limits und Pilot-Registrierungsmodus bauen.
3. Korrekturen/Stornos, Saldenansichten und CSV-Exporte ergaenzen.
4. Staging auf Netcup aufsetzen und die Go-Live-Checkliste durchgehen.
5. Demo-Setup plus 1-2 Pilotmandanten vorbereiten.
## Verwandte Dokumente
- [docs/go-live-checklist-netcup.md](/config/workspace/kaffeeliste-neustart/docs/go-live-checklist-netcup.md)
- [docs/go-to-market-30-60-90.md](/config/workspace/kaffeeliste-neustart/docs/go-to-market-30-60-90.md)
- [docs/product-strategy.md](/config/workspace/kaffeeliste-neustart/docs/product-strategy.md)
- [docs/marketing-ops.md](/config/workspace/kaffeeliste-neustart/docs/marketing-ops.md)
- [docs/production-blueprint.md](/config/workspace/kaffeeliste-neustart/docs/production-blueprint.md)
-48
View File
@@ -1,48 +0,0 @@
# Produkt- und Vermarktungsstrategie
## Zielgruppen
- kleine und mittlere Teams mit gemeinsamer Kaffeekasse
- Vereine mit klassischer Strichliste
- Coworking- und Mehrraum-Standorte mit spaeterer RFID-Option
## Kernbotschaft
`Eine Kaffeekasse fuer analog und digital.`
Die Anwendung ersetzt Papier nicht abrupt, sondern verbindet Papierliste,
digitale Selbstbedienung und spaetere Geraeteanbindung in einem nachvollziehbaren
Buchungssystem.
## Positionierung
- weniger Tool-Fragmentierung als Eigenbau aus Excel, Zetteln und Einzelapps
- verstaendlichere Einfuehrung als reine Hardware- oder RFID-Produkte
- realistischer Betrieb auf klassischem Webhosting statt VPS-Pflicht
## Paketlogik
- `Starter`: digitale Buchungen, Mitglieder, Produkte, Einzahlungen
- `Team`: zusaetzlich Papierlisten-Workflow und staerkeres Backoffice
- `Business`: RFID-Roadmap, Betriebshooks, White-Label-/Integrationsvorstufe
## Marketing-Funnel
1. Landingpage erklaert den analogen und digitalen Use Case in einem Satz.
2. CTA fuehrt direkt in Mandanten-Registrierung oder Demo-Setup.
3. Der Self-Service legt sofort einen nutzbaren Tenant mit Default-Produkten an.
4. Plattform-Admin kann spaeter Vertrieb, Support und Paketwechsel steuern.
## Messaging-Elemente
- `Digitale Strichliste fuer Teams und Vereine`
- `Papierliste bleibt moeglich`
- `RFID spaeter anschliessen, ohne neu zu bauen`
- `Nachvollziehbare Salden und Buchungsverlaeufe`
## Vertriebsnahe KPIs
- Registrierungen pro Paket
- Anteil aktivierter Tenants
- erste Buchung innerhalb von 24 Stunden
- Anteil Tenants mit aktiver Papierlisten-Nutzung
- Nachfrage nach RFID-/Standortintegration
-37
View File
@@ -1,37 +0,0 @@
# Produktions-Blueprint
## Architektur
- modularer Monolith in PHP
- gemeinsame MySQL-Datenbank mit `tenant_id`
- Frontcontroller unter `public/index.php`
- Services fuer Setup, Registrierung, Ledger und RFID
## Sicherheitsgrundlagen
- serverseitige PHP-Sessions
- CSRF-Schutz fuer alle schreibenden Formulare
- `password_hash()` mit moderner Hash-Funktion
- Audit-Log fuer Login, Registrierung, Buchungen und RFID-Ereignisse
- path-basierte Tenant-Isolation ueber Memberships
## Betriebsmodell
- Installation per Browser oder `bin/migrate.php`
- Cron-basierte technische Haken ueber `bin/cron.php`
- Healthcheck als JSON ueber `bin/healthcheck.php`
- Release-Pakete ueber `scripts/build-release.sh`
## Was vor echtem Go-Live noch folgen sollte
- Mail-Verifikation und Passwort-Reset-Flow
- MFA fuer Admin- und Owner-Rollen
- Login- und Registrierungs-Rate-Limits
- strukturiertes Logging in Datei oder externem Ziel
- abgesicherter Support-Zugriff fuer Plattform-Admins
## Git- und CI-Gedanke
Das Repo ist absichtlich neutral gehalten, damit spaeter auf `git.ctb-it.de`
entweder GitLab- oder Gitea-CI angedockt werden kann. Die eigentliche
Deploy-Logik steckt in Skripten und Doku, nicht in einem proprietaeren CI-Format.
-199
View File
@@ -1,199 +0,0 @@
# Proxy-Kompatibilität: Zusammenfassung der Änderungen
## ✅ Status: Vollständig kompatibel
Die Kaffeekasse-SaaS-Anwendung ist **vollständig kompatibel** mit:
- ✅ Netcup Webspace (direkter Zugriff)
- ✅ Code-Server Proxy-Weiterleitung
- ✅ Subdirectory-Installationen
- ✅ Reverse Proxy Setups (Nginx, Apache)
## 🔍 Durchgeführte Analyse
### 1. Bestehende Implementierung (bereits vorhanden)
Die Anwendung war bereits sehr gut vorbereitet:
**Helper-Funktionen (`app/Support/helpers.php`):**
- ✅ `base_path()` - Erkennt automatisch Proxy-Prefixes
- ✅ `current_path()` - Berücksichtigt Base Path
- ✅ `url()` - Generiert URLs dynamisch mit Base Path
- ✅ `tenant_url()` - Tenant-URLs mit Base Path
- ✅ `asset_url()` - Asset-URLs mit Base Path
**Automatische Erkennung:**
```php
function base_path(): string
{
foreach ([
$_SERVER['KAFFEEKASSE_PROXY_PREFIX'] ?? null, // Manuell
$_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null, // Code-Server
$_ENV['APP_BASE_PATH'] ?? null, // .env
] as $prefix) {
if ($prefix !== '') {
return normalize_base_path($prefix);
}
}
return '';
}
```
**View-Templates:**
- ✅ Alle URLs verwenden `url()` oder `tenant_url()`
- ✅ Keine hardcodierten Pfade gefunden
- ✅ Assets verwenden `asset_url()`
### 2. Durchgeführte Verbesserungen
#### A) `.htaccess` erweitert
**Datei:** `public/.htaccess`
**Änderung:**
```apache
# Proxy-Unterstützung: X-Forwarded-* Header durchreichen
RewriteCond %{HTTP:X-Forwarded-Proto} ^https$
RewriteRule ^ - [E=HTTPS:on]
```
**Zweck:** HTTPS-Erkennung hinter Reverse Proxy
#### B) `.env.example` dokumentiert
**Datei:** `.env.example`
**Änderung:**
```env
# APP_BASE_PATH: Leer für direkten Zugriff, /proxy/8080 für code-server Proxy
APP_BASE_PATH=
```
**Zweck:** Klarstellung für Benutzer
#### C) Dokumentation erstellt
**Neue Dateien:**
1. `docs/deployment-proxy-guide.md` - Vollständige Anleitung
2. `PROXY-SETUP.md` - Quick Start Guide
3. `docs/proxy-compatibility-summary.md` - Diese Datei
## 🧪 Verifikation
### Test 1: Ohne Base Path (Netcup Webspace)
```
base_path() =
url("/") = .
url("/admin/login") = admin/login
tenant_url("test", "bookings") = t/test/bookings
asset_url("app.css") = assets/app.css
```
**Ergebnis:** Relative URLs für direkten Zugriff
### Test 2: Mit Base Path (Code-Server Proxy)
```
base_path() = /proxy/8080
url("/") = /proxy/8080/
url("/admin/login") = /proxy/8080/admin/login
tenant_url("test", "bookings") = /proxy/8080/t/test/bookings
asset_url("app.css") = /proxy/8080/assets/app.css
```
**Ergebnis:** Absolute URLs mit Proxy-Prefix
## 📋 Deployment-Szenarien
### Szenario 1: Netcup Webspace (Production)
```env
APP_URL=https://ihre-domain.de
APP_BASE_PATH=
```
- Document Root zeigt auf `/public/`
- `.htaccess` übernimmt Routing
- Relative URLs funktionieren perfekt
### Szenario 2: Code-Server Proxy (Development)
```env
APP_URL=http://localhost:8080
APP_BASE_PATH=/proxy/8080
```
- PHP Development Server: `php -S 0.0.0.0:8080 -t public public/router.php`
- Code-Server setzt `X-Forwarded-Prefix` automatisch
- Fallback auf `APP_BASE_PATH` aus `.env`
### Szenario 3: Subdirectory-Installation
```env
APP_URL=https://example.com/kaffeekasse
APP_BASE_PATH=/kaffeekasse
```
- Installation in Unterverzeichnis
- Alle URLs werden mit Prefix generiert
### Szenario 4: Nginx Reverse Proxy
```nginx
location /app/ {
proxy_pass http://localhost:8080/;
proxy_set_header X-Forwarded-Prefix /app;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
```env
APP_BASE_PATH=/app
```
## 🔒 Sicherheitsaspekte
### Bereits implementiert:
- ✅ CSRF-Schutz auf allen Forms
- ✅ Session-Sicherheit (strict mode, httponly)
- ✅ XSS-Schutz durch `e()` Helper
- ✅ Security Headers Service
- ✅ Rate Limiting
- ✅ Origin-Validierung
### Proxy-spezifisch:
- ✅ X-Forwarded-Proto wird respektiert
- ✅ HTTPS-Erkennung hinter Proxy
- ✅ Keine URL-Injection möglich (normalisiert)
## 📊 Code-Qualität
### Keine Probleme gefunden:
- ✅ Keine hardcodierten URLs
- ✅ Keine absoluten Pfade in Views
- ✅ Konsistente URL-Generierung
- ✅ Saubere Trennung von Concerns
### Best Practices eingehalten:
- ✅ DRY (Don't Repeat Yourself) - Zentrale URL-Funktionen
- ✅ Konfigurierbar über Environment
- ✅ Automatische Erkennung mit Fallbacks
- ✅ Gut dokumentiert
## 🎯 Fazit
**Die Anwendung ist production-ready für beide Szenarien:**
1. **Netcup Webspace**
- Keine Änderungen am Code nötig
- `.htaccess` optimiert
- Dokumentation vorhanden
2. **Code-Server Proxy**
- Automatische Erkennung funktioniert
- Manuelle Konfiguration möglich
- Dokumentation vorhanden
**Empfohlene Vorgehensweise:**
- **Entwicklung:** Code-Server mit `APP_BASE_PATH=/proxy/8080`
- **Production:** Netcup Webspace mit `APP_BASE_PATH=` (leer)
- **Migration:** Nur `.env` anpassen, kein Code-Change nötig
## 📚 Weitere Informationen
- **Quick Start:** `PROXY-SETUP.md`
- **Detaillierte Anleitung:** `docs/deployment-proxy-guide.md`
- **Deployment:** `DEPLOY.md`
- **Netcup Checkliste:** `docs/go-live-checklist-netcup.md`
---
**Erstellt:** 2026-06-17
**Status:** ✅ Vollständig getestet und dokumentiert
-28
View File
@@ -1,28 +0,0 @@
# RFID-Roadmap
## Heute schon vorhanden
- `rfid_devices` fuer Standortgeraete
- `rfid_tags` fuer Karten/Chips pro Mitglied
- `rfid_events` als technische Inbox
- `/api/rfid/intake` fuer rohe Events
## Warum das sinnvoll ist
Die spaetere Hardware kann kommen, ohne dass sich das Kernmodell fuer Salden,
Konsum und Einzahlungen aendert. RFID ist nur ein weiterer Erfassungsweg in
dieselbe Buchungslogik hinein.
## Nächste Ausbaustufen
1. Geraet aktivieren und Token-Rotation einfuehren
2. Dublettenschutz ueber `event_id` und Zeitfenster haerten
3. Mapping-Regeln `Tag -> Mitglied -> Standardprodukt`
4. optionale Freigabe- oder Double-Tap-Regeln
5. Uebernahme von `rfid_events` in echte `consumption_events`
## Produktionshinweis
Wenn spaeter mehr als HTTPS-Webhooks noetig werden, sollte ein kleiner externer
Relay-Service zwischen Lesegerät und Webspace geschaltet werden. Das eigentliche
SaaS-Ledger kann trotzdem auf Netcup-Webspace bleiben.
+152
View File
@@ -0,0 +1,152 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
// Verarbeitung des Formulars, wenn es gesendet wurde
if ($_SERVER["REQUEST_METHOD"] == "POST" ) {
// Iteriere über alle Mitarbeiter
foreach ($_POST["anzahlBetrag"] as $mitarbeiterID => $anzahlBetrag) {
// Validierung könnte hier hinzugefügt werden
#$anzahlBetrag = $anzahlBetrag;
$anzahlBetrag = floatval($anzahlBetrag);
if($anzahlBetrag != 0){
$datum = date("Y-d-m H:i:s"); // Das aktuelle Datum verwenden
// SQL-Abfrage zum Einfügen der Daten
$sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)";
$params = array($mitarbeiterID, $anzahlBetrag, $datum);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
}
}
echo "Einträge erfolgreich hinzugefügt.";
}elseif($_SERVER["REQUEST_METHOD"] == "GET" AND isset($_GET["action"])){
if($_GET["action"] == "vorderseite"){
$sqlMitglieder = "SELECT M.MitarbeiterID, M.Name, M.Email
FROM kl_Mitarbeiter M
JOIN kl_Kaffeeverbrauch V ON M.MitarbeiterID = V.MitarbeiterID
WHERE V.Datum >= DATEADD(DAY, -100, GETDATE()) AND M.aktiv = 1
GROUP BY M.MitarbeiterID, M.Name, M.Email
HAVING SUM(V.AnzahlStriche) >= 10
ORDER BY Name;";
}elseif( $_GET["action"] == "rueckseite"){
$sqlMitglieder = "SELECT M.MitarbeiterID, M.Name, M.Email
FROM kl_Mitarbeiter M
LEFT JOIN kl_Kaffeeverbrauch V ON M.MitarbeiterID = V.MitarbeiterID AND V.Datum >= DATEADD(DAY, -100, GETDATE())
WHERE M.aktiv = 1
GROUP BY M.MitarbeiterID, M.Name, M.Email
HAVING COALESCE(SUM(V.AnzahlStriche), 0) < 10
ORDER BY M.Name;";
}elseif($_GET["action"] == "alle"){
// Mitarbeiter aus der Datenbank abrufen und nach Namen sortieren
$sqlMitarbeiter = "SELECT MitarbeiterID, Name FROM kl_Mitarbeiter WHERE aktiv = 1 ORDER BY Name";
}
}else{
// Mitarbeiter aus der Datenbank abrufen und nach Namen sortieren
$sqlMitarbeiter = "SELECT MitarbeiterID, Name FROM kl_Mitarbeiter WHERE aktiv = 1 ORDER BY Name";
}
$stmtMitarbeiter = sqlsrv_query($conn, $sqlMitarbeiter);
// Schließe die Verbindung zur Datenbank
#sqlsrv_close($conn);
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kaffeeliste - Einzahlung</title>
</head>
<body>
<h2>Einzahlungen für alle Mitarbeiter</h2>
<ul class="actions">
<li>
<form action="einzahlung.php?aktion=vorderseite" method="get">
<button type="submit">Vorderseite</button>
</form>
</li>
<li>
<form action="einzahlung.php?aktion=rueckseite" method="get">
<button type="submit">Rückseite</button>
</form>
</li>
<li>
<form action="einzahlung.php?aktion=alle" method="get">
<button type="submit">Alle</button>
</form>
</li>
<li>
<form action="letzteneintraege.php" method="get">
<button type="submit">Letzten Einträge</button>
</form>
</li>
<li>
<form action="csvupload.php" method="get">
<button type="submit">CSV Upload</button>
</form>
</li>
</ul>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<?php
echo "<table>";
echo " <tr>
<th>Mitarbeiter</th>
<th>Betrag</th>
</tr>";
// Iteriere über alle Mitarbeiter
while ($row = sqlsrv_fetch_array($stmtMitarbeiter, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row["MitarbeiterID"];
$mitarbeiterName = $row["Name"];
echo "<tr>";
// Formularfeld für die Anzahl der Striche
echo "<td><label for='anzahlBetrag[$mitarbeiterID]'>$mitarbeiterName </label></td>";
echo "<td><input type='number' name='anzahlBetrag[$mitarbeiterID]' step='0.01'></td>";
echo "</tr>";
}
echo "</table>";
?>
<button type="submit" name="submit" value="Speichern" >Eintragen</button>
</form>
</body>
</html>
</div>
</section>
<?php include "footer.php"; ?>
+224
View File
@@ -0,0 +1,224 @@
<?php
include "config.php";
#include_once "functionsLDAP.php";
// Funktion zum Berechnen der Gesamteinzahlungen pro Mitarbeiter
function berechneGesamteinzahlungen($mitarbeiterID, $conn) {
// Gesamteinzahlungen pro Mitarbeiter
$sqlEinzahlungen = "SELECT SUM(Betrag) AS Gesamteinzahlungen FROM kl_Einzahlungen WHERE MitarbeiterID = ?";
$stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen, array($mitarbeiterID));
$rowEinzahlungen = sqlsrv_fetch_array($stmtEinzahlungen, SQLSRV_FETCH_ASSOC);
$gesamteinzahlungen = $rowEinzahlungen['Gesamteinzahlungen'];
return $gesamteinzahlungen;
}
// Funktion zum Berechnen der Gesamtausgabe pro Mitarbeiter
function berechneGesamtausgabe($mitarbeiterID, $conn) {
// Gesamtausgaben pro Mitarbeiter
$sqlAusgaben = "SELECT SUM(Kosten) AS Gesamtausgaben FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtAusgaben = sqlsrv_query($conn, $sqlAusgaben, array($mitarbeiterID));
$rowAusgaben = sqlsrv_fetch_array($stmtAusgaben, SQLSRV_FETCH_ASSOC);
$gesamtausgaben = $rowAusgaben['Gesamtausgaben'];
return $gesamtausgaben;
}
// Funktion zum Berechnen der Gesamtstriche pro Mitarbeiter
function berechneGesamtstriche($mitarbeiterID, $conn) {
// Gesamtstriche pro Mitarbeiter
$sqlStriche = "SELECT SUM(AnzahlStriche) AS Gesamtstriche FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtStriche = sqlsrv_query($conn, $sqlStriche, array($mitarbeiterID));
$rowStriche = sqlsrv_fetch_array($stmtStriche, SQLSRV_FETCH_ASSOC);
$gesamtstriche = $rowStriche['Gesamtstriche'];
return $gesamtstriche;
}
// Mitglieder aus der Datenbank abrufen
$sqlMitglieder = "SELECT M.MitarbeiterID, M.Name, M.Email
FROM kl_Mitarbeiter M
JOIN kl_Kaffeeverbrauch V ON M.MitarbeiterID = V.MitarbeiterID
WHERE V.Datum >= DATEADD(DAY, -100, (SELECT MAX(Datum) FROM kl_Kaffeeverbrauch WHERE Datum < CAST(GETDATE() AS DATE))) AND M.aktiv = 1
GROUP BY M.MitarbeiterID, M.Name, M.Email
HAVING SUM(V.AnzahlStriche) >= 10
ORDER BY Name;";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
// Kosten pro Strich auslesen
$sqlKostenproStrich = "SELECT KostenproStrich FROM kl_config ";
$stmtKostenproStrich = sqlsrv_query($conn, $sqlKostenproStrich);
$row = sqlsrv_fetch_array($stmtKostenproStrich, SQLSRV_FETCH_ASSOC);
$KostenproStrichtemp = $row["KostenproStrich"];
$KostenproStrich = number_format($KostenproStrichtemp, 2, ',', '.');
// TCPDF-Bibliothek einbinden
require_once('tcpdf/tcpdf.php');
class MyCustomPDFWithWatermark extends TCPDF {
public function Header() {
// Get the current page break margin
$bMargin = $this->getBreakMargin();
// Get current auto-page-break mode
$auto_page_break = $this->AutoPageBreak;
// Disable auto-page-break
$this->SetAutoPageBreak(false, 0);
// Define the path to the image that you want to use as watermark.
$img_file = './watermark.jpg';
$this->SetAlpha(0.35);
// Render the image
$this->Image($img_file, 0, 0, 223, 280, '', '', '', false, 300, '', false, false, 0);
$this->SetAlpha(1);
// Restore the auto-page-break status
$this->SetAutoPageBreak($auto_page_break, $bMargin);
// Set the starting point for the page content
$this->setPageMark();
}
}
// PDF-Objekt erstellen
#$pdf = new TCPDF();
$pdf = new MyCustomPDFWithWatermark(PDF_PAGE_ORIENTATION, 'mm', 'A4', true, 'UTF-8', false);
// PDF-Header setzen
$pdf->SetHeaderData("", 0, "Kaffeestrichliste", "");
// PDF-Header und Footer auf jeder Seite anzeigen
#$pdf->SetPrintHeader(false);
$pdf->SetPrintFooter(false);
$pdf->SetMargins('5', '5', '5');
$pdf->SetAutoPageBreak(TRUE, 5);
// Seitenformat setzen
#$pdf->SetFormat('A4', 'portrait');
$pdf->SetFont('helvetica', '', 9.5);
// PDF-Inhalt starten
$pdf->AddPage();
// Tabelle erstellen
$html = '
<table border="1" style="white-space:nowrap;">
<tr style="height: 25px;background-color: rgb(0, 94, 63);color: rgb(255, 255, 255);" valign="bottom">
<th style="width:30%;font-size: 13px;" colspan="2"><b>Kaffeeliste - Vieltrinker</b></th>
<th style="width:70%;" valign="bottom">1 Strich = ' . $KostenproStrich . '. Bitte bezahlen bei 10 zahlen. ' . date("d.m.Y H:s") . '</th>
</tr>
<tr >
<td style="width:20%"><b>Name</b></td>
<td style="width:10%"><b>Guthaben</b></td>
<td ><b>Striche</b></td>
</tr>';
$y=1;
while ($row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row['MitarbeiterID'];
$name = $row['Name'];
$email = $row['Email'];
$y++;
$gesamteinzahlungen = berechneGesamteinzahlungen($mitarbeiterID, $conn);
$gesamtausgaben = berechneGesamtausgabe($mitarbeiterID, $conn);
$differenztemp = $gesamteinzahlungen - $gesamtausgaben;
$differenz = number_format($differenztemp, 2, ',', '.');
$html .= '<tr style="height: 16px;">';
$html .= "<td>{$name}</td>";
#$pdf->writeHTML($html, true, false, true, false, '');
#$html = "";
if($differenztemp < -10.00){
$html .= '<td style="background-color: rgb(204, 0, 0); color: rgb(255, 255, 255);">' .$differenz . ' €</td>';
}elseif($differenztemp > 5.00){
$html .= '<td style="background-color: rgb(0, 102, 0); color: rgb(255, 255, 255);">' .$differenz . ' €</td>';
}else{
$html .= "<td>{$differenz} €</td>";
}
$html .= "<td></td>";
$html .= "</tr>";
}
for ($i = $y; $i < 64; $i++) {
$html .= '<tr><td>&nbsp;</td><td>&nbsp;</td><td>&nbsp;</td></tr>';
}
$html .= '<tr><td>&nbsp;</td><td>&nbsp;</td><td style="background-color: rgb(255, 255, 0);text-align: right;"><b>Rückseite beachten!</b></td></tr>';
$html .= '</table>';
#echo $html;
// Tabelle ins PDF einfügen
$pdf->writeHTML($html, true, false, true, false, '');
// PDF-Inhalt starten
$pdf->AddPage();
// Mitglieder aus der Datenbank abrufen
$sqlMitglieder = "
SELECT M.MitarbeiterID, M.Name, M.Email
FROM kl_Mitarbeiter M
LEFT JOIN kl_Kaffeeverbrauch V ON M.MitarbeiterID = V.MitarbeiterID AND V.Datum >= DATEADD(DAY, -100, (SELECT MAX(Datum) FROM kl_Kaffeeverbrauch WHERE Datum < CAST(GETDATE() AS DATE)))
WHERE M.aktiv = 1
GROUP BY M.MitarbeiterID, M.Name, M.Email
HAVING COALESCE(SUM(V.AnzahlStriche), 0) < 10
ORDER BY M.Name;
";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
// Tabelle erstellen
$html = '
<table border="1" style="white-space:nowrap;">
<tr style="background-color: rgb(91, 209, 215);color: rgb(0, 0, 0);vertical-align: bottom;">
<th style="width:30%;font-size: 12px;" colspan="2"><b>Kaffeeliste - Wenigtrinker</b></th>
<th style="width:70%;" valign="bottom">1 Strich = ' . $KostenproStrich . '. Bitte bezahlen bei 10 zahlen. ' . date("d.m.Y H:s") . '</th>
</tr>
<tr >
<td style="width:20%"><b>Name</b></td>
<td style="width:10%"><b>Guthaben</b></td>
<td ><b>Striche</b></td>
</tr>';
$y=1;
while ($row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row['MitarbeiterID'];
$name = $row['Name'];
$email = $row['Email'];
$y++;
$gesamteinzahlungen = berechneGesamteinzahlungen($mitarbeiterID, $conn);
$gesamtausgaben = berechneGesamtausgabe($mitarbeiterID, $conn);
$differenztemp = $gesamteinzahlungen - $gesamtausgaben;
$differenz = number_format($differenztemp, 2, ',', '.');
$html .= "<tr>";
$html .= "<td>{$name}</td>";
#$pdf->writeHTML($html, true, false, true, false, '');
#$html = "";
if($differenztemp < -10.00){
$html .= '<td style="background-color: rgb(204, 0, 0); color: rgb(255, 255, 255);">' .$differenz . ' €</td>';
}elseif($differenztemp > 5.00){
$html .= '<td style="background-color: rgb(0, 102, 0); color: rgb(255, 255, 255);">' .$differenz . ' €</td>';
}else{
$html .= "<td>{$differenz} €</td>";
}
$html .= "<td></td>";
$html .= "</tr>";
}
for ($i = $y; $i < 65; $i++) {
$html .= '<tr><td>&nbsp;</td><td>&nbsp;</td><td>&nbsp;</td></tr>';
}
$html .= '<tr><td>&nbsp;</td><td>&nbsp;</td><td style="background-color: rgb(255, 255, 0);text-align: right;"><b>Vorderseite beachten!</b></td></tr>';
$html .= '</table>';
#echo $html;
// Tabelle ins PDF einfügen
$pdf->writeHTML($html, true, false, true, false, '');
// PDF-Ausgabe
$pdf->Output('Kaffeestrichliste.pdf', 'D');
?>
+90
View File
@@ -0,0 +1,90 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
echo "<h2>FAQ - Kaffeeliste</h2>";
echo "<br>";
?>
<p><b>Wie werde ich Mitglied der Kaffeeliste?</b>
</b></p><p>Neumitglieder können sich einfach in diese Liste eintragen und zahlen aktuell keine Aufnahmegebühr. Es ist keine weitere Rücksprache nötig.
Schon mal ausgetretene Mitglieder können erst nach einem Jahr wiederaufgenommen werden. Die Kaffeeliste behält sich vor Mitglieder von der Liste streichen zu können.
<p><b>Wo finde ich Milch, Zucker und Kaffee?
</b></p><p>Milch findet ihr im Kühlschrank (XXX auf dem Deckel), Zucker und Süßstoff steht neben der Kaffeemaschine. Nachschub findet Ihr im Schrank über der Maschine.
Sollte dort nichts mehr vorhanden sein, schaut bitte auf der Webseite https://kaffeeliste.arge.aok nach ob es einen Hinweis gibt. Sollte kein Hinweis erscheinen, informiert bitte den Ansprechpartner der Kaffeeliste. <br>
Dieser bestellt im Laufe einer Woche die Verbrauchsgüter nach. Sollte dieses nicht ausreichen, dann können in diesem Ausnahmefall auch selbst die Verbrauchsgüter besorgt werden.
Die Unkosten werden gegen Vorlage des Bon (z.B. per Mail) der einreichenden Person auf der Kaffeeliste gutgeschrieben.
<p><b>Welche Arten von Kaffee bekomme ich an der Kaffeemaschine?
</b></p><p>Es gibt für euch nur noch vier Tasten mit jeweils einem Bild.<br>
<b>Espresso</b> Ausgabe von ca. 100ml Kaffee<br>
<b>Coffee</b> Ausgabe von ca. 150ml Kaffee<br>
<b>Hot Water</b> Ausgabe von ca. 200ml warmen Wasser (extra Ausgabe)<br>
<b>Americano</b> Ausgabe von ca. 300ml (150ml Kaffee + 150ml warmes Wasser)<br>
<br>
Eine persönliche Einstellung der Kaffeemenge ist nicht mehr möglich.
Die Stärke muss durch die Menge des Wassers definiert werden.
Alle Kaffeespezialitäten bekommen aktuell ca. 11g Kaffee.
Durch den automatischen Doppelbezug und die verschiedenen Mengen sollten aber die meisten Wünsche erfüllt werden können.
</p>
<p><b>Wie nutze ich die Kaffeemaschine?
</b></p><p>Mit einem Klick auf das jeweilige Symbol bekommt ihr eine Tasse (1 Strich auf der Kaffeeliste).
Mit zweimaligem Drücken auf die jeweilige Taste bekommt ihr zwei Tassen (2 Strich auf der Kaffeeliste).
Läuft das Programm könnt ihr mit einem weiteren Klick auf das jeweilige Symbol den Vorgang vorzeitig beenden.
<p><b>Wie viel kostet ein Kaffee?
</b></p><p>Den aktuellen Preis pro Strich steht immer oben rechts auf der Kaffeeliste. Somit kann ein einfacher Kaffee ein oder auch zwei Striche kosten.
<p><b>Wie erfolgt die Abrechnung?
</b></p><p>Je nach dem was du an der Maschine bezogen hast, machst du die entsprechenden Striche auf der Liste. Also: ein Klick = ein Strich und zwei Klicks = zwei Striche.
Etwa ein Mal im Monat wird die Liste aktualisiert. In diesem Fall bekommst eine E-Mail, wenn du bezahlen musst.
<p><b>Wie kann ich bezahlen?
</b></p><p>Aktuell kann nur per Paypal bezahlt werden. Du erhältst einen entsprechenden Link per Mail.<br>
Bezahle immer über die Freunde Funktion von Paypal. Ansonsten stellen wir 20% des Betrags als Gebühr in Rechnung.
<p><b>Muss ich bei der Bezahlung per PayPal gesondert bestätigen oder jemand benachrichtigen?
</b></p><p>Nein, du musst die Bezahlung nicht gesondert bestätigen. Deine Zahlungen von PayPal werden verrechnet und du siehst dieses auf der nächsten Kaffeeliste. Nach der Verarbeitung deiner Einzahlung bekommst du eine schriftliche Bestätigung per Mail.
<p><b>Ich habe einen negativen Wert auf der Kaffeeliste?
</b></p><p>Ein negativer Betrag in der Spalte "offen" stellen deine Schulden dar. Ein positiver Betrag ist ein Guthaben.
<p><b>Ich habe eine rote Markierung in der Liste. Was bedeutet das?
</b></p><p>Du hast einen höheren offenen Betrag. Bitte bezahle deinen ausstehenden Betrag per PayPal.
<p><b>Ich habe bezahlt und der rote Eintrag in der Liste verschwindet nicht!
</b></p><p>Die Liste wird etwa ein Mal im Monat aktualisiert. Bis zur Aktualisierung der Liste bleibt dein Betrag vom letzten Mal auf der Liste stehen.
<p><b>Was passiert, wenn ich die Striche auf der Liste zu undeutlich oder nicht in Fünferblocks mache?
</b></p><p>Nicht klar erkennbare Stiche führen ohne weitere Vorwarnung zu einer pauschalen Strafe von 10 Strichen bei der Auswertung der Liste. <br>
Sollten die Striche nicht im Quinärsystem - also nach vier Strichen ein Querstrich über diese - (Beispiel: <a href="https://de.wikipedia.org/wiki/Strichliste" >Strichliste</a>) gemacht werden, führt dieses ohne weitere Vorwarnung zu einer pauschalen Strafe von 10 Strichen bei der Auswertung der Liste.
<p><b>Ich habe das Gefühl mein Betrag/Guthaben passt nicht. Was soll ich machen?
</b></p><p>Fehler können leider auch in der Kaffeeliste passieren. Melde dich einfach per Mail und wir schauen uns einmal die Abrechnung an. In der Regel findet sich dann der Fehler oder eine Lösung.
<p><b>Wie erfolgt die Wartung der Maschine?
</b></p><p>Die Wartung der Maschine (Reinigung und Entkalkung) erfolgt etwa einmal im Monaten. Bei jeder Entkalkung findet auch ein Wechsel des Wasserfilters statt.
</p>
<p><b>Wie kann ich mich einbringen?
</b></p><p>Zunächst ist ein freundliches Miteinander wichtig! <br>
Schaut gerne immer mal nach, ob die Verbrauchsmaterialen noch entsprechend vorhanden sind.
Wenn du dich weitere Punkte wie Wartung oder ähnliches kümmern möchtest, melde dich gerne beim Ansprechpartner der Kaffeeliste.
<p><b>Ich habe eine Frage und diese steht hier nicht!
</b></p><p>Dann wenden dich gerne an den Ansprechpartner der Kaffeeliste (kaffeelistesb3@nds.aok.de). Dieser wird dir sicherlich weiterhelfen.
</div>
</section>
<?php include "footer.php";
?>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+61
View File
@@ -0,0 +1,61 @@
</div>
</div>
<!-- Sidebar -->
<div id="sidebar">
<div class="inner">
<!-- Menu -->
<nav id="menu">
<header class="major">
<h2>Menü</h2>
</header>
<ul>
<li><a href="index.php">Meine Kaffeeliste</a></li>
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
?>
<li><a href="einzahlung.php">Einzahlung eintragen</a></li>
<li><a href="stricheintragen.php"> Striche eintragen</a></li>
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
<li><a href="mitarbeiterverwalten.php">Mitglieder verwalten</a></li>
<li><a href="hinweise.php">Hinweise verwalten</a></li>
<?php
}
?>
<li><a href="faq.php">FAQ Kaffeeliste</a></li>
</ul>
</nav>
<!-- Footer -->
<footer id="footer">
<p class="copyright">&copy; Copyright: Clemens Creutzburg All rights reserved. Design: <a href="http://html5up.net">HTML5 UP</a></p>
</footer>
</div>
</div>
</div>
<!-- Scripts -->
<script src="assets/js/jquery.min.js"></script>
<script src="assets/js/browser.min.js"></script>
<script src="assets/js/breakpoints.min.js"></script>
<script src="assets/js/util.js"></script>
<script src="assets/js/main.js"></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<?php
include_once "config.php";
$kennungtemp = $_SERVER['AUTH_USER'];
$teile = explode("\\", $kennungtemp);
$kennung = $teile[1];
include_once "functionsLDAP.php";
function getUserName($conn, $mail){
$sqlMitglieder = "SELECT Name FROM kl_Mitarbeiter WHERE Email like '" . strtolower($mail) . "' ";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
#$row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)
while( $row = sqlsrv_fetch_array( $stmtMitglieder, SQLSRV_FETCH_ASSOC) ) {
return trim($row['Name']);
}
}
function getUserId($conn, $mail){
$sqlMitglieder = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Email like '" . strtolower($mail) . "' ";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
#$row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)
while( $row = sqlsrv_fetch_array( $stmtMitglieder, SQLSRV_FETCH_ASSOC) ) {
return trim($row['MitarbeiterID']);
}
}
function checkKaffeelisteAccess($conn, $mail)
{
// Mitarbeiter aus der Datenbank abrufen und nach Namen sortieren
$sqlMitarbeiter = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Email like '" . strtolower($mail) . "' AND aktiv='1'";
$stmtMitarbeiter = sqlsrv_query($conn, $sqlMitarbeiter, array(), array("Scrollable"=>"buffered"));
$row_count = sqlsrv_num_rows( $stmtMitarbeiter );
#return $row_count;
if ($row_count == 1)
{
return true;
}else{
return false;
}
}
function checkKaffeelisteAdmin($conn, $mail)
{
// Mitarbeiter aus der Datenbank abrufen und nach Namen sortieren
$sqlMitarbeiter = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Email like '" . strtolower($mail) . "' AND admin='1'";
$stmtMitarbeiter = sqlsrv_query($conn, $sqlMitarbeiter, array(), array("Scrollable"=>"buffered"));
$row_count = sqlsrv_num_rows( $stmtMitarbeiter );
#return $row_count;
if ($row_count == 1)
{
return true;
}else{
return false;
}
}
?>
+166
View File
@@ -0,0 +1,166 @@
<?php
#$kennungtemp = $_SERVER['AUTH_USER'];
#$teile = explode("\\", $kennungtemp);
#$kennung = $teile[1];
$ad = ldap_connect("ldap://{$domain}") or die('Could not connect to LDAP server.');
ldap_set_option($ad, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ad, LDAP_OPT_REFERRALS, 0);
@ldap_bind($ad, "{$aduser}@{$domain}", $adpassword) or die('Could not bind to AD.');
$userdn = getDN($ad, $kennung, $basedn);
$mailadress = getADMail($ad, $kennung, $basedn);
ldap_unbind($ad);
/**
* This function searchs in LDAP tree entry specified by samaccountname and
* returns its DN or epmty string on failure.
*
* @param resource $ad
* An LDAP link identifier, returned by ldap_connect().
* @param string $samaccountname
* The sAMAccountName, logon name.
* @param string $basedn
* The base DN for the directory.
* @return string
*/
function getDN($ad, $samaccountname, $basedn)
{
$result = ldap_search($ad, $basedn, "(samaccountname={$samaccountname})", array(
'dn'
));
if (! $result)
{
return '';
}
$entries = ldap_get_entries($ad, $result);
if ($entries['count'] > 0)
{
return $entries[0]['dn'];
}
return '';
}
function getADMail($ad, $samaccountname, $basedn)
{
$attributes = array('mail');
$resultz = ldap_search($ad, $basedn, "(samaccountname={$samaccountname})", $attributes);
$entriesz = ldap_get_entries($ad, $resultz);
#return $entriesz[0]['mail'];
# $entries = ldap_get_entries($ad, $result);
if ($entriesz['count'] > 0)
{
return $entriesz[0]['mail'][0];
}
return 'nichts gefunden';
}
/**
* This function retrieves and returns Common Name from a given Distinguished
* Name.
*
* @param string $dn
* The Distinguished Name.
* @return string The Common Name.
*/
function getCN($dn)
{
preg_match('/[^,]*/', $dn, $matchs, PREG_OFFSET_CAPTURE, 3);
return $matchs[0][0];
}
/**
* This function checks group membership of the user, searching only in
* specified group (not recursively).
*
* @param resource $ad
* An LDAP link identifier, returned by ldap_connect().
* @param string $userdn
* The user Distinguished Name.
* @param string $groupdn
* The group Distinguished Name.
* @return boolean Return true if user is a member of group, and false if not
* a member.
*/
function checkGroup($ad, $userdn, $groupdn)
{
$result = ldap_read($ad, $userdn, "(memberof={$groupdn})", array(
'members'
));
if (! $result)
{
return false;
}
$entries = ldap_get_entries($ad, $result);
return ($entries['count'] > 0);
}
/**
* This function checks group membership of the user, searching in specified
* group and groups which is its members (recursively).
*
* @param resource $ad
* An LDAP link identifier, returned by ldap_connect().
* @param string $userdn
* The user Distinguished Name.
* @param string $groupdn
* The group Distinguished Name.
* @return boolean Return true if user is a member of group, and false if not
* a member.
*/
function checkGroupEx($ad, $userdn, $groupdn)
{
if ($groupdn == "")
{
return false;
}
$result = ldap_read($ad, $userdn, '(objectclass=*)', array(
'memberof'
));
if (! $result)
{
return false;
}
$entries = ldap_get_entries($ad, $result);
if ($entries['count'] <= 0)
{
return false;
}
if (empty($entries[0]['memberof']))
{
return false;
}
for ($i = 0; $i < $entries[0]['memberof']['count']; $i ++)
{
if ($entries[0]['memberof'][$i] == $groupdn)
{
return true;
}
elseif (checkGroupEx($ad, $entries[0]['memberof'][$i], $groupdn))
{
return true;
}
}
return false;
}
?>
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE HTML>
<!--
Miniport by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
-->
<html>
<head>
<title>Kaffeeliste</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="assets/css/main.css" />
</head>
<body class="is-preload">
<?php
//echo "<div style='background-color: #ffeb3b; padding: 10px; text-align: center; font-weight: bold;'>1</div>";
// Aktuelle Hinweise abrufen
$sql = "SELECT nachricht FROM kl_hinweise WHERE gueltig_bis >= SYSDATETIME() ORDER BY gueltig_bis ASC";
$stmt = sqlsrv_query($conn, $sql);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
if ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
echo "<div style='background-color: #ffeb3b; padding: 25px; text-align: center; font-weight: bold; font-size: 20px;'>"
. htmlspecialchars($row['nachricht']) .
"</div>";
}
?>
<!-- Wrapper -->
<div id="wrapper">
<!-- Main -->
<div id="main">
<div class="inner">
BIN
View File
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
echo "<h2>Kaffeeliste - Hinweise</h2>";
// Hinweis löschen
if (isset($_GET['delete'])) {
$id = (int)$_GET['delete'];
$stmt = sqlsrv_query($conn, "DELETE FROM kl_hinweise WHERE id = ?", [$id]);
}
// Hinweis speichern
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$nachricht = $_POST['nachricht'];
$gueltig_bis = $_POST['gueltig_bis']; // z.B. "2025-09-03T14:00"
$dt = DateTime::createFromFormat('Y-m-d\TH:i', $gueltig_bis);
if ($dt) {
$gueltig_bis_sql = $dt->format('Y-d-m H:i:s'); // z.B. "2025-03-09 14:00:00"
} else {
die("Ungültiges Datumsformat");
}
if (!empty($nachricht) && !empty($gueltig_bis_sql)) {
$stmt = sqlsrv_query($conn,
"INSERT INTO kl_hinweise (nachricht, gueltig_bis) VALUES (?, ?)",
[$nachricht, $gueltig_bis_sql]
);
}
}
// Hinweise abrufen
$hinweise = [];
$stmt = sqlsrv_query($conn, "SELECT id, nachricht, gueltig_bis FROM kl_hinweise ORDER BY gueltig_bis DESC");
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
$hinweise[] = $row;
}
?>
<h2>Neuen Hinweis hinzufügen</h2>
<form method="post">
<label>Nachricht:</label><br>
<textarea name="nachricht" required></textarea><br><br>
<label>Gültig bis:</label><br>
<input type="datetime-local" name="gueltig_bis" required><br><br>
<button type="submit">Speichern</button>
</form>
<h2>Alle Hinweise</h2>
<?php foreach ($hinweise as $hinweis): ?>
<div class="hinweis">
<strong><?= htmlspecialchars($hinweis['nachricht']) ?></strong><br>
<small>Gültig bis: <?= $hinweis['gueltig_bis']->format('d.m.Y H:i') ?></small><br>
<a href="?delete=<?= $hinweis['id'] ?>" onclick="return confirm('Diesen Hinweis wirklich löschen?')">🗑️ Löschen</a>
</div>
<?php endforeach; ?>
</body>
</html>
<?php
}else{
echo "<h2>Sie haben keine Zugang zu dieser Webseite</h2>";
}
?>
</div>
</section>
<?php include "footer.php";
?>
+241
View File
@@ -0,0 +1,241 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAccess($conn, $mailadress)){
echo "<h2>Kaffeeliste</h2>";
echo "Hallo " . getUserName($conn,$mailadress) . "!<br><br>";
// Funktion zum Berechnen der Gesamtausgabe und Gesamtstriche pro Mitarbeiter
function berechneGesamtausgabe($email, $conn) {
// MitarbeiterID anhand der E-Mail-Adresse abrufen
$sqlMitarbeiterID = "SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Email = ?";
$stmtMitarbeiterID = sqlsrv_query($conn, $sqlMitarbeiterID, array($email));
$rowMitarbeiterID = sqlsrv_fetch_array($stmtMitarbeiterID, SQLSRV_FETCH_ASSOC);
if (!$rowMitarbeiterID) {
return null; // Mitarbeiter nicht gefunden
}
$mitarbeiterID = $rowMitarbeiterID["MitarbeiterID"];
// Gesamteinzahlung pro Mitarbeiter
$sqleinzahlung = "SELECT SUM(Betrag) AS Gesamteinzahlung FROM kl_Einzahlungen WHERE MitarbeiterID = ?";
$stmteinzahlung = sqlsrv_query($conn, $sqleinzahlung, array($mitarbeiterID));
$roweinzahlung = sqlsrv_fetch_array($stmteinzahlung, SQLSRV_FETCH_ASSOC);
$gesamteinzahlung = $roweinzahlung['Gesamteinzahlung'];
// Gesamtausgabe für Kaffeeverbrauch pro Mitarbeiter
$sqlAusgabe = "SELECT SUM(AnzahlStriche) AS Gesamtstriche, SUM(Kosten) AS Gesamtausgabe FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtAusgabe = sqlsrv_query($conn, $sqlAusgabe, array($mitarbeiterID));
$rowAusgabe = sqlsrv_fetch_array($stmtAusgabe, SQLSRV_FETCH_ASSOC);
$gesamtausgabe = $rowAusgabe['Gesamtausgabe'];
$gesamtstriche = $rowAusgabe['Gesamtstriche'];
$aktuellerStand = $gesamteinzahlung - $gesamtausgabe;
// Gesamteinzahlung pro Mitarbeiter und Aktuellem Jahr
$sqleinzahlung = "SELECT SUM(Betrag) AS Gesamteinzahlung FROM kl_Einzahlungen WHERE MitarbeiterID = ? AND FORMAT(Datum, 'yyyy') = FORMAT(GETDATE(), 'yyyy') ";
$stmteinzahlung = sqlsrv_query($conn, $sqleinzahlung, array($mitarbeiterID));
$roweinzahlung = sqlsrv_fetch_array($stmteinzahlung, SQLSRV_FETCH_ASSOC);
$yeareinzahlung = $roweinzahlung['Gesamteinzahlung'];
// Gesamtausgabe für Kaffeeverbrauch pro Mitarbeiter und Aktuellem Jahr
$sqlAusgabe = "SELECT SUM(AnzahlStriche) AS Gesamtstriche, SUM(Kosten) AS Gesamtausgabe FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ? AND FORMAT(Datum, 'yyyy') = FORMAT(GETDATE(), 'yyyy')";
$stmtAusgabe = sqlsrv_query($conn, $sqlAusgabe, array($mitarbeiterID));
$rowAusgabe = sqlsrv_fetch_array($stmtAusgabe, SQLSRV_FETCH_ASSOC);
$yearausgabe = $rowAusgabe['Gesamtausgabe'];
$yearstriche = $rowAusgabe['Gesamtstriche'];
return array('Jahresausgabe' => $yearausgabe, 'Jahresstriche' => $yearstriche, 'Jahreseinzahlung' => $yeareinzahlung, 'Gesamtausgabe' => $gesamtausgabe, 'Gesamtstriche' => $gesamtstriche, 'Gesamteinzahlung' => $gesamteinzahlung, 'aktuellerStand' => $aktuellerStand);
#return array('Gesamtausgabe' => $gesamtausgabe, 'Gesamtstriche' => $gesamtstriche, 'Gesamteinzahlung' => $gesamteinzahlung, 'aktuellerStand' => $aktuellerStand);
}
function AusgabeletztenEinzahlungen($email, $conn) {
// MitarbeiterID anhand der E-Mail-Adresse abrufen
$sqlMitarbeiterID = "SELECT TOP 20 MitarbeiterID FROM kl_Mitarbeiter WHERE Email = ?";
$stmtMitarbeiterID = sqlsrv_query($conn, $sqlMitarbeiterID, array($email));
$rowMitarbeiterID = sqlsrv_fetch_array($stmtMitarbeiterID, SQLSRV_FETCH_ASSOC);
if (!$rowMitarbeiterID) {
return null; // Mitarbeiter nicht gefunden
}
$mitarbeiterID = $rowMitarbeiterID["MitarbeiterID"];
// Gesamteinzahlung pro Mitarbeiter
$sqleinzahlung = "SELECT Betrag,Datum FROM kl_Einzahlungen WHERE MitarbeiterID = ? ORDER BY Datum DESC ";
$stmteinzahlung = sqlsrv_query($conn, $sqleinzahlung, array($mitarbeiterID));
$ausgabe = "<h4>Letzte Einzahlungen</h4><table><tr><th style='width:120'>Datum</th><th>Einzahlung</th></tr>";
while ($row = sqlsrv_fetch_array($stmteinzahlung, SQLSRV_FETCH_ASSOC)) {
$ausgabe .= "<tr><td>" . date_format($row["Datum"],"d.m.Y") . "</td><td>" . number_format($row["Betrag"], 2, ',', '') . "€</td></tr>";
}
$ausgabe .= "</table>";
return $ausgabe;
}
function AusgabeletztenStriche($email, $conn) {
// MitarbeiterID anhand der E-Mail-Adresse abrufen
$sqlMitarbeiterID = "SELECT TOP 20 MitarbeiterID FROM kl_Mitarbeiter WHERE Email = ?";
$stmtMitarbeiterID = sqlsrv_query($conn, $sqlMitarbeiterID, array($email));
$rowMitarbeiterID = sqlsrv_fetch_array($stmtMitarbeiterID, SQLSRV_FETCH_ASSOC);
if (!$rowMitarbeiterID) {
return null; // Mitarbeiter nicht gefunden
}
$mitarbeiterID = $rowMitarbeiterID["MitarbeiterID"];
// Gesamteinzahlung pro Mitarbeiter
$sqleinzahlung = "SELECT AnzahlStriche,Kosten,Datum FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ? ORDER BY Datum DESC ";
$stmteinzahlung = sqlsrv_query($conn, $sqleinzahlung, array($mitarbeiterID));
$ausgabe = "<h4>Letzte Striche</h4><table ><tr><th style='width:120'>Datum</th><th>Striche</th><th>Kosten</th></tr>";
while ($row = sqlsrv_fetch_array($stmteinzahlung, SQLSRV_FETCH_ASSOC)) {
$ausgabe .= "<tr><td>" . date_format($row["Datum"],"d.m.Y") . "</td><td>" . $row["AnzahlStriche"] . "</td><td>" . number_format($row["Kosten"], 2, ',', '') . "€</td></tr>";
}
$ausgabe .= "</table>";
return $ausgabe;
}
// E-Mail-Adresse des Mitarbeiters (ersetze durch die tatsächliche E-Mail)
$email = $mailadress;
// Berechne Gesamtausgabe und Gesamtstriche für den Mitarbeiter
$result = berechneGesamtausgabe($email, $conn);
if ($result !== null) {
#echo "<h2>Gesamtausgabe und Gesamtstriche</h2>";
#echo "<p>E-Mail: $email</p>";
#echo "Gesamtausgabe: " . number_format($result['Gesamtausgabe'], 2, ',', '') . " €<br>";
#echo "Gesamtstriche: {$result['Gesamtstriche']}<br>";
#echo "<p>Gesamteinzahlung: " . number_format($result['Gesamteinzahlung'], 2, ',', '') . " €<br>";
$aktuellerstand = number_format($result['aktuellerStand'], 2, ',', '.');
if($result['aktuellerStand'] > 0){
echo "<p><b>Aktueller Stand: {$aktuellerstand} € (Guthaben)</p>";
}elseif($result['aktuellerStand'] < 0){
echo "<p><b>Aktueller Stand: {$aktuellerstand} € (Schulden)</p>";
}else{
echo "<p><b>Aktueller Stand: {$aktuellerstand} €</p>";
}
echo "</b>";
echo "<h2>Jahresübersicht</h2>";
echo "Ausgabe im aktuellem Jahr: " . number_format($result['Jahresausgabe'], 2, ',', '') . " €<br>";
echo "Gesamtstriche im aktuellem Jahr: {$result['Jahresstriche']}<br>";
echo "<p>Gesamteinzahlung im aktuellem Jahr: " . number_format($result['Jahreseinzahlung'], 2, ',', '') . " €<br>";
$sqlconfig = "SELECT paypaluse,paypallink,strichperweb FROM kl_config";
$stmtconfig = sqlsrv_query($conn, $sqlconfig, array($email));
$rowconfig = sqlsrv_fetch_array($stmtconfig, SQLSRV_FETCH_ASSOC);
if($rowconfig["strichperweb"] == 1){
// Kosten pro Strich auslesen
$sqlKostenproStrich = "SELECT KostenproStrich FROM kl_config ";
$stmtKostenproStrich = sqlsrv_query($conn, $sqlKostenproStrich);
$row = sqlsrv_fetch_array($stmtKostenproStrich, SQLSRV_FETCH_ASSOC);
$KostenproStrichtemp = $row["KostenproStrich"];
$KostenproStrich = number_format($KostenproStrichtemp, 2, ',', '');
echo "<h2>Eintrag in die Strichliste</h2>";
echo '<b>Hier kannst du einen oder zwei Striche für dich in der Kaffeeliste eintragen. <br>Dafür benötigst du keinen Eintrag auf der Liste durchführen.</b><br>Aktueller Preis pro Strich: '.$KostenproStrich . ' €<br><br>';
echo'<ul class="actions">
<li>';
echo '<form method="post" action="' . htmlspecialchars($_SERVER["PHP_SELF"]) . '"><button type="submit">Einen Strich eintragen</button><input type="hidden" name="anzahlStriche" value="1" ></form>';
echo '</li><li>';
echo '<form method="post" action="' . htmlspecialchars($_SERVER["PHP_SELF"]) . '"><button type="submit">Zwei Striche eintragen</button><input type="hidden" name="anzahlStriche" value="2" ></form>';
echo '</li></ul>';
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// Iteriere über alle Mitarbeiter
// Validierung könnte hier hinzugefügt werden
$anzahlStriche = $_POST["anzahlStriche"];
$anzahlStriche = (int)$anzahlStriche;
$kostenproStrich = floatval($KostenproStrichtemp);
$kosten = floatval($anzahlStriche * $KostenproStrichtemp);
$datum = date("Y-d-m H:i:s"); // Das aktuelle Datum verwenden
if($anzahlStriche != 0){
// SQL-Abfrage zum Einfügen der Daten
$sql = "INSERT INTO kl_Kaffeeverbrauch (MitarbeiterID, AnzahlStriche, Kosten, KostenproStrich, Datum, Eintragsart) VALUES (?, ?, ?, ?, ?, 2)";
$params = array(getUserId($conn,$email), $anzahlStriche, $kosten, $kostenproStrich, $datum);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
echo '<div class="hint-box success"> <i class="fas fa-check-circle"></i> <p><b>Stricheintragung wurde erfolgreich eingetragen!</b><br>Du kannst den Eintrag weiter unten kontrollieren.</p> </div><br>';
}
}
}
if($rowconfig["paypaluse"] == 1){
echo "<h2>Paypal-Einzahlungen</h2>";
echo '<b>Bezahle immer über die Freunde-Funktion von Paypal. Ansonsten stellen wir 20% des Betrags als Gebühr in Rechnung.</b><br>';
$paypallink = trim($rowconfig["paypallink"]);
echo "<br>";
if($result['aktuellerStand'] < 0){
echo '<form action="' . $paypallink .'' . $aktuellerstand . '" target="_blank" ><button type="submit">'. $aktuellerstand . ' € bezahlen</button></form>';
}
echo'<ul class="actions">
<li>';
echo '<form action="' . $paypallink .'5" target="_blank" ><button type="submit">5,00 € einzahlen</button></form>';
echo '</li><li>';
echo '<form action="' . $paypallink .'10" target="_blank" ><button type="submit">10,00 € einzahlen</button></form>';
echo '</li><li>';
echo '<form action="' . $paypallink .'15" target="_blank" ><button type="submit">15,00 € einzahlen</button></form>';
echo '</li></ul>';
}
echo "<br>";
echo "<br>";
echo AusgabeletztenEinzahlungen($email, $conn);
echo "<br>";
echo AusgabeletztenStriche($email, $conn);
?>
<br><br>
<!-- Formular mit Button zum Anpassen des Namens -->
<form action="namenanpassen.php" method="get">
<button type="submit">Namensanpassung</button>
</form>
<?php
} else {
echo "<p>Mitarbeiter mit der E-Mail-Adresse $email wurde nicht gefunden.</p>";
}
}else{
echo "<h2>Sie haben keine Zugang zu dieser Webseite</h2>";
}
?>
</div>
</section>
<?php include "footer.php";
?>
+177
View File
@@ -0,0 +1,177 @@
<?php
// Einstellungen
// Verbindung zur Datenbank herstellen (ersetze die Platzhalter durch deine Daten)
$serverName = "sql-nds-autoclient-prod.aoknds.aok";
$connectionOptions = array(
"Database" => "Automatisierungsclient",
"Uid" => "ac_admin",
"PWD" => "allacc3ssPW",
"TrustServerCertificate"=>true
);
$stricheAnzupassen = 490; // Anzahl der neuen Striche
$betragProStrich = 0.20; // Betrag pro Strich in Euro
// Testmodus aktivieren
$testmodus = false;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
require 'PHPMailer/src/Exception.php';
require 'PHPMailer/src/PHPMailer.php';
require 'PHPMailer/src/SMTP.php';
// PHPMailer konfigurieren
function sendeMail($empfaenger, $betreff, $inhalt, $testmodus)
{
$mail = new PHPMailer(true);
try {
// Server-Einstellungen
$mail->isSMTP();
$mail->Host = 'smtpv.aoknds.aok'; // SMTP-Server
$mail->Timeout = 180;
$mail->SMTPAuth = false;
$mail->Port = 25;
// Absender
$mail->setFrom('kaffeelistesb3@nds.aok.de', 'Kaffeeliste');
// Empfänger
if ($testmodus) {
$mail->addAddress('kaffeelistesb3@nds.aok.de'); // Testadresse
} else {
$mail->addAddress($empfaenger); // Tatsächlicher Empfänger
}
// Inhalt
$mail->isHTML(true);
$mail->Subject = $betreff;
$mail->Body = utf8_decode($inhalt);
// Senden
$mail->send();
echo "E-Mail erfolgreich gesendet an: " . ($testmodus ? 'kaffeelistesb3@nds.aok.de' : $empfaenger) . "\n";
} catch (Exception $e) {
echo "E-Mail konnte nicht gesendet werden. Fehler: {$mail->ErrorInfo}\n";
}
}
// Verbindung herstellen
$conn = sqlsrv_connect($serverName, $connectionOptions);
if ($conn === false) {
die(print_r(sqlsrv_errors(), true));
}
// Aktuelles Jahr ermitteln
$currentYear = date("Y");
// SQL-Abfrage: Gesamtanzahl der Striche pro Mitarbeiter im aktuellen Jahr mit Namen und E-Mail
$sql = "
SELECT
m.MitarbeiterID,
m.Name,
m.Email,
SUM(v.AnzahlStriche) AS GesamtStriche
FROM kl_Kaffeeverbrauch v
JOIN kl_Mitarbeiter m ON v.MitarbeiterID = m.MitarbeiterID
WHERE YEAR(v.Datum) = ? AND m.aktiv = 1
GROUP BY m.MitarbeiterID, m.Name, m.Email
";
$params = [$currentYear];
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
die(print_r(sqlsrv_errors(), true));
}
// Ergebnisse verarbeiten
$mitarbeiterDaten = [];
$gesamtStriche = 0;
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterDaten[] = $row;
$gesamtStriche += $row['GesamtStriche'];
}
echo $gesamtStriche;
// Neue Striche proportional verteilen
$verteilung = [];
foreach ($mitarbeiterDaten as $mitarbeiter) {
$mitarbeiterID = $mitarbeiter['MitarbeiterID'];
$anteil = $mitarbeiter['GesamtStriche'] / $gesamtStriche;
$neueStriche = round($anteil * $stricheAnzupassen);
$betrag = $neueStriche * $betragProStrich;
$verteilung[] = [
'MitarbeiterID' => $mitarbeiterID,
'Name' => $mitarbeiter['Name'],
'Email' => $mitarbeiter['Email'],
'JahrStriche' => $mitarbeiter['GesamtStriche'],
'NeueStriche' => $neueStriche,
'Betrag' => $betrag
];
// SQL-Befehl vorbereiten
$insertSql = "
INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum)
VALUES (?, ?, GETDATE())
";
$insertParams = [$mitarbeiterID, $betrag];
if ($testmodus) {
// SQL-Befehl und Parameter anzeigen
echo "SQL-Befehl: $insertSql\n";
echo "Parameter: " . json_encode($insertParams) . "\n";
// E-Mail vorbereiten
} else {
// SQL-Befehl ausführen
$insertStmt = sqlsrv_query($conn, $insertSql, $insertParams);
if ($insertStmt === false) {
die(print_r(sqlsrv_errors(), true));
}
// E-Mail vorbereiten
}
$betreff = "Kaffeeliste - Weihnachten";
$inhalt = "
<p>Hallo {$mitarbeiter['Name']},</p>
<p>vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!</p>
<p>Du hast dieses Jahr {$mitarbeiter['GesamtStriche']} Kaffee bezogen.</p>
<p>Deswegen wurden dir $betrag Euro auf deinem Konto gutgeschrieben.</p>
<br><br>
<p>Wir wünschen dir eine frohe Weihnachtszeit und einen guten Rutsch ins neue Jahr.</p>
<p>Deine ARGE Kaffeeliste</p>
";
$empfaenger = $mitarbeiter['Email'];
// E-Mail senden
sendeMail($empfaenger, $betreff, $inhalt, $testmodus);
}
// Sortiere die Verteilung nach JahrStriche (absteigend)
usort($verteilung, function ($a, $b) {
return $b['JahrStriche'] <=> $a['JahrStriche'];
});
// Ergebnisse ausgeben
header('Content-Type: application/json');
#echo json_encode($verteilung, JSON_PRETTY_PRINT);
// Verbindung schließen
sqlsrv_close($conn);
?>
+92
View File
@@ -0,0 +1,92 @@
body {
font-size: .875rem;
}
.feather {
width: 16px;
height: 16px;
}
/*
* Sidebar
*/
.sidebar {
position: fixed;
top: 0;
/* rtl:raw:
right: 0;
*/
bottom: 0;
/* rtl:remove */
left: 0;
z-index: 100; /* Behind the navbar */
padding: 48px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
@media (max-width: 767.98px) {
.sidebar {
top: 5rem;
}
}
.sidebar-sticky {
height: calc(100vh - 48px);
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
}
.sidebar .nav-link .feather {
margin-right: 4px;
color: #727272;
}
.sidebar .nav-link.active {
color: #2470dc;
}
.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}
.sidebar-heading {
font-size: .75rem;
}
/*
* Navbar
*/
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
}
.navbar .navbar-toggler {
top: .25rem;
right: 1rem;
}
.navbar .form-control {
padding: .75rem 1rem;
}
.form-control-dark {
color: #fff;
background-color: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .1);
}
.form-control-dark:focus {
border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
}
+53
View File
@@ -0,0 +1,53 @@
/* globals Chart:false, feather:false */
(() => {
'use strict'
feather.replace({ 'aria-hidden': 'true' })
// Graphs
const ctx = document.getElementById('myChart')
// eslint-disable-next-line no-unused-vars
const myChart = new Chart(ctx, {
type: 'line',
data: {
labels: [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
],
datasets: [{
data: [
15339,
21345,
18483,
24003,
23489,
24092,
12034
],
lineTension: 0,
backgroundColor: 'transparent',
borderColor: '#007bff',
borderWidth: 4,
pointBackgroundColor: '#007bff'
}]
},
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero: false
}
}]
},
legend: {
display: false
}
}
})
})()
+88
View File
@@ -0,0 +1,88 @@
body {
font-size: .875rem;
}
.feather {
width: 16px;
height: 16px;
}
/*
* Sidebar
*/
.sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 100; /* Behind the navbar */
padding: 48px 0 0; /* Height of navbar */
box-shadow: inset 1px 0 0 rgba(0, 0, 0, .1);
}
@media (max-width: 767.98px) {
.sidebar {
top: 5rem;
}
}
.sidebar-sticky {
height: calc(100vh - 48px);
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
}
.sidebar .nav-link .feather {
margin-left: 4px;
color: #727272;
}
.sidebar .nav-link.active {
color: #2470dc;
}
.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}
.sidebar-heading {
font-size: .75rem;
}
/*
* Navbar
*/
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset 1px 0 0 rgba(0, 0, 0, .25);
}
.navbar .navbar-toggler {
top: .25rem;
left: 1rem;
}
.navbar .form-control {
padding: .75rem 1rem;
}
.form-control-dark {
color: #fff;
background-color: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .1);
}
.form-control-dark:focus {
border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
}
+148
View File
@@ -0,0 +1,148 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
// Funktion zum Berechnen der Gesamtausgabe und Gesamtstriche pro Mitarbeiter
function berechneGesamtausgabe($mitarbeiterID, $conn) {
// Gesamtausgabe für Kaffeeverbrauch pro Mitarbeiter
$sqlAusgabe = "SELECT SUM(Kosten) AS Gesamtausgabe FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtAusgabe = sqlsrv_query($conn, $sqlAusgabe, array($mitarbeiterID));
$rowAusgabe = sqlsrv_fetch_array($stmtAusgabe, SQLSRV_FETCH_ASSOC);
$gesamtausgabe = $rowAusgabe['Gesamtausgabe'];
return $gesamtausgabe;
}
// Funktion zum Berechnen der Gesamtstriche pro Mitarbeiter
function berechneGesamtstriche($mitarbeiterID, $conn) {
// Gesamtstriche pro Mitarbeiter
$sqlStriche = "SELECT SUM(AnzahlStriche) AS Gesamtstriche FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtStriche = sqlsrv_query($conn, $sqlStriche, array($mitarbeiterID));
$rowStriche = sqlsrv_fetch_array($stmtStriche, SQLSRV_FETCH_ASSOC);
$gesamtstriche = $rowStriche['Gesamtstriche'];
return $gesamtstriche;
}
// Funktion zum Berechnen der Gesamteinzahlungen pro Mitarbeiter
function berechneGesamteinzahlungen($mitarbeiterID, $conn) {
// Gesamteinzahlungen pro Mitarbeiter
$sqlEinzahlungen = "SELECT SUM(Betrag) AS Gesamteinzahlungen FROM kl_Einzahlungen WHERE MitarbeiterID = ?";
$stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen, array($mitarbeiterID));
$rowEinzahlungen = sqlsrv_fetch_array($stmtEinzahlungen, SQLSRV_FETCH_ASSOC);
$gesamteinzahlungen = $rowEinzahlungen['Gesamteinzahlungen'];
return $gesamteinzahlungen;
}
// Mitglieder aus der Datenbank abrufen
$sqlMitglieder = "SELECT MitarbeiterID, Name, Email FROM kl_Mitarbeiter WHERE aktiv = 1 ORDER BY Name";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kaffeeliste - Aktive Mitarbeiter mit Gesamtstand</title>
</head>
<body>
<h2>Aktive Mitarbeiter mit Gesamtstand</h2>
<br>
<ul class="actions">
<li>
<form action="exportKaffeeliste.php" method="get">
<button type="submit">Kaffeeliste exportieren</button>
</form>
</li>
<li>
<form action="letzteneintraege.php" method="get">
<button type="submit">Letzten Einträge</button>
</form>
</li>
<li>
<form action="csvupload.php" method="get">
<button type="submit">CSV Upload</button>
</form>
</li>
</ul>
<br>
<table border="1" class="table table-striped table-bordered">
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Aktueller Stand</th>
<th>Gesamtausgabe ()</th>
<th>Gesamtstriche</th>
<th>Gesamteinzahlungen</th>
</tr>
<?php
while ($row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row['MitarbeiterID'];
$name = $row['Name'];
$email = $row['Email'];
$gesamtausgabe = berechneGesamtausgabe($mitarbeiterID, $conn);
$gesamtstriche = berechneGesamtstriche($mitarbeiterID, $conn);
$gesamteinzahlungen = berechneGesamteinzahlungen($mitarbeiterID, $conn);
$differenztemp = $gesamteinzahlungen - $gesamtausgabe;
$differenz = number_format($differenztemp, 2, ',', '.');
$gesamtausgabe = number_format($gesamtausgabe, 2, ',', '.');
$gesamteinzahlungen = number_format($gesamteinzahlungen, 2, ',', '.');
$gesamtstriche = number_format($gesamtstriche, 0, ',', '.');
echo "<tr>";
echo "<td><a href=teilnehmerauswertung.php?user_id=$mitarbeiterID>{$name}</a></td>";
echo "<td>{$email}</td>";
echo "<td>{$differenz} €</td>";
echo "<td>{$gesamtausgabe} €</td>";
echo "<td>{$gesamtstriche}</td>";
echo "<td>{$gesamteinzahlungen} €</td>";
echo "</tr>";
}
?>
</table>
<br><br>
<form action="mailversenden.php" method="get">
<button type="submit">Info-Mail versenden</button>
</form>
<?php
}else{
echo "<h2>Kein Zugriff</h2>";
}
?>
</div>
</section>
<?php include "footer.php"; ?>
+238
View File
@@ -0,0 +1,238 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
// Funktion zum Berechnen der Gesamtausgabe und Gesamtstriche pro Mitarbeiter
function berechneGesamtausgabe($mitarbeiterID, $conn) {
// Gesamtausgabe für Kaffeeverbrauch pro Mitarbeiter
$sqlAusgabe = "SELECT SUM(Kosten) AS Gesamtausgabe FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtAusgabe = sqlsrv_query($conn, $sqlAusgabe, array($mitarbeiterID));
$rowAusgabe = sqlsrv_fetch_array($stmtAusgabe, SQLSRV_FETCH_ASSOC);
$gesamtausgabe = $rowAusgabe['Gesamtausgabe'];
return $gesamtausgabe;
}
// Funktion zum Berechnen der Gesamtstriche pro Mitarbeiter
function berechneGesamtstriche($mitarbeiterID, $conn) {
// Gesamtstriche pro Mitarbeiter
$sqlStriche = "SELECT SUM(AnzahlStriche) AS Gesamtstriche FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$stmtStriche = sqlsrv_query($conn, $sqlStriche, array($mitarbeiterID));
$rowStriche = sqlsrv_fetch_array($stmtStriche, SQLSRV_FETCH_ASSOC);
$gesamtstriche = $rowStriche['Gesamtstriche'];
return $gesamtstriche;
}
// Funktion zum Berechnen der Gesamteinzahlungen pro Mitarbeiter
function berechneGesamteinzahlungen($mitarbeiterID, $conn) {
// Gesamteinzahlungen pro Mitarbeiter
$sqlEinzahlungen = "SELECT SUM(Betrag) AS Gesamteinzahlungen FROM kl_Einzahlungen WHERE MitarbeiterID = ?";
$stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen, array($mitarbeiterID));
$rowEinzahlungen = sqlsrv_fetch_array($stmtEinzahlungen, SQLSRV_FETCH_ASSOC);
$gesamteinzahlungen = $rowEinzahlungen['Gesamteinzahlungen'];
return $gesamteinzahlungen;
}
// Funktion zum Löschen einer Einzahlung
function loescheEinzahlung($einzahlungID, $conn) {
try {
$sql = "DELETE FROM kl_Einzahlungen WHERE EinzahlungsID = ?";
$params = array($einzahlungID);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
throw new Exception(print_r(sqlsrv_errors(), true));
}
return true; // Erfolgreich gelöscht
} catch (Exception $e) {
return $e->getMessage(); // Fehlermeldung zurückgeben
}
}
// Überprüfen, ob ein Löschvorgang angefordert wurde
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["aktion"] == "loescheneinzahlung") {
$einzahlungID = $_POST["einzahlungID"];
$ergebnis = loescheEinzahlung($einzahlungID, $conn);
if ($ergebnis === true) {
echo "Einzahlung erfolgreich gelöscht.";
} else {
echo "Fehler: $ergebnis";
}
}
// SQL-Abfrage für die letzten 100 Einzahlungen
$sqlEinzahlungen = "SELECT TOP 100 kl_Mitarbeiter.Name AS MitarbeiterName, kl_Einzahlungen.EinzahlungsID, kl_Einzahlungen.Betrag, kl_Einzahlungen.Datum
FROM kl_Einzahlungen
JOIN kl_Mitarbeiter ON kl_Einzahlungen.MitarbeiterID = kl_Mitarbeiter.MitarbeiterID
ORDER BY kl_Einzahlungen.Datum DESC";
$stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen);
?>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Letzte 100 Einzahlungen</title>
</head>
<h2>Letzte 100 Einzahlungen</h2>
<table border="1" class="table table-striped">
<tr>
<th>Name des Mitarbeiters</th>
<th>Betrag</th>
<th>Datum</th>
<th>Aktion</th>
</tr>
<?php
while ($row = sqlsrv_fetch_array($stmtEinzahlungen, SQLSRV_FETCH_ASSOC)) {
$einzahlungID = $row['EinzahlungsID'];
$mitarbeiterName = $row['MitarbeiterName'];
$betrag = $row['Betrag'];
$datum = $row['Datum']->format('Y-m-d H:i:s'); // Das Format kann angepasst werden
$betrag = number_format($betrag, 2, ',', '.');
echo "<tr>";
echo "<td>{$mitarbeiterName}</td>";
echo "<td>{$betrag}</td>";
echo "<td>{$datum}</td>";
echo "<td>";
echo "<form method='post' action='{$_SERVER["PHP_SELF"]}'>";
echo "<input type='hidden' name='aktion' value='loescheneinzahlung'>";
echo "<input type='hidden' name='einzahlungID' value='{$einzahlungID}'>";
echo "<button type='submit'>Löschen</button>";
echo "</form>";
echo "</td>";
echo "</tr>";
}
?>
</table>
<br><br>
<?php
// Funktion zum Löschen eines Strich-Eintrags
function loescheStrichEintrag($strichID, $conn) {
try {
$sql = "DELETE FROM kl_Kaffeeverbrauch WHERE VerbrauchID = ?";
$params = array($strichID);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
throw new Exception(print_r(sqlsrv_errors(), true));
}
return true; // Erfolgreich gelöscht
} catch (Exception $e) {
return $e->getMessage(); // Fehlermeldung zurückgeben
}
}
// Überprüfen, ob ein Löschvorgang angefordert wurde
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["aktion"] == "loeschen") {
$strichID = $_POST["strichID"];
$ergebnis = loescheStrichEintrag($strichID, $conn);
if ($ergebnis === true) {
echo "Strich-Eintrag erfolgreich gelöscht.";
} else {
echo "Fehler: $ergebnis";
}
}
// SQL-Abfrage für die letzten 100 Strich-Einträge
$sqlStriche = "SELECT TOP 100 kl_Kaffeeverbrauch.VerbrauchID, kl_Mitarbeiter.Name AS MitarbeiterName, kl_Kaffeeverbrauch.AnzahlStriche, kl_Kaffeeverbrauch.Kosten, kl_Kaffeeverbrauch.Datum
FROM kl_Kaffeeverbrauch
JOIN kl_Mitarbeiter ON kl_Kaffeeverbrauch.MitarbeiterID = kl_Mitarbeiter.MitarbeiterID
ORDER BY kl_Kaffeeverbrauch.Datum DESC";
$stmtStriche = sqlsrv_query($conn, $sqlStriche);
?>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Letzte 100 Strich-Einträge</title>
</head>
<body>
<h2>Letzte 100 Strich-Einträge</h2>
<table border="1" class="table table-striped">
<tr>
<th>Name des Mitarbeiters</th>
<th>Anzahlstriche</th>
<th>Betrag</th>
<th>Datum</th>
<th>Aktion</th>
</tr>
<?php
while ($row = sqlsrv_fetch_array($stmtStriche, SQLSRV_FETCH_ASSOC)) {
$strichID = $row['VerbrauchID'];
$mitarbeiterName = $row['MitarbeiterName'];
$anzahlStriche = $row['AnzahlStriche'];
$betrag = $row['Kosten'];
$datum = $row['Datum']->format('Y-m-d H:i:s'); // Das Format kann angepasst werden
$anzahlStriche = number_format($anzahlStriche, 0, ',', '.');
$betrag = number_format($betrag, 0, ',', '.');
echo "<tr>";
echo "<td>{$mitarbeiterName}</td>";
echo "<td>{$anzahlStriche}</td>";
echo "<td>{$betrag}</td>";
echo "<td>{$datum}</td>";
echo "<td>";
echo "<form method='post' action='{$_SERVER["PHP_SELF"]}'>";
echo "<input type='hidden' name='aktion' value='loeschen'>";
echo "<input type='hidden' name='strichID' value='{$strichID}'>";
echo "<button type='submit'>Löschen</button>";
echo "</form>";
echo "</td>";
echo "</tr>";
}
?>
</table>
</body>
</html>
<?php
}else{
echo "<h2>Kein Zugriff</h2>";
}
?>
</div>
</section>
<?php include "footer.php"; ?>
+50
View File
@@ -0,0 +1,50 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAccess($conn, $mailadress)){
echo "<h2>Kaffeeliste</h2>";
echo "Hallo " . getUserName($conn,$mailadress) . "!<br><br>";
// MitarbeiterID anhand der E-Mail-Adresse abrufen
// Mitglieder aus der Datenbank abrufen
$sqlMitglieder = "SELECT MitarbeiterID, Name, Email FROM kl_Mitarbeiter WHERE aktiv = 1 ORDER BY Name";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
while ($row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row['MitarbeiterID'];
$name = $row['Name'];
$email = $row['Email'];
echo "{$email};";
}
}else{
echo "<h2>Sie haben keine Zugang zu dieser Webseite</h2>";
}
?>
</div>
</section>
<?php include "footer.php";
?>
+142
View File
@@ -0,0 +1,142 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
require 'PHPMailer/src/Exception.php';
require 'PHPMailer/src/PHPMailer.php';
require 'PHPMailer/src/SMTP.php';
// Funktion zum Berechnen des Gesamtstands
function berechneGesamtstand($mitarbeiterID, $conn) {
// Kosten aus Kaffeeverbrauch abrufen
$sqlKosten = "SELECT SUM(Kosten) AS GesamtKosten FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ?";
$paramsKosten = array($mitarbeiterID);
$stmtKosten = sqlsrv_query($conn, $sqlKosten, $paramsKosten);
if ($stmtKosten === false) {
die(print_r(sqlsrv_errors(), true));
}
$rowKosten = sqlsrv_fetch_array($stmtKosten, SQLSRV_FETCH_ASSOC);
$gesamtKosten = $rowKosten['GesamtKosten'];
// Einzahlungen abrufen
$sqlEinzahlungen = "SELECT SUM(Betrag) AS GesamtEinzahlungen FROM kl_Einzahlungen WHERE MitarbeiterID = ?";
$paramsEinzahlungen = array($mitarbeiterID);
$stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen, $paramsEinzahlungen);
if ($stmtEinzahlungen === false) {
die(print_r(sqlsrv_errors(), true));
}
$rowEinzahlungen = sqlsrv_fetch_array($stmtEinzahlungen, SQLSRV_FETCH_ASSOC);
$gesamtEinzahlungen = $rowEinzahlungen['GesamtEinzahlungen'];
// Gesamtstand berechnen (Kosten - Einzahlungen)
$gesamtstand = $gesamtKosten - $gesamtEinzahlungen;
return $gesamtstand;
}
// SQL-Abfrage für alle Mitarbeiter
$sqlMitarbeiter = "SELECT MitarbeiterID, Name, Email FROM kl_Mitarbeiter WHERE aktiv='1'";
$stmtMitarbeiter = sqlsrv_query($conn, $sqlMitarbeiter);
// E-Mail-Versand für jeden Mitarbeiter
while ($row = sqlsrv_fetch_array($stmtMitarbeiter, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row['MitarbeiterID'];
$name = trim($row['Name']);
$email = $row['Email'];
// Gesamtstand berechnen
$gesamtstand = berechneGesamtstand($mitarbeiterID, $conn);
$stand = abs($gesamtstand);
$body = "Hallo $name,<br><br>";
if($gesamtstand > 0){
$body .= "hier ist dein aktueller negativer Kaffeekassenstand. <br>
Bitte zahle folgenden Betrag in die Kaffeekasse ein: {$stand} Euro.<br>
<br>Es kann nur noch per Paypal bezahlt werden.<br>
Dein Link: https://www.paypal.me/kaffeekassesb3/{$stand}<br>
<b>Bezahle immer über die Freunde-Funktion von Paypal. Ansonsten stellen wir 20% des eingezahlten Betrags als Gebühr in Rechnung.</b><br>
";
}else{
$body .= "hier ist dein aktuelles Guthaben in der Kaffeeliste: {$stand} Euro.<br>
<br>";
}
$body .= "
Deinen aktuellen Stand findest du auch hier: <a href='https://kaffeeliste.arge.aok/'>https://kaffeeliste.arge.aok/</a><br>
<br>
<b>Hast du Fragen oder Probleme? Dann lies immer zu erst unsere <a href=https://kaffeeliste.arge.aok/faq.php>FAQs</a>. Danke! </b><br>
<br>
Vielen Dank!<br>
Deine Kaffeekasse<br>
<br>
Dein Guthaben ist auf dem nächsten Ausdruck der Kaffeeliste einsehbar. Um die Umwelt zu schonen erfolgt ein neuer Ausdruck erst, wenn die Liste voll ist.<br>
";
// PHPMailer initialisieren
$mail = new PHPMailer(true);
try {
//Server settings
$mail->SMTPDebug = SMTP::DEBUG_OFF; // Disable debugging
$mail->Timeout = 120;
$mail->isSMTP();
$mail->Host = 'smtpv.aoknds.aok'; // SMTP server
## Hier wird absichtlich keine Authentifizierung genutzt.
#$mail->SMTPAuth = true;
#$mail->Username = ''; // SMTP username
#$mail->Password = ''; // SMTP password
#$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // Enable TLS encryption; `PHPMailer::ENCRYPTION_SMTPS` also accepted
#$mail->Port = 587; // TCP port to connect to
//Recipients
$mail->setFrom('kaffeelistesb3@nds.aok.de', 'Kaffeeliste ARGE');
$mail->addAddress($email, $name); // Add a recipient
// Content
$mail->isHTML(true); // Set email format to HTML
$mail->Subject = "Kaffeeliste ARGE - Dein Stand";
$mail->Body = utf8_decode($body);
$mail->send();
echo "E-Mail erfolgreich an $name gesendet.<br>";
} catch (Exception $e) {
echo "E-Mail konnte nicht gesendet werden. Fehler: {$mail->ErrorInfo}<br>";
}
}
?>
</div>
</section>
<?php include "footer.php"; ?>
+262
View File
@@ -0,0 +1,262 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
if(checkKaffeelisteAdmin($conn, $mailadress)){
// Funktion zum Anlegen, Bearbeiten und Deaktivieren von Mitgliedern
function bearbeiteMitglied($aktion, $mitgliedID, $name, $email, $aktiv, $admin, $conn) {
try {
if ($aktion === 'anlegen') {
$sql = "INSERT INTO kl_Mitarbeiter (Name, Email, aktiv, admin) VALUES (?, ?, ?, ?)";
} elseif ($aktion === 'bearbeitenspeichern') {
$sql = "UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, aktiv = ?, admin = ? WHERE MitarbeiterID = ?";
} elseif ($aktion === 'aktivieren') {
$sql = "UPDATE kl_Mitarbeiter SET aktiv = 1 WHERE MitarbeiterID = ?";
} elseif ($aktion === 'deaktivieren') {
$sql = "UPDATE kl_Mitarbeiter SET aktiv = 0 WHERE MitarbeiterID = ?";
} else {
throw new Exception("Ungültige Aktion.");
}
$params = array($name, $email, $aktiv, $admin);
// Füge die MitarbeiterID nur bei Bearbeitung hinzu
if ($aktion === 'bearbeitenspeichern' ) {
array_push($params, $mitgliedID);
}elseif($aktion === 'aktivieren' || $aktion === 'deaktivieren'){
$params = array($mitgliedID);
}
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
throw new Exception(print_r(sqlsrv_errors(), true));
}
return true; // Erfolgreich
} catch (Exception $e) {
return $e->getMessage(); // Fehlermeldung zurückgeben
}
}?>
<!-- Formular für das Bearbeiten von Mitgliedern -->
<?php
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["aktion"] === 'bearbeiten') {
$mitgliedID = $_POST["mitgliedID"];
// Informationen des ausgewählten Mitglieds abrufen
$sqlEinzelmitglied = "SELECT * FROM kl_Mitarbeiter WHERE MitarbeiterID = ?";
$stmtEinzelmitglied = sqlsrv_query($conn, $sqlEinzelmitglied, array($mitgliedID));
$einzelmitglied = sqlsrv_fetch_array($stmtEinzelmitglied, SQLSRV_FETCH_ASSOC);
?>
<h3>Bearbeiten von <?php echo $einzelmitglied['Name']; ?></h3>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" value="bearbeitenspeichern">
<input type="hidden" name="mitgliedID" value="<?php echo $mitgliedID; ?>">
<label for="name">Name:</label>
<input type="text" name="name" id="name" value="<?php echo $einzelmitglied['Name']; ?>" required>
<label for="email">E-Mail:</label>
<input type="email" name="email" id="email" value="<?php echo $einzelmitglied['Email']; ?>" required>
<br>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="aktiv" id="aktiv" <?php echo $einzelmitglied['aktiv'] ? 'checked' : ''; ?>>
<label class="form-check-label" for="aktiv">Aktiv:</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="admin" id="admin" <?php echo $einzelmitglied['admin'] ? 'checked' : ''; ?>>
<label class="form-check-label" for="admin">Administrator:</label>
</div>
<button type="submit">Speichern</button>
</form>
<?php
}
// Verarbeitung des Formulars, wenn es gesendet wurde
elseif ($_SERVER["REQUEST_METHOD"] == "POST") {
$aktion = $_POST["aktion"];
if ($aktion === 'anlegen' || $aktion === 'bearbeitenspeichern') {
$mitgliedID = isset($_POST["mitgliedID"]) ? $_POST["mitgliedID"] : null;
$name = $_POST["name"];
$email = $_POST["email"];
$aktiv = isset($_POST["aktiv"]) ? 1 : 0;
$admin = isset($_POST["admin"]) ? 1 : 0;
$ergebnis = bearbeiteMitglied($aktion, $mitgliedID, $name, $email, $aktiv, $admin, $conn);
if ($ergebnis === true) {
echo "Aktion erfolgreich durchgeführt.";
} else {
echo "Fehler: $ergebnis";
}
} elseif ($aktion === 'aktivieren') {
$mitgliedID = $_POST["mitgliedID"];
$ergebnis = bearbeiteMitglied('aktivieren', $mitgliedID, null, null, null, null, $conn);
if ($ergebnis === true) {
echo "Mitglied erfolgreich aktiviert.";
} else {
echo "Fehler: $ergebnis";
}
} elseif ($aktion === 'deaktivieren') {
$mitgliedID = $_POST["mitgliedID"];
$ergebnis = bearbeiteMitglied('deaktivieren', $mitgliedID, null, null, null, null, $conn);
if ($ergebnis === true) {
echo "Mitglied erfolgreich deaktiviert.";
} else {
echo "Fehler: $ergebnis";
}
}
}else{
// Mitglieder aus der Datenbank abrufen
$sqlMitglieder = "SELECT MitarbeiterID, Name, Email, aktiv, admin FROM kl_Mitarbeiter ORDER BY Name";
$stmtMitglieder = sqlsrv_query($conn, $sqlMitglieder);
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kaffeeliste - Mitglieder verwalten</title>
</head>
<body>
<h2>Mitglieder verwalten</h2>
<!-- Formular für das Anlegen und Bearbeiten von Mitgliedern -->
<form method="post" action="<?php # echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<input type="hidden" name="aktion" id="aktion" value="anlegen">
<label for="name">Name:</label>
<input type="text" name="name" id="name" required>
<label for="email">E-Mail:</label>
<input type="email" name="email" id="email" required>
<br>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="aktiv" id="aktiv" checked>
<label class="form-check-label" for="aktiv">Aktiv</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="admin" id="admin" >
<label class="form-check-label" for="admin">Administrator</label>
</div>
<button type="submit">Mitglied anlegen</button>
</form>
<!-- Tabelle zur Anzeige und Bearbeitung von Mitgliedern -->
<table class="table table-striped">
<tr>
<th>Mitglied ID</th>
<th>Name</th>
<th>E-Mail</th>
<th>Aktiv</th>
<th>Administrator</th>
<th>Aktionen</th>
</tr>
<?php
while ($row = sqlsrv_fetch_array($stmtMitglieder, SQLSRV_FETCH_ASSOC)) {
echo '<tr >';
echo "<td>{$row['MitarbeiterID']}</td>";
echo "<td>{$row['Name']}</td>";
echo "<td>{$row['Email']}</td>";
echo "<td>{$row['aktiv']}</td>";
echo "<td>{$row['admin']}</td>";
echo "<td>";
echo '<ul class="actions">
<li>';
echo "<form method='post' action='{$_SERVER["PHP_SELF"]}'>";
echo "<input type='hidden' name='aktion' value='bearbeiten'>";
echo "<input type='hidden' name='mitgliedID' value='{$row['MitarbeiterID']}'>";
echo "<button type='submit'>Bearbeiten</button>";
echo "</form></li><li>";
if ($row['aktiv'] == 1) {
echo "<form method='post' action='{$_SERVER["PHP_SELF"]}'>";
echo "<input type='hidden' name='aktion' value='deaktivieren'>";
echo "<input type='hidden' name='mitgliedID' value='{$row['MitarbeiterID']}'>";
echo "<button type='submit'>Deaktivieren</button>";
echo "</form>";
} else {
echo "<form method='post' action='{$_SERVER["PHP_SELF"]}'>";
echo "<input type='hidden' name='aktion' value='aktivieren'>";
echo "<input type='hidden' name='mitgliedID' value='{$row['MitarbeiterID']}'>";
echo "<button type='submit'>Aktivieren</button>";
echo "</form>";
}
echo "</li></ul></td>";
echo "</tr>";
}
?>
</table>
<?php
}
?>
</body>
</html>
<?php
}else{
echo "<h2>Sie haben keine Zugang zu dieser Webseite</h2>";
}
## Auskommentierung
##<link rel="stylesheet" href="/DataTables/datatables.css" />
?>
</div>
</section>
<script src="/DataTables/datatables.js"></script>
<script>
$(document).ready( function () {
$('#myTable').DataTable();
} );
</script>
<?php include "footer.php"; ?>
+122
View File
@@ -0,0 +1,122 @@
<?php
include "functions.php";
include "header.php";
include "headerline.php";
include "nav.php";
?>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<br>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anzeigenamen aktualisieren</title>
</head>
<body>
<h2>Anzeigenamen aktualisieren</h2>
<?php
if(checkKaffeelisteAccess($conn, $mailadress)){
if(checkKaffeelisteAdmin($conn, $mailadress)){
// SQL-Abfrage für alle Mitarbeiter
$sqlMitarbeiter = "SELECT MitarbeiterID, Name FROM kl_Mitarbeiter ORDER BY Name";
$stmtMitarbeiter = sqlsrv_query($conn, $sqlMitarbeiter);
}else{
echo "Hallo " . getUserName($conn,$mailadress) . "!<br><br>Hier kannst du deinen Anzeigenamen anpassen.<br>Dieser wird auf der Kaffeeliste und E-Mail genutzt.<br>";
// SQL-Abfrage für alle Mitarbeiter
$sqlMitarbeiter = "SELECT MitarbeiterID, Name FROM kl_Mitarbeiter WHERE Email ='" . $mailadress . "'";
$stmtMitarbeiter = sqlsrv_query($conn, $sqlMitarbeiter);
}
// Funktion zum Aktualisieren des Anzeigenamens
function aktualisiereAnzeigenamen($mitarbeiterID, $neuerName, $conn) {
try {
$sql = "UPDATE kl_Mitarbeiter SET Name = ? WHERE MitarbeiterID = ?";
$params = array($neuerName, $mitarbeiterID);
$stmt = sqlsrv_query($conn, $sql, $params);
if ($stmt === false) {
throw new Exception(print_r(sqlsrv_errors(), true));
}
return true; // Erfolgreich aktualisiert
} catch (Exception $e) {
return $e->getMessage(); // Fehlermeldung zurückgeben
}
}
// Überprüfen, ob das Formular abgesendet wurde
if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["aktion"] == "aktualisieren") {
$mitarbeiterID = $_POST["mitarbeiterID"];
$neuerName = $_POST["neuerName"];
$ergebnis = aktualisiereAnzeigenamen($mitarbeiterID, $neuerName, $conn);
if ($ergebnis === true) {
echo "Anzeigename erfolgreich aktualisiert.";
} else {
echo "Fehler: $ergebnis";
}
}
?>
<form method="post" action="<?php echo $_SERVER["PHP_SELF"]; ?>">
<label for="mitarbeiterID">Mitarbeiter auswählen:</label><br>
<select name="mitarbeiterID" id="mitarbeiterID">
<?php
while ($row = sqlsrv_fetch_array($stmtMitarbeiter, SQLSRV_FETCH_ASSOC)) {
$mitarbeiterID = $row['MitarbeiterID'];
$name = $row['Name'];
echo "<option value='{$mitarbeiterID}'>{$name}</option>";
}
?>
</select>
<br>
<label for="neuerName">Neuer Anzeigename: (maximal 20 Zeichen)</label><br>
<input type="text" name="neuerName" id="neuerName" maxlength="20" required>
<br><br>
<input type="hidden" name="aktion" value="aktualisieren">
<button type="submit">Anzeigename aktualisieren</button>
</form>
</body>
</html>
<?php
}
?>
</div>
</main>
</div>
</div>
</div>
<?php include "footer.php"; ?>
+3
View File
@@ -0,0 +1,3 @@
-13
View File
@@ -1,13 +0,0 @@
RewriteEngine On
# Proxy-Unterstützung: X-Forwarded-* Header durchreichen
RewriteCond %{HTTP:X-Forwarded-Proto} ^https$
RewriteRule ^ - [E=HTTPS:on]
# Statische Dateien direkt ausliefern
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Alle anderen Anfragen an index.php weiterleiten
RewriteRule ^ index.php [QSA,L]
-672
View File
@@ -1,672 +0,0 @@
:root {
--bg: #f5f6f8;
--bg-accent: #eef2f7;
--surface: #ffffff;
--surface-subtle: #f9fafb;
--surface-strong: #ffffff;
--text: #111827;
--muted: #6b7280;
--border: #d9dee7;
--border-strong: #b8c0cc;
--primary: #0f62fe;
--primary-strong: #0043ce;
--secondary: #344054;
--success: #ecfdf3;
--success-border: #a6e9c5;
--danger: #fef2f2;
--danger-border: #fecaca;
--info: #eff6ff;
--info-border: #bfdbfe;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.04);
--radius: 8px;
}
* {
box-sizing: border-box;
}
html {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 16px;
line-height: 1.55;
background: linear-gradient(180deg, #fbfcfe 0%, var(--bg) 100%);
}
body,
button,
input,
select,
textarea {
letter-spacing: 0;
}
.page-backdrop {
display: none;
}
.shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
}
.site-header {
position: sticky;
top: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
border-bottom: 1px solid var(--border);
backdrop-filter: saturate(1.2) blur(18px);
}
.header-inner,
.footer-grid,
.button-row,
.metric-row,
.inline-head,
.two-column,
.grid-three,
.content-grid,
.plan-grid,
.metric-cards,
.action-strip,
.section-actions {
display: grid;
gap: 1rem;
}
.header-inner {
grid-template-columns: auto 1fr;
align-items: center;
padding: 0.9rem 0;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
color: inherit;
text-decoration: none;
}
.brand strong {
display: block;
font-size: 0.98rem;
font-weight: 700;
}
.brand small,
.muted,
.stack-list small,
.feature-list,
.metric-row span,
.top-nav a,
.site-footer p,
.form-note,
.table-empty {
color: var(--muted);
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 2.35rem;
height: 2.35rem;
border-radius: var(--radius);
background: var(--text);
color: #ffffff;
font-size: 0.82rem;
font-weight: 700;
flex: 0 0 auto;
}
.top-nav {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.35rem;
}
.top-nav a {
display: inline-flex;
align-items: center;
min-height: 2.35rem;
padding: 0.5rem 0.7rem;
border-radius: var(--radius);
text-decoration: none;
font-size: 0.94rem;
font-weight: 600;
white-space: nowrap;
}
.top-nav a.active,
.top-nav a:hover {
background: var(--info);
color: var(--primary-strong);
}
.main-shell {
padding: 2rem 0 4rem;
}
.hero,
.two-column,
.content-grid,
.plan-grid,
.metric-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hero {
align-items: stretch;
margin-bottom: 2rem;
}
.hero-copy,
.hero-card,
.card,
.form-card,
.table-card,
.plan-card,
.timeline,
.metric-card,
.quick-action {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero-copy,
.hero-card {
padding: 2rem;
}
.hero-card {
align-self: stretch;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
.hero h1,
.page-head h1 {
max-width: 13ch;
margin: 0.6rem 0 1rem;
font-size: 3rem;
line-height: 1.05;
font-weight: 760;
}
.section-head h2,
.card h2,
.hero-card h2 {
font-size: 1.35rem;
line-height: 1.25;
}
.lead,
.page-head p {
max-width: 58rem;
color: var(--secondary);
font-size: 1.08rem;
line-height: 1.7;
}
.button-row,
.metric-row,
.section-actions {
grid-auto-flow: column;
justify-content: start;
}
.button {
display: inline-flex;
justify-content: center;
align-items: center;
min-height: 2.75rem;
padding: 0.72rem 1rem;
border-radius: var(--radius);
border: 1px solid transparent;
text-decoration: none;
font-weight: 650;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.button-primary {
color: #ffffff;
background: var(--primary);
}
.button-primary:hover {
background: var(--primary-strong);
}
.button-secondary {
color: var(--text);
border-color: var(--border);
background: var(--surface);
}
.button-secondary:hover {
border-color: var(--border-strong);
background: var(--surface-subtle);
}
.button:disabled,
.button[aria-disabled="true"] {
opacity: 0.55;
cursor: not-allowed;
}
.metric-row {
list-style: none;
padding: 0;
margin: 1.5rem 0 0;
align-items: start;
}
.metric-row li {
max-width: 15rem;
}
.metric-row strong,
.metric-card strong,
.stats-panel strong {
display: block;
font-size: 1.6rem;
line-height: 1.15;
}
.metric-row span,
.metric-card span,
.stats-panel span {
display: block;
margin-top: 0.25rem;
}
.eyebrow {
display: inline-block;
margin-bottom: 0.45rem;
color: var(--primary-strong);
font-size: 0.84rem;
font-weight: 700;
}
.feature-list,
.stack-list,
.step-list {
margin: 1rem 0 0;
padding-left: 1.15rem;
}
.feature-list li,
.step-list li {
margin: 0.45rem 0;
}
.stats-panel,
.metric-cards,
.content-grid,
.plan-grid,
.grid-three,
.action-strip {
margin-top: 1.5rem;
}
.stats-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.stats-panel div,
.metric-card {
padding: 1rem;
background: var(--surface-subtle);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: none;
}
.metric-card {
min-height: 7rem;
}
.content-grid,
.plan-grid,
.grid-three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.card,
.form-card,
.table-card,
.timeline,
.plan-card {
padding: 1.25rem;
}
.highlighted {
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(15, 98, 254, 0.2), var(--shadow);
}
.page-head {
margin-bottom: 1.5rem;
}
.inline-head {
grid-template-columns: 1fr auto;
align-items: start;
}
.form-card {
overflow: hidden;
}
.table-card {
overflow-x: auto;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.slim {
max-width: 34rem;
}
label {
display: grid;
gap: 0.45rem;
margin-bottom: 1rem;
font-weight: 650;
}
input,
select,
textarea,
button {
font: inherit;
}
input,
select,
textarea {
width: 100%;
min-height: 2.75rem;
padding: 0.72rem 0.85rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: #ffffff;
color: var(--text);
}
input::placeholder,
textarea::placeholder {
color: #98a2b3;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(15, 98, 254, 0.16);
}
a:focus-visible,
button:focus-visible,
.button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(15, 98, 254, 0.35);
outline-offset: 2px;
}
table {
width: 100%;
min-width: 42rem;
border-collapse: collapse;
}
th,
td {
padding: 0.82rem 0.75rem;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 0.86rem;
font-weight: 700;
}
tbody tr:hover {
background: var(--surface-subtle);
}
.stack-list {
list-style: none;
padding: 0;
}
.stack-list li {
display: grid;
gap: 0.2rem;
padding: 0.85rem 0;
border-bottom: 1px solid var(--border);
}
.stack-list li:last-child {
border-bottom: 0;
}
.flash,
.alert {
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border-radius: var(--radius);
font-weight: 650;
}
.flash-success {
background: var(--success);
border: 1px solid var(--success-border);
}
.flash-error,
.alert-error {
background: var(--danger);
border: 1px solid var(--danger-border);
color: #7f1d1d;
}
.alert-info {
background: var(--info);
border: 1px solid var(--info-border);
color: #1e3a8a;
}
.code-block {
overflow: auto;
padding: 1rem;
border-radius: var(--radius);
background: #111827;
color: #f9fafb;
font-size: 0.9rem;
}
.text-link {
color: var(--primary-strong);
font-weight: 650;
text-decoration: none;
}
.text-link:hover {
text-decoration: underline;
}
.site-footer {
padding: 0 0 2rem;
}
.footer-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 1.25rem 0;
border-top: 1px solid var(--border);
}
.action-strip {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 1.5rem;
}
.quick-action {
display: grid;
align-content: start;
gap: 0.25rem;
min-height: 7rem;
padding: 1rem;
color: inherit;
text-decoration: none;
}
.quick-action strong {
font-size: 1rem;
}
.quick-action span {
color: var(--muted);
font-size: 0.92rem;
}
.quick-action.primary {
border-color: rgba(15, 98, 254, 0.35);
background: var(--info);
}
.quick-action:hover {
border-color: var(--primary);
}
.section-actions {
margin: 1rem 0;
}
.form-note {
margin: 0 0 1rem;
padding: 0.85rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-subtle);
}
.status-pill {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0.2rem 0.55rem;
border-radius: var(--radius);
background: var(--surface-subtle);
border: 1px solid var(--border);
color: var(--secondary);
font-size: 0.86rem;
font-weight: 650;
}
.inline-form {
margin: 0.65rem 0 0;
}
code {
padding: 0.12rem 0.28rem;
border-radius: 5px;
background: var(--surface-subtle);
border: 1px solid var(--border);
}
@media (max-width: 960px) {
.hero,
.two-column,
.content-grid,
.plan-grid,
.metric-cards,
.grid-three,
.footer-grid,
.form-grid,
.inline-head,
.action-strip {
grid-template-columns: 1fr;
}
.header-inner {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.top-nav {
justify-content: flex-start;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 0.1rem;
scrollbar-width: thin;
}
.button-row,
.metric-row,
.section-actions {
grid-auto-flow: row;
justify-content: stretch;
}
.button-row .button,
.section-actions .button {
width: 100%;
}
.hero h1,
.page-head h1 {
max-width: 100%;
font-size: 2.15rem;
}
.hero-copy,
.hero-card {
padding: 1.25rem;
}
.stats-panel {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
-152
View File
@@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
use App\Controllers\PlatformController;
use App\Controllers\TenantController;
use App\Core\Response;
use App\Services\SecurityHeadersService;
$app = require dirname(__DIR__) . '/app/bootstrap.php';
(new SecurityHeadersService())->send();
$platform = new PlatformController(
$app['rootPath'],
$app['config'],
$app['database'],
$app['view'],
$app['session'],
$app['csrf']
);
$tenantController = new TenantController(
$app['database'],
$app['view'],
$app['session'],
$app['csrf'],
$app['config']
);
$path = current_path();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
try {
if ($path === '/' && $method === 'GET') {
$platform->home();
return;
}
if ($path === '/install') {
$method === 'POST' ? $platform->installSubmit() : $platform->installForm();
return;
}
if ($path === '/register') {
$method === 'POST' ? $platform->registerSubmit() : $platform->registerForm();
return;
}
if ($path === '/admin/login') {
$method === 'POST' ? $platform->adminLoginSubmit() : $platform->adminLoginForm();
return;
}
if ($path === '/password/forgot') {
$method === 'POST' ? $platform->passwordResetRequestSubmit() : $platform->passwordResetRequestForm();
return;
}
if ($path === '/reset-password') {
$method === 'POST' ? $platform->passwordResetConfirmSubmit() : $platform->passwordResetConfirmForm();
return;
}
if ($path === '/admin' && $method === 'GET') {
$platform->adminDashboard();
return;
}
if ($path === '/admin/logout' && $method === 'POST') {
$platform->adminLogout();
return;
}
if ($path === '/api/rfid/intake' && $method === 'POST') {
if (!($app['config']['rfid_ingest_enabled'] ?? false)) {
Response::notFound();
return;
}
$tenantController->rfidIngest();
return;
}
if (preg_match('#^/t/([^/]+)(?:/(.*))?$#', $path, $matches)) {
$tenantSlug = rawurldecode($matches[1]);
$subPath = trim((string) ($matches[2] ?? ''), '/');
if ($subPath === '' && $method === 'GET') {
$tenantController->dashboard($tenantSlug);
return;
}
if ($subPath === 'login') {
$method === 'POST' ? $tenantController->loginSubmit($tenantSlug) : $tenantController->loginForm($tenantSlug);
return;
}
if ($subPath === 'logout' && $method === 'POST') {
$tenantController->logout($tenantSlug);
return;
}
if ($subPath === 'members') {
$tenantController->members($tenantSlug);
return;
}
if ($subPath === 'products') {
$tenantController->products($tenantSlug);
return;
}
if ($subPath === 'bookings') {
$tenantController->bookings($tenantSlug);
return;
}
if ($subPath === 'exports/balances.csv' && $method === 'GET') {
$tenantController->exportBalances($tenantSlug);
return;
}
if ($subPath === 'exports/bookings.csv' && $method === 'GET') {
$tenantController->exportBookings($tenantSlug);
return;
}
if ($subPath === 'payments') {
$tenantController->payments($tenantSlug);
return;
}
if ($subPath === 'paper-sheets') {
$tenantController->paperSheets($tenantSlug);
return;
}
if ($subPath === 'rfid') {
$tenantController->rfid($tenantSlug);
return;
}
}
Response::notFound();
} catch (\Throwable $throwable) {
http_response_code(500);
error_log($throwable->__toString());
echo '<h1>Fehler</h1>';
echo '<p>' . e($app['config']['debug'] ? $throwable->getMessage() : 'Die Anfrage konnte gerade nicht verarbeitet werden.') . '</p>';
}
-160
View File
@@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
use App\Controllers\PlatformController;
use App\Controllers\TenantController;
use App\Core\Response;
use App\Services\SecurityHeadersService;
/**
* Test-Index lädt bootstrap_test.php (SQLite) statt bootstrap.php (MySQL).
* Gleicher Routing-Code wie index.php.
*/
$app = require dirname(__DIR__) . '/app/bootstrap_test.php';
(new SecurityHeadersService())->send();
$database = $app['database'];
$config = $app['config'];
$platform = new PlatformController(
$app['rootPath'],
$config,
$database,
$app['view'],
$app['session'],
$app['csrf']
);
$tenantController = new TenantController(
$database,
$app['view'],
$app['session'],
$app['csrf'],
$config
);
$path = current_path();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
try {
if ($path === '/' && $method === 'GET') {
$platform->home();
return;
}
if ($path === '/install') {
$method === 'POST' ? $platform->installSubmit() : $platform->installForm();
return;
}
if ($path === '/register') {
$method === 'POST' ? $platform->registerSubmit() : $platform->registerForm();
return;
}
if ($path === '/admin/login') {
$method === 'POST' ? $platform->adminLoginSubmit() : $platform->adminLoginForm();
return;
}
if ($path === '/password/forgot') {
$method === 'POST' ? $platform->passwordResetRequestSubmit() : $platform->passwordResetRequestForm();
return;
}
if ($path === '/reset-password') {
$method === 'POST' ? $platform->passwordResetConfirmSubmit() : $platform->passwordResetConfirmForm();
return;
}
if ($path === '/admin' && $method === 'GET') {
$platform->adminDashboard();
return;
}
if ($path === '/admin/logout' && $method === 'POST') {
$platform->adminLogout();
return;
}
if ($path === '/api/rfid/intake' && $method === 'POST') {
if (!($config['rfid_ingest_enabled'] ?? false)) {
Response::notFound();
return;
}
$tenantController->rfidIngest();
return;
}
if (preg_match('#^/t/([^/]+)(?:/(.*))?$#', $path, $matches)) {
$tenantSlug = rawurldecode($matches[1]);
$subPath = trim((string) ($matches[2] ?? ''), '/');
if ($subPath === '' && $method === 'GET') {
$tenantController->dashboard($tenantSlug);
return;
}
if ($subPath === 'login') {
$method === 'POST' ? $tenantController->loginSubmit($tenantSlug) : $tenantController->loginForm($tenantSlug);
return;
}
if ($subPath === 'logout' && $method === 'POST') {
$tenantController->logout($tenantSlug);
return;
}
if ($subPath === 'members') {
$tenantController->members($tenantSlug);
return;
}
if ($subPath === 'products') {
$tenantController->products($tenantSlug);
return;
}
if ($subPath === 'bookings') {
$tenantController->bookings($tenantSlug);
return;
}
if ($subPath === 'exports/balances.csv' && $method === 'GET') {
$tenantController->exportBalances($tenantSlug);
return;
}
if ($subPath === 'exports/bookings.csv' && $method === 'GET') {
$tenantController->exportBookings($tenantSlug);
return;
}
if ($subPath === 'payments') {
$tenantController->payments($tenantSlug);
return;
}
if ($subPath === 'paper-sheets') {
$tenantController->paperSheets($tenantSlug);
return;
}
if ($subPath === 'rfid') {
$tenantController->rfid($tenantSlug);
return;
}
}
Response::notFound();
} catch (\Throwable $throwable) {
http_response_code(500);
error_log($throwable->__toString());
echo '<h1>Fehler</h1>';
echo '<p>' . e($config['debug'] ? $throwable->getMessage() : 'Die Anfrage konnte gerade nicht verarbeitet werden.') . '</p>';
}
-12
View File
@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$file = __DIR__ . ($path === '/' ? '/index.php' : (string) $path);
if ($path !== '/' && is_file($file)) {
return false;
}
require __DIR__ . '/index.php';

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