]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/POP3D.pm
No ext_urls
[public-inbox.git] / lib / PublicInbox / POP3D.pm
1 # Copyright (C) all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # represents an POP3D
5 package PublicInbox::POP3D;
6 use v5.12;
7 use parent qw(PublicInbox::Lock);
8 use DBI qw(:sql_types); # SQL_BLOB
9 use Carp ();
10 use File::Temp 0.19 (); # 0.19 for ->newdir
11 use PublicInbox::Config;
12 use PublicInbox::POP3;
13 use PublicInbox::Syscall;
14 use File::Temp 0.19 (); # 0.19 for ->newdir
15 use Fcntl qw(F_SETLK F_UNLCK F_WRLCK SEEK_SET);
16 my @FLOCK;
17 if ($^O eq 'linux' || $^O =~ /bsd/) {
18         require Config;
19         my $off_t;
20         my $sz = $Config::Config{lseeksize};
21
22         if ($sz == 8 && eval('length(pack("q", 1)) == 8')) { $off_t = 'q' }
23         elsif ($sz == 4) { $off_t = 'l' }
24         else { warn "sizeof(off_t)=$sz requires File::FcntlLock\n" }
25
26         if (defined($off_t)) {
27                 if ($^O eq 'linux') {
28                         @FLOCK = ("ss\@8$off_t$off_t\@32",
29                                 qw(l_type l_whence l_start l_len));
30                 } elsif ($^O =~ /bsd/) {
31                         @FLOCK = ("${off_t}${off_t}lss\@256",
32                                 qw(l_start l_len l_pid l_type l_whence));
33                 }
34         }
35 }
36 @FLOCK or eval { require File::FcntlLock } or
37         die "File::FcntlLock required for POP3 on $^O: $@\n";
38
39 sub new {
40         my ($cls) = @_;
41         bless {
42                 err => \*STDERR,
43                 out => \*STDOUT,
44                 # pi_cfg => PublicInbox::Config
45                 # lock_path => ...
46                 # interprocess lock is the $pop3state/txn.locks file
47                 # txn_locks => {}, # intraworker locks
48                 # ssl_ctx_opt => { SSL_cert_file => ..., SSL_key_file => ... }
49         }, $cls;
50 }
51
52 sub refresh_groups { # PublicInbox::Daemon callback
53         my ($self, $sig) = @_;
54         # TODO share pi_cfg with nntpd/imapd inside -netd
55         my $new = PublicInbox::Config->new;
56         my $d = $new->{'publicinbox.pop3state'} //
57                 die "publicinbox.pop3state undefined ($new->{-f})\n";
58         -d $d or do {
59                 require File::Path;
60                 File::Path::make_path($d, { mode => 0700 });
61                 PublicInbox::Syscall::nodatacow_dir($d);
62         };
63         $self->{lock_path} //= "$d/db.lock";
64         if (my $old = $self->{pi_cfg}) {
65                 my $s = 'publicinbox.pop3state';
66                 $new->{$s} //= $old->{$s};
67                 return warn <<EOM if $new->{$s} ne $old->{$s};
68 $s changed: `$old->{$s}' => `$new->{$s}', config reload ignored
69 EOM
70         }
71         $self->{pi_cfg} = $new;
72 }
73
74 # persistent tables
75 sub create_state_tables ($$) {
76         my ($self, $dbh) = @_;
77
78         $dbh->do(<<''); # map publicinbox.<name>.newsgroup to integers
79 CREATE TABLE IF NOT EXISTS newsgroups (
80         newsgroup_id INTEGER PRIMARY KEY NOT NULL,
81         newsgroup VARBINARY NOT NULL,
82         UNIQUE (newsgroup) )
83
84         # the $NEWSGROUP_NAME.$SLICE_INDEX is part of the POP3 username;
85         # POP3 has no concept of folders/mailboxes like IMAP/JMAP
86         $dbh->do(<<'');
87 CREATE TABLE IF NOT EXISTS mailboxes (
88         mailbox_id INTEGER PRIMARY KEY NOT NULL,
89         newsgroup_id INTEGER NOT NULL REFERENCES newsgroups,
90         slice INTEGER NOT NULL, /* -1 for most recent slice */
91         UNIQUE (newsgroup_id, slice) )
92
93         $dbh->do(<<''); # actual users are differentiated by their UUID
94 CREATE TABLE IF NOT EXISTS users (
95         user_id INTEGER PRIMARY KEY NOT NULL,
96         uuid VARBINARY NOT NULL,
97         last_seen INTEGER NOT NULL, /* to expire idle accounts */
98         UNIQUE (uuid) )
99
100         # we only track the highest-numbered deleted message per-UUID@mailbox
101         $dbh->do(<<'');
102 CREATE TABLE IF NOT EXISTS deletes (
103         txn_id INTEGER PRIMARY KEY NOT NULL, /* -1 == txn lock offset */
104         user_id INTEGER NOT NULL REFERENCES users,
105         mailbox_id INTEGER NOT NULL REFERENCES mailboxes,
106         uid_dele INTEGER NOT NULL DEFAULT -1, /* IMAP UID, NNTP article */
107         UNIQUE(user_id, mailbox_id) )
108
109 }
110
111 sub state_dbh_new {
112         my ($self) = @_;
113         my $f = "$self->{pi_cfg}->{'publicinbox.pop3state'}/db.sqlite3";
114         my $creat = !-s $f;
115         if ($creat) {
116                 open my $fh, '+>>', $f or Carp::croak "open($f): $!";
117                 PublicInbox::Syscall::nodatacow_fh($fh);
118         }
119
120         my $dbh = DBI->connect("dbi:SQLite:dbname=$f",'','', {
121                 AutoCommit => 1,
122                 RaiseError => 1,
123                 PrintError => 0,
124                 sqlite_use_immediate_transaction => 1,
125                 sqlite_see_if_its_a_number => 1,
126         });
127         $dbh->do('PRAGMA journal_mode = WAL') if $creat;
128         $dbh->do('PRAGMA foreign_keys = ON'); # don't forget this
129
130         # ensure the interprocess fcntl lock file exists
131         $f = "$self->{pi_cfg}->{'publicinbox.pop3state'}/txn.locks";
132         open my $fh, '+>>', $f or Carp::croak("open($f): $!");
133         $self->{txn_fh} = $fh;
134
135         create_state_tables($self, $dbh);
136         $dbh;
137 }
138
139 sub _setlk ($%) {
140         my ($self, %lk) = @_;
141         $lk{l_pid} = 0; # needed for *BSD
142         $lk{l_whence} = SEEK_SET;
143         if (@FLOCK) {
144                 fcntl($self->{txn_fh}, F_SETLK,
145                         pack($FLOCK[0], @lk{@FLOCK[1..$#FLOCK]}));
146         } else {
147                 my $fs = File::FcntlLock->new(%lk);
148                 $fs->lock($self->{txn_fh}, F_SETLK);
149         }
150 }
151
152 sub lock_mailbox {
153         my ($self, $pop3) = @_; # pop3 - PublicInbox::POP3 client object
154         my $lk = $self->lock_for_scope; # lock the SQLite DB, only
155         my $dbh = $self->{-state_dbh} //= state_dbh_new($self);
156         my ($user_id, $ngid, $mbid, $txn_id);
157         my $uuid = delete $pop3->{uuid};
158         $dbh->begin_work;
159
160         # 1. make sure the user exists, update `last_seen'
161         my $sth = $dbh->prepare_cached(<<'');
162 INSERT OR IGNORE INTO users (uuid, last_seen) VALUES (?,?)
163
164         $sth->bind_param(1, $uuid, SQL_BLOB);
165         $sth->bind_param(2, time);
166         if ($sth->execute == 0) { # existing user
167                 $sth = $dbh->prepare_cached(<<'', undef, 1);
168 SELECT user_id FROM users WHERE uuid = ?
169
170                 $sth->bind_param(1, $uuid, SQL_BLOB);
171                 $sth->execute;
172                 $user_id = $sth->fetchrow_array //
173                         die 'BUG: user '.unpack('H*', $uuid).' not found';
174                 $sth = $dbh->prepare_cached(<<'');
175 UPDATE users SET last_seen = ? WHERE user_id = ?
176
177                 $sth->execute(time, $user_id);
178         } else { # new user
179                 $user_id = $dbh->last_insert_id(undef, undef,
180                                                 'users', 'user_id')
181         }
182
183         # 2. make sure the newsgroup has an integer ID
184         $sth = $dbh->prepare_cached(<<'');
185 INSERT OR IGNORE INTO newsgroups (newsgroup) VALUES (?)
186
187         my $ng = $pop3->{ibx}->{newsgroup};
188         $sth->bind_param(1, $ng, SQL_BLOB);
189         if ($sth->execute == 0) {
190                 $sth = $dbh->prepare_cached(<<'', undef, 1);
191 SELECT newsgroup_id FROM newsgroups WHERE newsgroup = ?
192
193                 $sth->bind_param(1, $ng, SQL_BLOB);
194                 $sth->execute;
195                 $ngid = $sth->fetchrow_array // die "BUG: `$ng' not found";
196         } else {
197                 $ngid = $dbh->last_insert_id(undef, undef,
198                                                 'newsgroups', 'newsgroup_id');
199         }
200
201         # 3. ensure the mailbox exists
202         $sth = $dbh->prepare_cached(<<'');
203 INSERT OR IGNORE INTO mailboxes (newsgroup_id, slice) VALUES (?,?)
204
205         if ($sth->execute($ngid, $pop3->{slice}) == 0) {
206                 $sth = $dbh->prepare_cached(<<'', undef, 1);
207 SELECT mailbox_id FROM mailboxes WHERE newsgroup_id = ? AND slice = ?
208
209                 $sth->execute($ngid, $pop3->{slice});
210                 $mbid = $sth->fetchrow_array //
211                         die "BUG: mailbox_id for $ng.$pop3->{slice} not found";
212         } else {
213                 $mbid = $dbh->last_insert_id(undef, undef,
214                                                 'mailboxes', 'mailbox_id');
215         }
216
217         # 4. ensure the (max) deletes row exists for locking
218         $sth = $dbh->prepare_cached(<<'');
219 INSERT OR IGNORE INTO deletes (user_id,mailbox_id) VALUES (?,?)
220
221         if ($sth->execute($user_id, $mbid) == 0) {
222                 $sth = $dbh->prepare_cached(<<'', undef, 1);
223 SELECT txn_id,uid_dele FROM deletes WHERE user_id = ? AND mailbox_id = ?
224
225                 $sth->execute($user_id, $mbid);
226                 ($txn_id, $pop3->{uid_dele}) = $sth->fetchrow_array;
227         } else {
228                 $txn_id = $dbh->last_insert_id(undef, undef,
229                                                 'deletes', 'txn_id');
230         }
231         $dbh->commit;
232
233         # see if it's locked by the same worker:
234         return if $self->{txn_locks}->{$txn_id};
235
236         # see if it's locked by another worker:
237         _setlk($self, l_type => F_WRLCK, l_start => $txn_id - 1, l_len => 1)
238                 or return;
239
240         $pop3->{user_id} = $user_id;
241         $pop3->{txn_id} = $txn_id;
242         $self->{txn_locks}->{$txn_id} = 1;
243 }
244
245 sub unlock_mailbox {
246         my ($self, $pop3) = @_;
247         my $txn_id = delete($pop3->{txn_id}) // return;
248         if (!$pop3->{did_quit}) { # deal with QUIT-less disconnects
249                 my $lk = $self->lock_for_scope;
250                 $self->{-state_dbh}->begin_work;
251                 $pop3->__cleanup_state($txn_id);
252                 $self->{-state_dbh}->commit;
253         }
254         delete $self->{txn_locks}->{$txn_id}; # same worker
255
256         # other workers
257         _setlk($self, l_type => F_UNLCK, l_start => $txn_id - 1, l_len => 1)
258                 or die "F_UNLCK: $!";
259 }
260
261 1;