first commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
homework.db
|
||||||
|
config.ini
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
.env
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Binaries
|
||||||
|
homework-manager
|
||||||
|
homework-manager.exe
|
||||||
|
server
|
||||||
|
server.exe
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Vendor
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Local Config
|
||||||
|
config.ini
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Editor/IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# OS
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Builder stage
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install git if needed for fetching dependencies (sometimes needed even with go modules)
|
||||||
|
# RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy go mod and sum files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
# CGO_ENABLED=0 for static binary since we are using pure Go SQLite driver (glebarez/sqlite)
|
||||||
|
RUN CGO_ENABLED=0 go build -o server ./cmd/server/main.go
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/server .
|
||||||
|
|
||||||
|
# Copy web assets (templates, static files)
|
||||||
|
COPY --from=builder /app/web ./web
|
||||||
|
|
||||||
|
# Expose port (adjust if your app uses a different port)
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
ENTRYPOINT ["./server"]
|
||||||
651
LICENSE.md
Normal file
651
LICENSE.md
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
GNU Affero General Public License
|
||||||
|
=================================
|
||||||
|
|
||||||
|
_Version 3, 19 November 2007_
|
||||||
|
_Copyright © 2007 Free Software Foundation, Inc. <<http://fsf.org/>>_
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
## Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, 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
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: **(1)** assert copyright on the software, and **(2)** offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
## TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
### 0. Definitions
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as “you”. “Licensees” and
|
||||||
|
“recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a “modified version” of the
|
||||||
|
earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices”
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that **(1)** displays an appropriate copyright notice, and **(2)**
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
### 1. Source Code
|
||||||
|
|
||||||
|
The “source code” for a work means the preferred form of the work
|
||||||
|
for making modifications to it. “Object code” means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other
|
||||||
|
than the work as a whole, that **(a)** is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and **(b)** serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
“Major Component”, in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
### 2. Basic Permissions
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
### 4. Conveying Verbatim Copies
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
### 5. Conveying Modified Source Versions
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
* **a)** The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
* **b)** The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section 7.
|
||||||
|
This requirement modifies the requirement in section 4 to
|
||||||
|
“keep intact all notices”.
|
||||||
|
* **c)** You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
* **d)** If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
“aggregate” if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
### 6. Conveying Non-Source Forms
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
* **a)** Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
* **b)** Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either **(1)** a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or **(2)** access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
* **c)** Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
* **d)** Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
* **e)** Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either **(1)** a “consumer product”, which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or **(2)** anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, “normally used” refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
### 7. Additional Terms
|
||||||
|
|
||||||
|
“Additional permissions” are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
* **a)** Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
* **b)** Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
* **c)** Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
* **d)** Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
* **e)** Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
* **f)** Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further
|
||||||
|
restrictions” within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
### 8. Termination
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
### 9. Acceptance Not Required for Having Copies
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
### 10. Automatic Licensing of Downstream Recipients
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
### 11. Patents
|
||||||
|
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, “control” includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To “grant” such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either **(1)** cause the Corresponding Source to be so
|
||||||
|
available, or **(2)** arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or **(3)** arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. “Knowingly relying” means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license **(a)** in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or **(b)** primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
### 12. No Surrender of Others' Freedom
|
||||||
|
|
||||||
|
If 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 convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
### 13. Remote Network Interaction; Use with the GNU General Public License
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
### 14. Revised Versions of this License
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero 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
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License “or any later version” applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
### 15. Disclaimer of Warranty
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “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 PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
### 16. Limitation of Liability
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM 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 PROGRAM (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 PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
### 17. Interpretation of Sections 15 and 16
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
_END OF TERMS AND CONDITIONS_
|
||||||
|
|
||||||
|
## How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state 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 program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a “Source” link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a “copyright disclaimer” for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<<http://www.gnu.org/licenses/>>.
|
||||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Homework Manager
|
||||||
|
|
||||||
|
シンプルな課題管理アプリケーションです。学生の課題管理を効率化するために設計されています。
|
||||||
|
|
||||||
|
## 特徴
|
||||||
|
|
||||||
|
- **課題管理**: 課題の登録、編集、削除、完了状況の管理
|
||||||
|
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認
|
||||||
|
- **API対応**: 外部連携用のRESTful API (APIキー認証)
|
||||||
|
- **セキュリティ**:
|
||||||
|
- CSRF対策
|
||||||
|
- レート制限 (Rate Limiting)
|
||||||
|
- セキュアなセッション管理
|
||||||
|
- **ポータビリティ**: Pure Go SQLiteドライバー使用により、CGO不要でどこでも動作
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- 取り組み目安時間の登録
|
||||||
|
- SNS連携(もしかしたらやるかも)
|
||||||
|
|
||||||
|
## ドキュメント
|
||||||
|
|
||||||
|
詳細な仕様やAPIドキュメントは `docs/` ディレクトリを参照してください。
|
||||||
|
|
||||||
|
- [仕様書](docs/SPECIFICATION.md): 機能詳細、データモデル、設定項目
|
||||||
|
- [APIドキュメント](docs/API.md): APIのエンドポイント、リクエスト/レスポンス形式
|
||||||
|
|
||||||
|
## 前提条件
|
||||||
|
|
||||||
|
- Go 1.24 以上
|
||||||
|
|
||||||
|
## インストール方法
|
||||||
|
|
||||||
|
1. **リポジトリのクローン**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd Homework-Manager
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **依存関係のダウンロード**
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **アプリケーションのビルド**
|
||||||
|
```bash
|
||||||
|
go build -o homework-manager cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **設定ファイルの準備**
|
||||||
|
サンプル設定ファイルをコピーして、`config.ini` を作成します。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.ini.example config.ini
|
||||||
|
```
|
||||||
|
※ Windows (PowerShell): `Copy-Item config.ini.example config.ini`
|
||||||
|
|
||||||
|
**重要**: 本番環境で使用する場合は、必ず `[session] secret` と `[security] csrf_secret` を変更してください。
|
||||||
|
|
||||||
|
5. **アプリケーションの実行**
|
||||||
|
```bash
|
||||||
|
./homework-manager
|
||||||
|
```
|
||||||
|
※ Windows (PowerShell): `.\homework-manager.exe`
|
||||||
|
|
||||||
|
ブラウザで `http://localhost:8080` にアクセスしてください。
|
||||||
|
|
||||||
|
## Dockerでの実行
|
||||||
|
|
||||||
|
DockerおよびDocker Composeがインストールされている環境では、以下の手順で簡単に起動できます。
|
||||||
|
|
||||||
|
1. **設定ファイルの準備**
|
||||||
|
```bash
|
||||||
|
cp config.ini.example config.ini
|
||||||
|
```
|
||||||
|
※ 必須です。これを行わないとDockerがディレクトリとして作成してしまい起動に失敗します。
|
||||||
|
|
||||||
|
2. **コンテナの起動**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **アクセスの確認**
|
||||||
|
ブラウザで `http://localhost:8080` にアクセスしてください。
|
||||||
|
|
||||||
|
|
||||||
|
## 更新方法
|
||||||
|
|
||||||
|
1. `git pull` で最新コードを取得
|
||||||
|
2. `go build -o homework-manager cmd/server/main.go` で再ビルド
|
||||||
|
3. アプリケーションを再起動
|
||||||
|
|
||||||
|
## ライセンス
|
||||||
|
|
||||||
|
本ソフトウェアのライセンスはAGPLv3 (GNU Affero General Public License v3)です。
|
||||||
|
詳しくはLICENSEファイルをご覧ください。
|
||||||
55
config.ini.example
Normal file
55
config.ini.example
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
; Homework Manager 設定ファイル
|
||||||
|
; 環境変数が設定されている場合はそちらが優先されます
|
||||||
|
|
||||||
|
[server]
|
||||||
|
; サーバーポート
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
; デバッグモード (true/false)
|
||||||
|
debug = true
|
||||||
|
|
||||||
|
[database]
|
||||||
|
; データベースドライバー: sqlite, mysql, postgres
|
||||||
|
driver = sqlite
|
||||||
|
|
||||||
|
; SQLite用設定
|
||||||
|
path = homework.db
|
||||||
|
|
||||||
|
; MySQL/PostgreSQL用設定(driverをmysqlまたはpostgresに変更して使用)
|
||||||
|
; host = localhost
|
||||||
|
; port = 3306
|
||||||
|
; user = root
|
||||||
|
; password =
|
||||||
|
; name = homework_manager
|
||||||
|
|
||||||
|
; PostgreSQL例:
|
||||||
|
; driver = postgres
|
||||||
|
; host = localhost
|
||||||
|
; port = 5432
|
||||||
|
; user = postgres
|
||||||
|
; password = secret
|
||||||
|
; name = homework_manager
|
||||||
|
|
||||||
|
[session]
|
||||||
|
; セッション暗号化キー(本番環境では必ず変更してください)
|
||||||
|
secret = homework-manager-secret-key-change-in-production
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
; 新規ユーザー登録を許可するか (true/false)
|
||||||
|
; falseにすると登録ページが無効化されます
|
||||||
|
allow_registration = true
|
||||||
|
|
||||||
|
[security]
|
||||||
|
; HTTPS使用時はtrueに設定(Secure cookie属性が有効になります)
|
||||||
|
https = false
|
||||||
|
|
||||||
|
; CSRFトークン秘密鍵(本番環境では必ず変更してください)
|
||||||
|
csrf_secret = change-this-to-a-secure-random-string
|
||||||
|
# Enable rate limiting
|
||||||
|
rate_limit_enabled = true
|
||||||
|
# Max requests per window
|
||||||
|
rate_limit_requests = 100
|
||||||
|
# Window size in seconds
|
||||||
|
rate_limit_window = 60
|
||||||
|
# Trusted proxies (comma separated IP addresses or CIDR)
|
||||||
|
# trusted_proxies = 127.0.0.1, 10.0.0.0/8
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./homework.db:/app/homework.db
|
||||||
|
- ./config.ini:/app/config.ini
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Tokyo
|
||||||
|
restart: unless-stopped
|
||||||
419
docs/API.md
Normal file
419
docs/API.md
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
# Super Homework Manager API ドキュメント
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
Super Homework Manager REST APIは、課題管理機能をプログラムから利用するためのAPIです。
|
||||||
|
|
||||||
|
- **ベースURL**: `/api/v1`
|
||||||
|
- **認証方式**: APIキー認証
|
||||||
|
- **レスポンス形式**: JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 認証
|
||||||
|
|
||||||
|
すべてのAPIエンドポイントはAPIキー認証が必要です。
|
||||||
|
|
||||||
|
### APIキーの取得
|
||||||
|
|
||||||
|
1. 管理者アカウントでログイン
|
||||||
|
2. 管理画面 → APIキー管理へ移動
|
||||||
|
3. 新規APIキーを発行
|
||||||
|
|
||||||
|
### 認証ヘッダー
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### 認証エラー
|
||||||
|
|
||||||
|
| ステータスコード | レスポンス |
|
||||||
|
|------------------|------------|
|
||||||
|
| 401 Unauthorized | `{"error": "API key required"}` |
|
||||||
|
| 401 Unauthorized | `{"error": "Invalid API key"}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## エンドポイント一覧
|
||||||
|
|
||||||
|
| メソッド | パス | 説明 |
|
||||||
|
|----------|------|------|
|
||||||
|
| GET | `/api/v1/assignments` | 課題一覧取得 |
|
||||||
|
| GET | `/api/v1/assignments/:id` | 課題詳細取得 |
|
||||||
|
| POST | `/api/v1/assignments` | 課題作成 |
|
||||||
|
| PUT | `/api/v1/assignments/:id` | 課題更新 |
|
||||||
|
| DELETE | `/api/v1/assignments/:id` | 課題削除 |
|
||||||
|
| PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 課題一覧取得
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
### クエリパラメータ
|
||||||
|
|
||||||
|
| パラメータ | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `filter` | string | フィルタ: `pending`, `completed`, `overdue` (省略時: 全件) |
|
||||||
|
|
||||||
|
### レスポンス
|
||||||
|
|
||||||
|
**200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 1,
|
||||||
|
"title": "数学レポート",
|
||||||
|
"description": "第5章の練習問題",
|
||||||
|
"subject": "数学",
|
||||||
|
"due_date": "2025-01-15T23:59:00+09:00",
|
||||||
|
"is_completed": false,
|
||||||
|
"created_at": "2025-01-10T10:00:00+09:00",
|
||||||
|
"updated_at": "2025-01-10T10:00:00+09:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全件取得
|
||||||
|
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments
|
||||||
|
|
||||||
|
# 未完了のみ取得
|
||||||
|
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending
|
||||||
|
|
||||||
|
# 期限切れのみ取得
|
||||||
|
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 課題詳細取得
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/assignments/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### パスパラメータ
|
||||||
|
|
||||||
|
| パラメータ | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `id` | integer | 課題ID |
|
||||||
|
|
||||||
|
### レスポンス
|
||||||
|
|
||||||
|
**200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 1,
|
||||||
|
"title": "数学レポート",
|
||||||
|
"description": "第5章の練習問題",
|
||||||
|
"subject": "数学",
|
||||||
|
"due_date": "2025-01-15T23:59:00+09:00",
|
||||||
|
"is_completed": false,
|
||||||
|
"created_at": "2025-01-10T10:00:00+09:00",
|
||||||
|
"updated_at": "2025-01-10T10:00:00+09:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**404 Not Found**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Assignment not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments/1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 課題作成
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
### リクエストボディ
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
|------------|------|------|------|
|
||||||
|
| `title` | string | ✅ | 課題タイトル |
|
||||||
|
| `description` | string | | 説明 |
|
||||||
|
| `subject` | string | | 教科・科目 |
|
||||||
|
| `due_date` | string | ✅ | 提出期限(RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD`) |
|
||||||
|
|
||||||
|
### リクエスト例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "英語エッセイ",
|
||||||
|
"description": "テーマ自由、1000語以上",
|
||||||
|
"subject": "英語",
|
||||||
|
"due_date": "2025-01-20T17:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### レスポンス
|
||||||
|
|
||||||
|
**201 Created**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"user_id": 1,
|
||||||
|
"title": "英語エッセイ",
|
||||||
|
"description": "テーマ自由、1000語以上",
|
||||||
|
"subject": "英語",
|
||||||
|
"due_date": "2025-01-20T17:00:00+09:00",
|
||||||
|
"is_completed": false,
|
||||||
|
"created_at": "2025-01-10T11:00:00+09:00",
|
||||||
|
"updated_at": "2025-01-10T11:00:00+09:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**400 Bad Request**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Invalid input: title and due_date are required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-API-Key: hm_xxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \
|
||||||
|
http://localhost:8080/api/v1/assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 課題更新
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/assignments/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### パスパラメータ
|
||||||
|
|
||||||
|
| パラメータ | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `id` | integer | 課題ID |
|
||||||
|
|
||||||
|
### リクエストボディ
|
||||||
|
|
||||||
|
すべてのフィールドはオプションです。省略されたフィールドは既存の値を維持します。
|
||||||
|
|
||||||
|
| フィールド | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `title` | string | 課題タイトル |
|
||||||
|
| `description` | string | 説明 |
|
||||||
|
| `subject` | string | 教科・科目 |
|
||||||
|
| `due_date` | string | 提出期限 |
|
||||||
|
|
||||||
|
### リクエスト例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "英語エッセイ(修正版)",
|
||||||
|
"due_date": "2025-01-25T17:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### レスポンス
|
||||||
|
|
||||||
|
**200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"user_id": 1,
|
||||||
|
"title": "英語エッセイ(修正版)",
|
||||||
|
"description": "テーマ自由、1000語以上",
|
||||||
|
"subject": "英語",
|
||||||
|
"due_date": "2025-01-25T17:00:00+09:00",
|
||||||
|
"is_completed": false,
|
||||||
|
"created_at": "2025-01-10T11:00:00+09:00",
|
||||||
|
"updated_at": "2025-01-10T12:00:00+09:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**404 Not Found**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Assignment not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT \
|
||||||
|
-H "X-API-Key: hm_xxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"更新されたタイトル"}' \
|
||||||
|
http://localhost:8080/api/v1/assignments/2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 課題削除
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/assignments/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### パスパラメータ
|
||||||
|
|
||||||
|
| パラメータ | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `id` | integer | 課題ID |
|
||||||
|
|
||||||
|
### レスポンス
|
||||||
|
|
||||||
|
**200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Assignment deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**404 Not Found**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Assignment not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE \
|
||||||
|
-H "X-API-Key: hm_xxx" \
|
||||||
|
http://localhost:8080/api/v1/assignments/2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完了状態トグル
|
||||||
|
|
||||||
|
課題の完了状態を切り替えます(未完了 ↔ 完了)。
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/assignments/:id/toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
### パスパラメータ
|
||||||
|
|
||||||
|
| パラメータ | 型 | 説明 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `id` | integer | 課題ID |
|
||||||
|
|
||||||
|
### レスポンス
|
||||||
|
|
||||||
|
**200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 1,
|
||||||
|
"title": "数学レポート",
|
||||||
|
"description": "第5章の練習問題",
|
||||||
|
"subject": "数学",
|
||||||
|
"due_date": "2025-01-15T23:59:00+09:00",
|
||||||
|
"is_completed": true,
|
||||||
|
"completed_at": "2025-01-12T14:30:00+09:00",
|
||||||
|
"created_at": "2025-01-10T10:00:00+09:00",
|
||||||
|
"updated_at": "2025-01-12T14:30:00+09:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**404 Not Found**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Assignment not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH \
|
||||||
|
-H "X-API-Key: hm_xxx" \
|
||||||
|
http://localhost:8080/api/v1/assignments/1/toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## エラーレスポンス
|
||||||
|
|
||||||
|
すべてのエラーレスポンスは以下の形式で返されます:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "エラーメッセージ"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 共通エラーコード
|
||||||
|
|
||||||
|
| ステータスコード | 説明 |
|
||||||
|
|------------------|------|
|
||||||
|
| 400 Bad Request | リクエストの形式が不正 |
|
||||||
|
| 401 Unauthorized | 認証エラー |
|
||||||
|
| 404 Not Found | リソースが見つからない |
|
||||||
|
| 500 Internal Server Error | サーバー内部エラー |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 日付形式
|
||||||
|
|
||||||
|
APIは以下の日付形式を受け付けます(優先度順):
|
||||||
|
|
||||||
|
1. **RFC3339**: `2025-01-15T23:59:00+09:00`
|
||||||
|
2. **日時形式**: `2025-01-15T23:59`
|
||||||
|
3. **日付のみ**: `2025-01-15`(時刻は23:59に設定)
|
||||||
|
|
||||||
|
レスポンスの日付はすべてRFC3339形式で返されます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
アプリケーションレベルでのRate Limitingが実装されています。
|
||||||
|
|
||||||
|
- **制限単位**: IPアドレスごと
|
||||||
|
- **デフォルト制限**: 100リクエスト / 60秒
|
||||||
|
- **超過時レスポンス**: `429 Too Many Requests`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "リクエスト数が制限を超えました。しばらくしてからお試しください。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
設定ファイル (`config.ini`) または環境変数で制限値を変更可能です。
|
||||||
252
docs/SPECIFICATION.md
Normal file
252
docs/SPECIFICATION.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# Super Homework Manager 仕様書
|
||||||
|
|
||||||
|
## 1. 概要
|
||||||
|
|
||||||
|
Super Homework Managerは、学生の課題管理を支援するWebアプリケーションです。Go言語とGinフレームワークを使用して構築されており、課題の登録・管理、期限追跡、完了状況の管理機能を提供します。
|
||||||
|
|
||||||
|
### 1.1 技術スタック
|
||||||
|
|
||||||
|
| 項目 | 技術 |
|
||||||
|
|------|------|
|
||||||
|
| 言語 | Go |
|
||||||
|
| Webフレームワーク | Gin |
|
||||||
|
| データベース | SQLite (GORM with Pure Go driver - glebarez/sqlite) |
|
||||||
|
| セッション管理 | gin-contrib/sessions (Cookie store) |
|
||||||
|
| テンプレートエンジン | Go html/template |
|
||||||
|
| コンテナ | Docker対応 |
|
||||||
|
|
||||||
|
### 1.2 ディレクトリ構成
|
||||||
|
|
||||||
|
```
|
||||||
|
homework-manager/
|
||||||
|
├── cmd/server/ # アプリケーションエントリポイント
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 設定読み込み
|
||||||
|
│ ├── database/ # データベース接続・マイグレーション
|
||||||
|
│ ├── handler/ # HTTPハンドラ
|
||||||
|
│ ├── middleware/ # ミドルウェア
|
||||||
|
│ ├── models/ # データモデル
|
||||||
|
│ ├── repository/ # データアクセス層
|
||||||
|
│ └── service/ # ビジネスロジック
|
||||||
|
├── web/
|
||||||
|
│ ├── static/ # 静的ファイル (CSS, JS)
|
||||||
|
│ └── templates/ # HTMLテンプレート
|
||||||
|
├── Dockerfile
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. データモデル
|
||||||
|
|
||||||
|
### 2.1 User(ユーザー)
|
||||||
|
|
||||||
|
ユーザー情報を管理するモデル。
|
||||||
|
|
||||||
|
| フィールド | 型 | 説明 | 制約 |
|
||||||
|
|------------|------|------|------|
|
||||||
|
| ID | uint | ユーザーID | Primary Key |
|
||||||
|
| Email | string | メールアドレス | Unique, Not Null |
|
||||||
|
| PasswordHash | string | パスワードハッシュ | Not Null |
|
||||||
|
| Name | string | 表示名 | Not Null |
|
||||||
|
| Role | string | 権限 (`user` or `admin`) | Default: `user` |
|
||||||
|
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||||
|
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||||
|
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||||
|
|
||||||
|
### 2.2 Assignment(課題)
|
||||||
|
|
||||||
|
課題情報を管理するモデル。
|
||||||
|
|
||||||
|
| フィールド | 型 | 説明 | 制約 |
|
||||||
|
|------------|------|------|------|
|
||||||
|
| ID | uint | 課題ID | Primary Key |
|
||||||
|
| UserID | uint | 所有ユーザーID | Not Null, Index |
|
||||||
|
| Title | string | 課題タイトル | Not Null |
|
||||||
|
| Description | string | 説明 | - |
|
||||||
|
| Subject | string | 教科・科目 | - |
|
||||||
|
| DueDate | time.Time | 提出期限 | Not Null |
|
||||||
|
| IsCompleted | bool | 完了フラグ | Default: false |
|
||||||
|
| CompletedAt | *time.Time | 完了日時 | Nullable |
|
||||||
|
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||||
|
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||||
|
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||||
|
|
||||||
|
### 2.3 APIKey(APIキー)
|
||||||
|
|
||||||
|
REST API認証用のAPIキーを管理するモデル。
|
||||||
|
|
||||||
|
| フィールド | 型 | 説明 | 制約 |
|
||||||
|
|------------|------|------|------|
|
||||||
|
| ID | uint | APIキーID | Primary Key |
|
||||||
|
| UserID | uint | 所有ユーザーID | Not Null, Index |
|
||||||
|
| Name | string | キー名 | Not Null |
|
||||||
|
| KeyHash | string | キーハッシュ | Unique, Not Null |
|
||||||
|
| LastUsed | *time.Time | 最終使用日時 | Nullable |
|
||||||
|
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||||
|
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 認証・認可
|
||||||
|
|
||||||
|
### 3.1 Web認証
|
||||||
|
|
||||||
|
- **セッションベース認証**: Cookie storeを使用
|
||||||
|
- **セッション有効期限**: 7日間
|
||||||
|
- **パスワード要件**: 8文字以上
|
||||||
|
- **パスワードハッシュ**: bcryptを使用
|
||||||
|
- **CSRF対策**: 全フォームでのトークン検証
|
||||||
|
|
||||||
|
### 3.2 API認証
|
||||||
|
|
||||||
|
- **APIキー認証**: `X-API-Key` ヘッダーで認証
|
||||||
|
- **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列
|
||||||
|
- **ハッシュ保存**: SHA-256でハッシュ化して保存
|
||||||
|
|
||||||
|
### 3.3 ユーザーロール
|
||||||
|
|
||||||
|
| ロール | 権限 |
|
||||||
|
|--------|------|
|
||||||
|
| `user` | 自分の課題のCRUD操作、プロフィール管理 |
|
||||||
|
| `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 |
|
||||||
|
※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 機能一覧
|
||||||
|
|
||||||
|
### 4.1 認証機能
|
||||||
|
|
||||||
|
| 機能 | 説明 |
|
||||||
|
|------|------|
|
||||||
|
| 新規登録 | メールアドレス、パスワード、名前で登録 |
|
||||||
|
| ログイン | メールアドレスとパスワードでログイン |
|
||||||
|
| ログアウト | セッションをクリアしてログアウト |
|
||||||
|
|
||||||
|
### 4.2 課題管理機能
|
||||||
|
|
||||||
|
| 機能 | 説明 |
|
||||||
|
|------|------|
|
||||||
|
| ダッシュボード | 課題の統計情報、本日期限の課題、期限切れ課題、今週期限の課題を表示 |
|
||||||
|
| 課題一覧 | フィルタ付き(未完了/完了済み/期限切れ)で課題を一覧表示 |
|
||||||
|
| 課題登録 | タイトル、説明、教科、提出期限を入力して新規登録 |
|
||||||
|
| 課題編集 | 既存の課題情報を編集 |
|
||||||
|
| 課題削除 | 課題を論理削除 |
|
||||||
|
| 完了トグル | 課題の完了/未完了状態を切り替え |
|
||||||
|
|
||||||
|
### 4.3 プロフィール機能
|
||||||
|
|
||||||
|
| 機能 | 説明 |
|
||||||
|
|------|------|
|
||||||
|
| プロフィール表示 | ユーザー情報を表示 |
|
||||||
|
| プロフィール更新 | 表示名を変更 |
|
||||||
|
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
|
||||||
|
|
||||||
|
### 4.4 管理者機能
|
||||||
|
|
||||||
|
| 機能 | 説明 |
|
||||||
|
|------|------|
|
||||||
|
| ユーザー一覧 | 全ユーザーを一覧表示 |
|
||||||
|
| ユーザー削除 | ユーザーを論理削除(自分自身は削除不可) |
|
||||||
|
| 権限変更 | ユーザーのロールを変更(自分自身は変更不可) |
|
||||||
|
| APIキー一覧 | 全APIキーを一覧表示 |
|
||||||
|
| APIキー発行 | 新規APIキーを発行(発行時のみ平文表示) |
|
||||||
|
| APIキー削除 | APIキーを削除 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 設定
|
||||||
|
|
||||||
|
### 5.1 設定ファイル (config.ini)
|
||||||
|
|
||||||
|
アプリケーションは `config.ini` ファイルから設定を読み込みます。`config.ini.example` をコピーして使用してください。
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
debug = true
|
||||||
|
|
||||||
|
[database]
|
||||||
|
driver = sqlite
|
||||||
|
path = homework.db
|
||||||
|
|
||||||
|
[session]
|
||||||
|
secret = your-secure-secret-key
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
allow_registration = true
|
||||||
|
|
||||||
|
[security]
|
||||||
|
https = false
|
||||||
|
csrf_secret = your-secure-csrf-secret
|
||||||
|
rate_limit_enabled = true
|
||||||
|
rate_limit_requests = 100
|
||||||
|
rate_limit_window = 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 設定項目
|
||||||
|
|
||||||
|
| セクション | キー | 説明 | デフォルト値 |
|
||||||
|
|------------|------|------|--------------|
|
||||||
|
| `server` | `port` | サーバーポート | `8080` |
|
||||||
|
| `server` | `debug` | デバッグモード | `true` |
|
||||||
|
| `database` | `driver` | DBドライバー (sqlite, mysql, postgres) | `sqlite` |
|
||||||
|
| `database` | `path` | SQLiteファイルパス | `homework.db` |
|
||||||
|
| `session` | `secret` | セッション暗号化キー | (必須) |
|
||||||
|
| `auth` | `allow_registration` | 新規登録許可 | `true` |
|
||||||
|
| `security` | `https` | HTTPS設定(Secure Cookie) | `false` |
|
||||||
|
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | (必須) |
|
||||||
|
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
|
||||||
|
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
|
||||||
|
| `security` | `rate_limit_window` | 期間(秒) | `60` |
|
||||||
|
|
||||||
|
### 5.3 環境変数
|
||||||
|
|
||||||
|
環境変数が設定されている場合、config.iniの設定より優先されます。
|
||||||
|
|
||||||
|
| 変数名 | 説明 |
|
||||||
|
|--------|------|
|
||||||
|
| `PORT` | サーバーポート |
|
||||||
|
| `DATABASE_DRIVER` | データベースドライバー |
|
||||||
|
| `DATABASE_PATH` | SQLiteデータベースファイルパス |
|
||||||
|
| `SESSION_SECRET` | セッション暗号化キー |
|
||||||
|
| `CSRF_SECRET` | CSRFトークン秘密鍵 |
|
||||||
|
| `GIN_MODE` | `release` でリリースモード(debug=false) |
|
||||||
|
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) |
|
||||||
|
| `HTTPS` | HTTPSモード (true/false) |
|
||||||
|
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト |
|
||||||
|
|
||||||
|
### 5.4 設定の優先順位
|
||||||
|
|
||||||
|
1. 環境変数(最優先)
|
||||||
|
2. config.ini
|
||||||
|
3. デフォルト値
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. セキュリティ
|
||||||
|
|
||||||
|
### 6.1 実装済みセキュリティ機能
|
||||||
|
|
||||||
|
- **パスワードハッシュ化**: bcryptによるソルト付きハッシュ
|
||||||
|
- **セッションセキュリティ**: HttpOnly Cookie
|
||||||
|
- **入力バリデーション**: 各ハンドラで基本的な入力検証
|
||||||
|
- **CSFR対策**: Double Submit Cookieパターンまたは同期トークンによるCSRF保護
|
||||||
|
- **レート制限**: IPベースのリクエスト制限によるDoS対策
|
||||||
|
- **論理削除**: データの完全削除を防ぐソフトデリート
|
||||||
|
- **権限チェック**: ミドルウェアによるロールベースアクセス制御
|
||||||
|
- **Secure Cookie**: HTTPS設定時のSecure属性付与
|
||||||
|
|
||||||
|
### 6.2 推奨される本番環境設定
|
||||||
|
|
||||||
|
- `SESSION_SECRET` と `CSRF_SECRET` を強力なランダム文字列に変更
|
||||||
|
- HTTPSを有効化し、`HTTPS=true` を設定
|
||||||
|
- `GIN_MODE=release` を設定
|
||||||
|
- 必要に応じて `TRUSTED_PROXIES` を設定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ライセンス
|
||||||
|
|
||||||
|
AGPLv3 (GNU Affero General Public License v3)
|
||||||
69
go.mod
Normal file
69
go.mod
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
module homework-manager
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.11
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/sessions v1.0.4
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
|
gopkg.in/ini.v1 v1.67.0
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.14.2 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
modernc.org/libc v1.67.2 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.42.2 // indirect
|
||||||
|
)
|
||||||
174
go.sum
Normal file
174
go.sum
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||||
|
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||||
|
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||||
|
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||||
|
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||||
|
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||||
|
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||||
|
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||||
|
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.2 h1:ZbNmly1rcbjhot5jlOZG0q4p5VwFfjwWqZ5rY2xxOXo=
|
||||||
|
modernc.org/libc v1.67.2/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||||
|
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
182
internal/config/config.go
Normal file
182
internal/config/config.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/ini.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Driver string // sqlite, mysql, postgres
|
||||||
|
Path string // SQLiteの場合パス
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
SessionSecret string
|
||||||
|
Debug bool
|
||||||
|
AllowRegistration bool
|
||||||
|
HTTPS bool
|
||||||
|
CSRFSecret string
|
||||||
|
RateLimitEnabled bool
|
||||||
|
RateLimitRequests int
|
||||||
|
RateLimitWindow int
|
||||||
|
TrustedProxies []string
|
||||||
|
Database DatabaseConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(configPath string) *Config {
|
||||||
|
cfg := &Config{
|
||||||
|
Port: "8080",
|
||||||
|
SessionSecret: "",
|
||||||
|
Debug: true,
|
||||||
|
AllowRegistration: true,
|
||||||
|
HTTPS: false,
|
||||||
|
CSRFSecret: "",
|
||||||
|
RateLimitEnabled: true,
|
||||||
|
RateLimitRequests: 100,
|
||||||
|
RateLimitWindow: 60,
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
Driver: "sqlite",
|
||||||
|
Path: "homework.db",
|
||||||
|
Host: "localhost",
|
||||||
|
Port: "3306",
|
||||||
|
User: "root",
|
||||||
|
Password: "",
|
||||||
|
Name: "homework_manager",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = "config.ini"
|
||||||
|
}
|
||||||
|
|
||||||
|
if iniFile, err := ini.Load(configPath); err == nil {
|
||||||
|
log.Printf("Loading configuration from %s", configPath)
|
||||||
|
|
||||||
|
section := iniFile.Section("server")
|
||||||
|
if section.HasKey("port") {
|
||||||
|
cfg.Port = section.Key("port").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("debug") {
|
||||||
|
cfg.Debug = section.Key("debug").MustBool(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
section = iniFile.Section("database")
|
||||||
|
if section.HasKey("driver") {
|
||||||
|
cfg.Database.Driver = section.Key("driver").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("path") {
|
||||||
|
cfg.Database.Path = section.Key("path").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("host") {
|
||||||
|
cfg.Database.Host = section.Key("host").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("port") {
|
||||||
|
cfg.Database.Port = section.Key("port").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("user") {
|
||||||
|
cfg.Database.User = section.Key("user").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("password") {
|
||||||
|
cfg.Database.Password = section.Key("password").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("name") {
|
||||||
|
cfg.Database.Name = section.Key("name").String()
|
||||||
|
}
|
||||||
|
|
||||||
|
section = iniFile.Section("session")
|
||||||
|
if section.HasKey("secret") {
|
||||||
|
cfg.SessionSecret = section.Key("secret").String()
|
||||||
|
}
|
||||||
|
|
||||||
|
section = iniFile.Section("auth")
|
||||||
|
if section.HasKey("allow_registration") {
|
||||||
|
cfg.AllowRegistration = section.Key("allow_registration").MustBool(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
section = iniFile.Section("security")
|
||||||
|
if section.HasKey("https") {
|
||||||
|
cfg.HTTPS = section.Key("https").MustBool(false)
|
||||||
|
}
|
||||||
|
if section.HasKey("csrf_secret") {
|
||||||
|
cfg.CSRFSecret = section.Key("csrf_secret").String()
|
||||||
|
}
|
||||||
|
if section.HasKey("rate_limit_enabled") {
|
||||||
|
cfg.RateLimitEnabled = section.Key("rate_limit_enabled").MustBool(true)
|
||||||
|
}
|
||||||
|
if section.HasKey("rate_limit_requests") {
|
||||||
|
cfg.RateLimitRequests = section.Key("rate_limit_requests").MustInt(100)
|
||||||
|
}
|
||||||
|
if section.HasKey("rate_limit_window") {
|
||||||
|
cfg.RateLimitWindow = section.Key("rate_limit_window").MustInt(60)
|
||||||
|
}
|
||||||
|
if section.HasKey("trusted_proxies") {
|
||||||
|
proxies := section.Key("trusted_proxies").String()
|
||||||
|
if proxies != "" {
|
||||||
|
cfg.TrustedProxies = []string{proxies}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Println("config.ini not found, using environment variables or defaults")
|
||||||
|
}
|
||||||
|
|
||||||
|
if port := os.Getenv("PORT"); port != "" {
|
||||||
|
cfg.Port = port
|
||||||
|
}
|
||||||
|
if dbDriver := os.Getenv("DATABASE_DRIVER"); dbDriver != "" {
|
||||||
|
cfg.Database.Driver = dbDriver
|
||||||
|
}
|
||||||
|
if dbPath := os.Getenv("DATABASE_PATH"); dbPath != "" {
|
||||||
|
cfg.Database.Path = dbPath
|
||||||
|
}
|
||||||
|
if dbHost := os.Getenv("DATABASE_HOST"); dbHost != "" {
|
||||||
|
cfg.Database.Host = dbHost
|
||||||
|
}
|
||||||
|
if dbPort := os.Getenv("DATABASE_PORT"); dbPort != "" {
|
||||||
|
cfg.Database.Port = dbPort
|
||||||
|
}
|
||||||
|
if dbUser := os.Getenv("DATABASE_USER"); dbUser != "" {
|
||||||
|
cfg.Database.User = dbUser
|
||||||
|
}
|
||||||
|
if dbPassword := os.Getenv("DATABASE_PASSWORD"); dbPassword != "" {
|
||||||
|
cfg.Database.Password = dbPassword
|
||||||
|
}
|
||||||
|
if dbName := os.Getenv("DATABASE_NAME"); dbName != "" {
|
||||||
|
cfg.Database.Name = dbName
|
||||||
|
}
|
||||||
|
if sessionSecret := os.Getenv("SESSION_SECRET"); sessionSecret != "" {
|
||||||
|
cfg.SessionSecret = sessionSecret
|
||||||
|
}
|
||||||
|
if os.Getenv("GIN_MODE") == "release" {
|
||||||
|
cfg.Debug = false
|
||||||
|
}
|
||||||
|
if allowReg := os.Getenv("ALLOW_REGISTRATION"); allowReg != "" {
|
||||||
|
cfg.AllowRegistration = allowReg == "true" || allowReg == "1"
|
||||||
|
}
|
||||||
|
if https := os.Getenv("HTTPS"); https != "" {
|
||||||
|
cfg.HTTPS = https == "true" || https == "1"
|
||||||
|
}
|
||||||
|
if csrfSecret := os.Getenv("CSRF_SECRET"); csrfSecret != "" {
|
||||||
|
cfg.CSRFSecret = csrfSecret
|
||||||
|
}
|
||||||
|
if trustedProxies := os.Getenv("TRUSTED_PROXIES"); trustedProxies != "" {
|
||||||
|
cfg.TrustedProxies = []string{trustedProxies}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SessionSecret == "" {
|
||||||
|
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")
|
||||||
|
}
|
||||||
|
if cfg.CSRFSecret == "" {
|
||||||
|
log.Fatal("FATAL: CSRF secret is not set. Please set it in config.ini ([security] csrf_secret) or via CSRF_SECRET environment variable.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
79
internal/database/database.go
Normal file
79
internal/database/database.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"homework-manager/internal/config"
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func Connect(dbConfig config.DatabaseConfig, debug bool) error {
|
||||||
|
var logMode logger.LogLevel
|
||||||
|
if debug {
|
||||||
|
logMode = logger.Info
|
||||||
|
} else {
|
||||||
|
logMode = logger.Silent
|
||||||
|
}
|
||||||
|
|
||||||
|
gormConfig := &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logMode),
|
||||||
|
}
|
||||||
|
|
||||||
|
var db *gorm.DB
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch dbConfig.Driver {
|
||||||
|
case "mysql":
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
|
dbConfig.User,
|
||||||
|
dbConfig.Password,
|
||||||
|
dbConfig.Host,
|
||||||
|
dbConfig.Port,
|
||||||
|
dbConfig.Name,
|
||||||
|
)
|
||||||
|
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
|
||||||
|
|
||||||
|
case "postgres":
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
dbConfig.Host,
|
||||||
|
dbConfig.Port,
|
||||||
|
dbConfig.User,
|
||||||
|
dbConfig.Password,
|
||||||
|
dbConfig.Name,
|
||||||
|
)
|
||||||
|
db, err = gorm.Open(postgres.Open(dsn), gormConfig)
|
||||||
|
|
||||||
|
case "sqlite":
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
db, err = gorm.Open(sqlite.Open(dbConfig.Path), gormConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
DB = db
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate() error {
|
||||||
|
return DB.AutoMigrate(
|
||||||
|
&models.User{},
|
||||||
|
&models.Assignment{},
|
||||||
|
&models.APIKey{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDB() *gorm.DB {
|
||||||
|
return DB
|
||||||
|
}
|
||||||
|
|
||||||
165
internal/handler/admin_handler.go
Normal file
165
internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminHandler struct {
|
||||||
|
adminService *service.AdminService
|
||||||
|
apiKeyService *service.APIKeyService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminHandler() *AdminHandler {
|
||||||
|
return &AdminHandler{
|
||||||
|
adminService: service.NewAdminService(),
|
||||||
|
apiKeyService: service.NewAPIKeyService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) getUserID(c *gin.Context) uint {
|
||||||
|
userID, _ := c.Get(middleware.UserIDKey)
|
||||||
|
return userID.(uint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) Index(c *gin.Context) {
|
||||||
|
users, _ := h.adminService.GetAllUsers()
|
||||||
|
currentUserID := h.getUserID(c)
|
||||||
|
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "admin/users.html", gin.H{
|
||||||
|
"title": "ユーザー管理",
|
||||||
|
"users": users,
|
||||||
|
"currentUserID": currentUserID,
|
||||||
|
"isAdmin": true,
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||||
|
adminID := h.getUserID(c)
|
||||||
|
targetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "無効なユーザーID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.adminService.DeleteUser(adminID, uint(targetID))
|
||||||
|
if err != nil {
|
||||||
|
users, _ := h.adminService.GetAllUsers()
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "admin/users.html", gin.H{
|
||||||
|
"title": "ユーザー管理",
|
||||||
|
"users": users,
|
||||||
|
"currentUserID": adminID,
|
||||||
|
"error": err.Error(),
|
||||||
|
"isAdmin": true,
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ChangeRole(c *gin.Context) {
|
||||||
|
adminID := h.getUserID(c)
|
||||||
|
targetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "無効なユーザーID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newRole := c.PostForm("role")
|
||||||
|
|
||||||
|
err = h.adminService.ChangeRole(adminID, uint(targetID), newRole)
|
||||||
|
if err != nil {
|
||||||
|
users, _ := h.adminService.GetAllUsers()
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "admin/users.html", gin.H{
|
||||||
|
"title": "ユーザー管理",
|
||||||
|
"users": users,
|
||||||
|
"currentUserID": adminID,
|
||||||
|
"error": err.Error(),
|
||||||
|
"isAdmin": true,
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) APIKeys(c *gin.Context) {
|
||||||
|
keys, _ := h.apiKeyService.GetAllAPIKeys()
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||||
|
"title": "APIキー管理",
|
||||||
|
"apiKeys": keys,
|
||||||
|
"isAdmin": true,
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateAPIKey(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
keyName := c.PostForm("name")
|
||||||
|
|
||||||
|
plainKey, _, err := h.apiKeyService.CreateAPIKey(userID, keyName)
|
||||||
|
keys, _ := h.apiKeyService.GetAllAPIKeys()
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||||
|
"title": "APIキー管理",
|
||||||
|
"apiKeys": keys,
|
||||||
|
"error": err.Error(),
|
||||||
|
"isAdmin": true,
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||||
|
"title": "APIキー管理",
|
||||||
|
"apiKeys": keys,
|
||||||
|
"newKey": plainKey,
|
||||||
|
"newKeyName": keyName,
|
||||||
|
"isAdmin": true,
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteAPIKey(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "無効なAPIキーID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.apiKeyService.DeleteAPIKey(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
keys, _ := h.apiKeyService.GetAllAPIKeys()
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||||
|
"title": "APIキー管理",
|
||||||
|
"apiKeys": keys,
|
||||||
|
"error": err.Error(),
|
||||||
|
"isAdmin": true,
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/admin/api-keys")
|
||||||
|
}
|
||||||
|
|
||||||
398
internal/handler/api_handler.go
Normal file
398
internal/handler/api_handler.go
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIHandler struct {
|
||||||
|
assignmentService *service.AssignmentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIHandler() *APIHandler {
|
||||||
|
return &APIHandler{
|
||||||
|
assignmentService: service.NewAssignmentService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) getUserID(c *gin.Context) uint {
|
||||||
|
userID, _ := c.Get(middleware.UserIDKey)
|
||||||
|
return userID.(uint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAssignments returns all assignments for the authenticated user with pagination
|
||||||
|
// GET /api/v1/assignments?filter=pending&page=1&page_size=20
|
||||||
|
func (h *APIHandler) ListAssignments(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
filter := c.Query("filter") // pending, completed, overdue
|
||||||
|
|
||||||
|
// Parse pagination parameters
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
// Validate pagination parameters
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100 // Maximum page size to prevent abuse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use paginated methods for filtered queries
|
||||||
|
switch filter {
|
||||||
|
case "completed":
|
||||||
|
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": result.Assignments,
|
||||||
|
"count": len(result.Assignments),
|
||||||
|
"total_count": result.TotalCount,
|
||||||
|
"total_pages": result.TotalPages,
|
||||||
|
"current_page": result.CurrentPage,
|
||||||
|
"page_size": result.PageSize,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case "overdue":
|
||||||
|
result, err := h.assignmentService.GetOverdueByUserPaginated(userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": result.Assignments,
|
||||||
|
"count": len(result.Assignments),
|
||||||
|
"total_count": result.TotalCount,
|
||||||
|
"total_pages": result.TotalPages,
|
||||||
|
"current_page": result.CurrentPage,
|
||||||
|
"page_size": result.PageSize,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case "pending":
|
||||||
|
result, err := h.assignmentService.GetPendingByUserPaginated(userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": result.Assignments,
|
||||||
|
"count": len(result.Assignments),
|
||||||
|
"total_count": result.TotalCount,
|
||||||
|
"total_pages": result.TotalPages,
|
||||||
|
"current_page": result.CurrentPage,
|
||||||
|
"page_size": result.PageSize,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// For "all" filter, use simple pagination without a dedicated method
|
||||||
|
assignments, err := h.assignmentService.GetAllByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual pagination for all assignments
|
||||||
|
totalCount := len(assignments)
|
||||||
|
totalPages := (totalCount + pageSize - 1) / pageSize
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
end := start + pageSize
|
||||||
|
if start > totalCount {
|
||||||
|
start = totalCount
|
||||||
|
}
|
||||||
|
if end > totalCount {
|
||||||
|
end = totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": assignments[start:end],
|
||||||
|
"count": end - start,
|
||||||
|
"total_count": totalCount,
|
||||||
|
"total_pages": totalPages,
|
||||||
|
"current_page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPendingAssignments returns pending assignments with pagination
|
||||||
|
// GET /api/v1/assignments/pending?page=1&page_size=20
|
||||||
|
func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
page, pageSize := h.parsePagination(c)
|
||||||
|
|
||||||
|
result, err := h.assignmentService.GetPendingByUserPaginated(userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sendPaginatedResponse(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCompletedAssignments returns completed assignments with pagination
|
||||||
|
// GET /api/v1/assignments/completed?page=1&page_size=20
|
||||||
|
func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
page, pageSize := h.parsePagination(c)
|
||||||
|
|
||||||
|
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sendPaginatedResponse(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOverdueAssignments returns overdue assignments with pagination
|
||||||
|
// GET /api/v1/assignments/overdue?page=1&page_size=20
|
||||||
|
func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
page, pageSize := h.parsePagination(c)
|
||||||
|
|
||||||
|
result, err := h.assignmentService.GetOverdueByUserPaginated(userID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sendPaginatedResponse(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDueTodayAssignments returns assignments due today
|
||||||
|
// GET /api/v1/assignments/due-today
|
||||||
|
func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
|
assignments, err := h.assignmentService.GetDueTodayByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": assignments,
|
||||||
|
"count": len(assignments),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDueThisWeekAssignments returns assignments due within this week
|
||||||
|
// GET /api/v1/assignments/due-this-week
|
||||||
|
func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
|
assignments, err := h.assignmentService.GetDueThisWeekByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": assignments,
|
||||||
|
"count": len(assignments),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePagination extracts and validates pagination parameters
|
||||||
|
func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
|
||||||
|
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPaginatedResponse sends a standard paginated JSON response
|
||||||
|
func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"assignments": result.Assignments,
|
||||||
|
"count": len(result.Assignments),
|
||||||
|
"total_count": result.TotalCount,
|
||||||
|
"total_pages": result.TotalPages,
|
||||||
|
"current_page": result.CurrentPage,
|
||||||
|
"page_size": result.PageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssignment returns a single assignment by ID
|
||||||
|
// GET /api/v1/assignments/:id
|
||||||
|
func (h *APIHandler) GetAssignment(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.GetByID(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, assignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAssignmentInput represents the JSON input for creating an assignment
|
||||||
|
type CreateAssignmentInput struct {
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Priority string `json:"priority"` // low, medium, high (default: medium)
|
||||||
|
DueDate string `json:"due_date" binding:"required"` // RFC3339 or 2006-01-02T15:04
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAssignment creates a new assignment
|
||||||
|
// POST /api/v1/assignments
|
||||||
|
func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
|
var input CreateAssignmentInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: title and due_date are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dueDate, err := time.Parse(time.RFC3339, input.DueDate)
|
||||||
|
if err != nil {
|
||||||
|
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
dueDate, err = time.ParseInLocation("2006-01-02", input.DueDate, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, assignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAssignmentInput represents the JSON input for updating an assignment
|
||||||
|
type UpdateAssignmentInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
DueDate string `json:"due_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAssignment updates an existing assignment
|
||||||
|
// PUT /api/v1/assignments/:id
|
||||||
|
func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing assignment
|
||||||
|
existing, err := h.assignmentService.GetByID(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input UpdateAssignmentInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing values if not provided
|
||||||
|
title := input.Title
|
||||||
|
if title == "" {
|
||||||
|
title = existing.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
description := input.Description
|
||||||
|
subject := input.Subject
|
||||||
|
priority := input.Priority
|
||||||
|
if priority == "" {
|
||||||
|
priority = existing.Priority
|
||||||
|
}
|
||||||
|
|
||||||
|
dueDate := existing.DueDate
|
||||||
|
if input.DueDate != "" {
|
||||||
|
dueDate, err = time.Parse(time.RFC3339, input.DueDate)
|
||||||
|
if err != nil {
|
||||||
|
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, assignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAssignment deletes an assignment
|
||||||
|
// DELETE /api/v1/assignments/:id
|
||||||
|
func (h *APIHandler) DeleteAssignment(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.assignmentService.Delete(userID, uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleAssignment toggles the completion status of an assignment
|
||||||
|
// PATCH /api/v1/assignments/:id/toggle
|
||||||
|
func (h *APIHandler) ToggleAssignment(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.ToggleComplete(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, assignment)
|
||||||
|
}
|
||||||
233
internal/handler/assignment_handler.go
Normal file
233
internal/handler/assignment_handler.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AssignmentHandler struct {
|
||||||
|
assignmentService *service.AssignmentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAssignmentHandler() *AssignmentHandler {
|
||||||
|
return &AssignmentHandler{
|
||||||
|
assignmentService: service.NewAssignmentService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) getUserID(c *gin.Context) uint {
|
||||||
|
userID, _ := c.Get(middleware.UserIDKey)
|
||||||
|
return userID.(uint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) Dashboard(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
stats, _ := h.assignmentService.GetDashboardStats(userID)
|
||||||
|
dueToday, _ := h.assignmentService.GetDueTodayByUser(userID)
|
||||||
|
overdue, _ := h.assignmentService.GetOverdueByUser(userID)
|
||||||
|
upcoming, _ := h.assignmentService.GetDueThisWeekByUser(userID)
|
||||||
|
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "dashboard.html", gin.H{
|
||||||
|
"title": "ダッシュボード",
|
||||||
|
"stats": stats,
|
||||||
|
"dueToday": dueToday,
|
||||||
|
"overdue": overdue,
|
||||||
|
"upcoming": upcoming,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
filter := c.Query("filter")
|
||||||
|
filter = strings.TrimSpace(filter)
|
||||||
|
if filter == "" {
|
||||||
|
filter = "pending"
|
||||||
|
}
|
||||||
|
query := c.Query("q")
|
||||||
|
priority := c.Query("priority")
|
||||||
|
pageStr := c.DefaultQuery("page", "1")
|
||||||
|
page, err := strconv.Atoi(pageStr)
|
||||||
|
if err != nil || page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
const pageSize = 10
|
||||||
|
|
||||||
|
result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, page, pageSize)
|
||||||
|
|
||||||
|
var assignments []models.Assignment
|
||||||
|
var totalPages, currentPage int
|
||||||
|
if err != nil || result == nil {
|
||||||
|
assignments = []models.Assignment{}
|
||||||
|
totalPages = 1
|
||||||
|
currentPage = 1
|
||||||
|
} else {
|
||||||
|
assignments = result.Assignments
|
||||||
|
totalPages = result.TotalPages
|
||||||
|
currentPage = result.CurrentPage
|
||||||
|
}
|
||||||
|
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "assignments/index.html", gin.H{
|
||||||
|
"title": "課題一覧",
|
||||||
|
"assignments": assignments,
|
||||||
|
"filter": filter,
|
||||||
|
"query": query,
|
||||||
|
"priority": priority,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
"currentPage": currentPage,
|
||||||
|
"totalPages": totalPages,
|
||||||
|
"hasPrev": currentPage > 1,
|
||||||
|
"hasNext": currentPage < totalPages,
|
||||||
|
"prevPage": currentPage - 1,
|
||||||
|
"nextPage": currentPage + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) New(c *gin.Context) {
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||||
|
"title": "課題登録",
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
|
||||||
|
title := c.PostForm("title")
|
||||||
|
description := c.PostForm("description")
|
||||||
|
subject := c.PostForm("subject")
|
||||||
|
priority := c.PostForm("priority")
|
||||||
|
dueDateStr := c.PostForm("due_date")
|
||||||
|
|
||||||
|
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||||
|
"title": "課題登録",
|
||||||
|
"error": "提出期限の形式が正しくありません",
|
||||||
|
"formTitle": title,
|
||||||
|
"description": description,
|
||||||
|
"subject": subject,
|
||||||
|
"priority": priority,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate)
|
||||||
|
if err != nil {
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||||
|
"title": "課題登録",
|
||||||
|
"error": "課題の登録に失敗しました",
|
||||||
|
"formTitle": title,
|
||||||
|
"description": description,
|
||||||
|
"subject": subject,
|
||||||
|
"priority": priority,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) Edit(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
assignment, err := h.assignmentService.GetByID(userID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{
|
||||||
|
"title": "課題編集",
|
||||||
|
"assignment": assignment,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) Update(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
title := c.PostForm("title")
|
||||||
|
description := c.PostForm("description")
|
||||||
|
subject := c.PostForm("subject")
|
||||||
|
priority := c.PostForm("priority")
|
||||||
|
dueDateStr := c.PostForm("due_date")
|
||||||
|
|
||||||
|
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) Toggle(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
h.assignmentService.ToggleComplete(userID, uint(id))
|
||||||
|
|
||||||
|
referer := c.Request.Referer()
|
||||||
|
if referer == "" {
|
||||||
|
referer = "/assignments"
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, referer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssignmentHandler) Delete(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
h.assignmentService.Delete(userID, uint(id))
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
|
}
|
||||||
114
internal/handler/auth_handler.go
Normal file
114
internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler() *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
authService: service.NewAuthService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||||
|
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||||
|
"title": "ログイン",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
|
email := c.PostForm("email")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
user, err := h.authService.Login(email, password)
|
||||||
|
if err != nil {
|
||||||
|
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||||
|
"title": "ログイン",
|
||||||
|
"error": "メールアドレスまたはパスワードが正しくありません",
|
||||||
|
"email": email,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set(middleware.UserIDKey, user.ID)
|
||||||
|
session.Set(middleware.UserRoleKey, user.Role)
|
||||||
|
session.Set(middleware.UserNameKey, user.Name)
|
||||||
|
session.Save()
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
||||||
|
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||||
|
"title": "新規登録",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
|
email := c.PostForm("email")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
passwordConfirm := c.PostForm("password_confirm")
|
||||||
|
name := c.PostForm("name")
|
||||||
|
|
||||||
|
if password != passwordConfirm {
|
||||||
|
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||||
|
"title": "新規登録",
|
||||||
|
"error": "パスワードが一致しません",
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 8 {
|
||||||
|
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||||
|
"title": "新規登録",
|
||||||
|
"error": "パスワードは8文字以上で入力してください",
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.authService.Register(email, password, name)
|
||||||
|
if err != nil {
|
||||||
|
errorMsg := "登録に失敗しました"
|
||||||
|
if err == service.ErrEmailAlreadyExists {
|
||||||
|
errorMsg = "このメールアドレスは既に使用されています"
|
||||||
|
}
|
||||||
|
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||||
|
"title": "新規登録",
|
||||||
|
"error": errorMsg,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set(middleware.UserIDKey, user.ID)
|
||||||
|
session.Set(middleware.UserRoleKey, user.Role)
|
||||||
|
session.Set(middleware.UserNameKey, user.Name)
|
||||||
|
session.Save()
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Clear()
|
||||||
|
session.Save()
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
}
|
||||||
33
internal/handler/helper.go
Normal file
33
internal/handler/helper.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const csrfTokenKey = "csrf_token"
|
||||||
|
const csrfTokenFormKey = "_csrf"
|
||||||
|
|
||||||
|
func RenderHTML(c *gin.Context, code int, name string, obj gin.H) {
|
||||||
|
if obj == nil {
|
||||||
|
obj = gin.H{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if startTime, exists := c.Get("startTime"); exists {
|
||||||
|
duration := time.Since(startTime.(time.Time))
|
||||||
|
obj["processing_time"] = fmt.Sprintf("%.2fms", float64(duration.Microseconds())/1000.0)
|
||||||
|
} else {
|
||||||
|
obj["processing_time"] = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
if token, exists := c.Get(csrfTokenKey); exists {
|
||||||
|
obj["csrfToken"] = token.(string)
|
||||||
|
obj["csrfField"] = template.HTML(`<input type="hidden" name="` + csrfTokenFormKey + `" value="` + token.(string) + `">`)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(code, name, obj)
|
||||||
|
}
|
||||||
|
|
||||||
122
internal/handler/profile_handler.go
Normal file
122
internal/handler/profile_handler.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProfileHandler struct {
|
||||||
|
authService *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProfileHandler() *ProfileHandler {
|
||||||
|
return &ProfileHandler{
|
||||||
|
authService: service.NewAuthService(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) getUserID(c *gin.Context) uint {
|
||||||
|
userID, _ := c.Get(middleware.UserIDKey)
|
||||||
|
return userID.(uint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) Show(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
user, _ := h.authService.GetUserByID(userID)
|
||||||
|
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
|
"title": "プロフィール",
|
||||||
|
"user": user,
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) Update(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
name := c.PostForm("name")
|
||||||
|
|
||||||
|
err := h.authService.UpdateProfile(userID, name)
|
||||||
|
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
user, _ := h.authService.GetUserByID(userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
|
"title": "プロフィール",
|
||||||
|
"user": user,
|
||||||
|
"error": "プロフィールの更新に失敗しました",
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
|
"title": "プロフィール",
|
||||||
|
"user": user,
|
||||||
|
"success": "プロフィールを更新しました",
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": user.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
||||||
|
userID := h.getUserID(c)
|
||||||
|
oldPassword := c.PostForm("old_password")
|
||||||
|
newPassword := c.PostForm("new_password")
|
||||||
|
confirmPassword := c.PostForm("confirm_password")
|
||||||
|
|
||||||
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
user, _ := h.authService.GetUserByID(userID)
|
||||||
|
|
||||||
|
if newPassword != confirmPassword {
|
||||||
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
|
"title": "プロフィール",
|
||||||
|
"user": user,
|
||||||
|
"passwordError": "新しいパスワードが一致しません",
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newPassword) < 8 {
|
||||||
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
|
"title": "プロフィール",
|
||||||
|
"user": user,
|
||||||
|
"passwordError": "パスワードは8文字以上で入力してください",
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.authService.ChangePassword(userID, oldPassword, newPassword)
|
||||||
|
if err != nil {
|
||||||
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
|
"title": "プロフィール",
|
||||||
|
"user": user,
|
||||||
|
"passwordError": "現在のパスワードが正しくありません",
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
|
"title": "プロフィール",
|
||||||
|
"user": user,
|
||||||
|
"passwordSuccess": "パスワードを変更しました",
|
||||||
|
"isAdmin": role == "admin",
|
||||||
|
"userName": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
121
internal/middleware/auth.go
Normal file
121
internal/middleware/auth.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const UserIDKey = "user_id"
|
||||||
|
const UserRoleKey = "user_role"
|
||||||
|
const UserNameKey = "user_name"
|
||||||
|
|
||||||
|
func AuthRequired(authService *service.AuthService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
userID := session.Get(UserIDKey)
|
||||||
|
|
||||||
|
if userID == nil {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := authService.GetUserByID(userID.(uint))
|
||||||
|
if err != nil {
|
||||||
|
session.Clear()
|
||||||
|
session.Save()
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(UserIDKey, user.ID)
|
||||||
|
c.Set(UserRoleKey, user.Role)
|
||||||
|
c.Set(UserNameKey, user.Name)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminRequired() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
role, exists := c.Get(UserRoleKey)
|
||||||
|
if !exists || role != "admin" {
|
||||||
|
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
||||||
|
"title": "アクセス拒否",
|
||||||
|
"message": "この操作には管理者権限が必要です。",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GuestOnly() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
userID := session.Get(UserIDKey)
|
||||||
|
|
||||||
|
if userID != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InjectUserInfo() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
userID := session.Get(UserIDKey)
|
||||||
|
|
||||||
|
if userID != nil {
|
||||||
|
c.Set(UserIDKey, userID.(uint))
|
||||||
|
c.Set(UserRoleKey, session.Get(UserRoleKey))
|
||||||
|
c.Set(UserNameKey, session.Get(UserNameKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKeyValidator interface {
|
||||||
|
ValidateAPIKey(key string) (uint, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func APIKeyAuth(validator APIKeyValidator) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearerPrefix = "Bearer "
|
||||||
|
if len(authHeader) <= len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format. Use: Bearer <api_key>"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := authHeader[len(bearerPrefix):]
|
||||||
|
|
||||||
|
userID, err := validator.ValidateAPIKey(apiKey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(UserIDKey, userID)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
119
internal/middleware/csrf.go
Normal file
119
internal/middleware/csrf.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
csrfTokenKey = "csrf_token"
|
||||||
|
csrfTokenFormKey = "_csrf"
|
||||||
|
csrfTokenHeader = "X-CSRF-Token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CSRFConfig struct {
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateCSRFToken(secret string) (string, error) {
|
||||||
|
randomBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(randomBytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, []byte(secret))
|
||||||
|
h.Write(randomBytes)
|
||||||
|
signature := h.Sum(nil)
|
||||||
|
|
||||||
|
token := append(randomBytes, signature...)
|
||||||
|
return base64.URLEncoding.EncodeToString(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCSRFToken(token, secret string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.URLEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decoded) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
randomBytes := decoded[:32]
|
||||||
|
providedSignature := decoded[32:]
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, []byte(secret))
|
||||||
|
h.Write(randomBytes)
|
||||||
|
expectedSignature := h.Sum(nil)
|
||||||
|
|
||||||
|
return hmac.Equal(providedSignature, expectedSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CSRF(config CSRFConfig) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
|
||||||
|
csrfToken, ok := session.Get(csrfTokenKey).(string)
|
||||||
|
if !ok || csrfToken == "" || !validateCSRFToken(csrfToken, config.Secret) {
|
||||||
|
newToken, err := generateCSRFToken(config.Secret)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
csrfToken = newToken
|
||||||
|
session.Set(csrfTokenKey, csrfToken)
|
||||||
|
session.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(csrfTokenKey, csrfToken)
|
||||||
|
|
||||||
|
method := strings.ToUpper(c.Request.Method)
|
||||||
|
if method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submittedToken := c.PostForm(csrfTokenFormKey)
|
||||||
|
if submittedToken == "" {
|
||||||
|
submittedToken = c.GetHeader(csrfTokenHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken := session.Get(csrfTokenKey)
|
||||||
|
if sessionToken == nil || submittedToken != sessionToken.(string) {
|
||||||
|
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
||||||
|
"title": "CSRFエラー",
|
||||||
|
"message": "不正なリクエストです。ページを再読み込みしてください。",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
newToken, err := generateCSRFToken(config.Secret)
|
||||||
|
if err == nil {
|
||||||
|
session.Set(csrfTokenKey, newToken)
|
||||||
|
session.Save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CSRFField(c *gin.Context) template.HTML {
|
||||||
|
token, exists := c.Get(csrfTokenKey)
|
||||||
|
if !exists {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return template.HTML(`<input type="hidden" name="` + csrfTokenFormKey + `" value="` + token.(string) + `">`)
|
||||||
|
}
|
||||||
94
internal/middleware/ratelimit.go
Normal file
94
internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Requests int
|
||||||
|
Window int
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateLimitEntry struct {
|
||||||
|
count int
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateLimiter struct {
|
||||||
|
entries map[string]*rateLimitEntry
|
||||||
|
mu sync.Mutex
|
||||||
|
config RateLimitConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(config RateLimitConfig) *rateLimiter {
|
||||||
|
rl := &rateLimiter{
|
||||||
|
entries: make(map[string]*rateLimitEntry),
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
go rl.cleanup()
|
||||||
|
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) cleanup() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
rl.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for key, entry := range rl.entries {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(rl.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) allow(key string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
entry, exists := rl.entries[key]
|
||||||
|
|
||||||
|
if !exists || now.After(entry.expiresAt) {
|
||||||
|
rl.entries[key] = &rateLimitEntry{
|
||||||
|
count: 1,
|
||||||
|
expiresAt: now.Add(time.Duration(rl.config.Window) * time.Second),
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
return entry.count <= rl.config.Requests
|
||||||
|
}
|
||||||
|
|
||||||
|
func RateLimit(config RateLimitConfig) gin.HandlerFunc {
|
||||||
|
if !config.Enabled {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter := newRateLimiter(config)
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
|
if !limiter.allow(clientIP) {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||||
|
"error": "リクエスト数が制限を超えました。しばらくしてからお試しください。",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/middleware/security.go
Normal file
52
internal/middleware/security.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SecurityConfig struct {
|
||||||
|
HTTPS bool
|
||||||
|
}
|
||||||
|
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if config.HTTPS {
|
||||||
|
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
}
|
||||||
|
|
||||||
|
csp := []string{
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||||
|
"font-src 'self' https://cdn.jsdelivr.net",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
}
|
||||||
|
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
||||||
|
c.Header("X-Frame-Options", "DENY")
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
c.Header("X-XSS-Protection", "1; mode=block")
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForceHTTPS(config SecurityConfig) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if config.HTTPS && c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
|
||||||
|
|
||||||
|
host := c.Request.Host
|
||||||
|
target := "https://" + host + c.Request.URL.Path
|
||||||
|
if len(c.Request.URL.RawQuery) > 0 {
|
||||||
|
target += "?" + c.Request.URL.RawQuery
|
||||||
|
}
|
||||||
|
c.Redirect(301, target)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
14
internal/middleware/timer.go
Normal file
14
internal/middleware/timer.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequestTimer() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Set("startTime", time.Now())
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/models/api_key.go
Normal file
19
internal/models/api_key.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
KeyHash string `gorm:"not null;uniqueIndex" json:"-"`
|
||||||
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
}
|
||||||
41
internal/models/assignment.go
Normal file
41
internal/models/assignment.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Assignment struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
|
Title string `gorm:"not null" json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high
|
||||||
|
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||||
|
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Assignment) IsOverdue() bool {
|
||||||
|
return !a.IsCompleted && time.Now().After(a.DueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Assignment) IsDueToday() bool {
|
||||||
|
now := time.Now()
|
||||||
|
return a.DueDate.Year() == now.Year() &&
|
||||||
|
a.DueDate.Month() == now.Month() &&
|
||||||
|
a.DueDate.Day() == now.Day()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Assignment) IsDueThisWeek() bool {
|
||||||
|
now := time.Now()
|
||||||
|
weekLater := now.AddDate(0, 0, 7)
|
||||||
|
return a.DueDate.After(now) && a.DueDate.Before(weekLater)
|
||||||
|
}
|
||||||
28
internal/models/user.go
Normal file
28
internal/models/user.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||||
|
PasswordHash string `gorm:"not null" json:"-"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Assignments []Assignment `gorm:"foreignKey:UserID" json:"assignments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) IsAdmin() bool {
|
||||||
|
return u.Role == "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) GetID() uint {
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
188
internal/repository/assignment_repository.go
Normal file
188
internal/repository/assignment_repository.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"homework-manager/internal/database"
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AssignmentRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAssignmentRepository() *AssignmentRepository {
|
||||||
|
return &AssignmentRepository{db: database.GetDB()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) Create(assignment *models.Assignment) error {
|
||||||
|
return r.db.Create(assignment).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) FindByID(id uint) (*models.Assignment, error) {
|
||||||
|
var assignment models.Assignment
|
||||||
|
err := r.db.First(&assignment, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &assignment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) FindByUserID(userID uint) ([]models.Assignment, error) {
|
||||||
|
var assignments []models.Assignment
|
||||||
|
err := r.db.Where("user_id = ?", userID).Order("due_date ASC").Find(&assignments).Error
|
||||||
|
return assignments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) FindPendingByUserID(userID uint, limit, offset int) ([]models.Assignment, error) {
|
||||||
|
var assignments []models.Assignment
|
||||||
|
query := r.db.Where("user_id = ? AND is_completed = ?", userID, false).
|
||||||
|
Order("due_date ASC")
|
||||||
|
if limit > 0 {
|
||||||
|
query = query.Limit(limit).Offset(offset)
|
||||||
|
}
|
||||||
|
err := query.Find(&assignments).Error
|
||||||
|
return assignments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) FindCompletedByUserID(userID uint, limit, offset int) ([]models.Assignment, error) {
|
||||||
|
var assignments []models.Assignment
|
||||||
|
query := r.db.Where("user_id = ? AND is_completed = ?", userID, true).
|
||||||
|
Order("completed_at DESC")
|
||||||
|
if limit > 0 {
|
||||||
|
query = query.Limit(limit).Offset(offset)
|
||||||
|
}
|
||||||
|
err := query.Find(&assignments).Error
|
||||||
|
return assignments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) FindDueTodayByUserID(userID uint) ([]models.Assignment, error) {
|
||||||
|
now := time.Now()
|
||||||
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
var assignments []models.Assignment
|
||||||
|
err := r.db.Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||||
|
userID, false, startOfDay, endOfDay).
|
||||||
|
Order("due_date ASC").Find(&assignments).Error
|
||||||
|
return assignments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) FindDueThisWeekByUserID(userID uint) ([]models.Assignment, error) {
|
||||||
|
now := time.Now()
|
||||||
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
weekLater := startOfDay.AddDate(0, 0, 7)
|
||||||
|
|
||||||
|
var assignments []models.Assignment
|
||||||
|
err := r.db.Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||||
|
userID, false, startOfDay, weekLater).
|
||||||
|
Order("due_date ASC").Find(&assignments).Error
|
||||||
|
return assignments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) FindOverdueByUserID(userID uint, limit, offset int) ([]models.Assignment, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var assignments []models.Assignment
|
||||||
|
query := r.db.Where("user_id = ? AND is_completed = ? AND due_date < ?",
|
||||||
|
userID, false, now).
|
||||||
|
Order("due_date ASC")
|
||||||
|
if limit > 0 {
|
||||||
|
query = query.Limit(limit).Offset(offset)
|
||||||
|
}
|
||||||
|
err := query.Find(&assignments).Error
|
||||||
|
return assignments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) Update(assignment *models.Assignment) error {
|
||||||
|
return r.db.Save(assignment).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.Assignment{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) CountByUserID(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) CountPendingByUserID(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Assignment{}).
|
||||||
|
Where("user_id = ? AND is_completed = ?", userID, false).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) GetSubjectsByUserID(userID uint) ([]string, error) {
|
||||||
|
var subjects []string
|
||||||
|
err := r.db.Model(&models.Assignment{}).
|
||||||
|
Where("user_id = ? AND subject != ''", userID).
|
||||||
|
Distinct("subject").
|
||||||
|
Pluck("subject", &subjects).Error
|
||||||
|
return subjects, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) CountCompletedByUserID(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Assignment{}).
|
||||||
|
Where("user_id = ? AND is_completed = ?", userID, true).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) Search(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) {
|
||||||
|
var assignments []models.Assignment
|
||||||
|
var totalCount int64
|
||||||
|
|
||||||
|
dbQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
|
||||||
|
|
||||||
|
if queryStr != "" {
|
||||||
|
dbQuery = dbQuery.Where("title LIKE ? OR description LIKE ?", "%"+queryStr+"%", "%"+queryStr+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if priority != "" {
|
||||||
|
dbQuery = dbQuery.Where("priority = ?", priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
switch filter {
|
||||||
|
case "completed":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ?", true)
|
||||||
|
case "overdue":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
|
||||||
|
default: // pending
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ?", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dbQuery.Count(&totalCount).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter == "completed" {
|
||||||
|
dbQuery = dbQuery.Order("completed_at DESC")
|
||||||
|
} else {
|
||||||
|
dbQuery = dbQuery.Order("due_date ASC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
err := dbQuery.Limit(pageSize).Offset(offset).Find(&assignments).Error
|
||||||
|
return assignments, totalCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
now := time.Now()
|
||||||
|
err := r.db.Model(&models.Assignment{}).
|
||||||
|
Where("user_id = ? AND is_completed = ? AND due_date < ?", userID, false, now).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
61
internal/repository/user_repository.go
Normal file
61
internal/repository/user_repository.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"homework-manager/internal/database"
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository() *UserRepository {
|
||||||
|
return &UserRepository{db: database.GetDB()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(user *models.User) error {
|
||||||
|
return r.db.Create(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.Where("email = ?", email).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindAll() ([]models.User, error) {
|
||||||
|
var users []models.User
|
||||||
|
err := r.db.Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Update(user *models.User) error {
|
||||||
|
return r.db.Save(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Delete(id uint) error {
|
||||||
|
if err := r.db.Unscoped().Where("user_id = ?", id).Delete(&models.Assignment{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.db.Unscoped().Delete(&models.User{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Count() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.User{}).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
241
internal/router/router.go
Normal file
241
internal/router/router.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"homework-manager/internal/config"
|
||||||
|
"homework-manager/internal/handler"
|
||||||
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getFuncMap() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
"formatDate": func(t time.Time) string {
|
||||||
|
return t.Format("2006/01/02")
|
||||||
|
},
|
||||||
|
"formatDateTime": func(t time.Time) string {
|
||||||
|
return t.Format("2006/01/02 15:04")
|
||||||
|
},
|
||||||
|
"formatDateInput": func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02T15:04")
|
||||||
|
},
|
||||||
|
"isOverdue": func(t time.Time, completed bool) bool {
|
||||||
|
return !completed && time.Now().After(t)
|
||||||
|
},
|
||||||
|
"daysUntil": func(t time.Time) int {
|
||||||
|
return int(time.Until(t).Hours() / 24)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTemplates() (*template.Template, error) {
|
||||||
|
tmpl := template.New("").Funcs(getFuncMap())
|
||||||
|
|
||||||
|
baseContent, err := os.ReadFile("web/templates/layouts/base.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templateDirs := []struct {
|
||||||
|
pattern string
|
||||||
|
prefix string
|
||||||
|
}{
|
||||||
|
{"web/templates/auth/*.html", ""},
|
||||||
|
{"web/templates/pages/*.html", ""},
|
||||||
|
{"web/templates/assignments/*.html", "assignments/"},
|
||||||
|
{"web/templates/admin/*.html", "admin/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range templateDirs {
|
||||||
|
files, err := filepath.Glob(dir.pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
name := dir.prefix + filepath.Base(file)
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reDefine := regexp.MustCompile(`{{\s*define\s+"([^"]+)"\s*}}`)
|
||||||
|
reTemplate := regexp.MustCompile(`{{\s*template\s+"([^"]+)"\s*([^}]*)\s*}}`)
|
||||||
|
|
||||||
|
uniqueBase := reDefine.ReplaceAllStringFunc(string(baseContent), func(m string) string {
|
||||||
|
match := reDefine.FindStringSubmatch(m)
|
||||||
|
blockName := match[1]
|
||||||
|
if blockName == "head" || blockName == "scripts" || blockName == "content" || blockName == "base" {
|
||||||
|
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
uniqueBase = reTemplate.ReplaceAllStringFunc(uniqueBase, func(m string) string {
|
||||||
|
match := reTemplate.FindStringSubmatch(m)
|
||||||
|
blockName := match[1]
|
||||||
|
if blockName == "head" || blockName == "scripts" || blockName == "content" || blockName == "base" {
|
||||||
|
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
uniqueContent := reDefine.ReplaceAllStringFunc(string(content), func(m string) string {
|
||||||
|
match := reDefine.FindStringSubmatch(m)
|
||||||
|
blockName := match[1]
|
||||||
|
if blockName == "head" || blockName == "scripts" || blockName == "content" {
|
||||||
|
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
uniqueContent = reTemplate.ReplaceAllStringFunc(uniqueContent, func(m string) string {
|
||||||
|
match := reTemplate.FindStringSubmatch(m)
|
||||||
|
blockName := match[1]
|
||||||
|
if blockName == "base" {
|
||||||
|
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
combined := uniqueBase + "\n" + uniqueContent
|
||||||
|
_, err = tmpl.New(name).Parse(combined)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Setup(cfg *config.Config) *gin.Engine {
|
||||||
|
if !cfg.Debug {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
if len(cfg.TrustedProxies) > 0 {
|
||||||
|
r.SetTrustedProxies(cfg.TrustedProxies)
|
||||||
|
}
|
||||||
|
tmpl, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
panic("Failed to load templates: " + err.Error())
|
||||||
|
}
|
||||||
|
r.SetHTMLTemplate(tmpl)
|
||||||
|
|
||||||
|
r.Static("/static", "web/static")
|
||||||
|
|
||||||
|
store := cookie.NewStore([]byte(cfg.SessionSecret))
|
||||||
|
store.Options(sessions.Options{
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 86400 * 7, // 7 days
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: cfg.HTTPS,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
r.Use(sessions.Sessions("session", store))
|
||||||
|
r.Use(middleware.RequestTimer())
|
||||||
|
|
||||||
|
securityConfig := middleware.SecurityConfig{
|
||||||
|
HTTPS: cfg.HTTPS,
|
||||||
|
}
|
||||||
|
r.Use(middleware.SecurityHeaders(securityConfig))
|
||||||
|
r.Use(middleware.ForceHTTPS(securityConfig))
|
||||||
|
|
||||||
|
r.Use(middleware.RateLimit(middleware.RateLimitConfig{
|
||||||
|
Enabled: cfg.RateLimitEnabled,
|
||||||
|
Requests: cfg.RateLimitRequests,
|
||||||
|
Window: cfg.RateLimitWindow,
|
||||||
|
}))
|
||||||
|
|
||||||
|
csrfMiddleware := middleware.CSRF(middleware.CSRFConfig{
|
||||||
|
Secret: cfg.CSRFSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
authService := service.NewAuthService()
|
||||||
|
apiKeyService := service.NewAPIKeyService()
|
||||||
|
|
||||||
|
authHandler := handler.NewAuthHandler()
|
||||||
|
assignmentHandler := handler.NewAssignmentHandler()
|
||||||
|
adminHandler := handler.NewAdminHandler()
|
||||||
|
profileHandler := handler.NewProfileHandler()
|
||||||
|
apiHandler := handler.NewAPIHandler()
|
||||||
|
|
||||||
|
guest := r.Group("/")
|
||||||
|
guest.Use(middleware.GuestOnly())
|
||||||
|
guest.Use(csrfMiddleware)
|
||||||
|
{
|
||||||
|
guest.GET("/login", authHandler.ShowLogin)
|
||||||
|
guest.POST("/login", authHandler.Login)
|
||||||
|
if cfg.AllowRegistration {
|
||||||
|
guest.GET("/register", authHandler.ShowRegister)
|
||||||
|
guest.POST("/register", authHandler.Register)
|
||||||
|
} else {
|
||||||
|
guest.GET("/register", func(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
||||||
|
"title": "登録無効",
|
||||||
|
"message": "新規登録は現在受け付けておりません。",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := r.Group("/")
|
||||||
|
auth.Use(middleware.AuthRequired(authService))
|
||||||
|
auth.Use(csrfMiddleware)
|
||||||
|
{
|
||||||
|
auth.GET("/", assignmentHandler.Dashboard)
|
||||||
|
auth.POST("/logout", authHandler.Logout)
|
||||||
|
|
||||||
|
auth.GET("/assignments", assignmentHandler.Index)
|
||||||
|
auth.GET("/assignments/new", assignmentHandler.New)
|
||||||
|
auth.POST("/assignments", assignmentHandler.Create)
|
||||||
|
auth.GET("/assignments/:id/edit", assignmentHandler.Edit)
|
||||||
|
auth.POST("/assignments/:id", assignmentHandler.Update)
|
||||||
|
auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle)
|
||||||
|
auth.POST("/assignments/:id/delete", assignmentHandler.Delete)
|
||||||
|
|
||||||
|
auth.GET("/profile", profileHandler.Show)
|
||||||
|
auth.POST("/profile", profileHandler.Update)
|
||||||
|
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||||
|
admin := auth.Group("/admin")
|
||||||
|
admin.Use(middleware.AdminRequired())
|
||||||
|
{
|
||||||
|
admin.GET("/users", adminHandler.Index)
|
||||||
|
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
||||||
|
admin.POST("/users/:id/role", adminHandler.ChangeRole)
|
||||||
|
|
||||||
|
admin.GET("/api-keys", adminHandler.APIKeys)
|
||||||
|
admin.POST("/api-keys", adminHandler.CreateAPIKey)
|
||||||
|
admin.POST("/api-keys/:id/delete", adminHandler.DeleteAPIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
api.Use(middleware.APIKeyAuth(apiKeyService))
|
||||||
|
{
|
||||||
|
api.GET("/assignments", apiHandler.ListAssignments)
|
||||||
|
api.GET("/assignments/pending", apiHandler.ListPendingAssignments)
|
||||||
|
api.GET("/assignments/completed", apiHandler.ListCompletedAssignments)
|
||||||
|
api.GET("/assignments/overdue", apiHandler.ListOverdueAssignments)
|
||||||
|
api.GET("/assignments/due-today", apiHandler.ListDueTodayAssignments)
|
||||||
|
api.GET("/assignments/due-this-week", apiHandler.ListDueThisWeekAssignments)
|
||||||
|
api.GET("/assignments/:id", apiHandler.GetAssignment)
|
||||||
|
api.POST("/assignments", apiHandler.CreateAssignment)
|
||||||
|
api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
|
||||||
|
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
|
||||||
|
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
62
internal/service/admin_service.go
Normal file
62
internal/service/admin_service.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
"homework-manager/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCannotDeleteSelf = errors.New("cannot delete yourself")
|
||||||
|
ErrCannotChangeSelfRole = errors.New("cannot change your own role")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminService struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminService() *AdminService {
|
||||||
|
return &AdminService{
|
||||||
|
userRepo: repository.NewUserRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) GetAllUsers() ([]models.User, error) {
|
||||||
|
return s.userRepo.FindAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) GetUserByID(id uint) (*models.User, error) {
|
||||||
|
return s.userRepo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) DeleteUser(adminID, targetID uint) error {
|
||||||
|
if adminID == targetID {
|
||||||
|
return ErrCannotDeleteSelf
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.userRepo.FindByID(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.userRepo.Delete(targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ChangeRole(adminID, targetID uint, newRole string) error {
|
||||||
|
if adminID == targetID {
|
||||||
|
return ErrCannotChangeSelfRole
|
||||||
|
}
|
||||||
|
|
||||||
|
if newRole != "admin" && newRole != "user" {
|
||||||
|
return errors.New("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.FindByID(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Role = newRole
|
||||||
|
return s.userRepo.Update(user)
|
||||||
|
}
|
||||||
89
internal/service/api_key_service.go
Normal file
89
internal/service/api_key_service.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"homework-manager/internal/database"
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIKeyService struct{}
|
||||||
|
|
||||||
|
func NewAPIKeyService() *APIKeyService {
|
||||||
|
return &APIKeyService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (s *APIKeyService) generateRandomKey() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "hm_" + hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) hashKey(key string) string {
|
||||||
|
hash := sha256.Sum256([]byte(key))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) CreateAPIKey(userID uint, name string) (string, *models.APIKey, error) {
|
||||||
|
if name == "" {
|
||||||
|
return "", nil, errors.New("キー名を入力してください")
|
||||||
|
}
|
||||||
|
|
||||||
|
plainKey, err := s.generateRandomKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, errors.New("キーの生成に失敗しました")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := &models.APIKey{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
KeyHash: s.hashKey(plainKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.GetDB().Create(apiKey).Error; err != nil {
|
||||||
|
return "", nil, errors.New("キーの保存に失敗しました")
|
||||||
|
}
|
||||||
|
|
||||||
|
return plainKey, apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) ValidateAPIKey(plainKey string) (uint, error) {
|
||||||
|
hash := s.hashKey(plainKey)
|
||||||
|
|
||||||
|
var apiKey models.APIKey
|
||||||
|
if err := database.GetDB().Where("key_hash = ?", hash).First(&apiKey).Error; err != nil {
|
||||||
|
return 0, errors.New("無効なAPIキーです")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
database.GetDB().Model(&apiKey).Update("last_used", now)
|
||||||
|
|
||||||
|
return apiKey.UserID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) GetAllAPIKeys() ([]models.APIKey, error) {
|
||||||
|
var keys []models.APIKey
|
||||||
|
err := database.GetDB().Preload("User").Order("created_at desc").Find(&keys).Error
|
||||||
|
return keys, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) GetAPIKeysByUser(userID uint) ([]models.APIKey, error) {
|
||||||
|
var keys []models.APIKey
|
||||||
|
err := database.GetDB().Where("user_id = ?", userID).Order("created_at desc").Find(&keys).Error
|
||||||
|
return keys, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIKeyService) DeleteAPIKey(id uint) error {
|
||||||
|
result := database.GetDB().Delete(&models.APIKey{}, id)
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New("APIキーが見つかりません")
|
||||||
|
}
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
269
internal/service/assignment_service.go
Normal file
269
internal/service/assignment_service.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
"homework-manager/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAssignmentNotFound = errors.New("assignment not found")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
)
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Assignments []models.Assignment
|
||||||
|
TotalCount int64
|
||||||
|
TotalPages int
|
||||||
|
CurrentPage int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignmentService struct {
|
||||||
|
assignmentRepo *repository.AssignmentRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAssignmentService() *AssignmentService {
|
||||||
|
return &AssignmentService{
|
||||||
|
assignmentRepo: repository.NewAssignmentRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) {
|
||||||
|
if priority == "" {
|
||||||
|
priority = "medium"
|
||||||
|
}
|
||||||
|
assignment := &models.Assignment{
|
||||||
|
UserID: userID,
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Subject: subject,
|
||||||
|
Priority: priority,
|
||||||
|
DueDate: dueDate,
|
||||||
|
IsCompleted: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.assignmentRepo.Create(assignment); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetByID(userID, assignmentID uint) (*models.Assignment, error) {
|
||||||
|
assignment, err := s.assignmentRepo.FindByID(assignmentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrAssignmentNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignment.UserID != userID {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetAllByUser(userID uint) ([]models.Assignment, error) {
|
||||||
|
return s.assignmentRepo.FindByUserID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetPendingByUser(userID uint) ([]models.Assignment, error) {
|
||||||
|
return s.assignmentRepo.FindPendingByUserID(userID, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetPendingByUserPaginated(userID uint, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
assignments, err := s.assignmentRepo.FindPendingByUserID(userID, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount, _ := s.assignmentRepo.CountPendingByUserID(userID)
|
||||||
|
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||||
|
|
||||||
|
return &PaginatedResult{
|
||||||
|
Assignments: assignments,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
CurrentPage: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetCompletedByUser(userID uint) ([]models.Assignment, error) {
|
||||||
|
return s.assignmentRepo.FindCompletedByUserID(userID, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetCompletedByUserPaginated(userID uint, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
assignments, err := s.assignmentRepo.FindCompletedByUserID(userID, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount, _ := s.assignmentRepo.CountCompletedByUserID(userID)
|
||||||
|
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||||
|
|
||||||
|
return &PaginatedResult{
|
||||||
|
Assignments: assignments,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
CurrentPage: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetDueTodayByUser(userID uint) ([]models.Assignment, error) {
|
||||||
|
return s.assignmentRepo.FindDueTodayByUserID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetDueThisWeekByUser(userID uint) ([]models.Assignment, error) {
|
||||||
|
return s.assignmentRepo.FindDueThisWeekByUserID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetOverdueByUser(userID uint) ([]models.Assignment, error) {
|
||||||
|
return s.assignmentRepo.FindOverdueByUserID(userID, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetOverdueByUserPaginated(userID uint, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
assignments, err := s.assignmentRepo.FindOverdueByUserID(userID, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount, _ := s.assignmentRepo.CountOverdueByUserID(userID)
|
||||||
|
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||||
|
|
||||||
|
return &PaginatedResult{
|
||||||
|
Assignments: assignments,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
CurrentPage: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter string, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments, totalCount, err := s.assignmentRepo.Search(userID, query, priority, filter, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||||
|
|
||||||
|
return &PaginatedResult{
|
||||||
|
Assignments: assignments,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
CurrentPage: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) {
|
||||||
|
assignment, err := s.GetByID(userID, assignmentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment.Title = title
|
||||||
|
assignment.Description = description
|
||||||
|
assignment.Subject = subject
|
||||||
|
assignment.Priority = priority
|
||||||
|
assignment.DueDate = dueDate
|
||||||
|
|
||||||
|
if err := s.assignmentRepo.Update(assignment); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) ToggleComplete(userID, assignmentID uint) (*models.Assignment, error) {
|
||||||
|
assignment, err := s.GetByID(userID, assignmentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment.IsCompleted = !assignment.IsCompleted
|
||||||
|
if assignment.IsCompleted {
|
||||||
|
now := time.Now()
|
||||||
|
assignment.CompletedAt = &now
|
||||||
|
} else {
|
||||||
|
assignment.CompletedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.assignmentRepo.Update(assignment); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) Delete(userID, assignmentID uint) error {
|
||||||
|
assignment, err := s.GetByID(userID, assignmentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.assignmentRepo.Delete(assignment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetSubjectsByUser(userID uint) ([]string, error) {
|
||||||
|
return s.assignmentRepo.GetSubjectsByUserID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardStats struct {
|
||||||
|
TotalPending int64
|
||||||
|
DueToday int
|
||||||
|
DueThisWeek int
|
||||||
|
Overdue int
|
||||||
|
Subjects []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, error) {
|
||||||
|
pending, _ := s.assignmentRepo.CountPendingByUserID(userID)
|
||||||
|
dueToday, _ := s.assignmentRepo.FindDueTodayByUserID(userID)
|
||||||
|
dueThisWeek, _ := s.assignmentRepo.FindDueThisWeekByUserID(userID)
|
||||||
|
overdueCount, _ := s.assignmentRepo.CountOverdueByUserID(userID)
|
||||||
|
subjects, _ := s.assignmentRepo.GetSubjectsByUserID(userID)
|
||||||
|
|
||||||
|
return &DashboardStats{
|
||||||
|
TotalPending: pending,
|
||||||
|
DueToday: len(dueToday),
|
||||||
|
DueThisWeek: len(dueThisWeek),
|
||||||
|
Overdue: int(overdueCount),
|
||||||
|
Subjects: subjects,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
106
internal/service/auth_service.go
Normal file
106
internal/service/auth_service.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"homework-manager/internal/models"
|
||||||
|
"homework-manager/internal/repository"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrEmailAlreadyExists = errors.New("email already exists")
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService() *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
userRepo: repository.NewUserRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Register(email, password, name string) (*models.User, error) {
|
||||||
|
// Check if email already exists
|
||||||
|
_, err := s.userRepo.FindByEmail(email)
|
||||||
|
if err == nil {
|
||||||
|
return nil, ErrEmailAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine role (first user is admin)
|
||||||
|
role := "user"
|
||||||
|
count, _ := s.userRepo.Count()
|
||||||
|
if count == 0 {
|
||||||
|
role = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
Name: name,
|
||||||
|
Role: role,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.Create(user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Login(email, password string) (*models.User, error) {
|
||||||
|
user, err := s.userRepo.FindByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
|
||||||
|
return s.userRepo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = string(hashedPassword)
|
||||||
|
return s.userRepo.Update(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UpdateProfile(userID uint, name string) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = name
|
||||||
|
return s.userRepo.Update(user)
|
||||||
|
}
|
||||||
280
web/static/css/style.css
Normal file
280
web/static/css/style.css
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/* Custom styles for Homework Manager */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #4361ee;
|
||||||
|
--secondary-color: #3f37c9;
|
||||||
|
--success-color: #4cc9f0;
|
||||||
|
--warning-color: #f72585;
|
||||||
|
--danger-color: #e63946;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card enhancements */
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar customization */
|
||||||
|
.navbar {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table improvements */
|
||||||
|
.table {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: rgba(67, 97, 238, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.btn {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge styles */
|
||||||
|
.badge {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert enhancements */
|
||||||
|
.alert {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats cards */
|
||||||
|
.card.bg-primary,
|
||||||
|
.card.bg-warning,
|
||||||
|
.card.bg-info,
|
||||||
|
.card.bg-danger {
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.bg-primary:hover,
|
||||||
|
.card.bg-warning:hover,
|
||||||
|
.card.bg-info:hover,
|
||||||
|
.card.bg-danger:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs - Removed conflicted specific styles as they are managed in templates */
|
||||||
|
.nav-tabs .nav-link {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login/Register cards */
|
||||||
|
.card.shadow {
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty states */
|
||||||
|
.text-muted.display-1 {
|
||||||
|
color: #dee2e6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td,
|
||||||
|
.table th {
|
||||||
|
padding: 0.35rem 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations for Anxiety/Urgency */
|
||||||
|
@keyframes pulse-bg {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-color: #ffe69c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-bg-danger {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-color: #f5c2c7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink-text {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anxiety-warning {
|
||||||
|
animation: pulse-bg 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anxiety-danger {
|
||||||
|
animation: pulse-bg-danger 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Table Styles - Compact */
|
||||||
|
.custom-table thead th {
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 0.5rem !important;
|
||||||
|
padding-bottom: 0.5rem !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table tbody tr {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table tbody td {
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding-top: 0.5rem !important;
|
||||||
|
padding-bottom: 0.5rem !important;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact form elements */
|
||||||
|
.form-control-custom,
|
||||||
|
.form-select-custom,
|
||||||
|
.btn-custom {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Tab Styles */
|
||||||
|
.nav-tabs .nav-link {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: #6c757d;
|
||||||
|
/* text-muted */
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link:hover {
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
color: #000 !important;
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 3px solid #000 !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Icon Size */
|
||||||
|
.bi-circle,
|
||||||
|
.bi-check-circle-fill {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Urgency styles for countdown */
|
||||||
|
.countdown-urgent {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: 700;
|
||||||
|
animation: blink-text 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-warning {
|
||||||
|
color: #fd7e14;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
33
web/static/js/app.js
Normal file
33
web/static/js/app.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Homework Manager JavaScript
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Auto-dismiss alerts after 5 seconds
|
||||||
|
const alerts = document.querySelectorAll('.alert:not(.alert-danger)');
|
||||||
|
alerts.forEach(function(alert) {
|
||||||
|
setTimeout(function() {
|
||||||
|
alert.classList.add('fade');
|
||||||
|
setTimeout(function() {
|
||||||
|
alert.remove();
|
||||||
|
}, 150);
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm dialogs for dangerous actions
|
||||||
|
const confirmForms = document.querySelectorAll('form[data-confirm]');
|
||||||
|
confirmForms.forEach(function(form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (!confirm(form.dataset.confirm)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set default datetime to now + 1 day for new assignments
|
||||||
|
const dueDateInput = document.getElementById('due_date');
|
||||||
|
if (dueDateInput && !dueDateInput.value) {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(23, 59, 0, 0);
|
||||||
|
dueDateInput.value = tomorrow.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
});
|
||||||
115
web/templates/admin/api_keys.html
Normal file
115
web/templates/admin/api_keys.html
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1 class="mb-4"><i class="bi bi-key me-2"></i>APIキー管理</h1>
|
||||||
|
|
||||||
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if .newKey}}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h5 class="alert-heading"><i class="bi bi-check-circle me-2"></i>APIキーが作成されました</h5>
|
||||||
|
<p class="mb-2">キー名: <strong>{{.newKeyName}}</strong></p>
|
||||||
|
<p class="mb-0">以下のキーを安全な場所に保存してください。このキーは二度と表示されません。</p>
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<code class="flex-grow-1 bg-dark text-light p-2 rounded me-2" id="newApiKey">{{.newKey}}</code>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="copyKey()"><i class="bi bi-clipboard"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>新規APIキー作成
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/admin/api-keys" method="POST" class="row g-3">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="col-md-8">
|
||||||
|
<input type="text" class="form-control" name="name" placeholder="キー名(例: 外部連携用)" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus me-1"></i>作成</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .apiKeys}}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>キー名</th>
|
||||||
|
<th>作成者</th>
|
||||||
|
<th>最終使用</th>
|
||||||
|
<th>作成日</th>
|
||||||
|
<th style="width: 100px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .apiKeys}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td><i class="bi bi-key me-1"></i>{{.Name}}</td>
|
||||||
|
<td>{{if .User}}{{.User.Name}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{if .LastUsed}}{{formatDateTime .LastUsed}}{{else}}<span class="text-muted">未使用</span>{{end}}</td>
|
||||||
|
<td>{{formatDate .CreatedAt}}</td>
|
||||||
|
<td>
|
||||||
|
<form action="/admin/api-keys/{{.ID}}/delete" method="POST" class="d-inline"
|
||||||
|
onsubmit="return confirm('このAPIキーを削除しますか?')">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="削除"><i
|
||||||
|
class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-key display-1 text-muted"></i>
|
||||||
|
<h3 class="mt-3">APIキーがありません</h3>
|
||||||
|
<p class="text-muted">上のフォームから新しいAPIキーを作成してください。</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>API使用方法
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-2">APIにアクセスするには、<code>Authorization</code>ヘッダーにAPIキーを設定してください:</p>
|
||||||
|
<pre
|
||||||
|
class="bg-dark text-light p-3 rounded"><code>curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8080/api/v1/assignments</code></pre>
|
||||||
|
<h6 class="mt-3">利用可能なエンドポイント:</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li><code>GET /api/v1/assignments</code> - 課題一覧取得</li>
|
||||||
|
<li><code>GET /api/v1/assignments/pending</code> - 未完了の課題一覧</li>
|
||||||
|
<li><code>GET /api/v1/assignments/completed</code> - 完了済みの課題一覧</li>
|
||||||
|
<li><code>GET /api/v1/assignments/overdue</code> - 期限切れの課題一覧</li>
|
||||||
|
<li><code>GET /api/v1/assignments/due-today</code> - 今日が期限の課題一覧</li>
|
||||||
|
<li><code>GET /api/v1/assignments/due-this-week</code> - 今週中が期限の課題一覧</li>
|
||||||
|
<li><code>GET /api/v1/assignments/:id</code> - 課題詳細取得</li>
|
||||||
|
<li><code>POST /api/v1/assignments</code> - 課題作成</li>
|
||||||
|
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
|
||||||
|
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
|
||||||
|
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
function copyKey() {
|
||||||
|
const key = document.getElementById('newApiKey').innerText;
|
||||||
|
navigator.clipboard.writeText(key).then(() => {
|
||||||
|
alert('APIキーをコピーしました');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
65
web/templates/admin/users.html
Normal file
65
web/templates/admin/users.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1 class="mb-4"><i class="bi bi-people me-2"></i>ユーザー管理</h1>
|
||||||
|
|
||||||
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if .users}}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名前</th>
|
||||||
|
<th>メールアドレス</th>
|
||||||
|
<th>ロール</th>
|
||||||
|
<th>登録日</th>
|
||||||
|
<th style="width: 200px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .users}}
|
||||||
|
<tr {{if eq .ID $.currentUserID}}class="table-primary" {{end}}>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>{{.Name}}{{if eq .ID $.currentUserID}}<span class="badge bg-info ms-2">自分</span>{{end}}</td>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td>{{if eq .Role "admin"}}<span class="badge bg-danger">管理者</span>{{else}}<span
|
||||||
|
class="badge bg-secondary">ユーザー</span>{{end}}</td>
|
||||||
|
<td>{{formatDate .CreatedAt}}</td>
|
||||||
|
<td>
|
||||||
|
{{if ne .ID $.currentUserID}}
|
||||||
|
<form action="/admin/users/{{.ID}}/role" method="POST" class="d-inline" {{if eq .Role "admin"
|
||||||
|
}}onsubmit="return confirm('このユーザーを一般ユーザーに降格しますか?')"
|
||||||
|
{{else}}onsubmit="return confirm('このユーザーを管理者に昇格しますか?')" {{end}}>
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||||
|
{{if eq .Role "admin"}}
|
||||||
|
<input type="hidden" name="role" value="user">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary" title="ユーザーに降格"><i
|
||||||
|
class="bi bi-arrow-down"></i></button>
|
||||||
|
{{else}}
|
||||||
|
<input type="hidden" name="role" value="admin">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning" title="管理者に昇格"><i
|
||||||
|
class="bi bi-arrow-up"></i></button>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
<form action="/admin/users/{{.ID}}/delete" method="POST" class="d-inline"
|
||||||
|
onsubmit="return confirm('このユーザーを削除しますか?')">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="削除"><i
|
||||||
|
class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
{{else}}<span class="text-muted">-</span>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-people display-1 text-muted"></i>
|
||||||
|
<h3 class="mt-3">ユーザーがいません</h3>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
51
web/templates/assignments/edit.html
Normal file
51
web/templates/assignments/edit.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-pencil me-2"></i>課題編集</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/assignments/{{.assignment.ID}}">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="title" name="title" value="{{.assignment.Title}}"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="subject" class="form-label">科目</label>
|
||||||
|
<input type="text" class="form-control" id="subject" name="subject"
|
||||||
|
value="{{.assignment.Subject}}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="priority" class="form-label">重要度</label>
|
||||||
|
<select class="form-select" id="priority" name="priority">
|
||||||
|
<option value="low" {{if eq .assignment.Priority "low" }}selected{{end}}>小</option>
|
||||||
|
<option value="medium" {{if eq .assignment.Priority "medium" }}selected{{end}}>中</option>
|
||||||
|
<option value="high" {{if eq .assignment.Priority "high" }}selected{{end}}>大</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="due_date" class="form-label">提出期限 <span class="text-danger">*</span></label>
|
||||||
|
<input type="datetime-local" class="form-control" id="due_date" name="due_date"
|
||||||
|
value="{{formatDateInput .assignment.DueDate}}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">説明</label>
|
||||||
|
<textarea class="form-control" id="description" name="description"
|
||||||
|
rows="3">{{.assignment.Description}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||||
|
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
270
web/templates/assignments/index.html
Normal file
270
web/templates/assignments/index.html
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h4 class="mb-0 fw-bold"><i class="bi bi-list-task me-2"></i>課題一覧</h4>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-secondary text-white" onclick="toggleCountdown()" id="toggleCountdownBtn">
|
||||||
|
<i class="bi bi-clock me-1"></i><span id="countdownBtnText">カウントダウン表示中</span>
|
||||||
|
</button>
|
||||||
|
<a href="/assignments/new" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>新規登録
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " pending"}}fw-bold border-bottom border-dark
|
||||||
|
border-3{{else}}text-muted{{end}}" href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}"
|
||||||
|
style="{{if eq .filter " pending"}}color: black !important;{{end}}">未完了</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " completed"}}fw-bold border-bottom border-dark
|
||||||
|
border-3{{else}}text-muted{{end}}" href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}"
|
||||||
|
style="{{if eq .filter " completed"}}color: black !important;{{end}}">完了済み</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " overdue"}}fw-bold border-bottom border-dark
|
||||||
|
border-3{{else}}text-muted{{end}}" href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}"
|
||||||
|
style="{{if eq .filter " overdue"}}color: black !important;{{end}}">期限切れ</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
|
||||||
|
|
||||||
|
<!-- Filter Section -->
|
||||||
|
<form action="/assignments" method="GET" class="row g-2 mb-3 align-items-center">
|
||||||
|
<input type="hidden" name="filter" value="{{.filter}}">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text bg-white border-end-0 text-muted"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control border-start-0 ps-0 bg-white" name="q" placeholder="検索..."
|
||||||
|
value="{{.query}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<select class="form-select form-select-sm bg-white" name="priority" onchange="this.form.submit()">
|
||||||
|
<option value="">全ての重要度</option>
|
||||||
|
<option value="high" {{if eq .priority "high" }}selected{{end}}>高</option>
|
||||||
|
<option value="medium" {{if eq .priority "medium" }}selected{{end}}>中</option>
|
||||||
|
<option value="low" {{if eq .priority "low" }}selected{{end}}>低</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<a href="/assignments?filter={{.filter}}" class="btn btn-sm btn-outline-secondary w-100 bg-white">
|
||||||
|
クリア
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card shadow-sm border-0 rounded-0">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0 custom-table">
|
||||||
|
<thead class="bg-secondary-subtle">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
|
||||||
|
<th class="text-dark fw-bold">タイトル</th>
|
||||||
|
<th style="width: 120px;" class="text-dark fw-bold">科目</th>
|
||||||
|
<th style="width: 80px;" class="text-dark fw-bold">重要度</th>
|
||||||
|
<th style="width: 140px;" class="text-dark fw-bold">期限</th>
|
||||||
|
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
||||||
|
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .assignments}}
|
||||||
|
<tr class="assignment-row border-bottom" data-due-ts="{{.DueDate.Unix}}"
|
||||||
|
data-completed="{{.IsCompleted}}">
|
||||||
|
<td class="ps-3 text-center">
|
||||||
|
{{if .IsCompleted}}
|
||||||
|
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-link p-0 text-success text-decoration-none hover-dark"
|
||||||
|
title="未完了に戻す">
|
||||||
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-link p-0 text-secondary text-decoration-none hover-dark"
|
||||||
|
title="完了にする">
|
||||||
|
<i class="bi bi-circle"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold text-dark text-truncate" style="max-width: 300px;">{{.Title}}</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Priority "high"}}
|
||||||
|
<span class="badge bg-danger text-white border-0 fw-bold small">高</span>
|
||||||
|
{{else if eq .Priority "medium"}}
|
||||||
|
<span class="badge bg-warning text-dark border-0 fw-bold small">中</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-dark text-white border-0 fw-bold small">低</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="countdown-col">
|
||||||
|
{{if not .IsCompleted}}
|
||||||
|
<span class="countdown small fw-bold font-monospace text-dark">...</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-secondary small fw-bold">-</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
|
||||||
|
<i class="bi bi-pencil-fill"></i>
|
||||||
|
</a>
|
||||||
|
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
|
||||||
|
onsubmit="return confirm('削除しますか?');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent">
|
||||||
|
<i class="bi bi-trash-fill"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center py-4 text-secondary fw-bold small">
|
||||||
|
課題なし
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if gt .totalPages 1}}
|
||||||
|
<div class="card-footer bg-white border-top-0 py-2">
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||||
|
<li class="page-item {{if not .hasPrev}}disabled{{end}}">
|
||||||
|
<a class="page-link border-0 text-secondary"
|
||||||
|
href="/assignments?page={{.prevPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link border-0 text-dark fw-bold">{{.currentPage}} / {{.totalPages}}</span>
|
||||||
|
</li>
|
||||||
|
<li class="page-item {{if not .hasNext}}disabled{{end}}">
|
||||||
|
<a class="page-link border-0 text-secondary"
|
||||||
|
href="/assignments?page={{.nextPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateCountdowns() {
|
||||||
|
const now = new Date();
|
||||||
|
document.querySelectorAll('.assignment-row').forEach(row => {
|
||||||
|
if (row.getAttribute('data-completed') === 'true') return;
|
||||||
|
|
||||||
|
const dueTs = row.getAttribute('data-due-ts');
|
||||||
|
if (!dueTs) return;
|
||||||
|
|
||||||
|
// Fix: Use timestamp directly to avoid parsing issues
|
||||||
|
const due = new Date(parseInt(dueTs) * 1000);
|
||||||
|
if (isNaN(due.getTime())) return;
|
||||||
|
|
||||||
|
const diff = due - now;
|
||||||
|
const countdownEl = row.querySelector('.countdown');
|
||||||
|
|
||||||
|
// Reset classes
|
||||||
|
row.classList.remove('anxiety-danger', 'anxiety-warning', 'bg-danger-subtle');
|
||||||
|
if (countdownEl) countdownEl.className = 'countdown small fw-bold font-monospace';
|
||||||
|
|
||||||
|
if (diff < 0) {
|
||||||
|
if (countdownEl) {
|
||||||
|
countdownEl.textContent = "期限切れ";
|
||||||
|
countdownEl.classList.add('text-danger');
|
||||||
|
}
|
||||||
|
row.classList.add('bg-danger-subtle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
let remainingHours = (days * 24) + hours;
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
text += `${days}日 `;
|
||||||
|
}
|
||||||
|
text += `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
if (countdownEl) countdownEl.textContent = text;
|
||||||
|
|
||||||
|
// Anxiety Logic
|
||||||
|
if (remainingHours < 24) {
|
||||||
|
row.classList.add('anxiety-danger');
|
||||||
|
if (countdownEl) {
|
||||||
|
countdownEl.classList.add('text-danger', 'countdown-urgent');
|
||||||
|
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-1"></i>' + text;
|
||||||
|
}
|
||||||
|
} else if (days < 7) {
|
||||||
|
row.classList.add('anxiety-warning');
|
||||||
|
if (countdownEl) {
|
||||||
|
countdownEl.classList.add('text-dark');
|
||||||
|
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>' + text;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (countdownEl) countdownEl.classList.add('text-secondary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCountdown() {
|
||||||
|
const cols = document.querySelectorAll('.countdown-col');
|
||||||
|
const btnText = document.getElementById('countdownBtnText');
|
||||||
|
const isHidden = cols[0] && cols[0].style.display === 'none';
|
||||||
|
|
||||||
|
cols.forEach(col => {
|
||||||
|
col.style.display = isHidden ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnText.textContent = isHidden ? 'カウントダウン表示中' : 'カウントダウン非表示中';
|
||||||
|
localStorage.setItem('countdownHidden', !isHidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init with higher frequency for smooth panic
|
||||||
|
setInterval(updateCountdowns, 1000);
|
||||||
|
updateCountdowns();
|
||||||
|
|
||||||
|
// Check preference
|
||||||
|
const isHidden = localStorage.getItem('countdownHidden') === 'true';
|
||||||
|
if (isHidden) {
|
||||||
|
document.querySelectorAll('.countdown-col').forEach(col => {
|
||||||
|
col.style.display = 'none';
|
||||||
|
});
|
||||||
|
const btnText = document.getElementById('countdownBtnText');
|
||||||
|
if (btnText) btnText.textContent = 'カウントダウン非表示中';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
51
web/templates/assignments/new.html
Normal file
51
web/templates/assignments/new.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>課題登録</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/assignments">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="title" name="title" value="{{.formTitle}}" required
|
||||||
|
autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="subject" class="form-label">科目</label>
|
||||||
|
<input type="text" class="form-control" id="subject" name="subject" value="{{.subject}}"
|
||||||
|
placeholder="例: 数学、英語、情報">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="priority" class="form-label">重要度</label>
|
||||||
|
<select class="form-select" id="priority" name="priority">
|
||||||
|
<option value="low" {{if eq .priority "low" }}selected{{end}}>小</option>
|
||||||
|
<option value="medium" {{if not (or (eq .priority "low" ) (eq .priority "high"
|
||||||
|
))}}selected{{end}}>中</option>
|
||||||
|
<option value="high" {{if eq .priority "high" }}selected{{end}}>大</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="due_date" class="form-label">提出期限 <span class="text-danger">*</span></label>
|
||||||
|
<input type="datetime-local" class="form-control" id="due_date" name="due_date" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">説明</label>
|
||||||
|
<textarea class="form-control" id="description" name="description"
|
||||||
|
rows="3">{{.description}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
|
||||||
|
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
43
web/templates/auth/login.html
Normal file
43
web/templates/auth/login.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5 col-lg-4">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-journal-check display-4 text-primary"></i>
|
||||||
|
<h2 class="mt-2">ログイン</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .error}}
|
||||||
|
<div class="alert alert-danger">{{.error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">メールアドレス</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required
|
||||||
|
autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">パスワード</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="mb-0">アカウントをお持ちでない方は</p>
|
||||||
|
<a href="/register" class="btn btn-outline-secondary mt-2">新規登録</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
54
web/templates/auth/register.html
Normal file
54
web/templates/auth/register.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5 col-lg-4">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-person-plus display-4 text-primary"></i>
|
||||||
|
<h2 class="mt-2">新規登録</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .error}}
|
||||||
|
<div class="alert alert-danger">{{.error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/register">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">名前</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" value="{{.name}}" required
|
||||||
|
autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">メールアドレス</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">パスワード</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required
|
||||||
|
minlength="6">
|
||||||
|
<div class="form-text">6文字以上</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password_confirm" class="form-label">パスワード(確認)</label>
|
||||||
|
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
|
||||||
|
required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">登録</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="mb-0">既にアカウントをお持ちの方は</p>
|
||||||
|
<a href="/login" class="btn btn-outline-secondary mt-2">ログイン</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
105
web/templates/layouts/base.html
Normal file
105
web/templates/layouts/base.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{{define "base"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.title}} - Super Homework Manager</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="/static/css/style.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.navbar-dark .navbar-nav .nav-link,
|
||||||
|
.navbar-brand {
|
||||||
|
color: #fff !important;
|
||||||
|
transition: color 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-dark .navbar-nav .nav-link:hover,
|
||||||
|
.navbar-brand:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.75) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{template "head" .}}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{{if .userName}}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">
|
||||||
|
<i class="bi bi-journal-check me-2"></i>Super Homework Manager
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/"><i class="bi bi-house-door me-1"></i>ダッシュボード</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/assignments"><i class="bi bi-list-task me-1"></i>課題一覧</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/assignments/new"><i class="bi bi-plus-circle me-1"></i>課題登録</a>
|
||||||
|
</li>
|
||||||
|
{{if .isAdmin}}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin/users"><i class="bi bi-people me-1"></i>ユーザー管理</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin/api-keys"><i class="bi bi-key me-1"></i>APIキー管理</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>{{.userName}}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="/profile"><i class="bi bi-person me-2"></i>プロフィール</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<form action="/logout" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||||
|
<button type="submit" class="dropdown-item"><i
|
||||||
|
class="bi bi-box-arrow-right me-2"></i>ログアウト</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<main class="container py-2">
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer mt-auto py-1 bg-light">
|
||||||
|
<div class="container text-center">
|
||||||
|
<span class="text-muted small" style="font-size: 0.75rem;">Super Homework Manager</span><br>
|
||||||
|
<small class="text-muted" style="font-size: 0.65rem;">Licensed under <a
|
||||||
|
href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3</a> | Time:
|
||||||
|
{{.processing_time}}</small>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
{{template "scripts" .}}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "head"}}{{end}}
|
||||||
|
{{define "scripts"}}{{end}}
|
||||||
300
web/templates/pages/dashboard.html
Normal file
300
web/templates/pages/dashboard.html
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "head"}}
|
||||||
|
<style>
|
||||||
|
@keyframes pulse-bg {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-color: #ffe69c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-bg-danger {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-color: #f5c2c7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink-banner {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anxiety-warning {
|
||||||
|
animation: pulse-bg 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anxiety-danger {
|
||||||
|
animation: pulse-bg-danger 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-banner {
|
||||||
|
z-index: 1030;
|
||||||
|
animation: blink-banner 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-banner-danger {
|
||||||
|
background: linear-gradient(90deg, #dc3545, #c82333);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-banner-warning {
|
||||||
|
background: linear-gradient(90deg, #fd7e14, #e06c00);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-countdown {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div id="urgentBanner" class="urgent-banner py-3 text-center d-none">
|
||||||
|
<div class="container">
|
||||||
|
<i class="bi bi-exclamation-octagon-fill me-2"></i>
|
||||||
|
<span id="urgentMessage"></span>
|
||||||
|
<div class="urgent-countdown mt-1">
|
||||||
|
<i class="bi bi-stopwatch"></i> あと <span id="urgentCountdown"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mb-4"><i class="bi bi-house-door me-2"></i>ダッシュボード</h1>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-primary text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-white-50">未完了の課題</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-list-task display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-warning text-dark h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-dark-50">今日が期限</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.DueToday}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-calendar-event display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-info text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-white-50">今週が期限</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-calendar-week display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-danger text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-white-50">期限切れ</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.Overdue}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{{if .overdue}}
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-header bg-danger text-white"><i class="bi bi-exclamation-triangle me-2"></i>期限切れの課題</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{{range .overdue}}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||||
|
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||||
|
<div>
|
||||||
|
<strong>{{.Title}}</strong>
|
||||||
|
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||||
|
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||||
|
<br><small class="text-danger">{{formatDateTime .DueDate}}</small>
|
||||||
|
</div>
|
||||||
|
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||||
|
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
|
||||||
|
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .dueToday}}
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-header bg-warning text-dark"><i class="bi bi-calendar-event me-2"></i>今日が期限</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{{range .dueToday}}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||||
|
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||||
|
<div>
|
||||||
|
<strong>{{.Title}}</strong>
|
||||||
|
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||||
|
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||||
|
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||||
|
</div>
|
||||||
|
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||||
|
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
|
||||||
|
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .upcoming}}
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-info">
|
||||||
|
<div class="card-header bg-info text-white"><i class="bi bi-calendar-week me-2"></i>今週の課題</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{{range .upcoming}}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||||
|
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||||
|
<div>
|
||||||
|
<strong>{{.Title}}</strong>
|
||||||
|
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||||
|
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||||
|
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||||
|
</div>
|
||||||
|
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||||
|
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
|
||||||
|
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if and (not .overdue) (not .dueToday) (not .upcoming)}}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-emoji-smile display-1 text-success"></i>
|
||||||
|
<h3 class="mt-3">今週の課題はありません!</h3>
|
||||||
|
<p class="text-muted">新しい課題を登録しましょう</p>
|
||||||
|
<a href="/assignments/new" class="btn btn-primary"><i class="bi bi-plus-circle me-1"></i>課題を登録</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var banner = document.getElementById('urgentBanner');
|
||||||
|
var message = document.getElementById('urgentMessage');
|
||||||
|
var countdown = document.getElementById('urgentCountdown');
|
||||||
|
var body = document.body;
|
||||||
|
|
||||||
|
var items = document.querySelectorAll('[data-priority="high"][data-due]');
|
||||||
|
var mostUrgent = null;
|
||||||
|
var mostUrgentDue = Infinity;
|
||||||
|
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var due = parseInt(item.dataset.due);
|
||||||
|
var now = Date.now();
|
||||||
|
var diff = due - now;
|
||||||
|
|
||||||
|
if (diff > 0 && diff < mostUrgentDue) {
|
||||||
|
mostUrgentDue = diff;
|
||||||
|
var titleEl = item.querySelector('strong');
|
||||||
|
mostUrgent = { due: due, title: titleEl ? titleEl.textContent : '課題' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var hasOverdueHigh = false;
|
||||||
|
var overdueItems = document.querySelectorAll('[data-priority="high"]');
|
||||||
|
overdueItems.forEach(function (item) {
|
||||||
|
var due = parseInt(item.dataset.due);
|
||||||
|
if (due && due < Date.now()) {
|
||||||
|
hasOverdueHigh = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasOverdueHigh) {
|
||||||
|
banner.classList.remove('d-none');
|
||||||
|
banner.classList.add('urgent-banner-danger');
|
||||||
|
message.innerHTML = '🚨 <strong>期限切れの重要課題があります!</strong>';
|
||||||
|
countdown.textContent = '今すぐ対応してください!';
|
||||||
|
body.classList.add('anxiety-danger');
|
||||||
|
} else if (mostUrgent && mostUrgentDue < 24 * 60 * 60 * 1000) {
|
||||||
|
banner.classList.remove('d-none');
|
||||||
|
banner.classList.add('urgent-banner-danger');
|
||||||
|
message.innerHTML = '🚨 <strong>「' + mostUrgent.title + '」の期限が迫っています!</strong>';
|
||||||
|
body.classList.add('anxiety-danger');
|
||||||
|
updateCountdown();
|
||||||
|
setInterval(updateCountdown, 1000);
|
||||||
|
} else if (mostUrgent && mostUrgentDue < 3 * 24 * 60 * 60 * 1000) {
|
||||||
|
banner.classList.remove('d-none');
|
||||||
|
banner.classList.add('urgent-banner-warning');
|
||||||
|
message.innerHTML = '⚠️ <strong>「' + mostUrgent.title + '」の期限が近づいています</strong>';
|
||||||
|
body.classList.add('anxiety-warning');
|
||||||
|
updateCountdown();
|
||||||
|
setInterval(updateCountdown, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
if (!mostUrgent) return;
|
||||||
|
var now = Date.now();
|
||||||
|
var diff = mostUrgent.due - now;
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
countdown.textContent = '期限切れ!';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var days = Math.floor(diff / 86400000);
|
||||||
|
var hours = Math.floor((diff % 86400000) / 3600000);
|
||||||
|
var mins = Math.floor((diff % 3600000) / 60000);
|
||||||
|
var secs = Math.floor((diff % 60000) / 1000);
|
||||||
|
|
||||||
|
var text = '';
|
||||||
|
if (days > 0) text = days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||||
|
else if (hours > 0) text = hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||||
|
else text = mins + '分 ' + secs + '秒';
|
||||||
|
|
||||||
|
countdown.textContent = text;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
10
web/templates/pages/error.html
Normal file
10
web/templates/pages/error.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-exclamation-triangle display-1 text-danger"></i>
|
||||||
|
<h1 class="mt-4">{{.title}}</h1>
|
||||||
|
<p class="lead text-muted">{{.message}}</p>
|
||||||
|
<a href="/" class="btn btn-primary mt-3"><i class="bi bi-house-door me-1"></i>ダッシュボードに戻る</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
71
web/templates/pages/profile.html
Normal file
71
web/templates/pages/profile.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<h1 class="mb-4"><i class="bi bi-person me-2"></i>プロフィール</h1>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">アカウント情報</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||||
|
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
|
||||||
|
<form method="POST" action="/profile">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">メールアドレス</label>
|
||||||
|
<input type="email" class="form-control" id="email" value="{{.user.Email}}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">名前</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" value="{{.user.Name}}"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">ロール</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
value="{{if eq .user.Role `admin`}}管理者{{else}}ユーザー{{end}}" disabled>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">パスワード変更</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{if .passwordError}}<div class="alert alert-danger">{{.passwordError}}</div>{{end}}
|
||||||
|
{{if .passwordSuccess}}<div class="alert alert-success">{{.passwordSuccess}}</div>{{end}}
|
||||||
|
<form method="POST" action="/profile/password">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="old_password" class="form-label">現在のパスワード</label>
|
||||||
|
<input type="password" class="form-control" id="old_password" name="old_password"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">新しいパスワード</label>
|
||||||
|
<input type="password" class="form-control" id="new_password" name="new_password"
|
||||||
|
required minlength="6">
|
||||||
|
<div class="form-text">6文字以上</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">新しいパスワード(確認)</label>
|
||||||
|
<input type="password" class="form-control" id="confirm_password"
|
||||||
|
name="confirm_password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning"><i class="bi bi-key me-1"></i>パスワード変更</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user