renew
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
|
||||||
@@ -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
Vendored
+159
File diff suppressed because one or more lines are too long
Vendored
+358
File diff suppressed because one or more lines are too long
@@ -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/).
|
||||||
@@ -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!
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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`
|
|
||||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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" => '', '&' => '&', '<' => '<', '>' => '>');
|
||||||
|
$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;"> </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
|
||||||
|
//============================================================+
|
||||||
@@ -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
|
||||||
|
//============================================================+
|
||||||
@@ -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
@@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'] !== '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
Vendored
+101
File diff suppressed because one or more lines are too long
+2312
File diff suppressed because it is too large
Load Diff
Vendored
+2
@@ -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});
|
||||||
Vendored
+2
@@ -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});
|
||||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -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
@@ -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"; ?>
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
@@ -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>";
|
|
||||||
}
|
|
||||||
@@ -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`
|
|
||||||
@@ -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/
|
|
||||||
@@ -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/
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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"; ?>
|
||||||
@@ -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> </td><td> </td><td> </td></tr>';
|
||||||
|
}
|
||||||
|
$html .= '<tr><td> </td><td> </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> </td><td> </td><td> </td></tr>';
|
||||||
|
}
|
||||||
|
$html .= '<tr><td> </td><td> </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');
|
||||||
|
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
+61
@@ -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">© 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -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
@@ -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">
|
||||||
Binary file not shown.
@@ -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";
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -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);
|
||||||
|
?>
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -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
@@ -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"; ?>
|
||||||
@@ -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"; ?>
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -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"; ?>
|
||||||
@@ -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"; ?>
|
||||||
|
|
||||||
@@ -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"; ?>
|
||||||
@@ -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]
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>';
|
|
||||||
}
|
|
||||||
@@ -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>';
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user